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