排序在我们的的工程应用中无处不见,也有着非常重要的作用,比如你随意点开一个搜索引擎,搜索的结构就是经过排序而来。各种电商网站的秒杀活动,用户点击秒杀后,服务器会根据用户的请求时间进行排序。在我们的用的文档表格中,也存在各种排序。
所以排序真的是无处不见,因此,面试中出现关于排序的算法题也就不足为奇了。这篇文章通过面试中最经常出现的两种排序算法进行深度展开。
- 合并排序
- 快速排序
本文你将收获相应的思想和代码模板。
1.合并排序
合并排序本质上与二叉树的后序遍历非常类似的。
// 递归
function postOrder(root, array = []) {
if (root === null) return null;
postOrder(root.left, array);
postOrder(root.right, array);
array.push(root.val)
}
后序遍历有个三个重要的特点:
- 拿到子树的信息;
- 利用子树的信息;
- 整合树的信息;
这三个特点对应到合并排序就是:
- 拿到子数组的信息;
- 利用子数组的信息;
- 排序出数组的信息。
利用伪代码来表示就是:
function 后序遍历/合并排序:
子结构划分
sub = 子结构(子树/子数组)
full = 整合(sub)
这个伪代码总结为三个关键点:
- 划分子结构
- 获取子结构的信息
- 利用子结构的信息整合成结果
划分子结构
二叉树,子树的划分已经在数据结构里面约定好了:
root.left
root.right
数组,子结构的划分,如果想到达最优的效率,那么将数组切为平均的两半效率应该是最高的。
const mid = begin + ((end - begin)>>1)
数组a = [begin, mid) => 表示左子数组
数组a = [mid, end) => 表示右子数组
获取子结构的信息
二叉树,获取子树的信息的利用就是遍历左右子节点的信息。
postOrder(root.left)
postOrder(root.right)
合并排序,获取子数组的信息就是对左子数组和右子数组进行排序。对子数组的排序,只需要递归就可以了。
merge(a, begin, mid)
merge(a, mid, end)
利用子结构的信息整合成结果
二叉树,结果的合成就是将节点值添加到结果中。
array.push(root.val)
合并排序,结果的合成,我们需要将两个有序的子数组,合并成一个大的有序的数组。
let i = begin;
let j = mid;
let to = begin;
// 将两个数组合并,判断条件是,只有左右子数组中还有元素
while(i < mid || j < end) {
// 读取左数组的元素:
// - 左数组还存在元素并且左数组的开头元素小于右数组的开头元素
// - 右数组没有元素
if ((i < mid && a[i] =end) {
// t 为临时数组
t[to++] = a[i++];
} else {
// 读取右数组的元素
t[to++] = a[j++];
}
}
最后
最后,处理边界:
二叉树的边界就是节点不能为空。
if (root === null) return null;
合并排序的边界就是:
- 当 b >= e,说明这个区间是一个空区间,没有必要再排序;
- 当 b + 1 === e,说明只有一个元素,也没有必要排序。
if (b > e || b + 1 >= e) {
return
}
小结
二叉树,代码如下。
function postOrder(root, array = []) {
// 边界处理
if (root === null) return null;
// 第一步:划分子结构,二叉树在结构上已经划分了子结构 root.left、root.right 可以直接通过树的子节点拿
// 第二步:获取子结构信息(递归的方式)
postOrder(root.left, array);
postOrder(root.right, array);
// 第三步:整合子结构信息
array.push(root.val)
}
合并排序,如何切分左右子数组?如何进行合并,合并时注意循环的条件,以及稳定排序的写法?都是在写算法时需要注意的。整体代码如下:
function merge(a, t, b, e) {
// 边界处理
if (b > e || b + 1 >= e) {
return
}
/*********************核心代码****************************/
// 第一步:划分子结构
const mid = b + ((e-b)>>1);
// 第二步:获取子结构信息(递归的方式)
merge(a, t, b, mid); // 左边子结构
merge(a, t, mid, e); // 右边子结构
// 第三步:整合子结构信息
let i = b;
let j = mid;
let to = b;
// 注意:下面是一个很重要的模板❤❤❤❤❤❤❤❤❤❤❤❤
// 将两个数组合并,判断条件是,只有左右子数组中还有元素
while(i < mid || j < e) {
// 读取左数组的元素:
// - 左数组还存在元素并且左数组的开头元素小于右数组的开头元素
// - 右数组没有元素
if ((i < mid && a[i] =e) {
t[to++] = a[i++];
} else {
// 读取右数组的元素
t[to++] = a[j++];
}
}
/*********************核心代码****************************/
// 将合并的结果拷贝到源数组中
for (let i = b; i < e; i++) {
a[i] = t[i];
}
}
function mergeSort(nums) {
if (nums === null || nums.length === 0) {
return;
}
merge(nums, [], 0, nums.length)
return nums;
}
2.快速排序
快速排序和合并排序一样,可以利用二叉树的思想来解决,合并排序本质上与二叉树的后序遍历非常类似的,而快速排序本质上与二叉树的前序遍历非常类似的。
前序遍历:
// 递归
function preOrder(root, array = []) {
if (root === null) return null;
array.push(root.val);
postOrder(root.left, array);
postOrder(root.right, array);
}
后序遍历有个三个重要的特点:
- 整合树的信息;
- 拿到子树的信息;
- 利用子树的信息;
这三个特点对应到合并排序就是:
- 排序出数组的信息。
- 拿到子数组的信息;
- 利用子数组的信息;
利用伪代码来表示就是:
function 前序遍历/快速排序():
子结构划分
获取根节点信息;
将根节点的信息传递左右子树/左右子数组;
这个伪代码总结为三个关键点:
- 划分子结构
- 根节点的信息处理
- 将根节点的信息,传递给左右子树/左右子数组。
划分子结构
二叉树,子树的划分已经在数据结构里面约定好了:
root.left
root.right
数组,子结构的划分,选择一个数 X,并且利用这个数,将数组分成三部分:
- 小于 X 的部分;
- 等于 X 的部分;
- 大于 X 的部分;
利用 x 将数组分为三份
左子数组 = [小于 x 的部分] = [b, l)
根节点 = [等于 x 的部分] = [l, i)
右子数组 = [大于 x 的部分] = [i, e)
根节点的信息处理
二叉树,根节点就是当前节点,节点的处理也即是收集节点信息。
// 根节点信息处理
array.push(root.val);
排序算法的"根节点"也就是选择的元素,并且排序算法会通过划分的子结构和选中的元素来进行排序处理也就是上面说的特殊处理;对于排序算法来说,"根节点"和划分子结构息息相关。
if (a[i] < x) {
// 小于 x 的部分
} else if (a[i] === x) {
// 等于 x 的部分
} else {
// 大于 x 的部分
}
将根节点的信息,传递给左右子树/左右子数组
二叉树,通过递归的方式处理左右子树。
// 二叉树的前序遍历拿左右子树的信息
preOrder(root.left);
preOrder(root.right);
而排序算法需要分别对左子数组和右子数组进行排序,那么类似的对子数组的排序应该也只需要递归就可以了。
// 快速排序去拿左右子数组的信息
qsort(a, b, l);
qsort(a, i, e);
最后
最后,不管是二叉树还是快速排序都要考虑一下边界:
二叉树的边界就是节点不能为空。
if (root === null) return null;
快速排序的边界就是:
- 当 b >= e,说明这个区间是一个空区间,没有必要再排序;
- 当 b + 1 === e,说明只有一个元素,也没有必要排序。
if (b > e || b + 1 >= e) {
return;
}
小结
二叉树,代码如下。
function preOrder(root, array = []) {
// 边界处理
if (root === null) return null;
// 第一步:划分子结构,二叉树在结构上已经划分了子结构 root.left、root.right 可以直接通过树的子节点拿
// 第二步:根节点的信息处理
array.push(root.val)
// 第三步:将根节点的信息,传递给左右子树/左右子数组(递归的方式)
postOrder(root.left, array);
postOrder(root.right, array);
}
对于快速排序来说,如何划分子结构?如何到达最优的效率?都是在写算法时需要注意的。整体代码如下:
// 交换数组中两个元素的值
function swap(A, i, j) {
const t = A[i];
A[i] = A[j];
A[j] = t;
}
function qsort(a, begin, end) {
// 边界情况
if (b > e || b + 1 >= e) {
return
}
/*********************核心代码****************************/
// 第一步:划分子结构
const mid = b + ((end - begin) >> 1);
// 第二步:获取根节点信息 x
const x = a[mid];
// 根据 x 将数组一分为三 【三路切分】
let l = begin;
let i = begin;
let r = end - 1;
while(i < r) {
if (a[i] < x) {
// 小于 x 的部分
swap(a, l++, i++);
} else if (a[i] === x) {
// 等于 x 的部分
i++;
} else {
// 大于 x 的部分
swap(a, r--, i);
}
}
// 第三步:将根节点的信息传递左右子子树
qsort(a, b, l);
qsort(a, i, e);
/*********************核心代码****************************/
}
// 主函数,将数组nums排序
function quickSort(nums) {
if (nums == null)
return;
qsort(nums, 0, nums.length);
}
总结
通过合并排序和快速排序,可以得出结论,数组其实是另外一种形式的二叉树,只不过有时候需要我们动态地把左/右子树给切分出来,不同的切分方式,可以解决不同的问题。大家也可以自己思考和尝试,看看还能不能发现更多排序的特点和巧妙用法,并且将它们总结下来。欢迎大家一起在评论区交流。
参考
- https://leetcode.cn/problems/median-of-two-sorted-arrays/solutions/258842/xun-zhao-liang-ge-you-xu-shu-zu-de-zhong-wei-s-114/
- https://kaiwu.lagou.com/course/courseInfo.htm?courseId=685#/detail/pc?id=6697
- https://juejin.cn/post/7286307632193273915
- https://juejin.cn/post/7287914116296458275
- https://juejin.cn/post/7287473826060304445