回炉篇13—数据结构(12)之高级排序

前言:

前面的文章有提到冒泡、选择、插入排序算法,时间复杂度都是O(n2),数据量大时,速度会很慢。

本篇介绍两个高级排序算法:希尔排序和快速排序。


1.希尔排序

希尔排序是优化版的直接插入排序,在直接插入排序的基础上增加一个新特性。
希尔排序通过加大插入排序中元素的间隔,并在这些有间隔的元素中进行插入排序,从而使数据项能够大跨度的移动。当这些数据项排过一趟序后,希尔排序算法减小数据项的间隔再进行排序,依次进行下去,最后间隔为1时,就是我们上面说的简单的直接插入排序。
 下图显示了增量为4时对包含10个数组元素进行排序的第一个步骤,首先对下标为 0,4,8 的元素进行排序,完成排序之后,算法右移一步,对 1,5,9 号元素进行排序,依次类推,直到所有的元素完成一趟排序,也就是说间隔为4的元素都已经排列有序。
回炉篇13—数据结构(12)之高级排序_第1张图片
  当我们完成4-增量排序之后,在进行普通的插入排序,即1-增量排序,会比前面直接执行简单插入排序要快很多。


10个元素,以4为间隔,那100个、1000个数据,怎么选取间隔?
间隔序列中的数字互质是很重要的指标,也就是说,除了1,他们没有公约数。这个约束条件使得每一趟排序更有可能保持前一趟排序已经排好的结果,而希尔最初以N/2的间隔的低效性就是没有遵守这个准则。
用2.2整除每一个间隔。例如n=100,会产生45,20,9,4,1。
还有一种很常用的间隔序列:knuth 间隔序列 3h+1
回炉篇13—数据结构(12)之高级排序_第2张图片
必须满足一个条件,间隔最后一定要等于1,最后一定是简单的插入排序。

knuth间隔序列的希尔排序算法实现

//希尔排序 knuth 间隔序列 3h+1
public static void shellKnuthSort(int[] array){
    System.out.println("原数组为"+Arrays.toString(array));
    int step = 1 ;
    int len = array.length;
    while(step <= len/3){
        step = step*3 + 1;//1,4,13,40......
    }  
    while(step > 0){
        //分别对每个增量间隔进行排序
        for(int i = step ; i < len ; i++){
            int temp = array[i];
            int j = i;
            while(j > step-1 && temp <= array[j-step]){
                array[j] = array[j-step];
                j -= step;
            }
            array[j] = temp;
        }//end for
        System.out.println("间隔为"+step+"的排序结果为"+Arrays.toString(array));
        step = (step-1)/3;
    }//end while(step>0)
         
    System.out.println("最终排序:"+Arrays.toString(array));
}

2.快速排序

快速排序是冒泡排序的一种改进。
①、快速排序的基本思路
 一、先通过第一趟排序,将数组原地划分为两部分,其中一部分的所有数据都小于另一部分的所有数据。原数组被划分为2份

二、通过递归的处理, 再对原数组分割的两部分分别划分为两部分,同样是使得其中一部分的所有数据都小于另一部分的所有数据。 这个时候原数组被划分为了4份

三、就1,2被划分后的最小单元子数组来看,它们仍然是无序的,但是! 它们所组成的原数组却逐渐向有序的方向前进。

四、这样不断划分到最后,数组就被划分为多个由一个元素或多个相同元素组成的单元,这样数组就有序了。
具体实例:
回炉篇13—数据结构(12)之高级排序_第3张图片
对于上图的数组[3,1,4,1,5,9,2,6,5,3],通过第一趟排序将数组分成了[2,1,1]或[4,5,9,3,6,5,3]两个子数组,且对于任意元素,左边子数组总是小于右边子数组。通过不断的递归处理,最终得到有序数组[1 1 2 3 3 4 5 5 6]

②、快速排序的算法实现
假设被排序的无序区间为[A[i],…,A[j]]
 一、基准元素选取:选择其中的一个记录的关键字 v 作为基准元素(控制关键字);怎么选取关键字?

二、划分: 通过基准元素 v 把无序区间 A[I]…A[j] 划分为左右两部分,使得左边的各记录的关键字都小于 v;右边的各记录的关键字都大于等于 v;(如何划分?)

三、递归求解:重复上面的一、二步骤,分别对左边和右边两部分递归进行快速排序。

**四、组合:**左、右两部分均有序,那么整个序列都有序。

上面的第 三、四步不用多说,主要是第一步怎么选取关键字,从而实现第二步的划分?

划分的过程涉及到三个关键字:“基准元素”、“左游标”、“右游标”

基准元素:它是将数组划分为两个子数组的过程中,用于界定大小的值,以它为判断标准,将小于它的数组元素“划分”到一个“小数值的数组”中,而将大于它的数组元素“划分”到一个“大数值的数组”中,这样,我们就将数组分割为两个子数组,而其中一个子数组的元素恒小于另一个子数组里的元素。

左游标:它一开始指向待分割数组最左侧的数组元素,在排序的过程中,它将向右移动。

右游标:它一开始指向待分割数组最右侧的数组元素,在排序的过程中,它将向左移动。

注意:上面描述的基准元素/右游标/左游标都是针对单趟排序过程的, 也就是说,在整体排序过程的多趟排序中,各趟排序取得的基准元素/右游标/左游标一般都是不同的

对于基准元素的选取,原则上是任意的。但是一般我们选取数组中第一个元素为基准元素(假设数组是随机分布的)

③、快速排序图示
回炉篇13—数据结构(12)之高级排序_第4张图片
  上面表示的是一个无序数组,选取第一个元素 6 作为基准元素。左游标是 i 哨兵,右游标是 j 哨兵。然后左游标向左移动,右游标向右移动,它们遵循的规则如下:

一、左游标扫描, 跨过所有小于基准元素的数组元素, 直到遇到一个大于或等于基准元素的数组元素, 在那个位置停下

二、右游标扫描, 跨过所有大于基准元素的数组元素, 直到遇到一个小于或等于基准元素的数组元素,在那个位置停下

第一步:哨兵 j 先开始出动。因为此处设置的基准数是最左边的数,所以需要让哨兵 j 先开始出动,哨兵 j 一步一步的向左挪动,直到找到一个小于 6 的元素停下来。接下来,哨兵 i 再一步一步的向右挪动,直到找到一个大于 6 的元素停下来。最后哨兵 i 停在了数字 7 面前,哨兵 j 停在了数字 5 面前。
回炉篇13—数据结构(12)之高级排序_第5张图片
到此,第一次交换结束,接着哨兵 j 继续向左移动,它发现 4 比基准数 6 要小,那么在数字4面前停下来。哨兵 i 也接着向右移动,然后在数字 9 面前停下来,然后哨兵 i 和 哨兵 j 再次进行交换。
回炉篇13—数据结构(12)之高级排序_第6张图片
第二次交换结束,哨兵 j 继续向左移动,然后在数字 3 面前停下来;哨兵 i 继续向右移动,但是它发现和哨兵 j 相遇了。那么此时说明探测结束,将数字 3 和基准数字 6 进行交换,如下:
回炉篇13—数据结构(12)之高级排序_第7张图片
 到此,第一次探测真正结束,此时已基准点 6 为分界线,6 左边的数组元素都小于等于6,6右边的数组元素都大于等于6。

左边序列为【3,1,2,5,4】,右边序列为【9,7,10,8】。接着对于左边序列而言,以数字 3 为基准元素,重复上面的探测操作,探测完毕之后的序列为【2,1,3,5,4】;对于右边序列而言,以数字 9 位基准元素,也重复上面的探测操作。然后一步一步的划分,最后排序完全结束。
  通过这一步一步的分解,我们发现快速排序的每一轮操作就是将基准数字归位,直到所有的数都归位完成,排序就结束了。
  回炉篇13—数据结构(12)之高级排序_第8张图片
  ④、快速排序完整代码

package com.ys.high.sort;
 
public class QuickSort {
     
    //数组array中下标为i和j位置的元素进行交换
    private static void swap(int[] array , int i , int j){
        int temp = array[i];
        array[i] = array[j];
        array[j] = temp;
    }
     
    private static void recQuickSort(int[] array,int left,int right){
        if(right <= left){
            return;//终止递归
        }else{
             
            int partition = partitionIt(array,left,right);
            recQuickSort(array,left,partition-1);// 对上一轮排序(切分)时,基准元素左边的子数组进行递归
            recQuickSort(array,partition+1,right);// 对上一轮排序(切分)时,基准元素右边的子数组进行递归
        }
    }
     
    private static int partitionIt(int[] array,int left,int right){
        //为什么 j加一个1,而i没有加1,是因为下面的循环判断是从--j和++i开始的.
        //而基准元素选的array[left],即第一个元素,所以左游标从第二个元素开始比较
        int i = left;
        int j = right+1;
        int pivot = array[left];// pivot 为选取的基准元素(头元素)
        while(true){
            while(i<right && array[++i] < pivot){}
             
            while(j > 0 && array[--j] > pivot){}
             
            if(i >= j){// 左右游标相遇时候停止, 所以跳出外部while循环
                break;
            }else{
                swap(array, i, j);// 左右游标未相遇时停止, 交换各自所指元素,循环继续
            }
        }
        swap(array, left, j);//基准元素和游标相遇时所指元素交换,为最后一次交换
        return j;// 一趟排序完成, 返回基准元素位置(注意这里基准元素已经交换位置了)
    }
     
    public static void sort(int[] array){
        recQuickSort(array, 0, array.length-1);
    }
     
    //测试
    public static void main(String[] args) {
        //int[] array = {7,3,5,2,9,8,6,1,4,7};
        int[] array = {9,9,8,7,6,5,4,3,2,1};
        sort(array);
        for(int i : array){
            System.out.print(i+" ");
        }
        //打印结果为:1 2 3 4 5 6 7 7 8 9
    }
}

⑤、优化分析
假设我们是对一个逆序数组进行排序,选取第一个元素作为基准点,即最大的元素是基准点,那么第一次循环,左游标要执行到最右边,而右游标执行一次,然后两者进行交换。这也会划分成很多的子数组。

那么怎么解决呢?理想状态下,应该选择被排序数组的中值数据作为基准,也就是说一半的数大于基准数,一般的数小于基准数,这样会使得数组被划分为两个大小相等的子数组,对快速排序来说,拥有两个大小相等的子数组是最优的情况。

三项取中划分

为了找到一个数组中的中值数据,一般是取数组中第一个、中间的、最后一个,选择这三个数中位于中间的数。

//取数组下标第一个数、中间的数、最后一个数的中间值
private static int medianOf3(int[] array,int left,int right){
    int center = (right-left)/2+left;
    if(array[left] > array[right]){ //得到 array[left] < array[right]
        swap(array, left, right);
    }
    if(array[center] > array[right]){ //得到 array[left] array[center] < array[right]
        swap(array, center, right);
    }
    if(array[center] > array[left]){ //得到 array[center] <  array[left] < array[right]
        swap(array, center, left);
    }
     
    return array[left]; //array[left]的值已经被换成三数中的中位数, 将其返回
}
private static int partitionIt(int[] array,int left,int right){
    //为什么 j加一个1,而i没有加1,是因为下面的循环判断是从--j和++i开始的.
    //而基准元素选的array[left],即第一个元素,所以左游标从第二个元素开始比较
    int i = left;
    int j = right+1;
    int pivot = array[left];// pivot 为选取的基准元素(头元素)
     
    int size = right - left + 1;
    if(size >= 3){
        pivot = medianOf3(array, left, right); //数组范围大于3,基准元素选择中间值。
    }
    while(true){
        while(i<right && array[++i] < pivot){}
         
        while(j > 0 && array[--j] > pivot){}
         
        if(i >= j){// 左右游标相遇时候停止, 所以跳出外部while循环
            break;
        }else{
            swap(array, i, j);// 左右游标未相遇时停止, 交换各自所指元素,循环继续
        }
    }
    swap(array, left, j);//基准元素和游标相遇时所指元素交换,为最后一次交换
    return j;// 一趟排序完成, 返回基准元素位置(注意这里基准元素已经交换位置了)
}

处理小划分
如果使用三数据取中划分方法,则必须遵循快速排序算法不能执行三个或者少于三个的数据,如果大量的子数组都小于3个,那么使用快速排序是比较耗时的。联想到前面我们讲过简单的排序(冒泡、选择、插入)。

当数组长度小于M的时候(high-low <= M), 不进行快排,而进行插入排序。转换参数M的最佳值和系统是相关的,一般来说, 5到15间的任意值在多数情况下都能令人满意。

//插入排序
private static void insertSort(int[] array){
    for(int i = 1 ; i < array.length ; i++){
        int temp = array[i];
        int j = i;
        while(j > 0 && array[j-1] > temp){
            array[j] = array[j-1];
            j--;
        }
        array[j] = temp;
    }
}

你可能感兴趣的:(回炉)