冒泡排序和快速排序都属于交换排序,其中快排更像是冒泡的进化版本,我们先看冒泡排序:
思想:每轮循环将本轮最大元素移动到数组末尾,以此类推,直到长度为一。假设数组长度为 n,第一轮循环将最大元素移动到下标为 n-1 的位置,第二轮需要判断前 n -1 个元素,并将最大元素移动到 n-2 的位置,以此类推。计算最大值通过比较交换完成。
private void sort(int[] a) {
// 长度为 n 的数组需要 n-1 轮循环
for (int i = 1; i <= a.length - 1; i++) {
// 每次需要判断到第几个元素
for (int j = 0; j < a.length - i; j++) {
if (a[j] > a[j + 1]) {
int k = a[j];
a[j] = a[j + 1];
a[j + 1] = k;
}
}
}
}
快排在冒泡的基础上做了优化,它不在局限于每轮找最大的或最小的,而是找比它小的和比它大的。每次将比它小的元素放在自身左边,比它大的元素放在右边,从全局层面保证有序。
思想:以数组下标开始元素为基准,比较交换。交换后左边都是比自己小的元素,右边都是比自己大的元素。左集合和右集合再分别进行上述判断,直到集合中只有一个元素。
private void quickSort(int[] a, int left, int right) {
if (left >= right) {
return;
}
int index = sort(a, left, right);
quickSort(a, left, index - 1);
quickSort(a, index + 1, right);
}
private int sort(int[] a, int left, int right) {
int key = a[left];
while (left < right) {
while (left < right && a[right] >= key) {
right--;
}
a[left] = a[right];
while (left < right && a[left] <= key) {
left++;
}
a[right] = a[left];
}
a[left] = key;
return left;
}
从代码可以看出:quickSort() 计算出的 index 越靠近中心,递归的次数越少,性能越好。如果每次计算出的中点恰好都在中间,那时间复杂度仅为 O(nlog2n)。如果每次计算出的中点恰好都在边沿,时间复杂度为 O(n^2),和冒泡排序相等。
通常情况下由于数组无序,冒泡排序的时间复杂度约处于 O(nlog2n)。但当数组本身有序时,由于快排总拿第一个元素作为基准数,这样计算出的 index 总在边沿,时间复杂度非常高。为了解决该问题,可以从基准数的选择优化快排,通常有两种方式优化:
- 三数取中间数
- 随机选基准数
优化后的代码如下:
// 三数取中法
int x1 = a[left], x2 = a[right], x3 = a[(left + right) / 2];
if (x1 >= x2) {
if (x2 >= x3) {
return x2;
} else {
return Math.min(x1, x3);
}
} else {
if (x1 >= x3) {
return x1;
} else {
return Math.min(x2, x3);
}
}
// 随机选基准数
int key = a[left + new Random().nextInt(right - left + 1)];
new Random().nextInt(n):返回小于 n 大于等于 0 的所有整数
只要保证选择的基准数尽可能的可以将数组划分为平均的两部分,就可以提高快排的性能
quickSort() 在满意 left <= right 条件时总要进行递归计算,当数组长度很小时,快排的效率可能不如冒泡排序,因为此时时间复杂度相等但快排涉及到创建栈的消耗。此时就可以优化,当数组长度很小时,直接采用冒泡排序:
// 数组长度小于等于5时不采用快排
private void quickSort(int[] a, int left, int right) {
if (left >= right) {
return;
}
if(right - left >= 5) {
int index = sort(a, left, right);
quickSort(a, left, index - 1);
quickSort(a, index + 1, right);
} else {
// 采用冒泡排序
sort(a, left, right);
}
}
假设数组中存在多个和基准数相同的数,目前的逻辑相等不交换,从全局维度来说,划分的并不是很干净,举个例子:
55326458
经过一次快排后,分割为:4532 5 658
从结果来说,虽然左边全部是小于等于它的,右边全部是大于等于它的,但两者都包含和相等值,后续递归过程中仍需判断。我们可以想办法让相等的值集中在中间,这样下轮循环就可以不判断这部分值了,拿上面的例子来说,理想状态为分割为 432 555 68,下次只需递归 432 和 68 集合即可,下面我给出聚合以及跳过的逻辑:
private void quickSort(int[] a, int left, int right) {
if (left >= right) {
return;
}
// 越过相等的基准数值
int index = sort(a, left, right), l = index - 1, r = index + 1;
while (l > left && a[l] == a[index]) {
l--;
}
while (r < right && a[r] == a[index]) {
r++;
}
quickSort(a, left, l);
quickSort(a, r, right);
}
private int sort(int[] a, int left, int right) {
int key = a[left], l = left, r = right;
while (left < right) {
while (left < right && a[right] >= key) {
right--;
}
a[left] = a[right];
while (left < right && a[left] <= key) {
left++;
}
a[right] = a[left];
}
a[left] = key;
// 交换逻辑
int p = left - 1, q = right + 1;
while (l < p) {
if (a[l] == key) {
while (p > l && a[p] == key) {
p--;
}
sawp(a, l, p);
}
l++;
}
while (r > q) {
if (a[r] == key) {
while (q < r && a[q] == key) {
p++;
}
sawp(a, r, q);
}
r--;
}
return left;
}
private void sawp(int[] n, int x, int y) {
if (x == y) {
return;
}
int z = n[x];
n[x] = n[y];
n[y] = z;
}
至此关于快排的三种优化介绍完毕:
- 基准数选择优化
- 小数组采用其它排序方式,如插入、冒泡
- 基准数相等聚合跳过
最后由于快排采用分治的思想,单次任务实际上可以分配到不同的线程执行:对于每次任务,创新新线程执行,线程体就是快排的实现逻辑,把递归改为创建新线程执行即可,充分发挥 CPU 效率:
class DemoThread implements Runnable {
private int[] a;
int left;
int right;
public DemoThread(int[] a, int left, int right) {
this.a = a;
this.left = left;
this.right = right;
}
@Override
public void run() {
int index = sort(a, left, right);
if (left < index - 1) {
new Thread(new DemoThread(a, left, index - 1)).start();
}
if (index + 1 > right) {
new Thread(new DemoThread(a, index + 1, right)).start();
}
}
private int sort(int[] a, int left, int right) {
int key = a[left];
while (left < right) {
while (left < right && a[right] >= key) {
right--;
}
a[left] = a[right];
while (left < right && a[left] <= key) {
left++;
}
a[right] = a[left];
}
a[left] = key;
return left;
}
}
一般情况下,数组不大时没必要创建,因为创建线程本身也有资源消耗,并且还有上下文切换的消耗,绝大多数情况下单线程运行就可以了。