1、算法思路
快速排序是一种基于分治思想的一种排序方法
(下面的讨论均是基于将数组从小到大排序)
具体步骤
-
寻找分界点
分界点的选择没有具体要求,可以数组的左右端点,也可以选择数组的中间位置,也可以选择随机的某个位置。
-
调整区间
调整后的结果,就是使得分界点左边的所有数值小于等于分界点的数值,右边的所有数值大于等于分界点的数值。
-
递归处理左右两侧区间
2、模板代码
以下代码模板参考Acwing
void quick_sort(int q[], int l, int r)
{
//递归的边界
if(l >= r)return;
//1、选择中间点为分界点
int x = q[l + r >> 1];
int i = l - 1, j = r + 1;
//2、调整区间
while(i < j)
{
do i ++; while(q[i] < x);
do j --; while(q[j] > x);
if(i < j)swap(q[i],q[j]);
}
//3、以j为划分递归处理左右区间
quick_sort(q, l, j);
quick_sort(q, j + 1, r);
}
1、代码解释
上面的模板中,区间调整使用的是双指针的方式进行的。
i指针从左边开始进行遍历,直到找到一个大于等于分界点的值。
同样,j指针从右边开始遍历,直到找到一个小于等于分界点的值。
此时
-
i指针在分界点的左侧
指向的是一个大于等于分界点的值
应该被放到分界点右侧
-
j指针在分界点的右侧
指向的是一个小于等于分界点的值
应该被放到分界点左侧
这时候交换二者,则实现了区间的调整
2、具体示例
对于一个数组
6 4 3 5 2 1
首先,我们选择分界点,选择数组中间的位置为分界点,就是3
6 4 `3` 5 2 1
然后调整分区,将小于3
的数字都挪到3
的左侧,大于3
的数字都挪到其右侧
- 先走i指针
2 6 `3` 5 1 6
i i
q[i] < 3
2 6 `3` 5 1 6
i
q[i] >= 3
- 再走j指针
2 6 `3` 5 1 6
j
q[j] > 3
2 6 `3` 5 1 6
j
q[j] <= 3
- 交换二者
2 1 3 5 6 6
随后递归处理两侧区间
对于左侧
2 1
还是按照之前的步骤选择分界点2
,并调整分区,调整后的结果为
1 `2`
显然左右两侧区间已经无需处理
对于右侧
5 4 6
按照之前的步骤选择分界点4
,并调整分区有
`4` 5 6
合并上面的讨论可以发现数组已经排列有序
算法证明可以参考其他资料
3、边界问题分析
1. 避免递归时区间无限划分问题
以j
为划分时,分界点x
不能选q[r]
(若以i
为划分,则x
不能选q[l]
)
模板代码中,我们选择的x是q[l + r >> 1]
如果x = q[r],本轮操作的区间为q[l,r]
,递归的区间划分为q[l,j]
和 q[j + 1, r]
,若发生无限划分必然有q[l,r]中,j = r 或者 q[j + 1 ,r] 中,j + 1 = l
因为j的最小值为l,所以q[j + 1, r] 不会变成 q[l , r]
而j = r是有可能的,也就是在q[l … r - 1] 均小于q[r]的时候,此时就会出现无限划分的情况。
2. do i++; while(q[i] < x)和do j–; while(q[j] > x)不能用q[i] <= x 和 q[j] >= x
这样可以保证指针一定可以在l,r这个限定的范围内活动,不会越界,因为区间中一定有可以达到跳出while循环的x存在。倘若q[l,r]全是x,指针就会很丝滑的越界了。
3、为什么要在递归处理的时候,使用(l,j) 和 (j + 1, r)进行递归呢
首先,我们要明确一点,递归是为了分别处理左侧小于等于x的区间和大于等于x的区间。所以我们要选取的区间自然就是要区间划分成这两个样子
第二,为什么(l,j)是小于等于x的区间,(j + 1 ,r)是大于等于x的区间呢?
让我们关注一下while(i < j)循环中最后一轮的处理情况,此时
do j --; while(q[j] > x);
则j一定是在第一个q[j] <= x的地方停下。那么q[j + 1] 就一定大于等于x。
后面继续判断,如果i < j的时候,就处于调整区间的阶段,但是最后一轮时一定j <= i,所以左边区间就是(l,j)为所有小于等于x的数,右侧区间就是(j + 1,r)
除了这些边界条件的分析,更多的分析可以查看参考资料1,为了避免写代码时对这些边界分析的痛苦,还是找一个模板背一下比较好,当然要建立在理解的情况下。
3、 算法复杂度分析
平均时间复杂度 | 最坏时间复杂度 | 最优时间复杂度 | 平均空间复杂度 | 最差空间复杂度 | 稳定性 |
---|---|---|---|---|---|
O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) | O ( n 2 ) O(n^2) O(n2) | O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) | O ( l o g 2 n ) O(log_2n) O(log2n) | O ( n ) O(n) O(n) | 不稳定 |
因为快速排序是基于递归的,所以他的分析求解需要一个递推公式
记 T ( N ) T(N) T(N)为快速排序处理N个值所需的时间。易得 T ( 0 ) = T ( 1 ) = 1 T(0) = T(1) = 1 T(0)=T(1)=1
快速排序的运行时间等于两个递归调用的运行时间再加上分割区间的线性时间
也就有基本的递推公式(1)
T ( N ) = T ( 左 边 元 素 数 量 ) + T ( 右 边 元 素 数 量 ) + c N T(N) = T(左边元素数量) + T(右边元素数量) + cN T(N)=T(左边元素数量)+T(右边元素数量)+cN
假设所有需要排序的元素数量为N
1、时间复杂度
1.最坏时间复杂度
此时我们选择的分界点x始终是最小元素
递归左边的元素为x,数量为1
递归右边的元素为q[1…r]数量为N - 1
则有
T ( N ) = T ( 1 ) + T ( N − 1 ) + c N , N > 1 T(N) = T(1) + T(N - 1) + cN, N > 1 T(N)=T(1)+T(N−1)+cN,N>1
忽略T(1) = 1则有
T ( N ) = T ( N − 1 ) + c N T(N) = T(N - 1) + cN T(N)=T(N−1)+cN
反复递推则有
T ( N − 1 ) = T ( N − 2 ) + c ( N − 1 ) T(N - 1) = T(N - 2) + c(N - 1) T(N−1)=T(N−2)+c(N−1)
T ( N − 2 ) = T ( N − 3 ) + c ( N − 2 ) T(N - 2) = T(N - 3) + c(N - 2) T(N−2)=T(N−3)+c(N−2)
…
T ( 2 ) = T ( 1 ) + c ( 2 ) T(2) = T(1) + c(2) T(2)=T(1)+c(2)
将所有的方程相加,并且进行消元得到
T ( N ) = T ( 1 ) + c ∑ i = 2 N i = O ( N 2 ) T(N) = T(1) + c\sum_{i = 2}^{N}i = O(N^2) T(N)=T(1)+c∑i=2Ni=O(N2)
2.最优时间复杂度
此时我们选择的分界点x正好可以将区间分成元素数量相同的两部分
T ( N ) = 2 T ( N / 2 ) + c N T(N) = 2T(N / 2) + cN T(N)=2T(N/2)+cN
方程两边同除以N
有
T ( N ) N = T ( N / 2 ) N / 2 + c \frac{T(N) }{N}= \frac{T(N / 2)}{N/2} + c NT(N)=N/2T(N/2)+c
T ( N / 2 ) N / 2 = T ( N / 4 ) N / 4 + c \frac{T(N/2) }{N/2}= \frac{T(N / 4)}{N/4} + c N/2T(N/2)=N/4T(N/4)+c
…
T ( 2 ) 2 = T ( 1 ) 1 + c \frac{T(2) }{2}= \frac{T(1)}{1} + c 2T(2)=1T(1)+c
将上面的式子相加并消元,并注意到一共有 l o g 2 N log_2{N} log2N个式子,于是有
T ( N ) N = T ( 1 ) 1 + c l o g N \frac{T(N) }{N}= \frac{T(1)}{1} + clogN NT(N)=1T(1)+clogN
得到 T ( N ) = c N l o g N + N = O ( N l o g N ) T(N) = cNlogN + N = O(NlogN) T(N)=cNlogN+N=O(NlogN)
3.平均时间复杂度
不考虑最好或者最坏,那么每一次分割左右元素的数量是随机的,每个数量都是等可能出现的。计算平均每次分割后的左右两边的平均时间复杂度
T ( i ) = ( 1 N ) ∑ j = 0 N − 1 T ( j ) T(i) = (\frac{1}{N})\sum_{j=0}^{N-1}T(j) T(i)=(N1)∑j=0N−1T(j)
带入基本递推公式(1)有
T ( N ) = 2 N ∑ j = 0 N − 1 T ( j ) + c N T(N) = \frac{2}{N}\sum_{j=0}^{N-1}T(j) + cN T(N)=N2∑j=0N−1T(j)+cN
用N乘以上式有
N T ( N ) = 2 ∑ j = 0 N − 1 T ( j ) + c N 2 NT(N) = 2\sum_{j=0}^{N-1}T(j) + cN^2 NT(N)=2∑j=0N−1T(j)+cN2
对于N - 1同样有
( N − 1 ) T ( N − 1 ) = 2 ∑ j = 0 N − 2 T ( j ) + c ( N − 1 ) 2 (N-1)T(N-1) = 2\sum_{j=0}^{N-2}T(j) + c(N-1)^2 (N−1)T(N−1)=2∑j=0N−2T(j)+c(N−1)2
与上式一起做减法有
N T ( N ) − ( N − 1 ) T ( N − 1 ) = 2 T ( N − 1 ) + 2 c N − c NT(N)-(N-1)T(N-1)=2T(N-1)+2cN-c NT(N)−(N−1)T(N−1)=2T(N−1)+2cN−c
移项合并可以得到
N T ( N ) = ( N + 1 ) T ( N − 1 ) + 2 c N NT(N)=(N+1)T(N-1)+2cN NT(N)=(N+1)T(N−1)+2cN
现在就有了一个只用T(N-1)表示T(N)的公式
两边同除以N(N+1)进行变形,然后进行叠缩
T ( N ) N + 1 = T ( N − 1 ) N + 2 c N + 1 \frac{T(N) }{N+1}= \frac{T(N-1)}{N} + \frac{2c}{N+1} N+1T(N)=NT(N−1)+N+12c
T ( N − 1 ) N = T ( N − 2 ) N − 1 + 2 c N \frac{T(N -1) }{N}= \frac{T(N-2)}{N-1} + \frac{2c}{N} NT(N−1)=N−1T(N−2)+N2c
…
T ( 2 ) 3 = T ( 1 ) 2 + 2 c 3 \frac{T(2) }{3}= \frac{T(1)}{2} + \frac{2c}{3} 3T(2)=2T(1)+32c
将上面式子相加得到
T ( N ) N + 1 = T ( 1 ) 2 + 2 c ∑ i = 3 N + 1 1 i \frac{T(N) }{N+1}= \frac{T(1)}{2} + 2c\sum_{i=3}^{N+1}\frac{1}{i} N+1T(N)=2T(1)+2c∑i=3N+1i1
2 c ∑ i = 3 N + 1 1 i 2c\sum_{i=3}^{N+1}\frac{1}{i} 2c∑i=3N+1i1大约为 l o g e ( N + 1 ) + γ − 3 2 log_e(N+1)+\gamma -\frac{3}{2} loge(N+1)+γ−23 ( γ \gamma γ成为欧拉常数 约为0.577)
从而得到 T ( N ) = O ( N l o g 2 N ) T(N)=O(Nlog_2N) T(N)=O(Nlog2N)
2、空间复杂度分析
最差的情况下要递归调用n层,平均需要递归调用 l o g n logn logn层,所以空间复杂度如上表中
3、稳定性
为什么是不稳定的这里不多说,其实也可以改造成稳定的算法,只需要将每个元素视为不同的数字即可。例如给每个数字加一个标签,排序的时候先排值,再排标签就可以变成稳定的了
参考资料
- 快速排序算法的证明与边界分析
- Acwing
- 数据结构与算法分析C语言描述(第二版) 7.7.5