排序在我们的的工程应用中无处不见,也有着非常重要的作用,比如你随意点开一个搜索引擎,搜索的结构就是经过排序而来。各种电商网站的秒杀活动,用户点击秒杀后,服务器会根据用户的请求时间进行排序。在我们的用的文档表格中,也存在各种排序。
所以排序真的是无处不见,所以在我们面试中出现排序也不足为奇了。今天就为大家带来面试中经常出现的一种排序算法,合并排序进行深度解析。
合并排序本质上是一个后续遍历
合并排序本质上与二叉树的后序遍历非常类似的。
首先你还先回忆一下二叉树的后续遍历,后序遍历有个三个重要的特点:
- 拿到子树的信息;
- 利用子树的信息;
- 整合出整棵树的信息。
对于合并排序来说,其实也是非常类似:
- 拿到子数组的信息;
- 利用子数组的信息;
- 整合(排序)出整个数组的信息。
简单利用伪代码表示就是:
不管是后续遍历,还是合并排序的三个特点,这里可以总结为三个关键点:
- 划分子结构
- 获取子结构的信息
- 利用子结构的信息整合成一个树/结果
1. 划分子结构
对于二叉树而言,子树的划分是天然的,已经在数据结构里面约定好了,比如 Node.left、Node.right。
但是对于数组而言,在切分的时候,如果想到达最优的效率,那么将数组切为平均的两半效率应该是最高的。
2. 获取子结构的信息
对于二叉树来说,获取子结构的信息就是或者左右子节点的信息。
对于合并排序来说,那么就分别需要对左子数组和右子数组进行排序。对子数组的排序,只需要递归就可以了。
3. 整合(排序)出整个数组/树的信息。
接下来,我们需要将从子结构里面拿到的信息进行加工。不同的需求会导致加工的方式也不太一样。
对于二叉树来说,非常简单,就是将节点值添加到结果中。
对于合并排序而言,我们需要将两个有序的子数组,合并成一个大的有序的数组。
最后,不管是二叉树还是合并排序都要考虑一下边界:
二叉树的边界就是节点不能为空。
合并排序的边界就是:
- 当 b >= e,说明这个区间是一个空区间,没有必要再排序;
- 当 b + 1 === e,说明只有一个元素,也没有必要排序。
小结
对于二叉树来说,代码相对比较简单。
对于二叉树来说,如何切分左右子数组?如何进行合并,合并时注意循环的条件,以及稳定排序的写法?都是在写算法时需要注意的。
接着我们利用刚才将的例子来看几个例子。
例1:排序链表
给你链表的头结点 head ,请将其按 升序 排列并返回 排序后的链表 。
这道题目就可以套用我们上面提到的模板。
第一步:划分子结构,对于链表来说划分子结构,也就是找到链表的中间节点。链表找中间节点也就是利用我上一篇文章中讲到的“快慢指针”。
第二步:获取子结构信息(递归的方式)。
第三步:整合信息,有了两个子结构信息,也就需要将两个子结构信息合成一个,对于链表来说就是合并两个有序链表。这里合并的过程中,还可以用到到我上一篇文章说到的“链表第一板斧,假头”。
最后少不了临界条件的判断。
完整的代码如下:
例2:寻找两个正序数组的中位数
给定两个大小分别为 m 和 n 的正序(从小到大)数组 nums1 和 nums2。请你找出并返回这两个正序数组的 中位数 。
算法的时间复杂度应该为 O(log (m+n)) 。
这是一道来自百度的面试题。解法有很多,我们重点介绍基于合并模板的解法。
如果单纯的不考虑复杂度,通过合并排序,我们已经能够将两个有序的数组合并成一个有序的数组了,再取这个有序数组的中位数。
但是这样操作的话,时间复杂度就变成 O(N),并且空间复杂度也是 O(N)。
如果在面试现场,面试官一定会问你,有没有更好的办法?所以我们应该有效地利用两个数组的有序性解决这道题。下面我会从简单的情况开始分析。
假设我们有一个一维有序数组,如果我们要拿第 9 小的数。(注:第 1 小就是最小的数。)只需要将前面 8 个数扔掉,然后排在前面的数就是第 9 小的数。
但是现在我们有多个有序数据,怎么办了?但是非常确认的是,我们如果想拿到第 9 小的数,一定需要丢 8 个数。
那么接下来,思考一下在两个数组 A,B 中如何扔掉这 8 个数?
- 要扔掉 4 个数,我们需要看一下两个数组前 4 个元素(平均分配一下);此时设 A[3] = L,B[3] = W。假设 L >= W,就需要证明:当 L >= W 的时候,[0, W] 都不可能是第 9 小的数,可以扔掉。
图片
- 当我们扔掉 4 个数之后,两个有序数组已经变成如下图所示的样子,由于我们的目标是扔掉 8 个数,扔掉 4 个数之后,还需要再扔 4 个数。此时我们只需要比较数组开头的一个元素 A[0], B[M] 的大小,谁小就把谁扔掉。这里我们假设 A[0] 比较小。
图片
- 此时还剩下 3 个数需要扔掉,那么按照上面的方式在进行丢弃就行。
所以总结一下,当我们需要丢弃 K 个元素的时候。k 是偶数的时候,我们只需要比较 A[k/2-1] 和 B[k/2-1] 的大小,谁小就扔掉对应的 [0...k/2-1] 这一段;k 是奇数的时候,我们只需要比较 A[k/2] 和 B[k/2] 的大小,谁小就扔掉对应的 [0...k/2] 这一段。不过由于整数在程序中的整除特性,我们可以将奇数和偶数的情况统一起来。需要扔掉 k 个数的时候,p = (k-1)/ 2,你只需要比较 A[p] 和 B[p] 的大小即可。如果 A[p] >= B[p],那么就可以把 B[0....p] 这段都扔掉。
一共要合并的长度可以认为是 N/2,然后每次取一半进行合并。因此,合并次数为 O(lgN),空间复杂度为 O(1)。
总结
通过合并排序,可以将两个有序的数组合并成一个有序的数组了。合并是一个非常经典的模板代码,你一定要理解并且背下来,很多地方都会用。比如合并有序链表,合并数组。一个小小的合并模板可就以解决这么多问题,多积累模版可以帮助我们在面试中快速答题。