上回讲到 二分查找算法和它的迁移应用,和查找算法一样,排序算法也是基本且超级常用的算法。而排序算法的思想又能拿来解决很多其他问题,比如归并排序、堆排序和快速排序。话休絮烦,今天主要来说说快速排序的思路怎么应用。
“快排,快排”,念起来好似布谷鸟的叫声,“谷谷,谷谷”,声声的叫着夏天。可快排算法一点也不干脆利落,不似降龙十八掌般的摧古拉朽,倒像乾坤大挪移的移形换位,转来转去。
快排算法是Tony Hoare大佬在1959年提出的,目前仍然广泛应用,快速排序有时候也叫分割-交换(partition-exchange)排序,后面这个名字倒是把排序的过程描绘出来了。
快排也可以说是另一种分治算法,不过它不像归并排序,归并排序是分开解决子问题,然后进行合并,而快排是一股脑把问题全部解决。快排的平均时间复杂度是O(nlogn),最坏情况下是O(n^2),好在最坏情况是很少出现的。如果实现的比较好的话,快排能比归并排序和堆排序快2~3倍。
快排的实现
快排的实现有两种方式,一种是Lomuto提出的,我们姑且称为大L,这种方法也写在了大L的算法导论一书中。
前面提到了快速排序也使用了分治思想。下面是对一个典型的子数组A[p…r]使用大L法进行快速排序的三步分治过程:
-分解:数组A[p…r]被划分为两个(可能为空)子数组A[p…q-1]和A[q+1…r],使得A[p…q-1]中的每一个元素都小于等于A[q],而A[q]也小于等于A[q+1…r]中的每个元素。其中,计算下标q也是划分过程的一部分。
-解决:通过递归调用快速排序,对子数组A[p…q-1]和A[q+1…r]进行排序。
-合并:因为子数组都是原址排序的,所以不需要合并操作:数组A[p…r]已经有序。
假设数组的下标从0开始,算法的伪代码如下所示:
QUICKSORT(A, p, r)
if p < r
q = PARTITION(A, p, r)
QUICKSORT(A, p, q - 1)
QUICKSORT(A, q + 1, r)
PARTITION(A, p, r)
x = A[r]
i = p - 1
for j = p to r - 1
if A[j] <= x
i = i + 1
exchange A[i] with A[j]
exchange A[i + 1] with A[r]
return i + 1
在PARTITION的最后两行中,通过将主元与最左的大于x的元素进行交换,就可以将主元移动到它在数组中的正确位置上,并返回主元的新下标。
PARTITION在子数组A[p…r]上的时间复杂度是O(n),其中n=r-p+1。
举个例子来看一下partition的过程,对于数组[5,7,6,8,3,4]
,选择枢轴元素为数组的最后一个,也就是4,那么partition的过程如下图所示,从左到右进行遍历,如果元素值小于等于枢轴元素,会和红色区域的5进行交换,遍历过程结束后,会把枢轴元素与红色区域的7进行交换,交换后,枢轴元素会到达它最终要呆的位置。
接下来再看一下Hoare的方式(大H),这种方式用了双指针,一个指针在最左边,一个指针在最右边,相向而行,左边指针指向的元素小于枢轴元素,左指针就一直向右走,右边指针指向的元素大于枢轴元素,右指针就一直向左走,如果走不下去了,就交换左右指针指向的元素,然后继续走,直到左右指针错过对方。举个例子来描述partition的过程。
对于数组[5,7,6,8,3,4]
,选择枢轴元素为数组的第一个,也就是5,那么partition的过程如下图所示,最终返回绿色指针的位置。
需要注意的是大H算法,partition后返回的指针位置并不一定是枢轴元素最终的位置。
算法伪代码如下:
algorithm quicksort(A, lo, hi) is
if lo < hi then
p := partition(A, lo, hi)
quicksort(A, lo, p)
quicksort(A, p + 1, hi)
algorithm partition(A, lo, hi) is
pivot := A[lo]
i := lo – 1
j := hi + 1
loop forever
do
i := i + 1
while A[i] < pivot
do
j := j – 1
while A[j] > pivot
if i >= j then
return j
swap A[i] with A[j]
在讨论快速排序的平均性能时,我们假设输入序列的所有排列都是等概率的,这个假设并不总是成立,很多人都选择随机化版本的快速排序作为大数据输入情况下的排序算法。
在随机化的快速排序算法中,我们只需要在随机选择了枢轴元素后,把数组的最后一个元素(第一个元素)和枢轴元素交换,这样的话我们就又可以利用上面的算法了。
快排的迁移应用
快排算法的写法是比较固定的,一般来说算法题目不会直接考察快速排序,而会让我们使用快排的思想去解决一些问题,比较典型的应用是快速选择算法,对应LeetCode中的K系列题目:
接下来重点分析一下第215题,其他几个题目运用的思想都是一样的。原题如下:
在未排序的数组中找到第 k 个最大的元素。请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。
示例 1:
输入: [3,2,1,5,6,4] 和 k = 2
输出: 5
示例 2:
输入: [3,2,3,1,2,4,5,5,6] 和 k = 4
输出: 4
说明: 你可以假设 k 总是有效的,且 1 ≤ k ≤数组的长度。
这道题目有三种解法,第一种是直接排序,然后取第k大的元素,时间复杂度是O(nlogn);第二种是使用最小堆(优先队列)来做,时间复杂度是O(nlogk);第三种是使用基于快速排序的选择算法,也就是接下来要分析的方法。
由于我们需要确切知道第K大的元素的位置,所以这里只能采用大L的算法,由此可以发现每次经过“分解”操作后,我们一定可以确定一个元素的最终位置,即 x
的最终位置为 q
,并且保证 a[l⋯q−1]
中的每个元素小于等于 a[q]
,且 a[q]
小于等于 a[q+1⋯r]
中的每个元素。所以只要某次分解的 q
为倒数第 k
个下标(正数第n-k+1
个)的时候,我们就已经找到了答案。 我们只关心这一点,至于 a[l⋯q−1]
和 a[q+1⋯r]
是否是有序的,我们不关心。
因此我们可以改进快速排序算法来解决这个问题:在分解的过程当中,我们会对子数组进行划分,如果划分得到的 q
正好就是我们需要的下标,就直接返回 a[q]
;否则,如果 q
比目标下标小,就递归右子区间,否则递归左子区间。这样就可以把原来递归两个区间变成只递归一个区间,提高了时间效率。这就是「快速选择」算法。
我们知道快速排序的性能和分解出的子数组的长度密切相关。直观地理解如果每次规模为 n
的问题我们都划分成 1
和 n - 1
,每次递归的时候又向 n−1
的集合中递归,这种情况是最坏的,时间代价是 O(n^2)
。我们可以引入随机化来加速这个过程,它的时间代价的期望是 O(n)
。
上代码。
class Solution {
private Random random = new Random();
public int findKthLargest(int[] nums, int k) {
int n = nums.length;
quickSelect(nums, 0, n - 1, n - k);
return nums[n - k];
}
private void quickSelect(int[] nums, int left, int right, int k) {
if (left >= right) {
return;
}
int p = randomPartition(nums, left, right);
if (p == k) {
return;
} else if (p > k) {
quickSelect(nums, left, p - 1, k);
} else {
quickSelect(nums, p + 1, right, k);
}
}
public int randomPartition(int[] a, int l, int r) {
int i = random.nextInt(r - l + 1) + l;
swap(a, i, r);
return partition(a, l, r);
}
private int partition(int[] nums, int left, int right) {
int x = nums[right];
int i = left - 1;
for (int j = left; j < right; j++) {
if (nums[j] <= x) {
i = i + 1;
swap(nums, i, j);
}
}
swap(nums, i + 1, right);
return i + 1;
}
private void swap(int nums[], int i, int j) {
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
}
扫二维码关注,第一时间读到硬核的技术文章,和故事软文。