前言
虽说网上有许多的排序文章了,但是在叙述方面,往往都直接以 代码 + 图片 的形式来说明。
既没有思路,又没有推导过程,往往会导致死记硬背,考虑到对新手并不友好,于是写下这篇文章。
希望按通俗易懂的思路聊聊排序,也巩固一下自己的记忆。
开胃小菜之冒泡排序
原理
// 思路: 每次确定一个最大值置于队尾,直到确定了所有的最大值
那么问题就分解为两个
- 如何确定一个最大值
- 如何将最大值置于队尾
冒泡排序给出了巧妙的解答:比较 j 和 j + 1,哪个大哪个往后放,通过不断循环增 j,一定能把最大值运送到最后一位。 这种做法即达成了确定最大值,又将最大值运送到了最后一位,一箭双雕。
实现
// 第一重 for 循环:每次能确定 1 个最大值,所以要走 n - 1 遍
// 第二重 for 循环:比较 j 和 j + 1,哪个大哪个往后放,通过不断循环,一定能把最大值运送到最后一位
const bubbleSort = (arr) => {
for (let i = 0; i < arr.length - 1; i++) {
for (let j = 0; j < arr.length - i; j++) {
if (arr[j] > arr[j + 1]) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
}
}
}
return arr;
};
分析
复杂度
- 最好情况:O(n)(当输入数组已经是有序时,仅需一轮检查)
- 平均情况:O(n²)(需要多次遍历)
- 最坏情况:O(n²)(当输入数组是反向排序的时,需进行最大次数的交换)
O(n²) 很好理解,双重 for 循环,自然会产生平方,但最好情况的 O(n)是如何产生的呢?
实际上,想达到 O(n),代码是不够完善的,我们需要稍作修改:
const bubbleSort = (arr) => {
for (let i = 0; i < arr.length - 1; i++) {
let swapped = false; // 标记是否发生了交换
for (let j = 0; j < arr.length - 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;
};
可以看到,我们可以通过引入一个标志位 swapped,在每次内层循环中判断是否有元素发生了交换。如果没有交换,说明数组已经是有序的,可以提前终止外层循环。
这么一来,只走一次 n ,就已经结束了代码,这才是真正的 O(n)
稳定性
稳定性一般指,如果 a 和 b 相等,且 a 原先在 b 前,那么排序后,a 也应该在 b 前。
注意看我们的核心交换代码
if (arr[j] > arr[j + 1]) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
}
仅在 a > b 时,才进行交换,而 a = b 时并不做处理,所以他是稳定的。
剖析快速排序
原理
// 快速排序:充分利用递归思想的排序,每次取出一个值,并将其他值分为左右两堆, 递归 处理
问题也划分为两个:
- 怎么取出来一个值?
- 递归怎么写
对于问题1,随便取即可,一般会取数组中间那个值。而问题2,相信写过斐波那契数列的同学应该并不陌生,直接来看代码吧。
实现
const quickSort = (arr) => {
// 递归的尽头,数组长度小于等于 1 就不需要排序了
if (arr.length <= 1) {
return arr;
}
// 选一个基准数
const midIndex = Math.floor(arr.length / 2);
const mid = arr[midIndex];
const left = [];
const right = [];
// 排除这个基准数
arr = [...arr.slice(0, midIndex), ...arr.slice(midIndex + 1)];
// 分堆
arr.forEach((item) => {
item > mid ? right.push(item) : left.push(item);
});
// 递归
return [...quickSort(left), mid, ...quickSort(right)];
};
分析
复杂度
- 最好情况:O(n log n)
- 平均情况:O(n log n)
- 最坏情况:O(n²)
先来看最坏情况是如何产生的:每次通过 mid 进行分堆时,都分出来一个空堆,一个满元素堆,这相当于只排序了一个 mid 元素,所以一共要分 n 个才能分完。而每次划分,我都需要比较 n 次,当前项比 mid 更大还是更小,这么一来就是 n * n 的复杂度了。
再说最好情况:显然,应该跟最坏情况相对,每次都能分出来两个数量相等的堆。
这么一来,问题转化为了:要多少次 / 2 才能使最后堆的元素数量为 1 ?
我们可以列出公式:设第k步后,n/(2^k) = 1
那么就可以推导出:k = log₂(n), 缩写即为 k = logn。
所以我们需要划分 logn 次,并且每次划分,都需要比较 n 次,那么复杂度自然就为:n * logn,即 O(nlogn)
稳定性
回顾冒泡排序的稳定性,通过 a = b 时并不做处理的方式来实现了稳定。但快速排序不同,由于抽取不同的基准数,会导致一些不稳定的情况。举个例子说明吧:
比方说 [-1,-1,-1]这个数组,第二个 -1 被抽取为基准数时,第一和第三个 -1 都会被划分到另一边去,最终和基准数合并。显然,顺序相较于之前被打乱了,所以它是不稳定的。
其他排序算法大同小异,暂不赘述了。如果这篇文章帮到你,欢迎点赞~