「这是我参与2022首次更文挑战的第5天,活动详情查看:2022首次更文挑战」。
分治法和快速排序
要理解快速排序,首先要理解分治法的思想。
分而治之
分治法并非可用于解决问题的算法,而是一种解决问题的思路。分治法的核心思想是:
- 找出简单的基线条件(终止条件);
- 确定如何缩小问题的规模,使其符合终止条件。
关于基线条件和终止条件,我在写给前端开发的算法简介一文中有介绍。
分土地问题
来自于《图解算法》
如何将一块地均匀地分成方块(正方形),并确保分出的方块是最大的呢?
终止条件:余下的土地一条边的长度是另一条边的整数倍。
比如,出现下面这种情况,那么最大方块的边长就是 25m。
递归条件:先找出可容纳的最大方块,对余下土地使用同样的算法。
最终,找到了这块土地能被均匀划分的最大方块!
数组求和
给定一个数字数组,将数字相加并返回结果。
[2, 4, 6]
使用循环:
const getSum = (arr) => {
let sum = 0
for (let i = 0, len = arr.length; i < len; i++) {
sum += arr[i]
}
return sum
}
使用分治:
终止条件:数组为空或数组只有一项。
[] 空数组,值为 0
[5] 数组只有一项,值为 5
递归条件:缩小数组的长度(缩小问题规模)。
sum([2, 4, 6])
2 + sum([4, 6])
2 + 4 + sum([6])
实现代码如下:
const getSum = (arr) => {
if (arr.length === 0) { // 如果数组为空,值为0,就返回0
return 0
} else {
const firstNum = arr.shift() // 每次弹出一个值,缩小数组的长度
return firstNum + getSum(arr)
}
}
计算数组包含的元素数
编写一个递归函数来计算一个数组包含的元素数
终止条件:数组为空,长度就是0;数组只有一项,长度就是1
递归条件:缩小数组的长度(缩小问题规模)。
const getLength = (arr) => {
if (JSON.stringify(arr) === '[]') {
return 0
} else {
arr.shift()
return 1 + getLength(arr)
}
}
写法和上面的例2类似,不过不通过length来判断数组为空,总感觉很别扭,hh
找出数组中最大的数字
给定一个数字数组,找出数组中最大的数字 终止条件:数组只有两项,两个做比较,大的那个就是最大值。
递归条件:缩小数组的长度(缩小问题规模)。
const getMax = (arr) => {
if (arr.length === 2) {
return arr[0] > arr[1] ? arr[0] : arr[1]
} else {
const max = arr.shift()
return Math.max(max, getMax(arr))
}
}
二分查找
实现一个二分查找
基线条件:数组只有一个元素,和这个元素做比较,相等说明找到了,不等说明没找到。
递归条件:缩小数组的长度,每次缩小一半。
const binarySearch = (list, item) => {
let low = 0
let high = list.length - 1
while (low < high) {
const mid = Math.floor((low + high) / 2)
if (list[mid] === item) {
return mid
}
if (list[mid] < item) {
low = mid + 1
}
if (list[mid] > item) {
high = mid - 1
}
}
return null
}
快速排序
快排也是分治
快速排序是一种常用的排序方法,比选择排序快得多,快速排序也用到了分治法。
基线条件:数组为空数组或只有一个元素,排序就完成了。
递归条件:缩小数组的长度。
具体来说就是数组中指定一个元素作为标尺,比标尺小的放数组左边,比标尺大的放数组右边。然后再分别递归操作左边的数组和右边的数组,直到全部排序完成。
代码实现如下:
const quickSort = (arr) => {
if (arr.length < 2) { // 终止条件:数组为空或只有一个元素
return arr
} else {
const flag = arr[0] // 随便找一个元素作为标尺
const less = []
const greater = []
for (let i = 1, len = arr.length; i < len; i++) {
if (arr[i] < flag) {
less.push(arr[i]) // 比标尺小的放左边
} else {
greater.push(arr[i]) // 比标尺大的放右边
}
}
return [...quickSort(less), flag, ...quickSort(greater)] // 合并
}
}
优化空间复杂度
上面代码的写法定义了两个数组 less 和 greater 来存放比标尺小或者大的元素,空间复杂度高,其实还可以优化一下:
我们不占用额外的空间,通过交换数组内元素来实现:
const swap = (arr, i, j) => { // 交换数组内元素方法
[arr[i], arr[j]] = [arr[j], arr[i]]
}
const finndCenter = (arr, left, right) => { // 这个函数用来做分区操作,返回一个idx,分区后的数组 idx 左边都比它小,idx 右边都比它大
const flag = arr[left] // 随便找一个元素作为标尺,这里找数组第一个
let idx = left + 1 // 定义一个指针指向标尺右边的元素,从标尺右边的元素开始遍历
for (let i = idx; i <= right; i++) {
if (arr[i] < flag) {
swap(arr, i, idx) // 如果比标尺元素小,就和标尺右边的元素进行交换
idx++ // 交换完了,idx向右移一位
}
}
swap(arr, left, idx - 1) // 遍历完了之后,把标尺元素交换到比它小的元素右边去,只需要和最右边的元素交换即可。
return idx
}
const quickSort = (arr, left = 0, right = arr.length - 1) => {
if (left < right) {
const center = finndCenter(arr, left, right) // 拿到标尺元素分区后的下标
quickSort(arr, left, center - 1) // 对左边的元素继续重复上面的操作
quickSort(arr, center + 1, right) // 对右边的元素继续重复上面的操作
}
return arr // 元素已排好序,直接返回 arr
}
最坏情况和最好情况
上面的代码中,我随机选择了数组中的一个元素作为基准元素,假如我刚好选到的是数组中的最小值或者最大值,就会出现最坏的情况,如下图:
我们来分析下时间复杂度,要排序肯定要把数组遍历一遍,就占去了 n,然后又遇到上图这种选到最小值为基准值分治,递归调用栈的长度也为 n。
这样的话就是最坏的时间复杂度 O(n^2)
如果刚好选中中间值,情况会好一些:
调用栈长度为 logn,时间复杂度为 O(nlogn)。
所以快速排序最坏的时间复杂度是 O(n^2),平均时间复杂度是 O(nlogn),平均时间复杂度也是最佳情况。
快速排序和归并排序
快排和归排的复杂度都是O(n*log n),且归并排序更稳定,为什么都用快排而不用归排?
算法的每一步实际上都需要一个固定时间量,被称为常量。
我们平时考虑时间复杂度的时候并不考虑常量的影响。
举个例子,简单查找和二分查找,假设简单查找常量为 10毫秒,二分查找常量为 1秒
现在在有 40个亿元素的列表中查找:
简单查找 | 二分查找 |
---|---|
10毫秒 * 40亿 = 463 天 | 1秒 * log(40亿) = 32 秒 |
可以看到,二分查找还是快得多,常量根本没什么影响。
但是对于快速排序和归并排序来说,常量就有影响了。
快排的常量比合并排序小,他们的运行时间都为 O(nlogn),快排的速度会更快。
但是你可能会问,人家归排比较稳定,最坏也是 O(nlogn),快排最坏是 O(n^2),怎么不考虑最坏的情况呢?
其实,绝大多数情况下,快排遇到的都是平均情况,也就是最佳情况,只有极个别的时候会是最坏情况,因此往往不考虑这种糟糕的情况。
小结
本文介绍了分治法,快速排序以及大 O 表示法的常量因素。
- 分治法将问题逐步分解,使用分治法处理列表时,基线情况很可能是空数组或只包含一个元素。
- 实现快速排序时,随机选择基准元素的情况下,就是时间复杂度的平均情况,也是最佳情况。
- 决定算法快慢还有常量因素,不同时间复杂度(简单查找和二分查找)的情况下常量无关紧要,相同时间复杂度(快排和归排)的情况下常量很重要。