在 https://visualgo.net/zh/sorting 的 QUI 标签中可以看到快排序动画演示。
快速排序
平均时间复杂度:O(nlogn),最坏情况下 O(n^2)
空间复杂度:O(1)
基本思想
分治。
找一个基准元素,以基准元素分解,左边是比基准元素小的,右边是比基准元素大的。
这样就把一个待排序数组分成了左右两部分。
对左、右分别进行上面的步骤。
4 3 5 6 2 1
我们选取 4 作为基准值。
1 3 2 4 5 6
在对 [1 3 2] 和 [5 6] 进行操作。
基础实现
// 返回 p, arr[l, p - 1] < arr[p]; arr[p + 1, r] > arr[p];
template<typename T>
int __partition(T arr[], int l, int r) {
T v = arr[l]; // 选取第一个元素为基准值
int j = l;
for (int i = l + 1; i <= r; ++i) {
if (arr[i] < v) {
swap(arr[++j], arr[i]);
}
}
swap(arr[l], arr[j]);
return j;
}
template<typename T>
void __quickSort(T arr[], int l, int r) {
if (l >= r) return;
// 经过 __partition 后,[l, p-1] < arr[p],[p + 1, r] > arr[p]
T p = __partition(arr, l, r);
__quickSort(arr, l, p - 1);
__quickSort(arr, p + 1, r);
}
template<typename T>
void quickSort(T arr[], int n) {
__quickSort(arr, 0, n - 1);
}
10k 个随机数 [0, 10k] 排序:
归并排序 : 0.001776 s
快速排序 : 0.001609 s
归并排序、快排均属于 O(nlogn) 数量级的算法。
优化
随机基准值
上面的这种实现是有问题的,如果待排序数组已经接近有序了,我们选取第一个值作为基准值,将其分成两部分,就会造成一部分特别大。通过下面的测试可以看出:
10k 个接近有序的数 [0, 10k] 排序:
归并排序 : 0.004252 s
快速排序 : 2.7637 s
这个时候快排就退化为了 O(n^2) 级别。
究其原因,发现是选择基准数字的问题。
那么这个基准数字应该如何选择呢?
随机选一个数做基准值
第一次随机选取一个数,选取到刚好是最小的概率是 1/n;
第二次是 1/(n-1);
…
这样下来,每次都选取到的是一个很小的数字 1/n * 1/(n-1) * 1/(n-2) * ...
。是一个很小的概率。
template<typename T>
int __partition(T arr[], int l, int r) {
swap(arr[l], arr[rand() % (r-l+1) + l]);
T v = arr[l];
// arr[l + 1, j] < v; arr[j + 1, i] > v;
int j = l;
for (int i = l + 1; i <= r; ++i) {
if (arr[i] < v) {
swap(arr[++j], arr[i]);
}
}
swap(arr[l], arr[j]);
return j;
}
// arr[l, r]
template<typename T>
void __quickSort(T arr[], int l, int r) {
if (l >= r) return;
srand(time(NULL));
T p = __partition(arr, l, r);
__quickSort(arr, l, p - 1);
__quickSort(arr, p + 1, r);
}
双路快排
经过上面的 优化1,快速排序已经可以胜任一些场景下的排序了。
但是,考虑问题需要全面。
在上面的 优化1 中,将数据分成的两部分,默认把等于基准值的分在了有半部分,如果要排序的数组中和基准值相等的很多,比如 100k 个数都是 [0, 5] 的数,那就会出现左右不均,导致一边很多一边很少,这样快排的速度也会退化。
100k 个随机数,范围 [0, 5]:
归并排序 : 0.015571 s
快速排序 : 2.00576 s
如何解决呢?
双路快排!
将等于基准值的数平均分配给两边。
template<typename T>
int __partition2(T arr[], int l, int r) {
swap(arr[l], arr[rand() % (r - l + 1) + l]);
T v = arr[l];
int i = l + 1, j = r;
while (true) {
while (i <= r && arr[i] < v) i++;
while (j >= l + 1 && arr[j] > v) j--;
if (i > j) break;
swap(arr[i++], arr[j--]);
}
swap(arr[l], arr[j]);
return j;
}
双路快排,100k 个随机数,范围 [0, 5]:
归并排序 : 0.015794 s
快速排序 : 0.026956 s
可以看到,双路快排和归并已经在一个数量级了。
三路快排
通过上面的随机选基准值、双路快排,已经解决了一部分问题,但现在还不是最优。
100k 个随机数,范围 [0, 5],虽然双路快排基本平均的分成了两部分进行,但是对于大量的和基准值相同的数,做了很多重复操作。
三路快排就是将待排序数组分成【小于基准值,等于基准值,大于基准值】三部分,下一轮排序只对小于基准值和大于基准值的两个部分进行排序即可,这样就省去了很大一部分操作。
template <typename T>
void __quickSort3Ways(T arr[], int l, int r) {
if (l >= r) return ;
srand(time(NULL));
swap(arr[l], arr[rand() % (r - l + 1) + l]);
T v = arr[l];
// partition
int i = l + 1;
int lt = l;
int gt = r + 1;
while (i < gt) {
if (arr[i] < v) {
swap(arr[++lt], arr[i++]);
} else if (arr[i] > v) {
swap(arr[i], arr[--gt]);
} else {
++i;
}
}
swap(arr[l], arr[lt]);
__quickSort3Ways(arr, l, lt - 1);
__quickSort3Ways(arr, gt, r);
}
template<typename T>
void quickSort3Ways(T arr[], int n) {
__quickSort3Ways(arr, 0, n - 1);
}
三路快排,100k 个随机数,范围 [0, 5]:
归并排序 : 0.015375 s
双路快速排序 : 0.026594 s
三路快速排序 : 0.00316 s
可以看到三路快排性能有一个明显的提升!
性能测试
100k 个随机数,范围 [0, 100k]:
归并排序 : 0.022674 s
双路快速排序 : 0.03437 s
三路快速排序 : 0.037351 s
100k 个随机数,范围 [0, 5]:
归并排序 : 0.015524 s
双路快速排序 : 0.026816 s
三路快速排序 : 0.002967 s
100k 个接近有序的数,范围 [0, 100k]:
归并排序 : 0.003909 s
双路快速排序 : 0.020788 s
三路快速排序 : 0.036348 s
EOF