快速排序(C++)

212 阅读6分钟

快速排序

假设在待排序的元素序列中可以找到一个轴点(pivot),在轴点左侧/右侧的元素,均不比它更大/更小。因此可以以轴点为界,自然的将整个序列划分为两个子区间,这两个子区间可以分别排序后,原序列就自然有序。 如下所示的红色即是一个轴点,轴点具有的特性即:在已经排序号的序列中其也会处于这个位置。轴点在序列中必定已经就位,但是已经就位的不一定是轴点(因为其左(右)边也有可能有比它小(大)的元素)。快速排序的思想就是将所有元素逐个转换为轴点,所以快速排序的核心在于如何构造轴点。

1.1 快速划分(LUG)

快速划分算法将整个区间分为三个部分:L[0,lo)之间的元素都小于等于轴点,G(hi,size]之间的元素都大于等于轴点,而[lo,hi]之间的元素U[lo,hi]则是未处理的元素。划分前先将起始元素作为轴点元素,则这是其即空闲出来。

假设有一次迭代过程如下所示,灰色的表示空闲的位置(由lo指向)。从hi开始进行下一轮迭代,可以发现在这个迭代过程中一定有,且在迭代过程中,中的元素,和交替空闲。

一个例子:

  1. 首先选取首元素6作为轴点('pivot),lo指向元素6hi指向元素7。这时L、G的元素为空,U包含全部的元素,且lo指向的位置为空闲的。
  2. 首先从hi开始迭代,迭代到元素1时,因为其小于pivot,所以将元素1交换到lo指向的空闲位置。下一步从lo指向的元素1开始进行迭代,当其迭代到8时发现其大于基准元素pivot,因此将8hi指向的空闲位置交换。
  3. 这时while完成第一次迭代,lo指向空闲位置,hi指向元素8,开始下一轮迭代。
  4. 首先从hi开始迭代,迭代到元素5时,因为其小于pivot,所以将元素5交换到lo指向的空闲位置。下一步从lo指向的元素5开始进行迭代,当其迭代到9时发现其大于基准元素pivot,因此将9hi指向的空闲位置交换。
  5. 这时while完成第二次迭代,lo指向空闲位置,hi指向元素9,开始下一轮迭代。
  6. 首先从hi开始迭代,迭代到元素4时,因为其小于pivot,所以将元素4交换到lo指向的空闲位置。下一步从lo指向的元素4开始进行迭代,当其迭代到hi指向的空闲元素时,不再满足lo<hi的条件,因此两个循环都终止。
  7. 可以发现在最终时刻一定有lo==hi且都指向空闲位置,所以可以把空闲位置直接放在这里即可。这样即完成了一次划分。

这种划分方式采用的是随机选取轴点,而不是选取最左边或者最右边的元素,这种存在一个好处,当序列本已经有序时,若选取最左边的元素,则轴点每次都不会发生任何变动,进而导致快速排序每次只能变为减少一个元素的情况,这时其时间复杂度退化为,而随机选取则不会出现这个问题。 但是这里也会出现另外一个问题,即序列内元素全部相等时,即使是随机选取,也会则轴点每次都不会发生任何变动,进而导致快速排序每次只能变为减少一个元素的情况,这时其时间复杂度退化为。这时可以采取的办法是检查序列内元素是否全部相等,若全部相等,则直接结束迭代。整体代码如下所示,在第17行可知,当划分后轴点仍然会起始节点时,则很有可能是序列内元素全部相等的退化情况,这时可以做一个判断,若确实全部相等,则直接结束迭代。

int partition(vector<int> &v, int lo, int hi) {   //快速划分[lo,hi] 范围内的元素
    swap(v[lo], v[lo + rand() % (hi - lo + 1)]);      //随机交换(随机选取轴点)
    auto pivot = v[lo];      //选取lo作为轴点,经过上述交换等于随机选取
    while (lo != hi) {     //从两段向中间扫描
        while (lo < hi && pivot <= v[hi]) hi--;      //向左拓展G
        v[lo] = v[hi];       //小于轴点归入L
        while (lo < hi && v[lo] <= pivot) lo++;      //向左拓展L
        v[hi] = v[lo];      //大于轴点归入G
    }
    v[lo] = pivot;   //候选轴点归位
    return lo;
}

void quick_sort(vector<int> &v, int lo, int hi) {  //快速排序[lo,hi]之间的元素
    if (hi - lo < 1) return;    //当hi与lo相等时不需要再递归
    auto mi = partition(v, lo, hi);   //采用
    if (mi == lo) {
        if (all_of(v.begin() + lo, v.begin() + hi + 1, [num = v[lo]](int i) { return i == num; })) {
            return;
        }
    }
    quick_sort(v, lo, mi - 1);
    quick_sort(v, mi + 1, hi);
}

在运行若干组测试数据后,可以看到使用标准库的sort函数需要269ms,而上述的快排仅需要241ms

1.2 非递归版本

上述的快速排序主函数可以发现是一个尾递归函数,其很容易改成栈模拟的方式。在这个过程中,通过一个栈来模拟当前的迭代。

#define Put(K, s, t) { if ( s < t ) { K.push(s); K.push(t); } }
#define Get(K, s, t) { t = K.top(),K.pop(); s = K.top(),K.pop(); }

void quick_sort_I(vector<int> &v, int lo, int hi) {
    stack<int> task;
    Put(task, lo, hi);    //将初始任务推入栈中
    while (!task.empty()) {
        Get(task, lo, hi);
        auto mi = partition(v, lo, hi);
        if (mi == lo) {      //当mi等于lo时,有可能是因为元素全部相等,则直接跳过这一部分,注意这里不能用return
            if (all_of(v.begin() + lo, v.begin() + hi + 1, [num = v[lo]](int i) { return i == num; })) {
                continue;
            }
        }
        if (mi - lo < hi - mi) {     // 大任务优先入栈,保证总体递归深度不超过log n(辅助栈空间不过O(log n))
            Put(task, mi + 1, hi);
            Put(task, lo, mi - 1);
        } else {
            Put(task, lo, mi - 1);
            Put(task, mi + 1, hi);
        }
    }
}

注意这里有一个技巧是大任务优先入栈,也就是后出栈,如下所示的代码。若小任务优先入栈,而大任务后入栈先入栈,则很容易导致大任务不停地被分解为更小的任务,进而导致整体需要的空间开销。而每次都是让大任务入栈,也就保证其长度至少为一半,所以每次都能使得长度减半,所以这时额外空间只需要。

一个对比:

1.3 LUG版本变种

1.3.1 LUG`

LUG版本中,每次交换元素后仍然会进行元素的对比,如下所示:在第5行结束后会在第6行将v[hi]的元素赋值给v[lo],但是在第7行仍然会进行v[lo]<=pivot的对比,因为刚进行过比较,而这次对比显然是一定会成功的,所以可以通过++操作来使得第7行的循环直接跳过刚赋值过的lo

int partition(vector<int> &v, int lo, int hi) {   //快速划分[lo,hi] 范围内的元素
    swap(v[lo], v[lo + rand() % (hi - lo + 1)]);      //随机交换(随机选取轴点)
    auto pivot = v[lo];      //选取lo作为轴点,经过上述交换等于随机选取
    while (lo != hi) {     //从两段向中间扫描
        while (lo < hi && pivot <= v[hi]) hi--;      //向左拓展G
        v[lo] = v[hi];       //小于轴点归入L
        while (lo < hi && v[lo] <= pivot) lo++;      //向左拓展L
        v[hi] = v[lo];      //大于轴点归入G
    }
    v[lo] = pivot;   //候选轴点归位
    return lo;
}

但是这里需要注意在进行++操作前,相较于LUG版本,需要判断是否满足lo<hi的条件,若已经不满足,则再进行++操作,虽然在这里赋值不会出错,但是在候选轴点归位时有可能触发内存错误,同时也会导致lo不处于真正的候选轴点位置,其代码如下:

int partition_lug_op(vector<int> &v, int lo, int hi) {   //快速划分[lo,hi] 范围内的元素
    swap(v[lo], v[lo + rand() % (hi - lo + 1)]);      //随机交换(随机选取轴点)
    auto pivot = v[lo];      //选取lo作为轴点,经过上述交换等于随机选取
    while (lo != hi) {     //从两段向中间扫描
        while (lo < hi && pivot <= v[hi]) hi--;      //向左拓展G
        if (lo < hi) {
            v[lo++] = v[hi];       //小于轴点归入L
        }

        while (lo < hi && v[lo] <= pivot) lo++;      //向左拓展L
        if (lo < hi) {
            v[hi--] = v[lo];      //大于轴点归入G
        }
    }
    v[lo] = pivot;   //候选轴点归位
    return lo;
}
1.3.2 DUP

LUG版本存在的一个问题是,当序列内的元素全部相等时,其会变成退化版本,每次迭代只会使序列前进一个单位,进而时间复杂度退化为。主要原因在于当当前元素与基准元素相等时,并不会发生交换,指针直接向后移动即可。

DUP版本对此的改变将等于条件去除,此时当序列内元素完全相等时,左右两边的元素会交替的向中间移动,进而会使得这种元素全部相等的情况,轴点定位到中间位置,其代码如下。在这种划分方式中,只要元素相等则会停止下来。

int partition_dup(vector<int> &v, int lo, int hi) {
    swap(v[lo], v[lo + rand() % (hi - lo + 1)]);      //随机交换(随机选取轴点)
    auto pivot = v[lo];      //选取lo作为轴点,经过上述交换等于随机选取
    while (lo < hi) {   //从两段交替的向中间扫描
        while (lo < hi && pivot < v[hi]) {   //向左拓展G
            hi--;
        }
        if (lo < hi) {
            v[lo++] = v[hi]; //不大于轴点的归入L
        }
        while (lo < hi && v[lo] < pivot) {     //向右探索L
            lo++;
        }
        if (lo < hi) {
            v[hi--] = v[lo];     //不小于轴点的归入G
        }
    }
    v[lo] = pivot;     //候选轴点归位
    return lo;
}

此版本由LUG版的勤于拓展、懒于交换转为懒于拓展、勤于交换。但是这个版本会使得交换操作增加,更加的不稳定。

1.3.3 LUG 版(模板)

上述的版本采用的思想是有两个指针分别指向L的结束元素与G的起止元素,而这两个指针中间的元素则是待处理的元素。LUG版本则是将G、U位置调换。这种调换可以使得代码更加简介。

image.png

其代码如下所示,在每次迭代的过程中,其不变形为,且一定有。这时有如下的条件判断:

int partition_lgu(vector<int> v, int lo, int hi) {
    swap(v[lo], v[lo + rand() % (hi - lo + 1)]);      //随机交换(随机选取轴点)
    auto pivot = v[lo];      //选取lo作为轴点,经过上述交换等于随机选取mlyyds
    int mi = lo;
    for (auto k = lo + 1; k < hi; ++k) {     //从左向右考察每个k
        if (v[k] < pivot]) {     //若k小于候选轴点,则将其交换
            swap(v[++mi], v[k]);  //与[mi]交换,L向右扩展
        }
    }
    swap(v[lo], v[mi]);  //候选轴点归位
    return mi;       //返回候选轴点
}

下面举一个例子来叙述上述算法的划分过程:

  1. 首先选取首元素6作为轴点,mi也等于lok取做lo+1。第一次迭代时,因为k指向元素3小于候选轴点,所以需要将其归入L部分,因此有V[++mi]=3
  2. 在第二次迭代时,8大于基准元素,不需要调整
  3. 第三次迭代时,1小于基准元素,因此有元素3、1的交换。剩下的迭代过程和上述一致。

image.png

但是这个划分在应用时也会出现序列内元素全部相等时,则时间复杂度会退化为O(n^2),因此最好在最终返回的轴点位置与lo相等时,判断序列元素是否是全部相等的,若全部相等,则直接结束本次递归。