这是我参与更文挑战的第 18 天,活动详情查看: 更文挑战
排序在工程应用中有非常重要的作用,比如你随意点开一个搜索引擎,通过搜索得到的结果就是经过排序处理的;你参加互联网电商的秒杀活动,用户请求到达服务器之后,服务端程序会根据请求到达的时间进行排序处理。在数据库的设计中,字段的有序性也会影响我们的查询性能。
因此,面试中出现关于排序的算法题也就不足为奇了,接下来我将探讨面试中最经常出现的排序算法之一:归并排序
归并排序
归并排序是将一个数组里面的元素进行排序的有效手段。它应该是在读书时学习的一个非常经典的排序算法了。不过这里我们不再采用教科书上的讲解方式,而是采用与“二叉树”进行结合的方式来学习。合并排序本质上与二叉树的后序遍历非常类似的。
后序遍历有个重要的特点:拿到子树的信息,利用子树的信息,整合出整棵树的信息。
如果我们把有序性也当成信息,那么归并排序本质上就是一个后序遍历。
可以用伪代码表示如下:
function 后序遍历/归并排序():
sub_info = 子结构(子树/子数组)
full_info = 整合(sub_info)
复制代码
那么合并排序/后序遍历的考点就可以总结为以下 3 点:
- 如何划分子结构
- 获取子结构的信息
- 利用子结构的信息,整合出整棵树的信息
那么接下来我们就从这三方面入手。并且与二叉树的后序遍历的代码对照着一起看。
在正式开始讲题目之前,我们先学习一下开闭原则。实际上,绝大部分语言在设计的时候,都是按照这个原则。比如数组的第一个元素取下标 0,那么长度为 n 的数组,就需要用开闭原则区间 [0, n) 来表示。
这样表示好处理,区间长度直接就是右边界减去左边界。比如 [0, 10) 的区间长度就是 10。但是如果使用双闭区间,比如 [0, 9],那么求区间长度时,运行式为:9 – 0 + 1。还需要在减法的基础上加 1。
1. 划分
首先我们看一下如何划分子数组。对于二叉树而言,子树的划分是天然的,已经在数据结构里面约定好了,比如 TreeNode.left、TreeNode.right。
但是对于数组而言,在切分的时候,如果想到达最优的效率,那么将数组切为平均的两半效率应该是最高的(可以联想到二叉平衡树的效率)。
二叉树的写法如下:
// 二叉树的后序遍历左右子树
root.left, root.right; 可以直接通过root的结构体信息得到。
复制代码
归并排序的写法如下:
// 归并排序切分左右子数组
final int m = b + ((e-b)>>1);
数组a, [b, m) => 表示左子数组。
数组a, [m, e) => 表示右子数组。
需要通过计算得到。
复制代码
2. 子数组的信息
由于这里是排序,那么就分别需要对左子数组和右子数组进行排序。如果你还能想起来我们在“第 06 讲”介绍过的“二叉树的后序遍历”,那么对子数组的排序,只需要递归就可以了。
// 二叉树的后序遍历拿左右子树的信息
postOrder(root.left);
postOrder(root.right);
复制代码
归并排序则需要这样写:
// 归并排序去拿左右子数组的信息
merge_sort(a, b, m);
merge_sort(a, m, e);
复制代码
3. 信息的整合
接下来,我们需要将从子树/子数组里面拿到的信息进行加工。不同的需求会导致加工的方式也不太一样。对于合并排序而言,我们需要将两个有序的子数组,合并成一个大的有序的数组。
最后,还需要考虑一下边界:
- 当 b >= e,说明这个区间是一个空区间,没有必要再排序;
- 当 b + 1 == e,说明只有一个元素,也没有必要排序。
以上两种边界情况分别可以对应到当二叉树为空,以及二叉树只有一个结点的情况。
完整代码
到这里,我们已经可以写出归并排序的代码了(解析在注释里):
class Solution {
private void msort(int[] a, int b, int e, int t[]) {
// 空区间 或 只有一个元素
// 为了防止b + 1溢出,这里用b >= e先判断一下
if (b >= e || b + 1 >= e) {
return;
}
// 分成两半, 二叉树可以自动取得root.left, root.right
// 这里我们需要通过计算来得到左右子数组。
final int m = b + ((e - b) >> 1);
// 类比二叉树分别遍历左子树和右子树。
msort(a, b, m, t);
msort(a, m, e, t);
// i指向左子数组的开头,j指向右子数组的开头
// to指向要临时数组t与区间[b, e)对应的位置
int i = b;
int j = m;
int to = b;
// 将两个子数组进行合并, 注意下面是一个很重要的模板
// 这里的判断条是,只要两个子数组中还有元素
while (i < m || j < e) {
// 如果右子数组没有元素 或 左子数组开头的元素小于右子数组开头的元素
// 那么取走左子数组开头的元素
// 考点:a[i] <= a[j]这样可以保证合并排序是稳定的,不要写错!
if (j >= e || i < m && a[i] <= a[j]) {
t[to++] = a[i++];
} else {
// 否则就是取右子数组开头的元素
t[to++] = a[j++];
}
}
// 把合并的结果拷回原来的数组a[]
for (i = b; i < e; i++) {
a[i] = t[i];
}
}
public void mergeSort(int[] nums) {
// 如果传进来的数组为空
if (nums == null || nums.length == 0) {
return;
}
// t是一个临时中转的数组
int[] t = new int[nums.length];
msort(nums, 0, nums.length, t);
}
}
复制代码
复杂度分析:时间复杂度 O(NlgN),空间复杂度 O(N)。
小结
这里我们归纳一下归并排序的考点:
- 如何切分左右子数组;
- 如何进行合并,合并时注意循环的条件,以及稳定排序的写法;
- 开闭原则。