leetcode中有几道关于归并排序的题目,本文主要介绍归并排序及其非递归实现,并解决148,315和493三道mid和hard级别的题目。
归并排序简介
归并排序是一种分治算法,它主要基于merge操作(详见let题目88),将两个有序数组合并成一个大的有序数组。所以我们可以将一个数组分割成两部分,分别递归的排序,再将两个排序结果归并成一个大的有序数组,完成排序。该算法能保证长度为N的数组,排序所消耗的时间与NlgN成正比,唯一不足的是,需要额外的N空间。
_merge函数的声明如下,result是归并排序所需的额外空间,[start, mid),[mid,end)都是nums中的有序区间,归并为一个大的有序区间[start1, end2),临时存放在result中;
所以merge完还需要把result中的数据倒腾回nums;
void _merge(vector<int>& result, const vector<int>& nums, size_t start, size_t mid, size_t end);
稳定性
归并排序是稳定的。
三个优化点
1 merge优化
上述merge函数有一个优化点:如果两个子数组没有交集,即第一个数组的最后一个元素<=第二个数组的第一个元素,那么不需要归并,可以直接返回。
2 小数组排序优化
当子数组足够小的时候,直接使用插入排序。避免来回的merge拷贝。
3 编程技巧
不要让辅助数组result和nums之间来回copy,而是利用语言特性避免拷贝。对于c++来说,就是利用vector的swap操作来消除拷贝;
实现
常见的归并排序实现是自顶向下的递归实现,本文要介绍的是自底向上的迭代实现,不使用递归。
以长度为8的数组 2 7 8 0 3 6 1 9 为例,
方法:
初始化step为1,
第一步:俩俩合并长度为step的子数组,第一次合并后,结果为:
2 7 0 8 3 6 1 9
第二部,step翻倍,即step为2,回到第一步,继续合并,结果为:
0 2 7 8 1 3 6 9
现在,这个数组的两个大小为4的子数组都各自有序了。
继续step翻倍,合并,结果为:
0 1 2 3 6 7 8 9
继续step翻倍,现在是8,达到了数组的长度,归并排序结束了。
代码如下:
void mergeSort(vector<int>& nums) {
if (nums.size() <= 1)
return;
vector<int> result(nums.size()); // 额外的辅助空间
for (size_t step = 1; step < nums.size(); step *= 2) {
// nums[i + step , i + step * 2), nums[i + step * 2, i + step * 3) is sorted,
// now merge them, i is 0 to nums.size() by 2*step
for (size_t i = 0; i < nums.size(); i += 2 * step) {
size_t start1 = i, mid = std::min(i + step, nums.size());
size_t end2 = std::min(mid + step, nums.size());
_merge(result, nums, start1, mid, end2);
}
nums.swap(result); // 利用swap避免来回拷贝
}
}
2019.08.09补充优化点:
对于自底向上的迭代归并排序,有几个优化方法:
1 自然归并排序,timsort中有类似思想:
普通自底向上算法,有序子序列大小是从1开始的,浪费了数组中的有序信息,比如:
对于序列 1 3 5 7 2 4 6 8
按照普通做法,会先merge 13, 57, 24, 68,再merge 1357, 2468。。。
事实上,该序列已经是两个有序子序列组成的: 1357 + 2468
所以应该先预处理数组,得到每个有序子序列的结束点:4(元素2)和8(数组尾),意思是[0,4),[4,8)这些序列是有序的,直接两两归并。
不过尝试实现了一下,可能代码写的有问题?性能并没有什么变化。
vector<int> result(nums.size());
queue<int> sorted_index
// 先O(N)预处理,记录有序子序列的终点。每一个终点是下一个子序列的起点。
for (size_t i = 0; i < nums.size(); ) {
size_t sorted = i+1;
for (; sorted < nums.size(); sorted++) {
if (nums[sorted] < nums[sorted - 1])
break;
}
if (i == 0 && sorted == nums.size())
return;
sorted_index.push(sorted);
i = sorted;
}
while (sorted_index.size() >= 2) {
size_t start = 0;
std::queue<int> new_sorted_index;
// 两两归并子序列
while (sorted_index.size() >= 2) {
size_t mid = sorted_index.front();
sorted_index.pop();
size_t end = sorted_index.front();
sorted_index.pop();
_merge(result, nums, start, mid, end);
std::copy(result.begin() + start,
result.begin() + end,
nums.begin() + start);
new_sorted_index.push(end);
start = end;
}
if (!sorted_index.empty()) {
new_sorted_index.push(sorted_index.front());
}
sorted_index.swap(new_sorted_index);
}
2 利用插入排序预先优化
依然是这个问题:普通自底向上算法,有序子序列大小是从1开始的;
为了提高归并时初始的子序列长度,先用插入排序分段处理整个数组,然后再执行归并;
简单测试了一下,在debug模式下性能提高了30%,release模式下,提高了7%左右,效果还是比较明显。
// 先插入排序优化,提高初始归并的子序列长度。
// 处理后,数组的[0,16), [16,32)...的子序列已经有序了。
int initStep = 16;
for (size_t i = 0; i < nums.size(); i += initStep) {
size_t end = i + initStep;
if (end > nums.size())
end = nums.size();
_insertSort(nums, i, end);
}
// 开始归并,初始长度为16,不再是1.
for (size_t step = initStep; step <= nums.size(); step *= 2) {
// nums[i + step , i + step * 2), nums[i + step * 2, i + step * 3) is sorted,
// now merge them, i is 0 to nums.size() by 2*step
for (size_t i = 0; i < nums.size(); i += 2 * step) {
size_t start1 = i, end1 = std::min(i + step, nums.size());
size_t start2 = end1, end2 = std::min(end1 + step, nums.size());
(void)start2;
_merge(result, nums, start1, end1, end2);
}
nums.swap(result);
}
let相关题目
148. 链表的排序
Loading...leetcode.com![7b95c0208975b472c6d41d9c3e8f10be.png](https://i-blog.csdnimg.cn/blog_migrate/03240f53f1b1bf0b180de157ff362cf8.jpeg)
该题目要求以nlgn的复杂度排序链表。很容易想到快速排序或归并排序,但是链表不像数组,切分是有复杂度的,需要遍历。所以我参考了一下std::list的sort实现,发现它正是使用自底向上的归并排序解决的。试了一下,通过测试只用了28ms,beat 100%~好吧,STL的算法就是屌。
和数组归并排序不同的是,链表归并排序只需要lgn的空间,就能实现nlgn的排序。也就是说基本可以认为不需要额外空间(例如就算链表100万个节点,也只需要20个的额外节点,忽略不计)。
还是先举个例子便于理解:
以下描述中,额外空间固定的是ListNode* tmp[64],也就是64个指针,可以为长度为2的64次方个节点的链表进行排序。并设置一个toFill变量,表示tmp的下标,它是一个优化,toFill之后的tmp不需要考虑,初始化为0。
假设待排序链表是: list "5,3,1,4,2"
第1步: 将链表头节点 5挪到tmp[0],更新toFill = 1
第2步: 继续将链表新头节点 3放到tmp[0], 因为tmp[0]上已经挂着一个节点,直接归并,得到了子链表"3,5", 现在将它挪到tmp[1],更新toFill = 2
第3步: 继续将链表新头节点 1 放到tmp[0], 因为tmp[0]是空的,啥都不做。
第4步: 继续将链表新头节点 4放到tmp[0], 因为tmp[0]上已经挂着一个节点1,直接归并,得到了子链表"1,4", 现在将它挪到tmp[1],而在第二步tmp[1]已经挂了链表“3,5”,所以再归并,得到了一个有序子链表:"1,3,4,5",挪到tmp[2],更新toFill = 3
第5步: 继续将链表的最后一个节点 2 放到tmp[0], 因为tmp[0]是空的,啥都不做,toFill还是3
好了,现在,待排序链表空了,而tmp是这样的:
tmp[0] 保存了长度为1的链表: "2"
tmp[2]保存了长度为4的链表:"1,3,4,5"。
现在toFill = 3。
所以直接将tmp[1],tmp[2]归并到tmp[0],那么tmp[0]最终是"1,2,3,4,5",直接返回头指针即可。
代码不贴了,有兴趣可以直接查看STL的list源码。
let315 统计右边比自己小的元素个数。
待写。我的实现提交后比较低效,beat 36%。。。 优化后把这道题补上
let493
理由同上。
扩展练习
1.以nlgn的复杂度,lgn的额外空间,随机打乱一个链表。
2.编写一个不改变数组的归并排序,返回一个int perm[]数组,其中perm[i]表示原数组中第i小的元素的位置。