快速排序(quick sort)是排序算法中最经典的之一
无论是知乎还是其他技术博客网站,快速排序可能已经被很多人写过,甚至都快写烂了~
但是!我还是想写一个我的版本!一个可能是东半球最容易理解的快速排序讲解!
快速排序的思想其实挺简单
第一步,在数组中找一个支点(pivot),把数组中小于支点值的数都放到支点左边,把数组中大于支点值的数都放在支点的右边 —— 比如我们拿 数字 7 作为支点,那么我们想得到的效果如下
第二步,递归地对支点左边的数字使用第一步的方法处理一遍
如
第三步,递归地对支点右边的数字使用第一步的方法处理一遍
最后就得到了一个排好序的数组了
思想简单,但是翻译成代码对不少程序员就有难度了
首先,快速排序是个递归算法,先写一点伪代码
private void sort(int[] arr, int low, int high) {
// 把数组按上述第一步处理,并返回支点下标
int pivotIndex = partition(arr, low, high);
// 递归
sort(arr, low, pivotIndex - 1);
sort(arr, pivotIndex + 1, high);
}
很明显,核心算法在 partition 里
partition 算法里的第一个任务是确定支点 ,通常有以下三种方法:
1. 直接使用 high 下标对应的值 即 arr[high]
2. 取数组中 arr[low], arr[(low + high) / 2] , arr[high] 这三个值的中位数
3. 随机取一个下标,使用它对应的值 即 arr[randomIndex]
接下来是如何 把数组中的元素根据小于支点的值在左,大于等于支点的值在右的方式重新排列
实际上有很多种实现,我列举常用的三种:
实现一
创建额外两个数组,遍历当前数组,把小于支点的数放在数组A里,大于等于支点的数放在数组B里。遍历结束后,把数组A和数组B的数覆盖到原数组上。
这个方法我就不写实现了,简单粗暴,但是需要使用额外的空间,所以不是最优解
有兴趣可以看看 阮一峰老师 早期的这篇博客
快速排序(Quicksort)的Javascript实现 - 阮一峰的网络日志www.ruanyifeng.com实现二
先把支点与当前排序的最后一个数调换位置(这一步可以让算法大大简化,后面会讨论)
然后对剩余的数做如下操作:
a. 自左向右查找 大于等于支点的数,找到后暂停,记下标 left
b. 自右向左查找 小于支点的数,找到后记下标 right
c. 调换 left 和 right 位置上的数 —— 让左边的数都是小数,而右边都是大数
d. 重复 a +b + c 直到 a与b 相遇,即 left 已经大于等于 right
e. 继续重复 a 直到 找到大于等于支点的数 或者 到达末尾
f. 最后把 left 对应的值与数组最后一个数交换位置 —— 因为 left 大于等于支点,且 left 之前的数都一定小于支点;最后一位就是支点;交换过后,就达到了我们要的效果,即支点左边都小于它,支点右边都大于等于它。
private int partition(int[] arr, int low, int high) {
// 随机取一个数作为支点
int pivotIndex = random.nextInt(high - low + 1) + low;
int pivot = arr[pivotIndex];
// 先把支点与最后一位交换位置 —— 方便下面的遍历
swap(arr, pivotIndex, high);
int left = low, right = high - 1;
while (left < right) {
// left要找到的是 大于等于支点 的数的下标
while (left < high && arr[left] < pivot) {
left++;
}
if (left >= right) {
break;
}
// right 要找到的是 小于支点 的数的下标
while (right >= low && arr[right] >= pivot) {
right--;
}
if (left >= right) {
break;
}
// 调换 left 和 right 位置上的数 —— 让左边的数都是小数,而右边都是大数
swap(arr, left, right);
left++;
right--;
}
// 继续自左向右遍历,确保 left 对应的数 大于等于支点
while (left < high && arr[left] < pivot) {
left++;
}
// 因为left上的数大于等于支点,与 high 交换位置后, left 上的值就是支点,left 左边的值都小于支点,右边都大于等于支点
swap(arr, left, high);
return left;
}
// 交换数组上 i 和 j 的数
private void swap(int[] arr, int i, int j) {
if (i == j) {
return;
}
int n = arr[i];
arr[i] = arr[j];
arr[j] = n;
}
这种实现便于理解,但是稍微有点繁琐 —— 不是代码繁琐而是一些边缘情况的逻辑细节
有读者可能会说,我见过这种实现更”简洁“的写法 —— 我也见过,其实无非就是把 自增操作放在条件语句判断里再去掉大括号如
while (left < high && arr[left++] < pivot);
while (right >= low && arr[right--] >= pivot);
我个人是比较反对这种写法的,看起来代码行数少了,但这不代表简洁 —— 简洁的目的不只在于代码少,还要保证可读性 —— 几个月后你再回来读这段代码还能快速理解吗?扯远了~
总结:这种实现核心算法是维护两个指针(下标),自两端向中间扫描交换位置,最后将末尾的支点放在中间
实现三
与实现二类似,先把支点与当前排序的最后一个数调换位置
维护一个指针 j(下标)指向 自左向右第一个(可能)大于等于支点的数 —— 为什么是”可能“,因为这个指针的初始值是 low,不一定大于等于支点;而且在一种极端情况下(支点是最大值),这个指针也不会指到大于支点的数。
自左向右遍历数组,遇到比支点小的数就把这个数与指针 j 对应的数 交换位置 —— 确保左边的数都能小于支点,并把 指针 j 向右移动一位
最后,交换 指针 j 与 最后一位数
private int partition1(int[] arr, int low, int high) {
// 随机取一个数作为支点
int pivotIndex = random.nextInt(high - low + 1) + low;
int pivot = arr[pivotIndex];
// 先把支点与最后一位交换位置 —— 方便下面的遍历
swap(arr, pivotIndex, high);
// 指针 j 指向 自左向右第一个(可能)大于等于支点的数
int j = low;
for (int i = low; i < high; i++) {
if (arr[i] < pivot) {
swap(arr, i, j); // 小于支点的数就跟 指针j 交换位置
j++;
}
}
// 因为 指针j上 的数大于等于支点,与 high 交换位置后, j 上的值就是支点,j 左边的值都小于支点,右边都大于等于支点
swap(arr, high, j);
return j;
}
明显感觉到这种实现比实现二更简洁,但是理解起来稍稍困难一点。
实现二使用的是左右两个指针 相向而行,而实现三使用的是两个指针(其中一个指针用于遍历)同向而行 即 都是自左向右。
实现二可以想象成两个小矮人从两端相向开始走,左边的小矮人采大蘑菇,右边的小矮人采小蘑菇,两个人都采到时就抛给对方交换 —— 所以 左边的蘑菇都是小蘑菇 而 右边都是大蘑菇
实现三可以想象两个小矮人都从左边向前出发,走后边的小矮人一遇到大蘑菇就蹲在那儿等,走前边的小矮人采到小蘑菇就回头跟后边的小矮人交换 —— 所以 后边的蘑菇都是小蘑菇 而 前边都是大蘑菇
有读者可能已经发现了个问题 —— 假如 指针j 指向的数是比 支点小的数 怎么办?
答:其实没问题的。这种情况只会发生在遍历开始的时候,指针 j 的初始位置 low 可能指向的是 比 支点小的数,但遍历指针 i 也会看到这个数,于是跟 指针 j 交换 (此时 i 跟 j 其实是相等的)它们的位置交换等同于什么都没发生。—— 只要记住一点 :遍历使用的指针 i 一定是比 指针j 更靠前 或者 跟指针j 在一样的位置
实现二和三 共同的一点是都首先把 支点与最后一位交换,这样做的目的是 把支点先从之后的遍历中摘出来、不参与,最后再把支点放到中间位置,简化了整个流程。
相比实现一,实现二和三 都不需要额外空间,属于原地排序,对内存使用是比较友好的。
总结
快速排序算法是个递归算法
通常有三种选择支点的方式
partition 的实现通常有三种,后两种都是原地排序不需要额外空间,都使用到了双指针 —— 尝试使用小矮人采蘑菇的比喻来理解
尽量保持算法代码可读性,方便自己记忆和复习
拓展
快速排序算法还可以用在其他算法题上,比如找到数组中第 K 小的数 —— 如果支点在位置 k 上,支点不就是第 K 小的数了吗?
快速排序中使用到的 双指针 是解算法题的一大利器,熟练掌握双指针可以攻克很多算法题