经典排序相关面试题

该文章总结自牛课网的在线算法课程(https://www.nowcoder.com/)

经典排序算法就是前面讲那几个(可以看我之前写的关于经典排序的文章哦~)
经典排序(一)
经典排序(二)堆排序
经典排序(三)计数排序和基数排序

一般不能说某个算法就是最快的,要看实际应用情况。工程上一般是多种算法结合使用。下面贴几道和排序有关的经典问题和解法,题目来自牛课网。

我在写解答时候尽量把思路解释清楚再贴代码,并且在给出最优解(我认为的)之前有一个优化的过程。因为在面试或在线做题时候,对于我这样的学渣,突然拿到一个陌生题的时候很难一下子想到最优解,此时可以先把最先想到的方法写出来,然后(在面试官的提醒下)再逐步优化到满足要求的复杂度。我觉得即使没一下子做出来,这样也能体现自己解决问题的能力,还能有一个顺畅的沟通过程(满足面试官装逼的需求。。嘿嘿)。好吧。。。以上都是我胡扯的😂,能直接做出来当然是最好的。下面直接看题吧。


一、小范围排序问题

问题:

已知一个几乎有序的数组,几乎有序是指,如果把数组排好顺序的话,每个元素移动的距离可以不超过k,并且k相对于数组来说比较小。请选择一个合适的排序算法针对这个数据进行排序。
给定一个int数组A,同时给定A的大小n和题意中的k,请返回排序后的数组。
测试样例:

[2,1,4,3,6,5,8,7,10,9],10,2
返回:[1,2,3,4,5,6,7,8,9,10]

思路:

首先,因为不知道输入值的范围,所以计数排序不考虑。而冒泡排序和选择排序和输入序列是否有序无关,也不是最好选择。。。

其实很容易想到插入排序是个不错的选择,因为之前讲插入排序希尔排序时候都提到过,插入排序在几乎有序情况下很好,接近线性时间。本题说每个元素移动的距离可以不超过k,也就意味着每个元素插入过程中比较交换次数不会超过k,所以用插入排序时间复杂度可以到O(n*k)。

那么有没有更好的解决方法呢?我们接下来考虑一下O(nlogn)级别的那几个算法有没有合适的。

快排归并和输入序列是否有序也没有关系,那堆排序呢?其实这道题的最优解就是利用堆排序的思想做的,算是一个修改过的堆排吧。用下面这个数组举个例子。假设n=8,k=2,输入为下面数组。


输入数组

可以看出来最小元素一定在前3个位置(k+1),所以我们可以建立一个大小为3的最小堆,先把前三个元素插入,再删除最小根并放在第一个位置,然后插入第四个元素,弹出最小根放到第二个位置,依次进行直到结束。每次插入删除操作为O(logk),执行n次,所以这种方法可以做到时间复杂度O(nlogk)。下面是我写的代码。其实应该单独写一个Heap类,然后把堆的各种方法封装起来,这样更符合java规范。如果懒得自己写Heap类,在线题也可以直接使用现成的优先队列。毕竟我们是在用java,别弄得好像还在用c做题一样。我这个代码是偷懒把之前的代码拷贝过来改了改,写的很乱,大家看看就好。

解答:
package fuckingtest;

import java.util.*;
 
public class ScaleSort {
    public int[] sortElement(int[] A, int n, int k) {
        int[] heap=new int[k+1];
        for(int i=0;i<k+1;i++)
            insert(heap,i,A[i]);
         
        for(int i=0,count=k;i<n;i++){
            A[i]=delete(heap,count--);
             
            if(i+k+1<n){                
                insert(heap,++count,A[i+k+1]);
            }
        }
        return A;
    }
     
    void insert(int[] A, int count, int data){
        int i,temp;
        i=count;
        A[i]=data;
        while(i!=0&&(i-1)/2>=0&&A[(i-1)/2]>data){
            temp=A[i];
            A[i]=A[(i-1)/2];
            A[(i-1)/2]=temp;
            i=(i-1)/2;
        }
    }
     
    void down(int[] A,int count,int i){
        int r,l;
        int maxson=-1;
        for(;;){
            l=2*i+1;
            r=2*i+2;
            if(r<=count){
                if(A[l]<A[r])
                    maxson=l;
                else
                    maxson=r;
            }
            else if(l<=count&&r>count)
                maxson=l;
            else if(l>count)
                break;
            if(A[maxson]<A[i]){
                int temp=A[i];
                A[i]=A[maxson];
                A[maxson]=temp;
                i=maxson;
            }
            else
                break;
        }
    }
     
    int delete(int[] A, int count){
        if(count==-1)
            return -1;//当堆为空时,报错
        int data = A[0];
        A[0]=A[count];
        A[count--]=0;
        down(A,count,0);
        return data;//返回删除的值
    }
     
  public static void main(String[] args) {
        int[] r=new int[10];
        int[] a=new int[]{3,1,2,6,5,4,7,8,10,9};
        ScaleSort h = new ScaleSort();
        r = h.sortElement(a,10,2);
        for(int t:r){
            System.out.println(t);
        }
    }
     
}

二、重复值判断问题

问题:

请设计一个高效算法,判断数组中是否有重复值。必须保证额外空间复杂度为O(1)。
给定一个int数组A及它的大小n,请返回它是否有重复值。
测试样例:
[1,2,3,4,5,5,6],7
返回:true

思路:

拿到这个题目,我首先想到的是计数排序,简单高效。实际上如果没有额外空间复杂度的限制,我们应该用哈希表来做这道题(如果你不清楚哈希表,上次我实现的计数排序其实就差不多,你可以暂时把它们当一个东西理解)。用哈希表遍历一般数组,记录数值出现次数。时间复杂度和额外空间复杂度都是O(n)。

因为这里有空间复杂度限制,然后我想到可以用二重循环,逐个检查各元素是否有重复值。

boolean cheak(int A[] ,int n ){
  for(int i=0; i<n ;i++)
    for(int j=i+1; j<n; j++)
      if(A[i]==A[j])
        return ture;
   return false;
}

额外空间复杂度O(1),时间复杂度O(n²)

能不能优化呢?答案是肯定的。一般看到复杂度O(n²)O(n³)这样都是有优化空间的。我们可以利用排序,对数组排序后再进行一次遍历比较相邻元素有没有重复的。先排序,后判断,判断过程变成O(n),现在就是看在原地排序算法里那个时间复杂度最低了。现在我们就知道,应该选取堆排序来处理排序过程啦~(不知道的同学看我前面的文章哦)

不过需要注意的是,书上给出的堆排序经典实现使用的是递归的方式。递归虽然方便理解,但是递归需要用栈保存函数,空间大小是O(logn),所以需要自己写非递归的堆排。下面是我的实现,这个代码只写了接口实现,没写测试。

代码:
import java.util.*;
 
public class Checker {
    public boolean checkDuplicate(int[] a, int n) {
        heapSort(a,n);
        for(int i =0;i<n-1;i++){
            if(a[i]==a[i+1])
                return true;
        }
        return false;
    }
     
    public void heapSort(int[] A, int n) {
        int count=n-1;
        build(A,count);
        for(;count>0;){
            int temp = A[0];
            A[0]=A[count--];
            down(A, count,0);
            A[count+1]=temp;
        }
        
       
             
    }
     
    void build(int[] A,int count){
        for(int i=(count-1)/2;i>=0;i--){
            down(A,count,i);
            System.out.println(i);
        }
        System.out.println("建堆ok:");
        for(int t:A){
            System.out.println(t);
        }
        System.out.println(" ");
    }
     
    void down(int[] A,int count,int i){
        int r,l;
        int maxson=-1;
        for(;;){
            l=2*i+1;
            r=2*i+2;
            if(r<=count){
                if(A[l]>A[r])
                    maxson=l;
                else
                    maxson=r;
            }
            else if(l<=count&&r>count)
                maxson=l;
            else if(l>count)
                break;
            if(A[maxson]>A[i]){
                int temp=A[i];
                A[i]=A[maxson];
                A[maxson]=temp;
                i=maxson;
            }
            else
                break;
        }
    }
}

三、相邻两数最大差值

问题:

有一个整形数组A,请设计一个复杂度为O(n)的算法,算出排序后相邻两数的最大差值。
给定一个int数组A和A的大小n,请返回最大的差值。保证数组元素多于1个。
测试样例:
[1,2,5,4,6],5
返回:2

思路:

这道题感觉有点难度,因为他要复杂度O(n),还要完成排序,然后计算相邻两数最大差值。这需求提的,这不是坑爹嘛¬_¬。


看来常规思路是行不通了。既然如此,只好放大招了,别忘了我们还有一个可以转换时空的超必杀技,用空间换时间之究极计数排序大法!


我首先想到的满足它时间复杂度要求的解决方案就是使用计数排序,因为他要复杂度为O(n)嘛,而我们知道的O(n)级别的算法里也就是计数排序比较灵活了。还是像上次文章里写的一样,用桶排序思想实现。先装桶,再遍历一遍桶,计算非空桶序号最大差值。这个方法时间上满足了,缺点就是计数排序通病,不知道输入值范围,空间需求可会非常大。如果在线做题,时间紧,其实想到这里应该也可以通过大部分测试样例了。反正他也没要求空间复杂度,只要内存不爆炸就可以了。。。

而我看的视频里则给出了更好的方法,下面介绍这种方法,它可以做到时间复杂度空间复杂度都是O(n)。这种方法也是利用了桶排序的思想。我们接着我上面给出的想法往下想。其实桶排序这种思想很灵活,知道输入值范围的时候我们可以根据值大小创建桶(计数排序),而如果这样做桶的数量不可控,我们则可以根据每一位上的数字创建10个桶(基数排序)。

在这个问题里,我们可以只创建n个桶。记输入值最小值min,最大值,max。我们要用n个桶装范围是minmax的n个数,那么每个桶装的范围就是min+ixmin+(i+1)x,x是区间长度,x=(max-min+1)/n。可能这么你不太理解,举个例子就懂了。

假如输入{7,9,3,4,2,1,8},7个元素。我们把最大值到最小值这个范围等量分成7个区间。换句话说就是要7个区间要能装下1~9的数字,所以每个区间的长度就是9/7,例如第一个区间从1开始范围就是[1,16/7),第二个区间[16/7,25/7),以此类推。

接着遍历一遍数组,根据元素的值对应的范围,放到相应的桶里。首先我们知道,会有中间有空桶和没有空桶两种情况。

先考虑没有空桶的情况:

  1. 这个时候n个数平均分布在n个桶里,这种情况是在输入值增量比较平均的时候,比如输入是{1,5,9},准备3个桶,区间分别为[1,4)[4,7)[7,10),每个桶一个元素。这种情况找相邻两数最大差值,只需要比较每个相邻的桶里的元素的差值,找最大的即可。

再考虑有空桶情况:

  1. n个桶,n个数,有空桶意味着有的桶里不止一个元素。我们知道同一个桶里相邻元素差值小于区间值,而空桶两端的桶里的相邻元素一定大于区间值。也就是说,有空桶的话,相邻两数最大差值处在空桶两端的桶里的相邻元素。这个和我们刚才在计数排序实现的方法里也是一样的,很好理解。

刚刚说了半天,其实就是为了证明,我们完全不用考虑来自同一个桶的相邻元素差值,我们只需要考虑来自不同桶的相邻元素即可。也就是只要比较后一个桶的最小值减去前一个桶的最大值。所以我们只需要记住每个桶里的最大值和最小值,以及后一个桶的最小值和前一个桶的最大值的差值中的最大值即可。装桶过程O(n),遍历桶O(n),两个操作累加,所以时间复杂度O(n)。

写这么久有点累了。。懒得画图,可能直接文字描述有点不太好理解,你可以自己举个例子,画个图,会更好理解一些。下面是我的代码实现。

代码:
package fuckingtest;

import java.util.*;

public class Gap {
    public int maxGap(int[] A, int n) {
        // write code here
        //先找最大值最小值,确定桶范围
        int min=A[0];
        int max=A[0];
        for(int i=0;i<n;i++){
            if(A[i]<min)
                min=A[i];
            if(A[i]>max)
                max=A[i];
        }
        if(max==min)
            return 0;
        //设x为每个桶的区间长
        int x =(max-min)/n;
        //定义桶数组B,总共有n个桶,每个桶大小为2,存储该区间内最大值和最小值,然后初始化
        int B[][]=new int[n][2];
        for(int i=0;i<n;i++){
            B[i][0]=-99999;
            B[i][1]=-99999;
        }
        //装桶
        for(int i=0;i<n;i++){
            int k=getkey(A[i],min,max,n);
            if(B[k][0]==B[k][1]&&B[k][0]==-99999)
                B[k][1]=B[k][0]=A[i];
            if(A[i]>B[k][1])
                B[k][1]=A[i];
            if(A[i]<B[k][0])
                B[k][0]=A[i];
        }
        for(int i=0;i<n;i++){
            System.out.println("min:"+B[i][0]+" max:"+B[i][1]);
        }
            
        //比较后一个桶最小值和前一个桶最大值的差值
        int r=0;
        int temp;
        for(int i=0,j=1,f=1;i<n-1;i=i+f){
            System.out.println("i:"+i);
            j=1;
            f=1;
            while(B[i+j][0]==-99999){
                j++;
                f++;
            }
            System.out.println("j:"+j);
            temp=B[i+j][0]-B[i][1];
            if(temp>r)
                r=temp;
        }
        return r;
    }
    
    int getkey(int v,int min,int max,int n){
        
        return (int)(v-min)*n/(max-min+1);
    }
    
    
    
    public static void main(String[] args) {
        int r;
        int[] a=new int[]{1,2,3,6,7,8};
        Gap h = new Gap();
        r = h.maxGap(a,6);
    
            System.out.println(r);
        
    }
}

四、有序数组合并

问题:

有两个从小到大排序以后的数组A和B,其中A的末端有足够的缓冲空容纳B。请编写一个方法,将B合并入A并排序。
给定两个有序int数组A和B,A中的缓冲空用0填充,同时给定A和B的真实大小int n和int m,请返回合并后的数组。

思路:

这道题很简单,就是一个归并的过程。和归并排序里面的归并函数做法基本一样,但需要注意的是,这道题是把数组B加入到数组A里。我们需要从后往前比较、加入,这样防止覆盖掉数组A前面的有用部分。过程大致为我们每次从两个列表后面元素选取较大的一个,放入A最后,直到某一个列表到达头部,再将另一个剩下部分逆序取出。时间复杂度O(n+m),空间O(1)。

代码:
import java.util.*;
 
public class Merge {
    public int[] mergeAB(int[] A, int[] B, int n, int m) {
        // write code here
        int i=n-1;
        int j=m-1;
        int k=m+n-1;
        for(;i>=0&&j>=0;){
            if(A[i]>B[j])
                A[k--]=A[i--];
            else
                A[k--]=B[j--];
        }
        while(i>=0){
            A[k--]=A[i--];
        }
        while(j>=0){
            A[k--]=B[j--];
        }
        return A;
    }
}

五、三色排序

问题:

有一个只由0,1,2三种元素构成的整数数组,请使用交换、原地排序而不是使用计数进行排序。
给定一个只含0,1,2的整数数组A及它的大小,请返回排序后的数组。保证数组大小小于等于500。
测试样例:
[0,1,1,0,2,2],6
返回:[0,0,1,1,2,2]

思路:

这是一个经典的荷兰国旗问题,处理过程和快排的划分过程相似,可以参考快排的划分技巧。时间复杂度O(n),空间O(1)。过程为:

遍历数组之前,在数组左端设立“0区”,初始大小0,在数组右端设立“2区”,初始大小0。遍历数组,如果是1,直接跳到下一个;如果是0,把当前元素与“0区”后一位交换,“0区”大小+1,遍历下一个元素;遇到2,把当前元素与“2区”前一位交换,“2区”大小+1,由于“2区”元素并没有遍历过,所以不跳到后一个位置,继续遍历该位置元素。

其实拿到这个问题我最先想到的是用计数排序处理,只要三个桶,几乎可以认为是原地的,简单多了。但这里明确说要用交换,而不是计数。在在线课程下面的评论区里,有小伙伴提出和我一样的疑问,老师的回答是,

如果数组里面放的不是int,long这种类型,而是具体一个一个实例呢?你还能压缩在一起吗?比如数组里面放的是“人”这个类的实例,每个实例有一个“身高”的数据项,请把小于160放左边,160~170放中间,170以上放右边。荷兰国旗问题重点介绍的是一种处理数组的技巧。这种技巧从快排中来,掌握了可以解决很多类似的问题。我并不是在强调这么做才对,只是一种技巧而已。

代码:
public class ThreeColor {
    public int[] sortThreeColor(int[] A, int n) {
        // write code here
        int i=-1;
        int j=n;
        int temp;
        for(int k=0;k<j;){
            if(A[k]==0){
                swap(A,++i,k++); 
            }
            else if(A[k]==2){
                swap(A,--j,k);
            }
            else
                k++;
        }
        return A;
    }
     
    void swap(int A[],int a,int b){
        int temp=A[a];
        A[a]=A[b];
        A[b]=temp;
    }
}

六、最短子数组问题

问题:

对于一个数组,请设计一个高效算法计算需要排序的最短子数组的长度。
给定一个int数组A和数组的大小n,请返回一个二元组,代表所求序列的长度。(原序列位置从0开始标号,若原序列有序,返回0)。保证A中元素均为正整数。
测试样例:
[1,4,6,5,9,10],6
返回:2

思路:

拿到这道题,我最直接的想法就是先排序,再比较排序后的数组有变化的位置,位置有变化的元素里的最左的一个到最右的一个,这之间的数组就是题目要求的需要排序的最短子数组。这种方法需要额外空间复杂度O(n),用来保存排序后的数组(或者保存原数组各元素下标情况,这取决于具体实现)。时间复杂度O(nlogn)。

由上面的这个思路,我们可以想到,其实只要知道,需要调整的元素里最右的元素和最左的元素的位置,就可以得到需要排序的最短子数组的长度。我们知道,如果是有序数组,一定是越往右,数值越大,越往左,数值越小,不满足这个条件的元素,那么就是需要调整的元素。于是可以想到下面的这种处理方法。它可以做到时间复杂度O(n),额外空间复杂度O(1)。处理过程大致为:

  1. 先向右遍历,记住遍历过的元素中的最大值max。如果遍历的当前元素i的值A[i]小于max,说明i是需要向左调整的,记住它。向右遍历,只记录需要向左调整的元素的最右的一个,记为R。
  2. 再从右至左遍历一次,这次记住遍历过的元素中的最小值min。同理,如果遍历的当前元素i的值A[i]大于min,说明i是需要向右调整的,记住它。遍历过程只记录要调整的最左的一个元素,记为L。
  3. A[l]~A[R]就是需要排序的最短子数组,它的长度是R-L+1.
代码:
public class Subsequence {
    public int shortestSubsequence(int[] A, int n) {
        // write code here
        int r=-1;
        int l=0;
        //从左至右遍历,记录最右的当前值小于最大值情况
        for(int i=0,max=A[0];i<n;i++){
            if(A[i]>=max)
                max=A[i];
            else
                r=i;
        }
        //从右至左遍历,记录最左的当前值大于最小值情况
        for(int i=n-1,min=A[n-1];i>-1;i--){
            if(A[i]<=min)
                min=A[i];
            else
                l=i;
        }
        return r-l+1;
    }
}

七、有序矩阵查找

问题:

现在有一个行和列都排好序的矩阵,请设计一个高效算法,快速查找矩阵中是否含有值x。
给定一个int矩阵mat,同时给定矩阵大小nxm及待查找的数x,请返回一个bool值,代表矩阵中是否存在x。所有矩阵中数字及x均为int范围内整数。保证n和m均小于等于1000。
测试样例:
[[1,2,3],[4,5,6],[7,8,9]],3,3,10
返回:false

思路:

这道题可以做到时间复杂度O(m+n),额外空间复杂度O(1)。用下面这个矩阵举例说明。


我们从右上角或左下角作为起始位置开始遍历。这么做是因为矩阵行列都是有序的,右上角是行最小,列最大,左下角相反。我们这里选择从右上角开始,假设待查值是3。当前值是5,如果待查值比当前值大,那么往下走一步,因为我们知道这一行当前位置是最大的,左面所有元素都小于该值,就不用考虑;如果待查值更小,那么往左走一步,理由同上;如果相等,返回true。待查值3<当前值5,往左走一步,当前值变成2。重复上面过程,当前值=4。3<4,所以再往左走,现在待查值3=当前值3,返回true。如果直到越界都没找到,则返回false。

代码:
public class Finder {
    public boolean findX(int[][] mat, int n, int m, int x) {
        // write code here
        int i=0;
        int j=m-1;
        for(;i<n&&j>-1;){
            if(mat[i][j]<x)
                i++;
            else if(mat[i][j]>x)
                j--;
            else
                return true;
        }
        return false;
    }
}

八、总结

我还有本书叫《数据结构与算法经典问题》,上面和排序有关的题还有好多,本来打算再写几道,但题太多实在写不动+_+,也不想写了,因为题总是做不完的(其实因为我好懒。。)。我感觉有牛课网的算法课介绍的这七道题就足够了,而且正如之前所提到的,最重要的并不是做了多少题,记住了多少解法,而是掌握这些处理方式的技巧和思路,并能够解决类似的问题。


推荐阅读更多精彩内容