快速排序及其思想的运用
序言:
快速排序采用了分治的思想,是对冒泡的改进,它的期望复杂度是
Θ
(
n
lg
n
)
\Theta(n\lg n)
Θ(nlgn),而且其中隐含的常数因子非常小。本文将笔记其算法的核心思想及应用(参考《算法导论》第3版)。
1. 快速排序的描述
快排与归排同样,思想就是分治。即所谓,1分2解3合:
- 1分:
第一步,分解。我们用递归的形式将原数组划分成两部分,即 A [ p . . r ] → A [ p . . q − 1 ] a n d A [ q + 1.. r ] A[p..r] \to A[p..q-1]\ {\rm and}\ A[q + 1..r] A[p..r]→A[p..q−1] and A[q+1..r],并且前一段元素均小于等于 A [ q ] A[q] A[q], 后一段反之; - 2解:
第二步,解决。递归地调用快速排序,从而对子数组 A [ p . . q − 1 ] a n d A [ q + 1.. r ] A[p..q-1]\ {\rm and}\ A[q + 1..r] A[p..q−1] and A[q+1..r] 进行原址排序; - 3合:
第三步,合并。但是,由于快排的操作是原址的,因此不需要进行合并操作(归排则不同)。
其中,第一步是算法的关键。我们需要从子数组 A [ p . . r ] A[p..r] A[p..r] 中选择一个数作为“主元”,并围绕它来划分子数组 A [ p . . r ] A[p..r] A[p..r]。而我个人更习惯于称之为“隔板”。
- C++实现
int partition(vector<int>& arr, int p, int r) {
int x = arr[r]; //选择隔板,一般选择末尾元素
int i = p; //i代表隔板左边的最大下标
for (int j = p; j < r; ++j) {
if (arr[j] <= arr[r]) swap(arr[j], arr[i++]);
//若当前元素小于等于隔板, 作交换
}
swap(arr[i], arr[r]);
return i;
}
解释:
首先,我们选定子数组的末尾元素作为“隔板”,并以
i
i
i 标记“隔板”最终所在位置,初始化为
p
p
p;
尔后,遍历开始,遍历下标为
j
j
j。如果当前遍历元素小于或等于“隔板”,说明找到了一个“隔板”左边的数字,那么我们需要交换
a
r
r
[
i
]
arr[i]
arr[i] 与
a
r
r
[
j
]
arr[j]
arr[j],并且隔板下标
i
=
i
+
1
i = i + 1
i=i+1;
最后,当遍历完成后,
i
i
i 指示的恰好是“隔板”应该处在的位置,因此需要最后一次交换,将“隔板”,即末尾元素交换到
i
i
i 的位置。
详见算法导论第3版96页图7-1及相关细致描述
完成第一步后,第二步便是一个简单的递归过程。
- C++实现
void quickSort(vector<int>& arr, int p, int r) {
if (p < r) {
int q = partition(arr, p, r);
quickSort(arr, p, q - 1);
quickSort(arr, q + 1, r);
}
}
2. 快速排序的复杂度分析
2.1 最差情况分析
如果,我们每次划分都极度不平衡,即有一个子数组为空的话,此时算法运行时间的递归式为
T
(
n
)
=
T
(
n
−
1
)
+
T
(
0
)
+
c
n
=
T
(
n
−
1
)
+
c
n
T(n) = T(n - 1) + T(0) + cn = T(n - 1) + cn
T(n)=T(n−1)+T(0)+cn=T(n−1)+cn
迭加,可知
T
(
n
)
=
Θ
(
n
2
)
T(n) = \Theta(n^2)
T(n)=Θ(n2)
2.2 最佳情况分析
如果,我们每次划分都能将尽量平衡,即两个子数组的长度都不大于
n
/
2
n/2
n/2的话,此时算法运行时间的递归式为
T
(
n
)
=
2
T
(
n
/
2
)
+
c
n
=
2
(
T
(
n
/
4
)
+
c
n
/
2
)
+
c
n
=
2
T
(
0
)
+
c
n
lg
n
\begin{aligned} T(n) &= 2T(n/2) + cn\\ &=2(T(n / 4) + cn/ 2) + cn\\ &=2T(0) + cn\lg n \end{aligned}
T(n)=2T(n/2)+cn=2(T(n/4)+cn/2)+cn=2T(0)+cnlgn
因此最佳情况下的时间复杂度为
Θ
(
n
lg
n
)
\Theta(n\lg n)
Θ(nlgn)。
2.3 平均情况分析
平均情况下其实与最佳情况类似,只要我们的子数组长度是倍缩的(只有在最坏情况下,子数组长度是递减而不是倍缩),递归深度定是
Θ
(
lg
n
)
\Theta(\lg n)
Θ(lgn),最终的时间复杂度总是
O
(
n
lg
n
)
O(n\lg n)
O(nlgn)。
3. 快速排序思想的应用
快排的partition函数对于我们处理很多算法题时是有启发意义的,下面举例说明。
3.1 根据“某种”规则,将数组划分为二
例如,要求将数组以奇数在前,偶数在后的原则,将数组重排;又或者给定一个
t
a
r
g
e
t
target
target,使得数组小于
t
a
r
g
e
t
target
target 的在前,大于
t
a
r
g
e
t
target
target 的在后。
对于这些类型的题目,我们都可以采用partition函数里的方法(快排的主元是内定的,但在实际应用中也可以是外定的),在
O
(
n
)
O(n)
O(n)的复杂度内解决问题。
3.2 找到数组中第
k
k
k 小(大) 的数
比如说,我们要找到数组中第
k
k
k 小的数。我们对数组执行一次partition函数,
- 如果,返回的主元下标 i i i 满足 i + 1 = k i + 1 = k i+1=k (下标为0的是第1小的数,因此要+1),则 a r r [ i ] arr[i] arr[i] 便是所求。
- 如果,返回的主元下标 i i i 满足 i + 1 < k i + 1 < k i+1<k ,说明要找的数在主元的右边,因此对右半部分进行递归,左边不用继续处理。
- 最后一种,自然是对左半部分进行递归,右边不用继续处理。
该算法的复杂度为
O
(
n
)
O(n)
O(n),而不是
O
(
n
lg
n
)
O(n \lg n)
O(nlgn),为何?因为其比标准的快排相比,只需处理一半的数据即可。方便起见,我们考虑最佳情况(一般情况同之)
T
(
n
)
=
2
T
(
n
/
2
)
+
c
n
=
2
(
T
(
n
/
4
)
+
c
n
/
2
)
+
c
n
=
2
T
(
0
)
+
c
(
n
+
n
/
2
+
n
/
4
+
.
.
.
)
\begin{aligned} T(n) &= \sout{2}T(n/2) + cn\\ &=\sout{2}(T(n / 4) + cn/ 2) + cn\\ &=\sout{2}T(0) + c(n + n/2 + n/4 +...) \end{aligned}
T(n)=2T(n/2)+cn=2(T(n/4)+cn/2)+cn=2T(0)+c(n+n/2+n/4+...)
注意到
∑
k
=
0
+
∞
1
2
k
<
2
\sum_{k=0}^{+\infty}{\frac{1}{2^k}} < 2
∑k=0+∞2k1<2, 因此
T
(
n
)
=
O
(
n
)
T(n) = O(n)
T(n)=O(n).