各位老铁,从本节开始,我们进入排序算法的世界。
对于前端来说,排序算法在应用方面似乎始终不是什么瓶颈——JS 天生地提供了对排序能力的支持,很多时候,我们实现排序只需要这样寥寥数行的代码:
arr.sort((a,b) => {
return a - b
})
以某一个排序算法为“引子”,顺藤摸瓜式地盘问,可以问出非常多的东西,这也是排序算法始终热门的一个重要原因——面试官可以通过这种方式在较短的时间里试探出候选人算法能力的扎实程度和知识链路的完整性。因此排序算法在面试中的权重不容小觑。
以面试为导向来看,需要大家着重掌握的排序算法,主要是以下5种:
基础排序算法:
- 冒泡排序
- 插入排序
- 选择排序
- 进阶排序算法
- 归并排序
- 快速排序
我们的学习安排就按照这个从基础到进阶的次序来。
和以往不同的是,本专题的讲解线索不再是“题目”,而是排序算法本身:针对每一种算法,我都会首先介绍其思想,然后为大家逐步示范一遍真实的排序过程,接着为大家做编码教学。最后,别忘了,排序算法的时间复杂度也是一个不能忽视的考点,“编码复盘”部分我们不见不散。
注意:考虑到排序类题目在未经特别声明的情况下,都默认以“从小到大排列”为有序标准。因此下文中所有”有序“的描述指代的都是“从小到大排列”。
# 冒泡排序
30 秒速记
- 相邻两个比,前面大就交换,每一轮把剩下的最大值「冒」到右边
- 第
i轮结束,右边i个元素已经在最终位置 - 加
swapped标记:一轮没交换就提前结束,有序输入最好O(n) - 平均、最坏
O(n²),原地O(1);只在严格>时交换才稳定 - 生产代码别手写冒泡,直接用
Array.prototype.sort,从ES2019起规范要求它稳定
冒泡排序就是相邻两个数比大小,前面大就换,一轮下来最大的数会被一路推到最右边,像气泡浮上来一样。 所以每过一轮,右边就多一个排好的元素,下一轮不用再管它。我写的时候会加一个 swapped 标记,一整轮都没交换说明已经有序,直接退出,这样有序输入只要 O(n)。平均和最坏还是 O(n²),空间 O(1),比较用严格大于就能保证稳定。
function bubbleSort(arr) {
for (let i = 0; i < arr.length - 1; i++) {
let swapped = false
for (let j = 0; j < arr.length - 1 - i; j++) {
if (arr[j] > arr[j + 1]) { // 用 > 不用 >=,相等不换才稳定
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]
swapped = true
}
}
if (!swapped) break
}
return arr
}
bubbleSort([5, 3, 2, 4, 1]) // [1, 2, 3, 4, 5]
回答参考:“冒泡维护的是右侧已排好区间。每轮扫描相邻对,把最大值逐步推到边界;我会加 swapped 标记优化已有序输入。”
面试官追问
追问 1比较条件写成 >=,结果是对的,有什么问题?
相等的两个元素也会被交换,排序就不稳定了。比如订单按金额排,同金额的两条本来按下单时间排好,排完顺序乱了。改成严格 >。
追问 2输入已经有序,你的冒泡还会跑满 n 轮吗?
加了 swapped 就不会。第一轮比较 n-1 次没发生交换,直接 break,这就是「最好 O(n)」的来源。没加标记的版本,有序输入也是 O(n²)。
追问 3完全逆序的数组排完,末尾有个大数没归位,先查哪?
查内层边界。每轮要比到未排序区最后一对,也就是 j < len - 1 - i,写成 len - 2 - i 就少比一对。逆序输入每一对都要换,最容易把这种差一错误暴露出来。
追问 4记录「最后一次交换的位置」比 swapped 好在哪?
swapped 只能判断要不要提前结束,最后交换位置还能告诉你它后面都已经有序,下一轮直接比到那里。对「前半乱、后半有序」的数据更省,但多一个变量,边界更容易写错,面试写 swapped 就够了。
追问 5真实项目里会用冒泡吗?
基本不会,直接 arr.sort((a, b) => a - b)。V8 从 7.0 开始用 TimSort,稳定且对近乎有序的数据很快。冒泡的价值在于面试考你能不能把循环边界写对。
# 基本思路分析
30 秒速记
- 从头开始比相邻两项,前大后小就交换,否则不动
- 一轮下来,未排序区的最大值一定被推到区间最右边
- 第
round轮结束后,最后round个元素归位,下轮不用再碰 - 内层只比到
j < n - 1 - round,访问arr[j]和arr[j + 1] - 外层其实跑
n - 1轮就够,剩最后一个时它自然就位
冒泡的思路就一句话:一直比相邻两个,前面大就换,每一轮能确定一个当前最大值的位置。 为什么最大值一定会到最右边?因为它不管从哪开始,往右遇到的都比它小,每次比较都会被换过去,一路换到底。所以 n 个数最多 n - 1 轮就排完了,而且每轮的扫描范围都可以缩短一格。说清这个「右侧已排好区间」的不变量,比背代码重要,面试官追问边界时全靠它。
冒泡排序的过程,就是从第一个元素开始,重复比较相邻的两个项,若第一项比第二项更大,则交换两者的位置;反之不动。
每一轮操作,都会将这一轮中最大的元素放置到数组的末尾。假如数组的长度是 n,那么当我们重复完 n 轮的时候,整个数组就有序了。
面试官追问
追问 1为什么一轮结束最大值一定在最右边,而不是第二大的?
最大值从它所在的位置开始,跟右边每个数比都更大,每次都会被换到右边一格,直到区间末尾。第二大的数遇到最大值就停了,过不去。
追问 2内层改成每隔一个比一次,看起来也在交换,为什么不对?
冒泡靠的是相邻比较组成一条传递链,最大值才能一格一格挪到底。跳着比链就断了,比如 [1, 3, 2] 中 3 和 2 永远没比过,结果可能根本不有序。
追问 3n 个元素一定要跑 n 轮吗?
n - 1 轮就够,前 n - 1 大都归位了,剩下那个只能是最小的。再加上 swapped 标记,中途有序还能更早停。
追问 4只有随机数组测试,你会补哪几个用例?
空数组、单元素、两个元素正序和逆序、全相同、已排序、完全逆序。两元素逆序专门抓 j < n - 1 - round 写多减一的问题,全相同的用来检查是不是做了多余交换。
追问 5怎么用「右侧已排好区间」来调试一个错误实现?
每轮结束打印数组,断言最后 round 个元素和标准排序结果的尾部一致。哪一轮第一次断言失败,就是哪一轮的内层边界或比较条件出了问题。
# 真实排序过程演示
30 秒速记
- 以
[5, 3, 2, 4, 1]为例:第一轮5一路换到末尾 →[3, 2, 4, 1, 5] - 第二轮
4归位 →[2, 3, 1, 4, 5],第三轮3归位 →[2, 1, 3, 4, 5] - 第四轮
2和1交换后整体有序 →[1, 2, 3, 4, 5] - 不交换的比较也算一步(比如
3和4比完不换),演示时别漏 - 基础写法有序了也不会停,要靠
swapped才能提前结束
手推冒泡的关键是看每一轮结束时,右边多了哪个排好的数。 拿 [5, 3, 2, 4, 1] 来说,第一轮 5 连换四次到了末尾,变成 [3, 2, 4, 1, 5],这时候只是 5 归位了,整体还是乱的。第二轮 4 归位,第三轮 3 归位,第四轮把 2 和 1 一换就全有序了。基础写法这时候不会停,还会接着比,这就是后面要加标记优化的原因。
下面我们基于冒泡排序的思路,尝试对以下数组进行排序:
[5, 3, 2, 4, 1]
首先,将第一个元素 5 和它相邻的元素 3 作比较,发现5 比 3 大,故将 5 和 3 交换:
[3, 5, 2, 4, 1]
↑ ↑
将第二个元素 5 和第三个元素 2 作比较,发现 5 比 2大,故将 5 和 2 交换:
[3, 2, 5, 4, 1]
↑ ↑
将第三个元素 5 和第四个元素 4 作比较,发现 5 比 4 大,故将 5 和 4 交换:
[3, 2, 4, 5, 1]
↑ ↑
将第四个元素 5 和第五个元素 1 作比较,发现 5 比 1 大,故将 5 和 1 交换:
[3, 2, 4, 1, 5]
↑ ↑
至此我们就完成了一轮排序,此时,五个数中最大的数字 5 仿佛气泡浮出水面一样,被”冒“到了数组顶部。这也是冒泡排序得名的原因。
重复上面的操作,我们继续从第一个元素开始看起。比较 3 和 2,发现 3 比 2 大,交换两者:
[2, 3, 4, 1, 5]
↑ ↑
比较 3 和 4,发现 3 比 4 小,符合从小到大排列的原则,故保持不动:
[2, 3, 4, 1, 5]
↑ ↑
比较 4 和 1,发现 4 比 1 大,交换两者:
[2, 3, 1, 4, 5]
↑ ↑
比较 4 和 5,发现 4 比 5 小,符合从小到大排列的原则,故保持不动:
[2, 3, 1, 4, 5]
↑ ↑
