算法导论第四版ch7快速排序中文笔记与相应算法c++实现

作者:Claude Du

快速排序的优点:

  1. 平均性能非常好,期望时间复杂度是 Θ ( n l g n ) \Theta(nlgn) Θ(nlgn) 且常数因子非常小。
  2. 能够原址排序,甚至在虚拟环境中也能很好地工作。

快速排序的缺点:

  1. 最坏情况下时间复杂度为 Θ ( n 2 ) \Theta(n^2) Θ(n2)

快排通常是实际排序应用中最好的选择。

7.1快速排序的描述

与并归排序类似,快排也使用分治思想。

对一个子数组进行一下快排的3步分治过程:

分解:数组 A [ l e f t : r i g h t ] A[left:right] A[left:right] 被分解为两个子数组 A [ l e f t : q − 1 ] A[left:q-1] A[left:q1] A [ q + 1 : r i g h t ] A[q + 1 : right] A[q+1:right] , 使得 A [ l e f t : q − 1 ] A[left:q-1] A[left:q1]中的每个元素都小于等于 A [ q ] A[q] A[q] , 而 A [ q ] A[q] A[q] 也小于等于 A [ q + 1 : r i g h t ] A[q+1:right] A[q+1:right] 的每个元素

解决:通过递归调用快速排序,对子数组 A [ l e f t : q − 1 ] A[left:q-1] A[left:q1] A [ q + 1 : r i g h t ] A[q + 1 : right] A[q+1:right] 进行排序。

合并:因为子数组都是原址排序的,所以不需要合并操作。

快速排序基本流程c++实现如下:

void QuickSort(vector<int>& arr, int left, int right) {
    if (left < right) {
        int pivotIndex = Partition(arr, left, right);
        QuickSort(arr, left, pivotIndex -1);
        QuickSort(arr, pivotIndex + 1, right);
    }
}
void QuickSort(vector<int>& arr) {
    return QuickSort(arr, 0, arr.size() - 1);
}

数组的划分

Partition是快排的关键,它实现了对子数组 a r r [ l e f t : r i g h t ] arr[left:right] arr[left:right] 的原址重排,其c++实现如下:

int Partition(vector<int>& arr, int left, int right) {
    int x = arr[right]; // the pivot
    int i = left - 1; // highest index into the low side
    // process each element other than the pivot
    for (int j = left; j < right; ++j) { 
        if (arr[j] <= x) { // does jth element belong on the low side
            ++i;
            // exchange A[i] with A[j]
            int temp = arr[j];
            arr[j] = arr[i];
            arr[i] = temp;
        }
    }
    // exchange A[i+1] with A[right]
    ++i;
    arr[right] = arr[i];
    arr[i] = x;
    return i;
}

Partition 总会选择一个 x = a r r [ r i g h t ] x = arr[right] x=arr[right] 作为主元(pivot element),并使用它来划分子数组 a r r [ l e f t : r i g h t ] arr[left:right] arr[left:right] ,确保划分出来的左子数组 A [ l e f t : q − 1 ] A[left:q-1] A[left:q1]中的每个元素都小于等于 x x x , 剩下的元素都是置换到右子数组中。

简单验证Partition算法的正确性

对于Partition算法中的for循环部分,我们将以下这些性质作为循环不变量:对于for循环的每次迭代开始前,对于任何数组下标k, 以下性质都成立:

  1. 如果 l e f t ≤ k ≤ i left\leq k \leq i leftki , 则 A [ k ] ≤ x A[k] \leq x A[k]x
  2. 如果 i + 1 ≤ k ≤ j − 1 i+1\leq k \leq j-1 i+1kj1 , 则 A [ k ] > x A[k] \gt x A[k]>x ;
  3. 如果 k = r i g h t k = right k=right , 则 A [ k ] = x A[k] = x A[k]=x ;

该循环不变量在第一次迭代前是成立的,在每一轮迭代后仍然都成立,在循环结束时,该循环不变量还可以为证明算法正确性提供有用的信息。

初始化(Initialization):在第一次迭代前, i < l e f t i < left i<left 以及 j = l e f t j = left j=left, 性质1和2中的区间为空,不存在符合条件的k, 则性质1和性质2都满足,性质3也显然成立。

保持(Maintenance) : 假设上一轮迭代后,循环不变量成立, 本轮迭代如图7.3所示【1】,根据Partition C++实现的第7行“if (arr[j] <= x) ”的不同结果,我们要考虑两种情况:

在这里插入图片描述

情况一如图7.3(a):当 a r r [ j ] > x arr[j] > x arr[j]>x 时,我们只用 ++j。当本次迭代结束后(++j后), 性质2依然满足,性质1, 3则维持不变(依旧成立),则本轮迭代后,依然循环不变量都成立。

情况二如图7.3 (b) : 当 a r r [ j ] ≤ x arr[j] \leq x arr[j]x , 此时 ++i后, 有 a r r [ i ] > x arr[i] > x arr[i]>x ,交换 a r r [ i ] arr[i] arr[i] a r r [ j ] arr[j] arr[j] 和++j后(本轮迭代结束后),我们有 a r r [ i ] ≤ x arr[i] \leq x arr[i]x a r r [ j − 1 ] > x arr[j-1] \gt x arr[j1]>x , 再基于我们已假设上轮迭代后循环不变量不变,则本轮的三个循环不变量的性质依然成立。

终止 (Termination): 当for循环终止时, j = r j = r j=r 。数组中的每个元素都在循环不变量描述的三个集合情形中。在PartitionC++代码中最后4行代码,将主元与最左侧的大于x的元素进行交换,主元移到了相应正确的位置,并返回其新下标。Partition的满足其划分数组的目的,其实比原来的目标还要更严格: A [ q ] A[q] A[q] 严格小于 A [ q + 1 : r i g h t ] A[q+1:right] A[q+1:right] 的每个元素。

快排的c++实现与简单用例验证:

#include <iostream>
#include <vector>
#include <algorithm>

using std::vector;
class Solution {
private:
    int Partition(vector<int>& arr, int left, int right) {
        int x = arr[right]; // the pivot
        int i = left - 1; // highest index into the low side
        // process each element other than the pivot
        for (int j = left; j < right; ++j) { 

            if (arr[j] <= x) { // does thw element belong on the low side
                ++i;
                // exchange A[i] with A[j]
                int temp = arr[j];
                arr[j] = arr[i];
                arr[i] = temp;
            }
        }
        // exchange A[i+1] with A[right]
        ++i;
        arr[right] = arr[i];
        arr[i] = x;
        return i;
    }
public:
    void QuickSort(vector<int>& arr, int left, int right) {
        if (left < right) {
            int pivotIndex = Partition(arr, left, right);
            QuickSort(arr, left, pivotIndex -1);
            QuickSort(arr, pivotIndex + 1, right);
        }
    }
    void QuickSort(vector<int>& arr) {
        return QuickSort(arr, 0, arr.size() - 1);
    }
};

int main()
{
    vector<int> arr = {13, 19, 5, 12, 8, 7, 4, 21, 2, 6, 11};
    Solution sol;
    sol.QuickSort(arr);
    for (int i = 0; i < arr.size(); ++i) {
        std::cout << arr[i] << " ";
    }
}

7.2 快速排序的性能

快排的运行时间依赖于划分是否左右平衡。而左右平衡与否又依赖于用于作为主元(pivot)的元素。

如果划分一直左右平衡,那么快排时间复杂度 T ( n ) T(n) T(n) (最好情形)和并排的时间复杂度渐进相同:
T ( n ) = { c n = 1 2 T ( n / 2 ) + Θ ( n ) n > 1 T(n) = \left\{\begin{array}{lcl} c & { n =1}\\ 2T(n/2) + \Theta(n) & {n>1} \end{array} \right. T(n)={c2T(n/2)+Θ(n)n=1n>1
可计算得出 T ( n ) T(n) T(n) Θ ( n l g n ) \Theta(nlgn) Θ(nlgn)

如果划分一直不平衡,则快排的时间复杂度 T ( n ) T(n) T(n) (最差情形)就接近插入排序,为 Θ ( n 2 ) \Theta(n^2) Θ(n2)

快排的平均运行时间更接近于最好情形,详情见 7.4章节快速排序分析。

7.3 快速排序的随机化版本

有时我们可以通过在算法中引入随机性,从而使得算法对于所有的输入都能获得较好的期望性能。很多人都选择随机化版本的快排作为大数据输入情况下的排序算法。

我们通过采用随机抽样(random sampling)技术, 可让分析变得更简单。和之前始终采用 a r r [ r i g h t ] arr[right] arr[right] 作为主元的方法不同,随机抽样是从 a r r [ l e f t : r i g h t ] arr[left:right] arr[left:right] 随机抽取一个元素与 a r r [ r i g h t ] arr[right] arr[right] 交换,交换过后,把现在新的 a r r [ r i g h t ] arr[right] arr[right] 作为主元。通过随机抽样,我们可以保证主元素 x = a r r [ r i g h t ] x = arr[right] x=arr[right] 是等概率地从子数组 r − p + 1 r-p +1 rp+1 个元素中选取的,同时我们期望在平均情况下,对输入数组的划分是比较均衡的。

在新的划分程序(RandomizedPartition)中,我们要在真正划分前,随机抽样一个元素,并让该元素与 a r r [ r i g h t ] arr[right] arr[right] 交换,其c++实现如下:

int RandomizedPartition(vector<int>& arr, int left, int right) {
    // i = RANDOM(left, right)
    int randInd = left + (rand() % (right - left + 1));
    // exchange arr[right] eith arr[randInd]
    std::swap(arr[randInd], arr[right]);
    return Partition(arr, left, right);
}

随机化版的快排要调用的划分程序便是上面的RandomizedPartition,随机化版的快排主程序c++实现如下,(留意要使用c++的srand):

void RandomizedQuickSort(vector<int>& arr, int left, int right) {
    if (left >= right) return;
    int pivotIndex = RandomizedPartition(arr, left, right);
    RandomizedQuickSort(arr, left, pivotIndex - 1);
    RandomizedQuickSort(arr, pivotIndex + 1, right);
}
void RandomizedQuickSort(vector<int>& arr) {
    srand(time(NULL));
    RandomizedQuickSort(arr,  0, arr.size() - 1);
}

完整版快排与单个用例验证的整体代码如下:

#include <iostream>
#include <vector>
#include <algorithm>
#include <stdlib.h>
#include <time.h>

using std::vector;
class Solution {
private:
    int Partition(vector<int>& arr, int left, int right) {
        int x = arr[right]; // the pivot
        int i = left - 1; // highest index into the low side
        // process each element other than the pivot
        for (int j = left; j < right; ++j) { 
            if (arr[j] <= x) { // does jth element belong on the low side
                ++i;
                // exchange A[i] with A[j]
                int temp = arr[j];
                arr[j] = arr[i];
                arr[i] = temp;
            }
        }
        // exchange A[i+1] with A[right]
        ++i;
        arr[right] = arr[i];
        arr[i] = x;
        return i;
    }
    int RandomizedPartition(vector<int>& arr, int left, int right) {
        // i = RANDOM(left, right)
        int randInd = left + (rand() % (right - left + 1));
        // exchange arr[right] eith arr[randInd]
        std::swap(arr[randInd], arr[right]);
        return Partition(arr, left, right);
    }
public:
    void RandomizedQuickSort(vector<int>& arr, int left, int right) {
        if (left >= right) return;
        int pivotIndex = RandomizedPartition(arr, left, right);
        RandomizedQuickSort(arr, left, pivotIndex - 1);
        RandomizedQuickSort(arr, pivotIndex + 1, right);
    }
    void RandomizedQuickSort(vector<int>& arr) {
        srand(time(NULL));
        RandomizedQuickSort(arr,  0, arr.size() - 1);
    }

    
};

int main()
{
    vector<int> arr = {13, 19, 5, 12, 8, 7, 4, 21, 2, 6, 11};
    Solution sol;
    sol.RandomizedQuickSort(arr);
    for (int i = 0; i < arr.size(); ++i) {
        std::cout << arr[i] << " ";
    }
}

7.4 快速排序分析

我们需要给出快排性能更严谨的分析。先从最坏情况开始。

7.4.1 最坏情况分析

本篇章相对严谨地使用4.3节代入法证明了7.2节的最坏情况的时间复杂度就是 Θ ( n 2 ) \Theta(n^2) Θ(n2) ,具体可以看算导书中7.4.1节。

7.4.2 期望运行时间(来自算导第四版英文版)

首先,在分析期望运行时间时,有个假设前提:所有待排序的元素是始终互异的。

先直观上理解为何RandomizedQuickSort的期望运行时间是 O ( n l g n ) O(nlgn) O(nlgn) : 如果在递归的每一层上,RandomizedPartition 将任意常数比例的元素划分到一个子数组中,则算法的递归树深度为 Θ ( l g n ) \Theta(lgn) Θ(lgn) , 并且每一层上的工作量都为 O ( n ) O(n) O(n) 。即使在最不平衡的划分情况下,会增加一些新层次, 但总运行时间依然是 O ( n l g n ) O(nlgn) O(nlgn)

运行时间和比较操作

QuickSort 和 RandomizedQuickSort 除了如何选择主元素有差异外,其他方面完全相同。因此我们可以先分析QuickSort 和 Partition 步骤,以此作为基础,再分析RandomizedQuickSort。

引理 7.1

在n个元素的数组上进行QuickSort的运行时间是 O ( n + X ) O(n + X) O(n+X) , 其中 X X X 表示 Partition中 第5行的元素比较(“if (arr[j] <= x)”)的运行次数。

引理7.1的证明:QuickSort的运行时间主要由Partition操作上所花费的时间决定的。每一次Partition的调用都会选择一个主元,而且该主元不会出现在后续的QuickSort和Partition的调用中,因此,在快排整个执行期间,Partition最多能被调用n次。每次QuickSort调用Partition, 也会递归调用2次自己, 因此QuickSort自身最多也只能被调用2n次。

调用一次Partition的时间为 O ( 1 ) O(1) O(1) 和内部for循环所消耗的时间。很显然,内部for循环所消耗时间和(“if (arr[j] <= x)”)的运行次数成正比,这意味着在整个快排整个执行期间,所有调用Partition的内部for循环消耗时间之和 和 X X X成正比。

到这里,因为Partition之多调用n次,在partition中所有除了内部for循环外的总耗时是 O ( n ) O(n) O(n) (因为调用一次Partition除去内部for循环外的耗时为 O ( 1 ) O(1) O(1)),因此quickSort的总耗时为 O ( n + X ) O(n + X) O(n+X) , 引理 7.1 得证。

为了分析 RandomizedQuickSort ,我们要计算随机变量 X X X 的期望值 E [ X ] E[X] E[X] , 这里 X X X 代表RandomizedQuickSort整个执行过程中所有调用Partition中元素比较的总次数 。 为此,我们必须了解何时元素比较会发生,何时不会发生。为了便于分析,我们将数组 A A A 的各个元素重新命名成 z 1 , z 2 , . . . , z n z_{1}, z_{2},...,z_{n} z1,z2,...,zn , 其中 z 1 < z 2 < . . . < z n z_{1}\lt z_{2}\lt ...\lt z_{n} z1<z2<...<zn , 所有元素严格不等,因为所有待排序的元素是始终互异的 。 我们还定义 Z i j = { z i , z i + 1 , . . . , z j } Z_{ij} = \left\{ z_{i}, z_{i+1},...,z_{j}\right\} Zij={zi,zi+1,...,zj}

下一个引理描述两个元素何时会互相比较。

引理7.2

在一个有n个元素, z 1 < z 2 < . . . < z n z_{1}\lt z_{2} \lt . . . \lt z_{n} z1<z2<...<zn , 的数组上执行RandomizedQuickSort过程中,一个元素 z i z_{i} zi 会与另一个元素 z j z_{j} zj (其中 i < j i \lt j i<j) 发生一次比较, 当且仅当 z i z_{i} zi z j z_{j} zj 中的某一个元素在集合 Z i j Z_{ij} Zij 的所有其他元素前选为主元(pivot)。而且,每对元素最多比较一次。

引理7.2的证明:首先在RandomizedPartition算法执行过程中,集合 Z i j Z_{ij} Zij 中的一个元素 x x x 被选为主元的话,会有以下三种情形需要考虑:

  1. z i < x < z j z_{i} \lt x \lt z_{j} zi<x<zj , 那么 z i z_{i} zi z j z_{j} zj 之间以后就不会相互比较了,因为它们会被分到 主元 x x x的两侧,属于不同的子数组了。
  2. x = z i x = z_{i} x=zi ,则Partition 会比较 z i z_{i} zi 和 集合 Z i j Z_{ij} Zij 的每个其他元素。
  3. x = z j x = z_{j} x=zj ,则Partition 会比较 z j z_{j} zj 和 集合 Z i j Z_{ij} Zij 的每个其他元素。

因此 z i z_{i} zi z j z_{j} zj 之间发生比较操作当且仅当 集合 Z i j Z_{ij} Zij 中第一个被选为主元的元素为 z i z_{i} zi z j z_{j} zj 的其中之一。在最后两个情形中, z i z_{i} zi z j z_{j} zj 的其中之一被选为主元,由于主元在一次partition完成后,无法再次参与后续的元素比较,所以任意 z i z_{i} zi z j z_{j} zj 之间比较过一次后,再也无法比较第二次, 引理7.2得证。

下一个引理是关于两个元素之间发生比较的概率。

引理7.3

在一个有n个元素, z 1 < z 2 < . . . < z n z_{1}\lt z_{2} \lt . . . \lt z_{n} z1<z2<...<zn , 的数组上执行RandomizedQuickSort过程中,任意两个元素 z i z_{i} zi z j z_{j} zj (其中 i < j i \lt j i<j),之间发生比较的概率为 2 / ( j − i + 1 ) 2/(j - i + 1) 2/(ji+1)

引理7.3的证明:我们来看看RandomizedQuickSort产生的递归调用树:初始时,根节点的集合包含数组中每一个元素,在集合$ Z_{ij}$ 中的一个元素 x x x 被选为主元完成划分前,集合$ Z_{ij}$ 的所有元素都呆在一个递归调用节点上。在划分后,主元 x x x 不会 出现在后续的递归调用的输入集合里。第一次任意 x ∈ Z i j x \in Z_{ij} xZij 被选为主元的概率为 1 / ( j − i + 1 ) 1/ (j -i +1) 1/(ji+1) , 因为 集合 Z i j Z_{ij} Zij 中的每个元素被选为主元的概率时相等的。那么,基于引理7.2, 我们有 :
P r { z i 与 z j 之 间 发 生 比 较 } = P r { z i 或 z j 在 集 合 Z i j 中 被 选 为 第 一 个 主 元 } = P r { z i 在 集 合 Z i j 中 被 选 为 第 一 个 主 元 }          + P r { z j 在 集 合 Z i j 中 被 选 为 第 一 个 主 元 } = 2 j − i + 1 \begin{aligned}Pr \left\{z_{i}与z_{j}之间发生比较\right\} &= Pr \left\{z_{i}或z_{j}在集合Z_{ij}中被选为第一个主元\right\} \\ &= Pr \left\{z_{i}在集合Z_{ij}中被选为第一个主元\right\} \\ & \space\space\space\space\space\space\space\space + Pr \left\{z_{j}在集合Z_{ij}中被选为第一个主元\right\} \\ &= \frac{2}{j - i +1} \end{aligned} Pr{zizj}=Pr{zizjZij}=Pr{ziZij}        +Pr{zjZij}=ji+12
上式中的第二行成立的原因在于两个事件是互斥的,引理7.3得证。

现在我们可以完成RandomizedQuickSort的分析了。

引理7.4

对于一个输入为有n个唯一值的元素, z 1 < z 2 < . . . < z n z_{1}\lt z_{2} \lt . . . \lt z_{n} z1<z2<...<zn , 的数组的RandomizedQuickSort的期望运行时间为 O ( n l g n ) O(nlgn) O(nlgn)

引理7.4的证明:我们的分析要用到指示器分析变量(见5.2节) 。 对于 1 ≤ i < j ≤ n 1 \leq i \lt j \leq n 1i<jn , 定义一个随机变量 X i j = I { z i 与 z j 之 间 发 生 比 较 } X_{ij} = I \left\{z_{i}与z_{j}之间发生比较\right\} Xij=I{zizj} , 从引理2,每一对 i , j i, j i,j 最多比较1次, 我们可以把总比较次数 X X X表达成:
X = ∑ i = 1 n − 1 ∑ j = i + 1 n X i j X = \sum _{i = 1} ^{n-1} \sum_{j = i + 1}^{n}X_{ij} X=i=1n1j=i+1nXij
对上式两边取期望,并利用期望值的线性相关性 和引理5.1 以及引理7.3,我们得到
E [ X ] = E [ ∑ i = 1 n − 1 ∑ j = i + 1 n X i j ] = ∑ i = 1 n − 1 ∑ j = i + 1 n E [ X i j ] = ∑ i = 1 n − 1 ∑ j = i + 1 n P r { z i 与 z j 之 间 发 生 比 较 } = ∑ i = 1 n − 1 ∑ j = i + 1 n 2 j − i + 1 \begin{aligned} E[X ]&=E\left[ \sum _{i = 1} ^{n-1} \sum_{j = i + 1}^{n}X_{ij}\right] \\ &=\sum _{i = 1} ^{n-1} \sum_{j = i + 1}^{n}E[X_{ij}] \\ &= \sum _{i = 1} ^{n-1} \sum_{j = i + 1}^{n}Pr \left\{z_{i}与z_{j}之间发生比较\right\} \\ &= \sum _{i = 1} ^{n-1} \sum_{j = i + 1}^{n}\frac{2}{j - i +1} \end{aligned} E[X]=E[i=1n1j=i+1nXij]=i=1n1j=i+1nE[Xij]=i=1n1j=i+1nPr{zizj}=i=1n1j=i+1nji+12
求解该累加和的时候,我们可以使用变量替换( k = j − i k = j - i k=ji)和 公式(A.9)中给出的调和级数的界,得到:
E [ X ] = ∑ i = 1 n − 1 ∑ j = i + 1 n 2 j − i + 1 = ∑ i = 1 n − 1 ∑ k = 1 n 2 k + 1 < ∑ i = 1 n − 1 ∑ k = 1 n 2 k = ∑ i = 1 n − 1 O ( l g n ) = O ( n l g n ) \begin{aligned} E[X ] &= \sum _{i = 1} ^{n-1} \sum_{j = i + 1}^{n}\frac{2}{j - i +1} \\ &= \sum _{i = 1} ^{n-1} \sum_{k = 1}^{n} \frac{2}{k +1} \\ &\lt \sum _{i = 1} ^{n-1} \sum_{k = 1}^{n} \frac{2}{k} \\ &= \sum _{i = 1} ^{n-1}O(lg n) \\ &= O(nlgn) \end{aligned} E[X]=i=1n1j=i+1nji+12=i=1n1k=1nk+12<i=1n1k=1nk2=i=1n1O(lgn)=O(nlgn)
上式和引理7.1可以让我们得出结论,在输入元素互异的情况下,RandomizedQuickSort的期望运行时间为 O ( n l g n ) O(nlgn) O(nlgn)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值