查看原文
其他

图解:什么是快速排序?

景禹 景禹 2022-06-09

快速排序

快速排序思想篇

与归并排序一样,我们今天要学习的快速排序也是一个分治算法。

快速排序首先选一个基准(你也可以认为是要放到排序后数组正确位置的元素)pivot,然后将数组按照选取的基准 pivot 进行划分。而选取 pivot 的方式又有很多种,所以快速排序具有很多版本。

  1. 总是选择第一个元素作为基准 pivot;
  2. 总是选择最后一个元素作为基准;(本文后续的实现就是依次为例)
  3. 随机的选择一个元素作为基准;
  4. 选择最中间的元素作为基准;

快速排序的关键是划分 partion() 。每一趟划分,我们就可以将作为 pivot 的值 x 放到排序数组的正确位置,并且将所有比 x 小的放到 x 的左边,所有比 x 大的元素放到 x 的右边。而且划分操作的时间复杂度是线性的奥,即 量级!

正所谓一图胜千文,我们看图学算法吧!

为了讲解快速排序并验证其稳定性,我们用下面的数组进行说明(其中两个4分别用不同的颜色标注):

首先选择数组当中的最后一个位置元素 7 作为 pivot:

然后就是执行第一趟快速排序啦!!!

第一步:设置一个指针 i = -1 (初始化为 -1,用于找到 pivot 的正确位置),设置一个遍历数组的指针 j = 0 ,用于遍历从 0 到 pivot 的前一个元素 4 (即两条竖线之间的元素,从而将 7 放到排序后数组的正确位置):

第二步:比较 j 当前指向的元素 171 <= 7 ;指针  i++ ,即  i = 0 ,交换 arr[i]arr[j] ,即 交换 arr[0]arr[0] (数组本身并无变化) ;然后指针  j  向右移动:

第三步:比较当前 j 指向的元素 8 和  7 (pivot),8 > 7;什么都不做;然后指针  j  向右移动:

第四步:比较当前 j 指向的元素 3 和  73 <= 7;指针  i++ ,即  i = 1 ,交换 arr[i]arr[j] ,即 交换 arr[1] = 8arr[2] = 3 ;然后指针  j  向右移动:

第五步:比较当前 j 指向的元素 9 和  79 > 7;什么都不做;然后指针  j  向右移动:

第六步:比较当前 j 指向的元素 4 和  74 <= 7;指针  i++ ,即  i = 2 ,交换 arr[i]arr[j] ,即交换 arr[2] = 8arr[4] = 4 ;然后指针  j  向右移动:

第七步:比较当前 j 指向的元素 5 和  75 <= 7;指针  i++ ,即  i = 3 ,交换 arr[i]arr[j] ,即交换 arr[3] = 9arr[5] = 5 ;然后指针  j  向右移动:

第八步:比较当前 j 指向的元素 4 和  74 <= 7;指针  i++ ,即  i = 4 ,交换 arr[i]arr[j] ,即交换 arr[4] = 8arr[6] = 4 ;然后指针  j  向右移动:

第九步:此时遍历结束,交换 arr[i+1]arr[high] = pivot ,即交换 97

此时第一趟快速排序结束啦,我们确定了最开始选择的 pivot 的正确位置。

接下就是分别对 7 左侧比 7 小的元素 [1,3,4,5,4] ,与右侧比 7 大的元素进行快速排序,过程和第一趟排序过程一样,此处不再赘述。

前面提到快速排序和归并排序一样均属于分治算法,而我之前在写归并排序时提到过,分治与递归就是一个孪生兄弟,提到了分治,怎能缺少递归呢?

递归三要素中最核心的就是确定一个函数的功能,而我们经过上面对一趟快速排序的介绍,可以发现,之后的每一趟快速排序事实上和第一趟是一样的,也就意味着反复调用同一个函数存在,即快速排序的过程中蕴含了递归思想,这也是分治的一个佐证。

但是我们也可以有更清晰的解释,且看下图:

首先根据原始数组 [1,8,3,9,4,5,4,7] ,将数组划分为小于等于 7 的数组 [1,3,4,5,4][8,9] ,然后将  [1,3,4,5,4] 根据 4 划分为 [1,3,4][5] ;将  [1,3,4] 根据 4 划分为 [1,3] ;将 [1,3] 根据 3 划分为 [1] ;将 [8,9] 根据 9 划分为 [8] ;这个过程不就是二分吗?

的确如此,只不过对于这个数组而言选择最末尾的元素作为 pivot 得到的树的高度并不是我们期望的 ,而是 4

说到这里,我们顺带说一下快速排序的缺点,对于一个有序数组 [1,3,4,4,5,7,8,9] 而言,如果每次选择最后一个元素作为 pivot ,就会得到下面一幅图:

而这时树的高度变成了 ,也就意味着快速排序退化成了一颗单链,这不是我们希望看到的。但是我们每一次选择最中间的元素作为 pivot ,又会怎么样呢?

思考: 如果将数组 [1,8,3,9,4,5,4,7] 重新调整顺序,使得快速排序的的分治过程如上图所示?

看着图就能写出来了,当然答案可能有很多个,最简单的一个就是 [1,4,3,5,8,9,7,4]

快速排序实现代码

class QuickSort 

    /*将最后一个元素作为 pivot 进行划分操作*/
    int partition(int arr[], int low, int high) 
    

        int pivot = arr[high];  
        int i = (low-1); // 比 pivot 小的元素的下标
        for (int j = low; j < high; j++) 
        { 
            // 如果当前的元素小于 pivot 
            if (arr[j] < pivot) 
            { 
                i++; 
                // 交换 arr[i] 和 arr[j]
                int temp = arr[i]; 
                arr[i] = arr[j]; 
                arr[j] = temp; 
            } 
        } 
        // 交换 arr[i+1] 和 arr[high] (也就是pivot) 
        int temp = arr[i+1]; 
        arr[i+1] = arr[high]; 
        arr[high] = temp; 
  
        return i+1
    } 
  
  
    /*分的阶段,利用递归调用实现快速排序*/
    void sort(int arr[], int low, int high) 
    

        if (low < high) 
        { 
            /* pi 就是 pivot 排序后位置的下标*/
            int pi = partition(arr, low, high); 
  
            // 递归调用 pivot 的前后数组
            sort(arr, low, pi-1); 
            sort(arr, pi+1, high); 
        } 
    } 
  
    /* 打印输出 */
    static void printArray(int arr[]) 
    

        int n = arr.length; 
        for (int i=0; i<n; ++i) 
            System.out.print(arr[i]+" "); 
        System.out.println(); 
    } 
  
    // 主函数 
    public static void main(String args[]) 
    

        int arr[] = {1,8,3,9,4,5,4,7}; 
        int n = arr.length; 
  
        QuickSort ob = new QuickSort(); 
        ob.sort(arr, 0, n-1); 
  
        System.out.println("sorted array"); 
        printArray(arr); 
    } 

复杂度分析

时间复杂度分析

快速排序的时间通常表示为:

其中 分别表示递归调用,而最后一项 表示 partition() 的处理过程, 表示比 pivot 小的元素的数目。

而快速排序的时间复杂度取决于输入的数组和划分策略,所以需要从三个方面分析:

一、最坏情况

最后情况就是我们每一次选择最大的元素或者最小的元素作为 pivot 。以我们上面讲快速排序选择做末尾的元素作为 pivot,最坏情况就是输入的待排序数组为有序数组(以升序为例),此时  ,那么:

,即: ;

所以最坏情况下的时间复杂度为 量级。

不理解推导没关系,看栗子,设对有序数组  [1,3,4,4,5,7,8,9] 进行快速排序,每次选择最末尾的元素作为 pivot,那么就会得到下图所示的情况:

也就说需要选择 个 pivot,并且以每一个 pivot 进行划分需要 的时间,那么总的时间就是 量级。

二、最好情况

当划分过程中每一次都能选择最中间的元素作为基准 pivot ,那么快速排序的时间复杂度就等于:

其中 表示快速排序的时间复杂度, 表示划分出的两个子数组排好序所用的时间, 表示 partion() 函数的执行时间。

根据主定理(Master Theorem),快速排序最好情况下的时间复杂度为 .

主定理不明白的可以看一下 图解「归并排序」算法(修订版)

当然我们也可以换一个角度来算,比如对数组  [1,8,3,9,4,5,4,7] 而言,我们希望得到的是下面一幅图:

这个树的高度就是 ,也就是选择 pivot 需要 次,而根据每一个 pivot 我们需要 的时间执行 partition() 函数,所以总的时间复杂度为 量级。

三、平均情况

对于平均时间复杂度分析而言,我们需要考虑数组的所有可能的排列,并计算出对每一个排列所需要的时间,然后求平均,但是实在太复杂了。我们可以考虑一个一般的假设,比如对于一个数组而言, 的元素每次比选择的 pivot 小,而 的元素比 pivot 大,那么快速排序的时间复杂度为:

.

根据主定理,快速排序的时间复杂度依旧是 , 也就意味着只要只要每一次不是选择最大或者最小的元素作为 pivot ,时间复杂度都在 量级。

快速排序的平均时间复杂度为 量级。

空间复杂度分析

快速排序的实现中,我们仅使用了一个临时变量用于交换操作,也就是其空间复杂度为 ,所以快速排序也是一个原地排序算法。

稳定性分析

快速排序的划分阶段会进行交换操作,而这种交换操作会破坏原始数组中元素之间的相对位置,也就意味着,快速排序是一个不稳定的排序算法。

当然所有的排序算法都可以变得稳定,有机会我们再谈稳定的快速排序如何实现。

快速排序 vs 归并排序

一、对数组中元素的划分

在归并排序中,数组总被划分为两半(即 );而快速排序,数组可能被划分为任意比例(比如之前提到的 ),而不是强制要求将数组划分为相等的两部分。

二、最坏时间复杂度

快速排序最坏情况下的时间复杂度为 ,而归并排序,最坏情况和平均情况下的时间复杂度均为

三、对数据的敏感性

归并排序适用于任何类型的数据集,不受数据集大小限制;而快速排序不适用于大规模数据集(简单来说,就是数组太大,快速排序效果并不好)。

四、空间复杂度

归并排序需要额外的存储空间 ,不是一个原地排序算法;而快速排序不需要额外的空间,空间复杂度为 ,为原地排序算法。

五、效率

归并排序在大规模的数据集上比快速排序更加高效,而快速排序在小规模的数据集上更高效。具体这个规模的界定,在 Java 的 sort 中有一个参考的界定。

六、排序方法

快速排序是一个内部排序算法,所有的数据都存储在主存当中;而归并排序是一个外部排序算法,待排序的数据无法容纳在主存中,而且需要额外的存储空间进行辅助合并。

七、稳定性

归并排序是一个稳定的排序算法,因为两个等值元素在排序前后的相对位置不会发生变化;快速排序不是一个稳定的排序算法,但是可能通过调整代码让其变得稳定。

八、对数组和链表的敏感度

快速排序更适用于数组排序,而归并排序两者皆适合。

综合来看,尽管快速排序最坏情况下的时间复杂度为 ,比归并排序最坏情况下的时间复杂度高。但是在实际应用中,快速排序排序更快,因在大多数的真实数据中,快速排序可以以更高效的方式实现。快速排序也可以通过改变 pivot 的选择方式而实现不同的版本,最坏情况其实很少发生。当然,归并排序在大规模和存储在外部存储器的数据更为适合。

推荐阅读:

图解「归并排序」算法(修订版)

漫画:什么是树状数组?

图解:什么是AVL树?

作者:景禹,一个求极致的共享主义者,想带你一起拥有更美好的生活,化作你的一把伞。

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存