快速排序的平均时间复杂度和堆排序、归并排序的平均时间复杂度一样都是O(nlogn)
,但是快速排序的最坏时间复杂度是O(n^2)
。实际上,快速排序的效率比堆排序和归并排序都要快,我们可以通过代码来调节最坏时间复杂度的出现的概率。
一、快速排序
快速排序(Quick Sort)在1960年由查尔斯·安东尼·理查德·霍尔(Charles Antony Richard Hoare,缩写为C. A. R. Hoare)提出,昵称为东尼·霍尔(Tony Hoare)。
1.1. 执行流程
- 从序列中选择一个轴点元素(
pivot
);- 假设每次选择0位置的元素为轴点元素
- 利用
pivot
将序列分割成2个子序列;- 将小于
pivot
的元素放在pivot
前面(左侧) - 将大于
pivot
的元素放在pivot
后面(右侧) - 等于
pivot
的元素放哪边都可以
- 将小于
- 对子序列进行1、2操作,直到不能再分割(子序列中只剩下1个元素)。
快速排序的本质:逐渐将每一个元素都转换成轴点元素。
1.2. 轴点构造
如上图,begin
指向首元素、end
指向尾部元素(注意,这里的end
和之前排序算法中的end
不太一样)。
- 排序之前,先把首元素(轴点元素)备份,数组中的元素从后往前开始比较
- 当
end
指向的元素大于备份的元素时,只需要end--
- 当
end
指向的元素小于备份的元素时,end
指向的元素覆盖begin
指向的位置,并且begin++
- 当
begin
指向的元素大于备份的元素时,begin
指向的元素覆盖end
指向的位置,并且end--
- 当
begin
指向的元素小于备份的元素时,只需要begin++
- 当
begin
或end
指向的元素和备份元素相等时,为了统一步骤,也让他做上面的覆盖操作 - 当
begin
和end
指向同一个位置时,轴点元素构造完毕,并把备份元素放入这个位置
上面的步骤中,当begin
发生覆盖时,下一步取end
指向的位置进行比较。当end
位置发生覆盖时,下一步取begin
指向的位置进行比较。两个交替运行。
注意:轴点把数据分割成了两部分,只要这两部分的
begin
和end
不在同一个位置,都需要再次进行快排(递归)。
1.3. 代码实现
1 | protected void quickSort() { |
1.4. 时间复杂度
在轴点左右元素数量比较均匀的情况下,同时也是最好的情况:T(n) = 2 * T(n/2) + O(n) = O(nlogn)
如果轴点左右元素数量极度不均匀,最坏情况:T(n) = T(n−1) + O(n) = O(n^2)
从下图也可以看出,最坏时间复杂度是三角形面积:
由于右边数据是空的,所以不考虑右边数据排序sort(mid + 1, end)
的耗时,只需要考虑左边的排序sort(begin, mid);
(即T(n-1)
),所以总耗时是T(n) = T(n-1) + O(n)
,计算得出(或者对比之前的公式)时间复杂度是O(n^2)
。
为了降低最坏情况的出现概率,一般采取的做法是:随机选择轴点元素。
1 | private int pivotIndex(int begin, int end) { |
最好、平均时间复杂度:O(nlogn)
最坏时间复杂度:O(n^2)
由于递归调用的缘故,空间复杂度:O(logn)
快速排序属于不稳定排序。
1.5. 与轴点相等的元素
如果序列中的所有元素都与轴点元素相等,利用目前的算法实现,轴点元素可以将序列分割成2个均匀的子序列:
思考:比较元素大小的判断分别改为≤、≥
会起到什么效果?
通过上图的分析可以发现,如果加上条件等于,轴点元素分割出来的子序列极度不均匀,最坏时间复杂度是O(n^2)
,效率极大降低。
误区:上面分析的是和轴点元素相等的情况,并不意味着加上等于就是稳定排序,比如上图中如果轴点元素是8,元素
...6a...6b...
排序后就是...6b...6a...
。
二、希尔排序
希尔排序(Shell Sort)在1959年由唐纳德·希尔(Donald Shell)提出。
希尔排序把序列看作是一个矩阵,分成m列,逐列进行排序,m从某个整数逐渐减为1,当m为1时,整个序列将完全有序。因此,希尔排序也被称为递减增量排序(Diminishing Increment Sort)。
矩阵的列数取决于步长序列(step sequence),比如,如果步长序列为{1,5,19,41,109,...}
,就代表依次分成109列、41列、19列、5列、1列进行排序。不同的步长序列,执行效率也不同。
2.1. 实例一
希尔本人给出的步长序列是n/(2^k)
,比如n为16时,步长序列是{1, 2, 4, 8}
。
1. 分成8列进行排序
2. 分成4列进行排序(在8列排序后的基础上排序)
3. 分成2列进行排序(在4列排序后的基础上排序)
4. 分成1列进行排序(在2列排序后的基础上排序)
思考:最后一次排序只有一列,为什么不直接分成一列呢?前面的3次排序有什么作用呢?
从上面的实例不难看出来,从8列变为1列的过程中,逆序对的数量在逐渐减少。因此希尔排序底层一般使用插入排序对每一列进行排序,也有很多资料认为希尔排序是插入排序的改进版。
2.2. 实例二
假设有11个元素,步长序列是{1, 2, 5}
:
假设元素在第col
列、第row
行,步长(总列数)是step
,那么这个元素在数组中的索引是col + row * step
。
- 比如
9
在排序前是第2列、第0行,那么它排序前的索引是2 + 0 * 5 = 2
- 比如
4
在排序前是第2列、第1行,那么它排序前的索引是2 + 1 * 5 = 7
2.3. 代码实现
希尔排序的核心逻辑就是分成多少列,每列进行插入排序(使用插入排序的原因是,后面的排序建立在之前已经排序好的基础上)。
1 | protected void shellSort() { |
2.4. 步长序列
希尔本人给出的步长序列,最坏情况时间复杂度是O(n^2)
。
1 | private List<Integer> shellStepSequence() { |
目前已知的最好的步长序列,最坏情况时间复杂度是O(n^(4/3))
,1986年由Robert Sedgewick提出。
- 当
k = 0
时,结果是1
; - 当
k = 1
时,结果是5
; - 当
k = 2
时,结果是19
; - 当
k = 3
时,结果是41
; - 当
k = 4
时,结果是109
; - ……
1 | private List<Integer> sedgewickStepSequence() { |
不同的步长序列,希尔排序的时间复杂度是不一样的(所以平均时间复杂度取决于步长序列),但是最坏时间复杂度范围是
O(n^(4/3)) ~ O(n^2)
。因为没有用到额外的存储空间,所以是原地排序,空间复杂度是O(1)
,是不稳定排序。在实际开发中也几乎用不到希尔排序,很多底层排序使用频率最高的还是快速排序、归并排序、插入排序等其他排序。