快速排序
假设在待排序的元素序列中可以找到一个轴点(pivot),在轴点左侧/右侧的元素,均不比它更大/更小。因此可以以轴点为界,自然的将整个序列划分为两个子区间,这两个子区间可以分别排序后,原序列就自然有序。 如下所示的红色即是一个轴点,轴点具有的特性即:在已经排序号的序列中其也会处于这个位置。轴点在序列中必定已经就位,但是已经就位的不一定是轴点(因为其左(右)边也有可能有比它小(大)的元素)。快速排序的思想就是将所有元素逐个转换为轴点,所以快速排序的核心在于如何构造轴点。
1.1 快速划分(LUG)
快速划分算法将整个区间分为三个部分:L[0,lo)之间的元素都小于等于轴点,G(hi,size]之间的元素都大于等于轴点,而[lo,hi]之间的元素U[lo,hi]则是未处理的元素。划分前先将起始元素作为轴点元素,则这是其即空闲出来。
假设有一次迭代过程如下所示,灰色的表示空闲的位置(由lo指向)。从hi开始进行下一轮迭代,可以发现在这个迭代过程中一定有,且在迭代过程中,中的元素,和交替空闲。
一个例子:
- 首先选取首元素
6作为轴点('pivot),lo指向元素6,hi指向元素7。这时L、G的元素为空,U包含全部的元素,且lo指向的位置为空闲的。 - 首先从
hi开始迭代,迭代到元素1时,因为其小于pivot,所以将元素1交换到lo指向的空闲位置。下一步从lo指向的元素1开始进行迭代,当其迭代到8时发现其大于基准元素pivot,因此将8与hi指向的空闲位置交换。 - 这时
while完成第一次迭代,lo指向空闲位置,hi指向元素8,开始下一轮迭代。 - 首先从
hi开始迭代,迭代到元素5时,因为其小于pivot,所以将元素5交换到lo指向的空闲位置。下一步从lo指向的元素5开始进行迭代,当其迭代到9时发现其大于基准元素pivot,因此将9与hi指向的空闲位置交换。 - 这时
while完成第二次迭代,lo指向空闲位置,hi指向元素9,开始下一轮迭代。 - 首先从
hi开始迭代,迭代到元素4时,因为其小于pivot,所以将元素4交换到lo指向的空闲位置。下一步从lo指向的元素4开始进行迭代,当其迭代到hi指向的空闲元素时,不再满足lo<hi的条件,因此两个循环都终止。 - 可以发现在最终时刻一定有
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位置调换。这种调换可以使得代码更加简介。
其代码如下所示,在每次迭代的过程中,其不变形为,且一定有。这时有如下的条件判断:
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; //返回候选轴点
}
下面举一个例子来叙述上述算法的划分过程:
- 首先选取首元素
6作为轴点,mi也等于lo,k取做lo+1。第一次迭代时,因为k指向元素3小于候选轴点,所以需要将其归入L部分,因此有V[++mi]=3。 - 在第二次迭代时,
8大于基准元素,不需要调整 - 第三次迭代时,
1小于基准元素,因此有元素3、1的交换。剩下的迭代过程和上述一致。
但是这个划分在应用时也会出现序列内元素全部相等时,则时间复杂度会退化为O(n^2),因此最好在最终返回的轴点位置与lo相等时,判断序列元素是否是全部相等的,若全部相等,则直接结束本次递归。