封面画师:Nengoro(ネんごろぅ) 封面ID:74731465
本文参考视频:小马哥教育(SEEMYGO) 2019 年 恋上数据结构与算法(第二季)
源码仓库:mofan212/data-structure-and-algorithm (github.com)
辅助学习网址:数据结构和算法动态可视化 Data Structure Visualizations
执行流程:本文统一以升序为例子。
1. 快速排序
1.1 前言
在前一篇文章中,谈到了归并排序,它的时间复杂度为 O(nlogn)。在初学排序算法时,谈到了堆排序,堆排序的时间复杂度也是 O(nlogn)。那么还有其他排序算法的时间复杂度也是 O(nlogn) 吗?
当然是有的,快速排序 就是其中一种。

根据上图可知,快速排序的最好、平均时间复杂度为 O(nlogn),但最坏时间复杂度是 O(n2),这点甚至赶不上归并排序和堆排序,那要是经常遇到最坏情况,效率岂不是很低?怎么对得起 快速 二字呢?
当然有办法减低最坏情况出现的概率,总体来说快速排序的效率还是很高的。相比于平均时间复杂度也为 O(nlogn) 的归并排序和堆排序,快速排序会 「更快」 一点。
1.2 执行流程
快速排序(Quick Sort),1960 年由 查尔斯·安东尼·理查德·霍尔(Charles Antony Richard Hoare,缩写为 C.A.R.Hoare) 提出,昵称为东尼·霍尔(一译托尼·霍尔,英语:Tony Hoare)。

这又是一位大佬,曾在 1980 年获颁图灵奖、在 2011 年获颁约翰·冯诺依曼奖。
执行流程
- 从序列中选择一个轴点元素
pivot,假设每次选择 0 位置的元素为轴点元素;
- 利用
pivot 将序列分割成 2 个子序列:
- 将小于
pivot 的元素放在 pivot 前面(左侧)
- 将大于
pivot 的元素放在 pivot 后面(右侧)
- 等于
pivot 的元素放在哪边都可以
- 对分割出的子序列重复操作,直到不能再分割(子序列中只剩下 1 个元素)
执行过程图
静态图:
动态图:

快速排序的本质是 逐渐将每一个元素都转换成轴点元素。 既然如此,需要先搞清楚 0 位置的元素是怎么变成轴点的。
1.3 轴点构造
先选择序列中 0 位置的元素作为轴点元素,然后设置两个指针 begin 和 end,其中 begin 指向序列首元素,end 指向序列尾元素。
在先前的设计中,常将 end 设置为序列的长度,在这里也可以将其设置为序列的长度,但更好是将 end 向后移一位,即将 end 指向序列尾元素。
选择好轴点元素后,还需要将其备份一份。
从序列 右边 开始遍历(从 end 向 begin 遍历),比较遍历元素与轴点元素的大小关系:
- 当
end 指向的元素 大于 轴点元素时, end--
- 当
end 指向的元素 小于等于 轴点元素时,end 不变,用 end 指向的元素覆盖 begin 指向的元素,然后 begin++
每当一个元素位置被确定时,需要从 相反方向 进行遍历(从 begin 到 end 遍历),比较遍历元素与轴点元素的大小关系:
- 当
begin 指向的元素 大于 轴点元素时, begin 不变,用 begin 指向的元素覆盖 end 指向的元素,然后 end--
- 当
begin 指向的元素 小于等于 轴点元素时, begin++
重复进行以上操作,直到 begin == end,这个时候用备份的元素覆盖 begin 和 end 指向的元素,这样一个轴点就构造出来了。

上图仅仅介绍了构造第一个轴点,如果需要对整个序列进行排序,相当于需要将每个元素都转换成轴点元素。
在确定了第一个轴点后,需要在轴点左右两边的序列中确认第二个、第三个轴点,然后在确认的轴点左右两边确认新的轴点,直到每个元素都被确认为轴点,这也标志着排序的结束。
这其实和归并排序很类似,属于一种递归。
1.4 编码实现
快速排序和归并排序类似,也可以采用递归的方式。先理一下思路:
- 备份
begin 位置的元素,确认第一个轴点的位置;
- 在上一步确认的轴点两边重复上一步操作,直到每个元素都转换成轴点元素。
这是一个递归的过程。
那么重点就是如何确认轴点了,而在上一节中已经分析的轴点的确认方法了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
| /**
* @author 默烦
* @date 2020/8/16
*/
public class QuickSort<E extends Comparable<E>> extends Sort<E> {
@Override
protected void sort() {
sort(0, array.length);
}
/**
* 对 [begin, end) 范围的元素进行快速排序
*
* @param begin 序列开始位置索引
* @param end 序列长度
*/
private void sort(int begin, int end) {
if (end - begin < 2) return;
// 确定轴点位置
int p = pivot(begin, end);
// 对子序列进行快速排序
sort(begin, p);
sort(p + 1, end);
}
/**
* 构造出 [begin, end) 范围的轴点元素
*
* @param begin 序列开始位置索引
* @param end 序列长度
* @return 轴点元素的最终位置
*/
private int pivot(int begin, int end) {
// 随机选择一个元素跟begin位置进行交换
swap(begin, begin + (int) (Math.random() * (end - begin)));
// 备份 begin 位置的元素
E pivot = array[begin];
// end 指向最后一个元素
end--;
while (begin < end) {
while (begin < end) {
if (cmp(pivot, array[end]) < 0) { // 右边元素 > 轴点元素
end--;
} else { // 右边元素 <= 轴点元素
array[begin++] = array[end];
// 赋值后换方向遍历
break;
}
}
while (begin < end) {
if (cmp(pivot, array[begin]) > 0) { // 左边元素 < 轴点元素
begin++;
} else { // 左边元素 >= 轴点元素
array[end--] = array[begin];
// 赋值后换方向遍历
break;
}
}
}
// 将轴点元素放入
array[begin] = pivot;
// 返回轴点元素的位置
return begin;
}
}
|
尝试对时间复杂度为 O(nlogn) 的三个排序方式进行测试。生成 5 万个 随机数,范围是 [1,50000],方法耗时如下:

可以看到快速排序是很优秀的,当然不同的硬件、不同的序列可能会导致这三种排序的耗时发生变化。
1.5 复杂度与稳定性
最好的情况
如果轴点左右元素数量比较均匀(各占一半)的情况下,就是最好的情况。
快速排序用了递归,可以像分析归并排序的时间复杂度一样,使用递推公式来计算快速排序的时间复杂度。

得到的递推公式跟分析归并排序时得到的递推公式一样。在最好情况下,快速排序的时间复杂度为 O(nlogn)。
最坏的情况
如果轴点左右元素数量极度不均匀,那就是最坏的情况。
那么在这种情况下的时间复杂度是多少呢?

根据递推公式,最坏情况下的时间复杂度是 O(n2)。
降低最坏情况出现概率
既然最坏情况下时间复杂度这么高,那有什么解决的办法吗?
为了降低最坏情况的出现概率,一般采取的做法是 随机选择轴点元素。
简单来说,可以用 begin 位置的元素与当前序列中除 begin 位置外的其他元素进行交换。
代码修改也很简单,只需要在 pivot() 方法最开始的位置添加一句代码就可以实现随机选择轴点位置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| /**
* 构造出 [begin, end) 范围的轴点元素
*
* @param begin 序列开始位置索引
* @param end 序列长度
* @return 轴点元素的最终位置
*/
private int pivot(int begin, int end) {
// 随机选择一个元素跟begin位置进行交换
swap(begin, begin + (int) (Math.random() * (end - begin)));
// 备份 begin 位置的元素
E pivot = array[begin];
// end 指向最后一个元素
end--;
while (begin < end) {
while (begin < end) {
if (cmp(pivot, array[end]) < 0) { // 右边元素 > 轴点元素
end--;
} else { // 右边元素 <= 轴点元素
array[begin++] = array[end];
// 赋值后换方向遍历
break;
}
}
while (begin < end) {
if (cmp(pivot, array[begin]) > 0) { // 左边元素 < 轴点元素
begin++;
} else { // 左边元素 >= 轴点元素
array[end--] = array[begin];
// 赋值后换方向遍历
break;
}
}
}
// 将轴点元素放入
array[begin] = pivot;
// 返回轴点元素的位置
return begin;
}
|
总结
-
最好时间复杂度为 O(nlogn)
-
最坏时间复杂度为 O(n2)
-
由于递归调用的缘故,空间复杂度为 O(logn)
-
快速排序属于不稳定排序(从轴点构造过程也可以看出来)
1.6 轴点相等元素
在前面编写的代码中,确认轴点(获取轴点位置)时,有一段下图所示的代码:
圈出的代码中不含相等判断,就是说当选中元素与轴点相等时,会将选中元素赋值到另一边的子序列中。

从上图中可以看到,如果序列中的所有元素都与轴点元素相等,利用当前的算法实现,轴点元素可以将序列分成 2 个 均匀 的子序列。
拓展思考
如果将 cmp() 位置的判断分别改成 >=、<= 会起到什么效果呢?
添加相等判断后,如果选中元素与轴点相等时,直接改变索引,不进行赋值转向。
添加相等判断后,轴点元素分割出来的子序列极其不均匀,导致出现最坏的时间复杂度 O(n2)。
使用快速排序对 1w 个相同的数进行排序:

第一种是未添加相等判断的,第二种是添加了相等判断的。
添加前后耗时相差甚远,后者耗时近乎是前者的 40 倍。
如果进一步扩大数据规模,比如对 5w 个相等的数进行快速排序,第二种情况甚至可能出现 StackOverflowError。
在实现快速时, 不要在比较元素时添加相等判断。
1.7 三路快排
三路快排,Three-way Quick Sort,是对普通快速排序算法的一种改进。
优势
在一般情况下,使用普通快速排序选择的基准恰好可以将序列中的元素对半分时,快速排序的时间复杂度为 O(nlogn)。
当序列中存在大量重复元素,甚至所有的元素都一样时,此时使用普通快速排序会导致基准两边的元素数量划分不平衡,时间复杂度退化成 O(n2)。此时如果使用三路快排,能够将重复元素集中在同一个区间内,使得元素划分更加均匀,减少递归次数。
原理
三路快排相比于普通快速排序,就是将序列划分为三部分:
- 小于基准元素的区间
- 等于基准元素的区间
- 大于基准元素的区间
之后对小于、大于基准元素的区间分别进行递归排序。
那么现在的问题就变成了怎么将序列划分为三部分,在 75. 颜色分类 一题中就需要实现类似的逻辑:
- 使用两个指针
i 和 j,最初 i 指向序列的第一个元素,j 指向序列的最后一个元素。除此之外,还需要一个指针 p 用来遍历序列,最初 p 也指向序列的第一个元素
- 当
p 指向的元素小于基准值时,交换 p 与 i 指向的元素,之后 p 和 i 还要向前移动一位
- 当
p 指向的元素大于基准值时,交换 p 与 j 指向的元素,之后 j 向前移动一位。注意,此时的 p 要保持不动,因为与 j 交换后,p 新指向的元素的大小关系还没判定,它可能恰好等于基准,也可能大于或者小于基准
- 当
p 指向的元素等于基准值时,直接将 p 向前移动一位
- 重复上述过程,直到
p 与 j 相遇
代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| public class ThreeWayQuickSort<E extends Comparable<E>> extends Sort<E> {
@Override
protected void sort() {
sort(0, array.length);
}
/**
* 对 [begin, end) 范围的元素进行快速排序
*
* @param begin 序列开始位置索引
* @param end 序列长度
*/
private void sort(int begin, int end) {
if (end - begin < 2) return;
// 随机选择一个元素跟 begin 位置进行交换
swap(begin, begin + (int) (Math.random() * (end - begin)));
E pivot = array[begin];
int i = begin;
int p = begin + 1;
int j = end;
while (p < j) {
int cmp = cmp(array[p], pivot);
if (cmp < 0) {
swap(i++, p++);
} else if (cmp > 0) {
swap(p, --j);
} else {
p++;
}
}
sort(begin, i);
sort(j, end);
}
}
|
2. 希尔排序
2.1 概念介绍
希尔排序(Shell Sort),由 唐纳德·刘易斯·希尔(Donald Lewis Shell) 在 1959 年提出。

希尔排序与前面的排序算法不同,希尔排序把序列看作一个 矩阵,分成 m 列,逐列进行排序:
- m 从某个整数逐渐减为 1
- 当 m 为 1 时,整个序列将完全有序
因此,希尔排序也被成为 递减增量排序(Diminishing Increment Sort)。
矩阵的列数取决于步长序列(step sequence):
- 比如,如果步长序列为 {1,5,19,41,109},就代表依次分成 109 列、41 列、19 列、5 列、1 列进行排序
- 不同的步长序列,执行效率也不同
2.2 排序实例
希尔本人给出的步长序列计算公式是 2kn,比如 n 为 16 时,步长序列为 {1,2,4,8}。
如果对下面这个序列进行升序排序:
分成 8 列进行排序:
分成 4 列进行排序:
分成 2 列进行排序:
分成 1 列进行排序:
上面的排序过程中,最后一步是将每个元素独自形成一列进行排序,那么为什么不一开始就分成一列进行排序,前面的分列排序岂不是显得很多余?
其实并不是,从 8 列变为 1 列的过程中,逆序对的数量在不断减少。
希尔排序底层一般使用插入排序对每一列进行排序,因此也有很多资料认为希尔排序是插入排序的改进版。
插入排序的时间复杂度与逆序对的数量成正比关系。
上述给出的示例过于理想,元素数量恰好是 2 的次幂,如果元素数量不是 2 的幂呢?
假设有 11 个元素,步长序列是 {5,2,1},分成 5 列进行排序,则有:

假设元素在第 col 列、第 row 行,步长(总列数)是 step:
- 那么这个元素在数组中的索引是 col+row×step
- 比如 9 在排序前是第 2 列、第 0 行,那么它排序前的索引是 2+0×5=2
- 比如 4 在排序前是第 2 列、第 1 行,那么它排序前的索引是 2+1×5=7
2.3 编码实现
首先根据希尔给出的步长序列计算公式创建一个步长序列。
获得步长序列后,对原数列进行分列,然后进行排序,比如:

上图红色粗体数字表示列数,对每列的数据进行排序后可以得到右边的结果。
针对每一列进行的排序,可以使用 插入排序 来实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| /**
* @author 默烦
* @date 2020/8/16
*/
public class ShellSort<E extends Comparable<E>> extends Sort<E> {
@Override
protected void sort() {
List<Integer> stepSequence = shellStepSequence();
for (Integer step : stepSequence) {
sort(step);
}
}
/**
* 分成 step 列进行排序
*
* @param step 列数
*/
private void sort(int step) {
// col : 第几列, column
for (int col = 0; col < step; col++) { // 对第col列进行排序
// col、col+step、col+2*step、col+3*step......
for (int begin = col + step; begin < array.length; begin += step) {
int cur = begin;
while (cur > col && cmp(cur, cur - step) < 0) {
swap(cur, cur - step);
cur -= step;
}
}
}
}
/**
* 获取步长序列
*/
private List<Integer> shellStepSequence() {
List<Integer> stepSequence = new ArrayList<>();
int step = array.length;
while ((step >>= 1) > 0) {
stepSequence.add(step);
}
return stepSequence;
}
}
|
2.4 复杂度与稳定性
最好情况是步长序列只有 1,且序列几乎有序,此时时间复杂度为 O(n)。
最坏的时间复杂度为 O(n34)∼O(n2),见下文分析。
平均时间复杂度取决于步长序列。
希尔排序采取原地排序,没有依赖额外存储空间也没有递归调用,因此空间复杂度为 O(1)。
希尔排序属于 不稳定排序(从分列排序示意图可以看出来)。
希尔排序是逐列排序的,其稳定性无法控制,也无法通过先前的稳定性测试代码测试出来,为了打印结果的准确,需要修改稳定性判断 isStable() 方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| private boolean isStable() {
if (this instanceof SelectionSort) return false;
if (this instanceof ShellSort) return false;
Student[] students = new Student[20];
for (int i = 0; i < students.length; i++) {
// 创建有序的序列
students[i] = new Student(i * 10, 10);
}
sort(((E[]) students)); // 进行排序
for (int i = 1; i < students.length; i++) {
int score = students[i].score;
int prevScore = students[i - 1].score;
// 检查序列顺序是否发生改变
if (score != prevScore + 10) return false;
}
return true;
}
|
生成 3w 个 随机数,范围 [1,30000],方法耗时如下:
2.5 步长序列
根据希尔本人给出的步长序列计算公式,最坏情况时间复杂度为 O(n2):
1 2 3 4 5 6 7 8
| private List<Integer> shellStepSequence(int count) {
List<Integer> stepSequence = new ArrayList<>();
int step = count;
while ((step >>= 1) > 0) {
stepSequence.add(step);
}
return stepSequence;
}
|
随着科学家们的研究,目前已知的最好的步长序列计算公式的最坏情况时间复杂度为 O(n34),这是由 Robert Sedgewick 在 1986 年提出的。

将上述代码替换到先前的实现中,其它代码不变:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| @Override
protected void sort() {
// 根据元素数量算出步长序列
List<Integer> stepSequence = sedgewickStepSequence();
// 按步长序列划分进行排序
for (Integer step : stepSequence) {
sort(step); // 按step进行排序
}
}
private List<Integer> sedgewickStepSequence() {
List<Integer> stepSequence = new LinkedList<>();
int k = 0, step = 0;
while (true) {
if (k % 2 == 0) {
int pow = (int) Math.pow(2, k >> 1);
step = 1 + 9 * (pow * pow - pow);
} else {
int pow1 = (int) Math.pow(2, (k - 1) >> 1);
int pow2 = (int) Math.pow(2, (k + 1) >> 1);
step = 1 + 8 * pow1 * pow2 - 6 * pow2;
}
if (step >= array.length) break;
stepSequence.add(0, step);
k++;
}
return stepSequence;
}
|