深入理解各类排序算法及复杂度分析(Java版本)

各类排序算法在面试中十分常见,特此进行整理,时见时新。以下皆以升序排列为例,降序反之即可。

1.冒泡排序

1.1基本思想  

冒泡排序,顾名思义,指针(下标)就像一个泡泡一样从最左向最右“冒”上去,在“冒”上去的过程中,每次都比较指针指向元素和右侧元素的大小,该元素大于右侧元素则交换位置,否则不交换,接着指针必然向右移一格。

上述即完成第一次冒泡,此时已经将最大的元素移至数组做右端,接着开始第二次冒泡,但此次冒泡中只需要“冒”至最右侧第二个元素即可,因为最右侧已经是最大的元素了。为了优化此过程,我们可以设置一个“是否调换元素”标志位flag,

flag为false表示,表示此轮冒泡未调换,说明整个数组已经有序,则立即退出冒泡即可,节约时间。代码如下:

public void BubbleSort(int[] arr){

      int tmp = 0;

      Boolean flag = false;

      int len = arr.length;

      for(int i=len-1;i>=0;i--){//两轮循环

         for(int j=0;j<i;j++){

             if(arr[j]>arr[j+1]){

                  tmp = arr[j];

                  arr[j] = arr[j+1]

                  arr[j+1] = tmp;

                  flag = true;

             }

            if(!flag){

            break;//冒泡完成 

            }

           flag = false;//每轮冒泡都重置flag标志位

         }

     }

}

1.2复杂度分析

最好情况下冒泡排序时间复杂度为O(N),即冒一次就完成排序,最差情况则需要冒(n-1)次,时间复杂度为O(n^2),平均时间复杂度为O(n^2),适用于长度较小的序列进行排序,时间复杂度稳定。

1.3稳定性分析

冒泡排序是稳定的,相同元素的相对位置不会发生改变。

 

2.选择排序

2.1排序思想

选择排序就是吗,每次遍历完数组都挑出其中最小的元素,将之调换至最左侧。

public void SelectSort(int arr[]){

     int min = 0;

     int minindex = 0;

     int len = arr.length;

    for(itn i=0;i<len-1;i++){

       min = arr[i];

      minindex = i;

     for(int j=i;j<len;j++){

        if(arr[j]<min){

           min = arr[j+1];

           minindex = j+1;   

       }

     }

     if(minindex!=i){

     arr[minindex] = tmp;

     arr[i] =min;

     }

   }

}

2.1时间复杂度分析

选择排序的时间复杂度为O(n^2),是不稳定的,选择排序可能会打乱值相同元素的原有顺序。

排序稳定性:即排序前后,数值相等的元素之间相对位置是否发生改变。也就是说数值相等的元素,一开始排在前面的,排完序还得在前面,则符合稳定性。

2.3稳定性分析

选择排序是不稳定的,因为最左方的元素会与最小元素交换位置,必然导排序前后相同元素的相对顺序发生改变。

 

3.快速排序

3.1排序思想

快速排序过程中需要确定一个基准(通常选择最左边的为基准值),小于等于基准的元素放在基准左边,大于等于基准的元素都放在基准右边。快排序算法的一大重点在于确定基准元素在数组中的位置。

接着对上述算法过程进行递归,直至完成排序。可将快排拆分为两部分(①确定基准元素排序后的位置②在基准元素的左右分别进行递归)

public static void quick_sort(int arr[],int l,int r){
        if(l >= r) return;
        //p为快速排序返回的基准的位置
        int p = partition2(arr,l,r);
        //对基准左边的数进行快排
        quick_sort(arr,l,p-1);
        //对基准右边的数进行快排
        quick_sort(arr,p+1,r);
    }

//快速排序,另一种从前后扫描的
    public static int partition2(int arr[],int l,int r){
        //基准元素设为第一个
        int v = arr[l];
        //i指向基准的下一个元素,j指向最后一个元素
        int i = l+1,j = r;
        while(true){
            while(i <= r && arr[i] < v) i++;
            while(j > l && arr[j] > v) j--;
            //循环终止条件
            if(i > j) break;
            //交换arr[i]与arr[j]
            int t = arr[i];
            arr[i] = arr[j];
            arr[j] = t;
            i++;
            j--;
        }
        //将基准元素与arr[j]交换
        int t = arr[l];
        arr[l] = arr[j];
        arr[j] = t;
        //返回基准元素所在位置
        return j;
    }

3.2复杂度分析

平均时间复杂度为O(nlog(n)),在数组元素高度有序情况下回退回到O(n^2),极端例子就是对有序数组进行快排,那每一次最左侧的基准元素位置都不会发生改变,相当于一直要递归n次,每次比较n次,即为O(n^2),

在无序情况下只需要递归log(n)次即可,类似于左右分治,是对数数量级的复杂度。

3.3稳定性分析

快排是不稳定的,简单来看选择最左侧元素为基准,次元素会被调换至中间位置,这显然可能会造成值相同元素的前后位置改变。

 

4.插入排序

4.1算法思想

插入排序是在原数组上(in-place)进行的,所以空间复杂度为O(n)。其思想在于在数组左侧维护已排好序的一段元素,然后将下一个元素挨个和前面的元素比较大小,若前面的元素小于其则调换位置,从而完成将新元素插入左侧有序部分。

public int[] insertswitchsort(int[] array) {
        if (array.length == 0)
            return array;
        int current;
        for (int i = 0; i < array.length - 1; i++) {
            current = array[i + 1];
            int preIndex = i;
            while (preIndex >= 0 && current < array[preIndex]) {
                array[preIndex + 1] = array[preIndex];
                preIndex--;
            }
            array[preIndex + 1] = current;
        }
        return array;
    }

4.2复杂度分析

最优时间复杂度为O(n),即本来就有序的时候,无须插入,遍历一遍即可;最差时间复杂度为O(n2)。空间复杂度为O(1),因为插入排序是in-place的。

4.3稳定性分析

插入排序是稳定的,相同元素的相对顺序不会改变。

 

5.希尔排序

5.1 算法思想

希尔排序是希尔(Donald Shell) 于1959年提出的一种排序算法。希尔排序也是一种插入排序,它是简单插入排序经过改进之后的一个更高效的版本,也称为缩小增量排序,同时该算法是冲破O(n2)的第一批算法之一。它与插入排序的不同之处在于,它会优先比较距离较远的元素。希尔排序又叫缩小增量排序。希尔排序是把记录按下表的一定增量分组,对每组使用直接插入排序算法排序;随着增量逐渐减少,每组包含的关键词越来越多,当增量减至1时,整个文件恰被分成一组,算法便终止。我们来看下希尔排序的基本步骤,在此我们选择增量gap=length/2,缩小增量继续以gap = gap/2的方式,这种增量选择我们可以用一个序列来表示,{n/2,(n/2)/2…1},称为增量序列。希尔排序的增量序列的选择与证明是个数学难题,我们选择的这个增量序列是比较常用的,也是希尔建议的增量,称为希尔增量,但其实这个增量序列不是最优的。此处我们做示例使用希尔增量。

先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,具体算法描述:
步骤1:选择一个增量序列t1,t2,…,tk,其中ti>tj,tk=1;
步骤2:按增量序列个数k,对序列进行k 趟排序;
步骤3:每趟排序,根据对应的增量ti,将待排序列分割成若干长度为m 的子序列,分别对各子表进行直接插入排序。仅增量因子为1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。
 

    public int[] ShellSort(int[] a) {//希尔排序需要结合图来理解 
        int len = a.length;
        if(len<=1) {
            return a;
        }
        int tmp = 0;
        int gap = len/2;
        while(gap>0) { //gap从len/2一步步变为1
            for(int i=gap;i<len;i++) {
                tmp = a[i];
                int curindex = i;
                while((curindex-gap)>=0 && tmp<a[curindex-gap] ) {
                    a[curindex] = a[curindex-gap];
                    curindex = curindex-gap;
                }
                a[curindex] = tmp;
            }
            gap = gap/2;
        }
        return a;
    }

5.2复杂度分析

时间复杂度O(nlogn),空间复杂度O(1)。

5.3稳定性分析

希尔排序不是稳定的。因为相同大小的元素一开始可能分布在不同的gap组合中。

 

6.归并排序

6.1算法思想

和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是O(n log n)的时间复杂度。代价是需要额外的内存空间。归并排序 是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。归并排序是一种稳定的排序方法。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为2-路归并。
步骤1:把长度为n的输入序列分成两个长度为n/2的子序列;
步骤2:对这两个子序列分别采用归并排序;
步骤3:将两个排序好的子序列合并成一个最终的排序序列。
代码分为两部分 MergeSort(int[]a,int[]b)作用是反复递归输出排序数组,真正起排序作用的是Merge(int[] left,int[] right)函数,它将left[]和right[]数组排序为一个有序数组

    public int[] MergeSort(int[] a) {
        int len = a.length;
        if(len<=1) {
            return a;//递归结束的出口 当每个数组只有一个元素时,结束递归
        }
        int mid = len/2;
        int[] left = Arrays.copyOfRange(a, 0, mid);//前闭后开
        int[] right = Arrays.copyOfRange(a,mid ,len);
        return merge(MergeSort(left),MergeSort(right));
    }
    public int[] merge(int[] left,int[] right) {//功能为按序合并两个有序数组
        int l1 = left.length;
        int l2 = right.length;
        int[] res =new int[l1+l2];
        for(int index=0,i=0,j=0;index<l1+l2;index++) { i/j分别是两个数组的指针
            if(i>=l1) {//left数组整理完了
                res[index] = right[j++];
            }else if(j>=l2) {
                res[index] = left[i++];
            }else if(left[i]>right[j]) {
                res[index] = right[j++];
            }else {
                res[index] = left[i++];
            }
        }
        return res;
    }

6.2复杂度分析

时间复杂度为O(nlogn),空间复杂度O(n), 因为是out-space的,合并的时候依赖外部空间进行排序

6.3稳定性分析

归并排序是稳定的,因为主要是相邻元素间的调换。且在merge的时候,左右数组按顺序归并,相同元素先后顺序不发生改变。

7.计数排序

7.排序思想

计数排序 的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。 作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。计数排序使用一个额外的数组C,其中第i个元素是待排序数组A中值等于i的元素的个数。然后根据数组C来将A中的元素排到正确的位置。它只能对整数进行排序。
步骤1:找出待排序的数组中最大和最小的元素;
步骤2:统计数组中每个值为i的元素出现的次数,存入数组C的第i项;
步骤3:对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加);
步骤4:反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1。

//计数排序
    public int[] countSort(int[] a) {
        int len = a.length;
        if(len<=1) {
            return a;
        }
        int bias=a[0],max=a[0],min =a[0];
        for(int i=0;i<len;i++) {
            if(a[i]>max) {
                max = a[i];
            }
            if(a[i]<min) {
                min = a[i];
            }
        }
        bias = 0-min;//bias的目的是保证bucket数组下表是非负整数
        int[] bucket = new int[max-min+1];//由于记录数组a中值等于i的数组有多少
        for(int i=0;i<len;i++) {
            bucket[a[i]+bias]++;
        }
        int index = 0;//输出数组的下标
        int ptr = 0;//bucket的下标
            for(int i=0;i<max-min+1&&index<len; ) {
                if(bucket[i]!=0) {
                    a[index] = i-bias;//这里是i-bias 注意
                    bucket[i]--;
                    index++;
                }else {
                i++;
            }
        }
        return a;
    }

7.2复杂度分析

时间复杂度为O(n+k),即横跨范围为n中的k个数,空间复杂度为O(n),因为要维护长度为n的bucket数组。此算法复杂度是线性的,但是这使得计数排序对于数据范围很大的数组,需要大量时间和内存。

7.3稳定性分析

我个人感觉计数排序谈不上稳定性,因为这个算法和原来数组中的元素没有关系,都是另外根据出现次数重新生成的整数并将之填入输出数组完成排序。

 

8.桶排序

8.1算法思路

一句话总结:划分多个范围相同的区间,每个子区间自排序,最后合并。

桶排序是计数排序的扩展版本,计数排序可以看成每个桶只存储相同元素,而桶排序每个桶存储一定范围的元素,通过映射函数,将待排序数组中的元素映射到各个对应的桶中,对每个桶中的元素进行排序,最后将非空桶中的元素逐个放入原序列中。

桶排序需要尽量保证元素分散均匀,否则当所有数据集中在同一个桶中时,桶排序失效。

有种在Bucket数组中的每个节点都维护一个大顶堆(小顶堆)的感觉。
步骤1:人为设置一个BucketSize,作为每个桶所能放置多少个不同数值(例如当BucketSize==5时,该桶可以存放{1,2,3,4,5}这几种数字,但是容量不限,即可以存放100个3);
步骤2:遍历输入数据,并且把数据一个一个放到对应的桶里去;
步骤3:对每个不是空的桶进行排序,可以使用其它排序方法,也可以递归使用桶排序;
步骤4:从不是空的桶里把排好序的数据拼接起来。

注意,如果递归使用桶排序为各个桶排序,则当桶数量为1时要手动减小BucketSize增加下一循环桶的数量,否则会陷入死循环,导致内存溢出。
图片演示

假如对数组nums进行桶排序,nums的长度为L,最小元素为A,最大元素为B。

则gap为(B-A)/L+1,桶的个数为(B-A)/gap+1。

另外一个重要的性质是,同一个桶中的元素相差最多为gap-1。

对nums中的元素nums[i],确定放入哪个桶的公式为:(nums[i]-A)/gap


    // 计算最大值与最小值
    int max = Integer.MIN_VALUE;
    int min = Integer.MAX_VALUE;
    for(int i = 0; i < arr.length; i++){
        max = Math.max(max, arr[i]);
        min = Math.min(min, arr[i]);
    }
    // 计算桶的数量
    int bucketNum = (max - min) / arr.length + 1;
    ArrayList<ArrayList<Integer>> bucketArr = new ArrayList<>(bucketNum);
    for(int i = 0; i < bucketNum; i++){
        bucketArr.add(new ArrayList<Integer>());
    }
    // 将每个元素放入桶
    for(int i = 0; i < arr.length; i++){
        int num = (arr[i] - min) / (arr.length);
        bucketArr.get(num).add(arr[i]);
    }
    // 对每个桶进行排序
    for(int i = 0; i < bucketArr.size(); i++){
        Collections.sort(bucketArr.get(i));
    }
    // 将桶中的元素赋值到原序列
    int index = 0;
    for(int i = 0; i < bucketArr.size(); i++){
        for(int j = 0; j < bucketArr.get(i).size(); j++){
            arr[index++] = bucketArr.get(i).get(j);
        }
    }  
}

8.2复杂度分析:

对于待排序序列大小为 N,共分为 M 个桶,主要步骤有:

N 次循环,将每个元素装入对应的桶中
M 次循环,对每个桶中的数据进行排序(平均每个桶有 N/M 个元素)
一般使用较为快速的排序算法,时间复杂度为 O ( N l o g N ) O(NlogN)O(NlogN),实际的桶排序过程是以链表形式插入的。

整个桶排序的时间复杂度为:

O ( N ) + O ( M ∗ ( N / M ∗ l o g ( N / M ) ) ) = O ( N ∗ ( l o g ( N / M ) + 1 ) ) O(N)+O(M*(N/M*log(N/M)))=O(N*(log(N/M)+1))O(N)+O(M∗(N/M∗log(N/M)))=O(N∗(log(N/M)+1))

当 N = M 时,复杂度为 O ( N ) O(N)O(N)
空间复杂度O(M+N)

8.3稳定性分析

依赖于每个桶内的排序算法。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值