经典排序算法

1,091 阅读26分钟

知识框架

image.png

排序算法概念

算法稳定性

如果待排序表中有两个元素 r1 和 r2,其对应的关键字 key1 = key2,使用某一排序算法后,r1 仍然在 r2 的前面,则这个算法是稳定的,否则是不稳定的。

排序算法的分类

在排序的过程中,根据数据元素是否完全在内存中,可将排序算法分为两类:

内部排序

内部排序是指在排序期间元素全部存放在内存中的排序。一般情况下,内部排序算法在执行过程中都要进行两种操作:比较和移动。通过比较两个关键字,确定对应的元素的前后关系,然后通过移动元素以达到有序。当然,并不是所有的内部排序算法都要基于比较操作,事实上,基数排序就不是基于比较的。 内部排序算法的性能取决于算法的时间复杂度和空间复杂度,而时间复杂度一般是由比较和移动的次数来决定的。

外部排序

外部排序是指在排序期间元素无法全部同时存放在内存中,必须在排序的过程中根据要求不断地在内、外存之间移动的排序。

内部排序

简单排序

选择排序

算法逻辑

一种最简单的排序算法是这样的:首先,找到数组中最小的那个元素,其次,将它和数组的第一个元素交换位置(如果第一个元素就是最小元素那么它就和自己交换)。再次,在剩下的元素中找到最小的元素,将它与数组的第二个元素交换位置。如此往复,直到将整个数组排序。这种方法叫做选择排序,因为它在不断地选择剩余元素之中的最小者。(即 先把最小的拿出来,然后把剩下的元素中在把最小的拿出来...)

849589-20171015224719590-1433219824.gif

源码

/**
 * @brief 交换数组中指定位置的两个元素
 *
 * @param i 目标元素
 * @param j 目标元素
 * @param targetArrary  目标数组
 */
static void MySwap(int i, int j, int *targetArrary)
{
    assert(i >= 0 && j >= 0);

    int temp = targetArrary[i];
    targetArrary[i] = targetArrary[j];
    targetArrary[j] = temp;
}

/**
 * @brief 选择排序
 *
 * @param targetArray 待排序的数组
 * @param arrarySize  数组大小
 */
static void SelectionSort(int *targetArray, int arrarySize)
{

    /*
     * j是遍历的索引,开始将 j = i;j开始向后不断的遍历,如果最小的元素就是 i,
     * 那么自己和自己交换下位置,
     * targetArrary[i ... arrarySize) 中最小的元素放在 targetArrary[i]的位置上
     * 但是不是自己,将 minIndex指向的元素和 I 指向的元素交换位置;
     *
     * targetArrary[0 ... i) 表示已经有序
     */
    for (int i = 0; i < arrarySize; ++i)
    {
        int minIndex = i;

        /* targetArrary[i ... arrarySize) 是无序状态 */
        for (int j = i; j < arrarySize; ++j)
        {

            /* 寻找最小的元素 */
            if (targetArray[j] < targetArray[minIndex])
            {
                /* 更新最小元素的index */
                minIndex = j;
            }
        }

        /* 当最小值就是自己时,不需要进行数据的交换 */
        if (i != minIndex)
            MySwap(i, minIndex, targetArray);
    }
}

时间复杂度

如下图所示,选择排序的内循环只是在比较当前元素与目前已知的最小元素(以及将当前索引加1和检查是否代码越界),这已经简单到了极点。交换元素的代码写在内循环之外,每次交换都能排定一个元素,因此交换的总次数是N。所以算法的时间效率取决于比较的次数。 image.png

  • 对于长度为N的数组,选择排序需要大约N2/2N^2/2次比较和N次交换

证明:可以通过算法的排序轨迹来证明这一点。我们用一张NxN的表格来表示排序的轨迹(见上图),其中每个非灰色字符都表示一次比较。表格中大约一半的元素不是灰色的-即对角线和其上部分的元素。对角线上的每个元素都对应着一次交换。通过查看代码我们可以更精确地得到,0到N-1的任意i都会进行一次交换和N-1-i次比较,因此总共有N次交换以及(N-1)+(N-2)+···+2+1=N(N-1)/2~N2/2N^2/2次比较。

  • 通过代码分析可知时间复杂度为 O(N2N^2)

1 + 2 + 3 + ... + n = 12n2\frac 12n^2 + 12\frac 12n

算法特点

总的来说,选择排序是容易理解和实现的简单算法,它有两个鲜明的特点:

  • 运行时间和输入无关

为了找出最小的元素而扫描一遍数组并不能为下一遍扫描提供什么信息。这种性质在某些情况下是缺点,因为使用选择排序的人可能会惊讶地发现,一个已经有序的数组或是主键全部相等的数组和一个元素随机排列的数组所用的排序时间竟然一样长!我们将会看到,其他算法会更善于利用输入的初始状态。

  • 数据移动是最少

每次交换都会改变两个数组元素的值,因此选择排序用了N次交换-交换次数和数组的大小是线性关系。我们将研究的其他任何算法都不具备这个特征(大部分的增长数量级都是线性对数或是平方级别)。

插入排序

算法逻辑

通常我们整扑克牌的方法是一张一张的来,将每一张牌插入到其他已经有序的牌中的适当位置。在计算机的实现中,为了给要插入的元素腾出空间,我们需要将其余所有元素在插入之前都向右移动一位,这种算法叫做插入排序。

如下图,对于1到N-1之间的每一个i,将a[i]与a[0]到a[i-1]中比它小的所有元素依次有序地交换。在索引i由左向右变化的过程中,它左侧的元素总是有序的,所以当i到达数组的右端时排序就完成了。 插入排序.gif

源码

/**
 * @brief 插入排序
 *
 * @param targetArray 待排序的数组
 * @param arraySize 数组大小
 */
static void InsertionSort(int *targetArray, int arraySize){

    /*
     * targetArrary[0 ... i) 表示已经有序
     * targetArrary[i ... n) 表示未排序
     * j是遍历的索引,从i的位置向前检索
     *
     * Note: i = 0 时已排序的数组为空
     */
    for (int i = 0; i < arraySize; ++ i){

        int temp = targetArray[i];
        int j = i;
        /*
         * 将 targetArrary[i]插入到合适的位置,
         * 将 i 元素的·与 i 前面已经排好序的元素逐一进行比较
         */
        for (j = i; j - 1 >= 0 && temp < targetArray[j - 1]; -- j){
            /* 将前面的元素向后移动(使用移动代替元素的交换) */
            targetArray[j] = targetArray[j - 1];
        }

        /* j为当前 targetArrary[i]应该插入的正确的位置 */
        targetArray[j] = temp;
    }
}

时间复杂度

如下图,与选择排序一样,当前索引左边的所有元素都是有序的,但它们的最终位置还不确定,为了给更小的元素腾出空间,它们可能会被移动。但是当索引到达数组的右端时,数组排序就完成了。

和选择排序不同的是,插入排序所需的时间取决于输入中元素的初始顺序。例如,对一个很大且其中的元素已经有序(或接近有序)的数组进行排序将会比对随机顺序的数组或是逆序数组进行排序要快得多。 image.png

  • 对于随机排列的长度为N且主键不重复的数组,平均情况下插入排序需要~N2/4N^2/4次比较以及 ~N2/4N^2/4次交换。最坏情况下需要~N2/2N^2/2次比较和~N2/2N^2/2次交换,最好情况下需要N-1次比较和0次交换。(通过代码分析时间也为 O(n2)(n^2))

证明: 通过一个NxN的轨迹表可以很容易就得到交换和比较的次数。最坏情况下对角线之下所有的元素都需要移动位置,最好情况下都不需要。对于随机排列的数组,在平均情况下每个元素都可能向后移动半个数组的长度,因此交换总数是对角线之下的元素总数的二分之一。

比较的总次数是交换的次数加上一个额外的项,该项为N减去被插入的元素正好是已知的最小元素的次数。在最坏情况下(逆序数组),这一项相对于总数可以忽略不计;在最好情况下(数组已经有序),这一项等于N-1。

  • 插入排序需要的交换操作和数组中倒置的数量相同,需要的比较次数大于等于倒置的数量,小于等于倒置的数量加上数组的大小再减一。

证明:每次交换都改变了两个顺序颠倒的元素的位置,相当于减少了一对倒置,当倒置数量为0时,排序就完成了。每次交换都对应着一次比较,且1到N-1之间的每个i都可能需要一次额外的比较(在a[i]没有达到数组的左端时)。

倒置指的是数组中的两个顺序颠倒的元素

算法特点

考虑一般的情况是部分有序的数组。比如EXAMPLE中有11对倒置:E-A、X-A、X-M、X-P、X-L、X-E、M-L、M-E、P-L、P-E以及L-E。如果数组中倒置的数量小于数组大小的某个倍数,那么我们说这个数组是部分有序的。下面是几种典型的部分有序的数组:

  • 数组中每个元素距离它的最终位置都不远;
  • 一个有序的大数组接一个小数组;
  • 数组中只有几个元素的位置不正确;

插入排序对这样的数组很有效,而选择排序则不然。事实上,当倒置的数量很少时,插入排序很可能比本章中的其他任何算法都要快。

总的来说,插入排序对于部分有序的数组十分高效,也很适合小规模数组。这很重要,因为这些类型的数组在实际应用中经常出现,而且它们也是高级排序算法的中间过程。

选择 VS 插入

选择排序:在已经排好的有序部分的元素已经是最终的结果;

插入排序:在已经排好的有序部分的元素不是最终的结果,而是暂时的排序结果;

image.png

  • 插入排序对于有序数组来说,时间复杂度是下降到 O(n), 但一般情况下算法时间复杂度还是 O(n2n^2)
  • 选择排序时间复杂度永远是 O(n2n^2)

归并排序

算法逻辑

将两个有序的数组归并成一个更大的有序数组。很快人们就根据这个操作发明了一种简单的递归排序算法:归并排序。要将一个数组排序,可以先(递归地)将它分成两半分别排序,然后将结果归并起来。你将会看到,归并排序最吸引人的性质是它能够保证将任意长度为N的数组排序所需时间和NlogNNlogN成正比;它的主要缺点则是它所需的额外空间和N成正比。 image.png

image.png

mergeSort2.gif

归并过程

/* 归并排序的伪代码 */
MergeSort(arr, l, r){

/* 待处理的元素为空或不需要再进行排序处理 */
if(l >= r) 
    return;
    
/* 计算数组的中间位置 */
int mid = (l + r) / 2;

/* 对 arr[l,mid] 进行排序 */
MergeSort(arr, l, mid);

/* 对 arr[mid + 1,r]进行排序 */
MergeSort(arr, mid + 1, r);

/* 将arr[l,mid]和arr[mid + 1,r]合并 */
merge(arr, l, mid, r);
}

mergesort.gif

为什么要进行原地归并?

实现归并的一种直截了当的办法是将两个不同的有序数组归并到第三个数组中,实现的方法很简单,创建一个适当大小的数组然后将两个输入数组中的元素一个个从小到大放入这个数组中。 但是,当用归并将一个大数组排序时,我们需要进行很多次归并,因此在每次归并时都创建一个新数组来存储排序结果会带来问题。我们更希望有一种能够在原地归并的方法,这样就可以先将前半部分排序,再将后半部分排序,然后在数组中移动元素而不需要使用额外的空间。

源码

归并排序的代码实现可以分为递归方法实现(自顶向下)或是非递归方法实现(自底向上),实现的核心思想是相同的;总的来说还是递归的方式实现起来更容易理解。

自顶向下(递归)

/***
 * 合并两个已经有序的数组到目标数组中,使其整体有序
 * @param dest 目标数组
 * @param source 数据源
 * @param l 数组的最小下标
 * @param h 数组的最大下标
 * @param mid 数据源数组的分界下标,其左右两边的数组都是有序的
 */
static void merge(int *temp, int *source, int l, int h, int mid){

    /* i 和 j 在归并过程中数组的下标 */
    int i = l;
    int j = mid + 1;

    /*
     * COPY 指定范围的数组到temp数组作为临时数组使用
     * temp的内存空间要大于等于source的内存空间(一次性开辟,避免多次申请内存)
     * 保证temp和source在[l ... h]范围内的元素相同
     */
    for (int i = l; i <= h; ++i)
        temp[i] = source[i];

    /* 将 source[l ... mid] 和 source[mid + 1 ... h] 归并成一个整体有序的数组*/
    for (int k = l; k <= h; ++k){

        /* 左边的数组元素已经处理完成,将右边的数组元素全部赋值到目标数组即可 */
        if (i > mid)
            source[k] = temp[j ++];
        /* 右边数组元素已经全部处理完成,将左边的数组元素全部赋值到目标数组即可 */
        else if (j > h)
            source[k] = temp[i ++];
        /* 将左边数组元素归并到目标数组 */
        else if (source[i] < temp[j])
            source[k] = temp[i ++];
        /* 将右边数组归并到目标数组 */
        else
            source[k] = temp[j ++];
    }
}

/**
 * 递归算法实现的归并排序
 * @param dest 最终有序的目标数组
 * @param source 待排序的数组
 * @param l
 * @param mid
 * @param h
 */
static void mergeSort(int *temp, int *source, int l, int h){

    if (l >= h)
        return;

    /* (h + l)/2 的计算方式可能超过类型的最大值 */
    int  mid = l + (h - l) / 2;

    /* 排序左边的数组 */
    mergeSort(temp, source, l, mid);
    /* 排序右边的数组 */
    mergeSort(temp, source, mid + 1, h);

    /* 将有序的两个数组merge成一个整体有序的数组 */
    merge(temp, source, l, h, mid);
}

递归归并排序的执行过程

假设数组a[]如下: image.png 要将a[0...15]排序,sort()方法会调用自己将a[0...7]排序,再在其中调用自己将a[0...3]和a[0...1]排序。在将a[0]和a[1]分别排序之后,终于才会开始将a[0]和a[1]归并(简单起见,我们在轨迹中把对单个元素的数组进行排序的调用省略了)。第二次归并是a[2]和a[3],然后是a[0...1]和a[2...3],以此类推。从这段轨迹可以看到,sort()方法的作用其实在于安排多次merge()方法调用的正确顺序。

image.png

算法复杂度

如下图所示,表示数组a[16]递归的树状图。每个结点都表示一个sort()方法通过merge()方法归并而 成的子数组。这棵树正好有n层。对于0到n-1之间的任意k,自顶向下的第k层有2k2^k个子数组,每个数组的长度为2nk2^{n-k},归并最多需要2nk2^{n-k}次比较。因此每层的比较次数为2k2^k*2nk2^{n-k}2n2^n,n 层总共为n2n2^n = NlgN。

image.png

  • 对于长度为N的任意数组,自顶向下的归并排序需要12NlgN\frac{1}{2}NlgNNlgNNlgN次比较。

证明:令C(N)表示将一个长度为N的数组排序时所需要的比较次数。我们有C(0)=C(1)=0,对于 N>0.通过递归的sort()方法我们可以由相应的归纳关系得到比较次数的上限:

C(N) \leq C(N/2\lceil N/2 \rceil)+ C(N/2\lfloor N/2 \rfloor) + N

右边的第一项是将数组的左半部分排序所用的比较次数,第二项是将数组的右半部分排序所用的比较次 数,第三项是归并所用的比较次数,因为归并所需的比较次数最少为N/2\lfloor N/2 \rfloor(比较左边或右边的数组元组),比较次数的下限是:

C(N) \geq C(N/2\lceil N/2 \rceil)+ C(N/2\lfloor N/2 \rfloor) + N

当N为2的幂(即N=2n2^n)且等号成立时我们能够得到一个解。首先,因为N/2\lceil N/2 \rceilN/2\lfloor N/2 \rfloor2n12^{n-1},可以得到:C(2n2^n) = 2(C(2n12^{n-1})) + 2n2^n

将两边同时除以2n2^n可得:C(2n2^n)/2n2^n = C(2n12^{n-1})/2n12^{n-1} + 1

用这个公式替换右边的第一项可得: C(2n2^n)/2n2^n = C(2n22^{n-2})/2n22^{n-2} + 1 + 1

将上一步重复n-1遍可得: C(2n2^n)/2n2^n = C(202^0)/202^0 + n

将两边同时乘以2n 2^n可得: C(N) = C(2n2^n) =n2n n2^n =NlgN NlgN

对于一般的N,得到的准确值要更复杂一些。但对比较次数的上下界不等式使用相同的方法不难证明前面所述的对于任意N的结论。这个结论对于任意输入值和顺序都成立。

  • 对于长度为N的任意数组,自顶向下的归并排序最多需要访问数组6NlgN6NlgN次。

证明:每次归并最多需要访问数组6N次(2N次用来复制,2N次用来将排好序的元素移动回去,另外 最多比较2N次,因为递归方法实现的mergeSort()中分别对数组的左右分别排序)。

优化
  • 可以减少在merge过程对数组元素的COPY
/**
 * @brief 将指定范围内的原数组归并到目标数组中
 *
 * @param dest
 * @param source
 * @param l
 * @param h
 * @param mid
 */
static void merge2(int *dest, int *source, int l, int h, int mid){

    /* i 和 j 在归并过程中数组的下标 */
    int i = l;
    int j = mid + 1;

    /* 将 source[l ... mid] 和 source[mid + 1 ... h] 归并成一个整体有序的数组*/
    for (int k = l; k <= h; ++k){

        /* 左边的数组元素已经处理完成,将右边的数组元素全部赋值到目标数组即可 */
        if (i > mid)
            dest[k] = source[j ++];
        /* 右边数组元素已经全部处理完成,将左边的数组元素全部赋值到目标数组即可 */
        else if (j > h)
            dest[k] = source[i ++];
        /* 将左边数组元素归并到目标数组 */
        else if (source[i] < source[j])
            dest[k] = source[i ++];
        /* 将右边数组归并到目标数组 */
        else
            dest[k] = source[j ++];
    }
}

/**
 * @brief 在mergeSort基础上进行优化,以节省将数组元素复制到用于归并的辅助数组所用的时间(但空间不行)。
 *        要做到这一点我们要调用两种排序方法:
 *          一种将数据从输入数组排序到辅助数组,
 *          一种将数据从辅助数组排序到输入数组。
 *
 * @param dest
 * @param source
 * @param l
 * @param h
 *
 * NOTE:使用 dest 表示原数组, source表示要排序的目标数组,
 *      使用一次性复制数组的优化后,要在递归调用的每个层次交换输入数组和辅助数组的角色.
 */
static void mergeSort2(int *dest, int *source, int l, int h){

    if (l >= h)
        return;

    int  mid = l + (h - l) / 2;

    /* 排序左边的数组 */
    mergeSort2(source, dest, l, mid);
    /* 排序右边的数组 */
    mergeSort2(source, dest, mid + 1, h);

    merge2(dest, source, l, h, mid);
}

  • 在数组已经有序的情况下,归并排序的时间复杂度降为O(n)
/**
 * 在原始的mergeSort进行了优化,如果数组整体有序时不在需要进行merge操作
 * @param temp 最终有序的目标数组
 * @param source 待排序的数组
 * @param l
 * @param mid
 * @param h
 */
static void mergeSort3(int *temp, int *source, int l, int h){

    if (l >= h)
        return;

    /* (h + l)/2 的计算方式可能超过类型的最大值 */
    int  mid = l + (h - l) / 2;

    /* 排序左边的数组 */
    mergeSort3(temp, source, l, mid);
    /* 排序右边的数组 */
    mergeSort3(temp, source, mid + 1, h);

    /* 
     * 将有序的两个数组merge成一个整体有序的数组,
     * 如果数组已经有序不在需要merge,时间复杂度为O(n)
     */
    if (source[mid] > source[mid + 1])
        merge(temp, source, l, h, mid);
}

证明:数组有序的情况下,根据上面的代码可知,sort()过程只是处理数组递归成的树的每个节点,不在需要merge过程,那么根据sort()实现逻辑递归构建的树的最下面一层的叶子节点的数量就是数组元素的总个数,倒数第二层节点的个数就是N/2\lceil N/2 \rceil个,以此类推,直到根节点。所以节点的总数量就是:

1 + N/2 + N/4 ... N = N2\frac{N}{2}(1-(12)m){\frac{1}{2}})^m)/12\frac{1}{2} < N = O(n)

自低向上(非递归)

递归实现的归并排序是算法设计中分治思想的典型应用。我们将一个大问题分割成小问题分别解决,然后用所有小问题的答案来解决整个大问题。尽管我们考虑的问题是归并两个大数组,实际上我们归并的数组大多数都非常小。
实现归并排序的另一种方法是先归并那些微型数组,然后再成对归并得到的子数组,如此这般,直到我们将整个数组归并在一起。这种实现方法比标准递归方法所需要的代码量更少。首先我们进行的是两两归并(把每个元素想象成一个大小为1的数组),然后是四四归并(将两个大小为2的数组归并成一个有4个元素的数组),然后是八八的归并,一直下去。在每一轮归并中,最后一次归并的第二个子数组可能比第一个子数组要小(但这对merge0)方法不是问题),如果不是的话所有的归并中两个数组大小都应该一样,而在下一轮中子数组的大小会翻倍.如下图所示: image.png merge-down-up.gif


/***
 * 合并两个已经有序的数组到目标数组中,使其整体有序
 * @param dest 目标数组
 * @param source 数据源
 * @param l 数组的最小下标
 * @param h 数组的最大下标
 * @param mid 数据源数组的分界下标,其左右两边的数组都是有序的
 */
static void merge(int *temp, int *source, int l, int h, int mid){

    /* i 和 j 在归并过程中数组的下标 */
    int i = l;
    int j = mid + 1;

    /*
     * COPY 指定范围的数组到temp数组作为临时数组使用
     * 保证temp和source在[l ... h]范围内的元素相同
     */
    for (int i = l; i <= h; ++i)
        temp[i] = source[i];

    /* 将 source[l ... mid] 和 source[mid + 1 ... h] 归并成一个整体有序的数组*/
    for (int k = l; k <= h; ++k){

        /* 左边的数组元素已经处理完成,将右边的数组元素全部赋值到目标数组即可 */
        if (i > mid)
            source[k] = temp[j ++];
        /* 右边数组元素已经全部处理完成,将左边的数组元素全部赋值到目标数组即可 */
        else if (j > h)
            source[k] = temp[i ++];
        /* 将左边数组元素归并到目标数组 */
        else if (source[i] < temp[j])
            source[k] = temp[i ++];
        /* 将右边数组归并到目标数组 */
        else
            source[k] = temp[j ++];
    }
}

/**
 * @brief 归并排序自底向上的实现方式
 *
 * @param temp merge过程使用的临时数组
 * @param source 待排序的数组
 * @param l 数组的最小下标
 * @param h 数组的最大下标
 * @param arrSize 数组大小
 */
static void mergeSortUP(int *temp, int *source, int l, int h, int arrSize){

    /*
     * sz 表示合并数组的大小(已经有序),终止条件表示当前合并数组的大小是否大于等于目标数组的大小
     * 小于数组的大小,合并区间的长度小于数组的大小,还需要继续进行
     * 合并区间长度大于等于arrSize时,变数整个数组已经有序
     */
    for (int sz = 1; sz < arrSize; sz += sz){

        /*
         * i += sz + sz : 每次处理(合并)两个sz大小数组,将其进行merge
         * source[i,i + sz - 1]  source[i + sz, i + sz + sz - 1]
         * 循环继续条件表示:i + sz < arrSize 存在第二个数组,此时第一个数组肯定存在,这两个空间需要进行合并
         * i + sz 表示第二个数组的起始的index
         * i 表示遍历合并的两个区间的起始位置,所以每次向后移动两个sz的大小
         */
        for (int i = 0; i + sz < arrSize; i += sz + sz){
            /*
             * 优化: source[i,i + sz - 1]  source[i + sz, i + sz + sz - 1] 两个数组是分别有序的,
             * 只有 source[i + sz - 1] > source[i + sz] 进行 merge才有意义
             */
            if(source[i + sz - 1] > source[i + sz]){
                /*
                 * 只能保证 i + sz  没能越界,但是不能保证最后的区间还包含 sz 个元素,
                 * 可能不足 sz 个元素(即 i + sz + sz - 1 可能越界)
                 * 当不足 sz 个元素时,用实际的长度作为数组的最后的边界
                 */
                merge(temp, source, i,
                      (i + 2 * sz - 1 <= arrSize - 1 ? i + 2 * sz - 1 : arrSize - 1),
                      i + sz - 1);
            }
        }
    }
}
算法复杂度
  • 对于长度为N的任意数组,自底向上的归并排序需要12NlgN\frac{1}{2}NlgNNlgNNlgN次比较,最多访问数组 6NlgN次。

证明: 处理一个数组的遍数正好是lgN\lceil lgN \rceil2n2^n<=N中的n,因为子数组大小的初始值为1,每次加倍)。每一遍会访问数组6N次,比较次数在N/2N/2NN之间。

快速排序

它可能是应用最广泛的排序算法了。快速排序流行的原因是它实现简单、适用于各种不同的输入数据且在一般应用中比其他排序算法都要快得多。快速排序引人注目的特点包括它是原地排序(只需要一个很小的辅助栈),且将长度为N的数组排序所需的时间和NlgNNlgN成正比。另外,快速排序的内循环比大多数排序算法都要短小,这意味着它无论是在理论上还是在实际中都要更快。

算法原理

快速排序是一种分治的排序算法。它将一个数组分成两个子数组,将两部分独立地排序。快速排序和归并排序是互补的:归并排序将数组分成两个子数组分别排序,并将有序的子数组归并以将整个数组排序;而快速排序将数组排序的方式则是当两个子数组都有序时整个数组也就自然有序了。在第一种情况中,递归调用发生在处理整个数组之前;在第二种情况中,递归调用发生在处理整个数组之后。在归并排序中,一个数组被等分为两半;在快速排序中,切分(partition)的位置取决于数组的内容(切分元素左面的数组元素不大于切分元素,右面数组元素不小于切分元素)。快速排序的大致过程如下图所示:

image.png

快排的切分

通过上面快排的原理发现,要完快排首先需要实现切分方法。[假设所有的元素都不相同]一般策略是先随意地取arr[lo]作为切分元素(v表示),即那个将会被排定的元素,然后我们逐渐遍历右边没有被访问的元素,在遍历的过程中整理数组,使其一部分是小于arr[lo]的,另一部分是大于arr[lo];其中大于v和小于v的分界点使用j表示(即arr[j]),当前正在访问的元素使用i表示(即arr[i]为当前正在访问的元素)。如下图所示:

image.png

partition1.gif

通过上面快排的原理发现,要完快排首先需要实现切分方法。一般策略是先随意地取arr[lo]作为切分元素,即那个将会被排定的元素,然后我们从数组的左端开始向右扫描直到找到一个大于等于它的元素,再从数组的右端开始向左扫描直到找到一个小于等于它的元素。这两个元素显然是没有排定的,因此我们交换它们的位置。如此继续,我们就可以保证左指针i的左侧元素都不大于切分元素,右指针j的右侧元素都不小于切分元素。当两个指针相遇时,我们只需要将切分元素arr[lo]和左子数组最右侧的元素(arr[j])交换然后返回j即可。切分方法的大致过程如下图所示。

image.png

切分实现
/**
 * @brief 交换数组中i和j两个位置的元素
 *
 * @param arr
 * @param i
 * @param j
 */
static void swap(int *arr, int i, int j){

    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}


/**
 * @brief 将数组指定范围内的元素切分成小于切分元素和大于等于切分元素两部分
 *
 * @param arr 数据源
 * @param lo 数组最小下标
 * @param hi 数组最大下标
 * @return int 返回切分元素的数组下标位置
 */
static int partition(int *arr, int lo, int hi){

    int j = lo;
    /*
     * arr[l + 1 ... j] < v , arr[j + 1 ... i - 1] >= v
     * 默认数组的第一个元素作为数组的切分元素
     * i 表示当前正在处理的元素下标,j 表示小于v的数组元素最大下标
     */
    for (int i = lo + 1; i <= hi; ++i){

        /*
         * 当前元素小于v,将小于v的j向右移动1位,然后交换当前元素和
         * arr[j](原arr[j + 1])位置的元素
         */
        if (arr[i] < arr[lo]){
            ++j;
            swap(arr, i, j);
        }
    }

    /*
     * 交换切分元素和arr[j]元素的位置,使数组arr[lo ... j - 1] < v
     * arr[j + 1 ... hi] >= v
     */
    swap(arr, lo, j);

    /* j指向的是切分元素的位置 */
    return j;
}

切分过程

这段代码按照arr[lo]的值v进行切分。在for循环中,arr[i]小于v时我们增大j,然后交换arr[j]和arr[i],arr[i]大于等于v时我们进行 i++,当i遍历到数组最后位置时,切分结束,最后交换切分元素和arr[j]的元素,来保证i左侧的元素都小于v,j右侧的元素都不小于v。如下图的例子:

partition_example.gif

快排的实现

/**
 * @brief 快速排序的递归实现
 *
 * @param arr 待排序的数组
 * @param lo 数组的最小下标
 * @param hi 数组的最大下标
 */
static void quickSort(int *arr, int lo, int hi){

    /* 待处理的数组大小为 1(一个元素不在需要排序,本身已经有序) */
    if (lo >= hi)
        return;

    /* 获取切分元素的数组下标,arr[p]本身已经有序 */
    int p = partition(arr, lo, hi);

    /* 对 arr[p] 左面的数组元素进行排序 */
    quickSort(arr, lo, p - 1);

    /* 对 arr[p] 右面的数组元素进行排序 */
    quickSort(arr, p + 1, hi);
}

我们就是通过递归地调用切分来排序的。因为切分过程总是能排定一个元素,用归纳法不难证明递归能够正确地将数组排序:如果左子数组和右子数组都是有序的,那么由左子数组(有序且没有任何元素大于切分元素)、切分元素和右子数组(有序且没有任何元素小于切分元素)组成的结果也一定是有序的。

快排的过程

快速排序递归地将子数组arr[lo ... hi]排序,先用partition方法将arr[j]放到一个合适位置,然后再用递归调用将其他位置的元素排序。 image.png

优化1

通过上面快排的实现过程可以发现,当输入的数组是完全有序时,算法的时间复杂度会成为 O(n2)O(n^2),递归的深度为O(n),因为当数组完全有序时,切分数组过程默认是选择的数组的第一个元素作为切分点对数组进行切分的,但是数组有序时每次总是返回数组的第一个元素,此时小于v(arr[lo])的数组部分是空,大于等于v的数组部分是N-1个元素;依此类推,整体执行的指令个数是 n+(n1)+(n2)+...1n + (n-1) + (n - 2) + ... 1

q1.gif

  • 上述的问题应该如何解决呢?

我们在切分元组时可以随机原则切分元素,而不是默认的使用数组的第一元素作为切分的元素,这样可以有效的避免上述的问题。虽然随机化以后还是存在每次都随机到数组的第一的位置作为切分元素,但是概率是极低的(几乎不可能发生): 1n1n1...1=1n!\frac {1}{n} * \frac {1}{n-1} * ... 1 = \frac{1}{n!}
当N非常小的时候,概率还是比较大的,但是如果数据量非常小时退化成O(n2)O(n^2)也是无所谓的。 随机化是找不到一个确定的测试用例使其算法退化成O(n2)O(n^2),但是如果指定数组中间或其他某个位置的元素作为切分点时是能够找到测试用例使其退化成O(n2)O(n^2)

为快排添加随机化
/**
 * @brief 将数组指定范围内的元素切分成小于切分元素和大于等于切分元素两部分
 *        增加随机化选取切分元素的过程,避免有序数组算法时间复杂度下降到O(n2)
 *
 * @param arr 数据源
 * @param lo 数组最小下标
 * @param hi 数组最大下标
 * @return int 返回切分元素的数组下标位置
 */
static int partitionOpt1(int *arr, int lo, int hi){

    /* 获取随机数组小标作为切分的元组 */
    int randP = MyRandSpecial(lo, hi);

    /* 交换arr[lo]和arr[randP]元素的位置,后面还是原始的逻辑 */
    swap(arr, lo, randP);

    int j = lo;
    /*
     * arr[l + 1 ... j] < v , arr[j + 1 ... i - 1] >= v
     * 默认数组的第一个元素作为数组的切分元素
     * i 表示当前正在处理的元素下标,j 表示小于v的数组元素最大下标
     */
    for (int i = lo + 1; i <= hi; ++i){

        /*
         * 当前元素小于v,将小于v的j向右移动1位,然后交换当前元素和
         * arr[j](原arr[j + 1])位置的元素
         */
        if (arr[i] < arr[lo]){
            ++j;
            swap(arr, i, j);
        }
    }

    /*
     * 交换切分元素和arr[j]元素的位置,使数组arr[lo ... j - 1] < v
     * arr[j + 1 ... hi] >= v
     */
    swap(arr, lo, j);

    /* j指向的是切分元素的位置 */
    return j;
}

优化2

前面介绍的快排还是存在可优化空间的,之间是假设数组的元素都是不同的,那么如果数组中的所有的元素都是相同的呢?会有什么问题吗?

  • 假设一个数组的所有的元素都是0,切分过程如下:将数组分成2部分并返回j指向的位置(该🌰为数组的第一个元素),即该切分的结果就是切分元素(arr[j])边没有任何元素而右边有 N1N-1 个元素,再次对 N1N-1 个元素进行上面相同的方式切分,依次递推不断地进行递归切分,所以算法的时间复杂度将会退化为 O(n2)O(n^2)[推算过程参考有序数组的切分]

q2.gif

可能大家还可能存在疑问,上面的切分逻辑是如果当前正在遍历的元素大于等于切分元素时将切分元素右面(arr[j+1])的区间增加一个元素(即i++操作)对于数组元素相同的情况是有问题的,那么我们将切分的逻辑改成当正在遍历的元素小于等于切分元素时增加切分元素左面的区间(即j++操作),切分的过程如下:

q2_1.gif

通过上面的切分过程可以知道改变切分的逻辑后还是存在问题的,与改变之前的区别就是之前的逻辑每次切分都返回数组的第一个元素且切分元素左面0个元素,切分元素右面N1N-1个元素;之后的逻辑是每次切分都返回数组的最后一个元素且切分元素左面是N1N-1个元素,切分元素右面是0个元素,最终的复杂度还是O(n2)O(n^2)

解决上述的问题可以使用双路快排的思想,其实双路指的就是切分数组元素的过程与之前的切分过程时存在区别的,但做的事情同样是先随机选择一个元素作为切分的元素,然后根据切分元素为界限将数组分成两部分,一部分是小于切分元素的,另外一部分是大于切分元素的,关键点是将等于切分元素的部分向办法将其平分在切分元素的两端。

双路快速排序

整体的算法思路和之前相同,不同的是数组的切分逻辑发生了变化。下面将介绍双路快排的partition的过程:

  1. 随机选取一个元素作为切分数组,然后跟数组的第一个位置的元素进行交换;
  2. 设置i和j分别作为遍历数组的变量,当arr[i]大于等于切分元素时暂不在继续向后遍历,当arr[j]小于等于切分元素时暂不向前继续遍历;
  3. 交换arr[i]和arr[j]的元素,然后分别执行 i++j-- 的操作;该交换元素的过程其实就是将等于切分元素的元素平均分在切分点的两端的过程

doubleQuickSort.gif

/**
 * @brief 如果数组中存在大量相同的元素时,使用partitionOpt1将会使算法的复杂度下降到O(n2)
 *        针对该问题的解决思路是将相同的元素平均分到切分元素的两端
 *
 * @param arr 数组源
 * @param lo 指定数组最小下标
 * @param hi 指定数组最大下标
 * @return int 切分元素的数组下标
 */
static int partitionOpt2(int *arr, int lo, int hi)
{
    /* 获取随机数组小标作为切分的元组 */
    int randP = MyRandSpecial(lo, hi);

    /* 交换arr[lo]和arr[randP]元素的位置,后面还是原始的逻辑 */
    swap(arr, lo, randP);

    /*
     * 初始时 arr[lo + 1 ... i - 1] --> arr[lo + 1, lo] 左边界大于右边界说明数组为空
     * 初始时 arr[j + 1 ... hi] --> arr[hi + 1, hi] 左边界大于右边界说明数组为空
     */
    int i = lo + 1;
    int j = hi;

    /* arr[lo + 1 ... i - 1] <= V  arr[j + 1 ... hi] >= V */
    while (true)
    {
        /* i 从前向后遍历 */
        while (i <= j && arr[i] < arr[lo])
            ++i;

        /* j 从后向前遍历 */
        while (j >= i && arr[j] > arr[lo])
            --j;

        /*
         * i > j 说明当前所有的元素都处理完了
         * i = j 说明当前还有没有处理的数组元素需要进行处理,arr[i] <= V arr[j] >= V
         * 说明i和j指向的是相同的元素,不用再处理了
         */
        if (i >= j)
            break;
        /*
         * 此时i指向了属于切分元素右端的元素,j指向了属于切分元素左端的元素
         * 交换两个元素,分别移动两个遍历的指针 i 和 j
         */
        swap(arr, i, j);

        ++i;
        --j;
    }

    /* 将切分元素放到正确的位置 */
    swap(arr, lo, j);

    return j;
}

/**
 * @brief 快速排序的递归实现
 *
 * @param arr 待排序的数组
 * @param lo 数组的最小下标
 * @param hi 数组的最大下标
 */
static void quickSort(int *arr, int lo, int hi){

    /* 待处理的数组大小为 1(一个元素不在需要排序,本身已经有序) */
    if (lo >= hi)
        return;

    /* 获取切分元素的数组下标,arr[p]本身已经有序 */
    int p = partitionOpt2(arr, lo, hi);

    /* 对 arr[p] 左面的数组元素进行排序 */
    quickSort(arr, lo, p - 1);

    /* 对 arr[p] 右面的数组元素进行排序 */
    quickSort(arr, p + 1, hi);
}
双路快排过程

partition2ways-example.gif

用双路快排处理相同元素的数组的效果如下图所示,将相同的元素分布到切分元素的两端 image.png

优化3

上面介绍的双路快排可以处理重复元素的数组,但是切分点两端都是相同的元素,因此没有必要再次处理切分完成的元素,为了避免上述的问题,我们将采用3ways快排: image.png

三路快速排序

三路快排不需要处理与切分点相同的元素,只需对小于和大于切分点的元素部分进行排序,它从左到右遍历数组一次,维护一个指针lt使得a[lo...lt-1]中的元素都小于V,一个指针gt使得a[gt+1...hi]中的元素都大于V,一个指针i使得a[lt...i-1]中的元素都等于V,a[i...gt]中的元素都还未确定,如下图所示,一开始i和lo相等,我们对a[i]进行三路比较来直接处理以下情况:

  • a[i]小于v,将 a[lt]和a[i]交换,将lt和i加一;
  • a[i]大于v,将 a[gt]和a[i]交换,将gt减一;
  • a[i]等于v,将i加一。

image.png

partition3ways-example.gif

/**
 * @brief 3路快排
 *
 * @param arr 待排序的数组
 * @param lo 数组的最小下标
 * @param hi 数组的最大下标
 */
static void quickSortBy3Ways(int *arr, int lo, int hi){

    if (lo >= hi)
        return;

    /* 获取随机数组小标作为切分的元组 */
    int randP = MyRandSpecial(lo, hi);

    /* 交换arr[lo]和arr[randP]元素的位置,后面还是原始的逻辑 */
    swap(arr, lo, randP);

    /**
     * arr[lo + 1, lt] < V
     * arr[lt + 1, i - 1] == V
     * arr[gt, hi] > V
     * arr[i] 表示当前正在处理的元素,具体属于哪个部分还未知
     */
    int lt = lo + 1;
    int gt = hi + 1;
    int i = lo + 1;

    /*
     * 根据切分元素将数组切分成<V,==V和>V三个部分;
     *
     * i == gt 时所有的数组元素已经处理完成,因为arr[gt, hi]是闭区间
     */
    while (i < gt)
    {
        /* arr[lo + 1, lt] < V */
        if (arr[i] < arr[lo]){
            ++lt;
            /* 将原arr[lt + 1]元素切换到了原arr[i]的位置,<V的区间扩大了 */
            swap(arr, i, lt);
            ++i;
        }
        /* arr[gt, hi] > V */
        else if (arr[i] > arr[lo]){
            --gt;
            /* i指向的是原arr[gt-1]的元素,是未处理的,所有不需要++i操作 */
            swap(arr, i, gt);
        }
        /* arr[lo + 1, lt] == V */
        else{
            ++i;
        }
    }

    /*
     * 将切分元素放到正确的位置
     * arr[lo, lt - 1] < V
     * arr[lt, gt - 1] == V
     * arr[gt, hi] > V
     */
    swap(arr, lt, lo);

    /* 对小于V部分元素进行排序 */
    quickSortBy3Ways(arr, lo, lt - 1);

    /* 对大于V部分元素进行排序 */
    quickSortBy3Ways(arr, gt, hi);
}

时间复杂度分析

快速排序是每次随机选定一个切分元素,根据切分元素将数组分成两部分,但是这两部分的元素并不一定是平均的,可能一端的元素多,另一端的元素少; 算法的复杂度应该是最坏的情况,快排的最坏为O(n2)O(n^2),但是概率是非常低的(如果数据量是各位数时即使为O(n2)O(n^2)也是提现不出来); 快排与之前的排序算法不同的一点是它是一种随机的算法,对于随机算法的时间复杂的分析不能根据最坏的情况来分析,应该使用数学期望进行分析,在切分数组过程虽然会不平均的将数组分成两部分,但是整体由于每个元素被选定的几率是相等的,从数学的期望上来看是将数组平分;所以层数的期望值是 O(logn)O(logn),在每层的操作是O(n)O(n),整体的时间复杂度是O(nlogn) O(nlogn) [详细的数学推导可以参考《算法导论》] image.png

... 更新 ...

外部排序

参考:

  • 算法(第4版)作者: [[美] Robert Sedgewick] / [[美] Kevin Wayne]