前文,我们讲解的递归是什么,要实现递归的三要素,递归在二分查找中的应用,这篇文章就讲一下递归在排序中的应用,如何将排序的时间复杂度从选择排序、插入排序的 O(n^2) 提升至 O(nlogn)。
归并排序 Merge Sort
归并排序会把序列分成长度相同的两个子序列,直至无法继续往下拆分(也就是每个子序列中只有一个数据),这是递的部分。
当子序列无法继续往下分,就对子序列进行归并(把两个排好序的子序列合并成一个有序序列),该操作会一直重复执行,直到所有子序列都归并为一个整体为止,这是归的部分。
比如对数组 6,4,3,7,5,1,2 进行归并排序:
⨳ 要想对 6,4,3,7,5,1,2 进行排序,必先对 6,4,3,7 和 5,1,2 进行排序;
⨳ 要想对 6,4,3,7 进行排序,必先对 6,4 和 3,7 进行排序;
⨳ 要想对 6,4 进行排序,必先对 6 和 4 进行排序;
⨳ 要想对 6 和 4 进行排序,因子数组序列只有一个元素不需要排序,为递归终止条件。
递的过程写的这,就能写出归并排序的大致逻辑了:
// 对 数组 arr 的 [head,tail] 进行排序
public void mergeSort(int[] arr,int head,int tail){
if(head>=tail){
return; // 最小问题,[head,tail] 中只有一个元素或没有元素,无需排序
}
// 拆分数组
int mid = (head+tail) / 2 ;
// 对 arr[head,mid] 进行排序
mergeSort(arr,head,mid);
// 对 arr[mid+1,tail] 进行排序
mergeSort(arr,mid+1,tail);
// 将 arr[head,mid] 和 arr[mid+1,tail] 进行合并
merge(arr,head,mid,tail);
}
直观上,mergeSort 调用了更小子序列的 mergeSort,不用深入进去,仅仅需要知道,当代码执行到第 11 行时,arr[head,mid] 已经排好序了,arr[mid+1,tail] 也已经排好序了,你要做的仅仅是将两个有序的子序列合并。
如果不理解,可以再回想一下递归那篇讲的例题:
⨳ 数组求和:已知函数 sum(arr,k) 可以计算出 arr[0,k] 中的和,现在求 sum(arr,n),直接调用 sum(arr,n-1) + arr[n] 即可
⨳ 第n个斐波那契数:已知函数 fib(k) 可以计算出第 k 个斐波那契数是多少,所以求解fib(n),直接调用 fib(n-1) + fib(n-2) 即可
⨳ 反转链表:已知函数 reverseList(head) 可以将 head 及其以后得链表进行反转,现在要反转以 node1 作为头节点的链表,只需要调用 reverseList(node1.next) 反转 node1 之后的子链表,再将 node1 挂载到子链表后面即可。
回归正题,mergeSort(arr,head,mid) 已经将 arr[head,mid] 排好序了,mergeSort(arr,mid+1,tail) 已经将 arr[mid+1,tail] 排好序了,现在要做的是怎么将两个排好序的子序列合并。
咋合并呢?
合并过程
这不就是数组 之 双指针法2 讲的合并两个有序数组题嘛。 leetcode.cn/problems/me…
给你两个按 非递减顺序 排列的整数数组
nums1和nums2,另有两个整数m和n,分别表示nums1和nums2中的元素数目。请你 合并
nums2到nums1中,使合并后的数组同样按 非递减顺序 排列。注意: 最终,合并后数组不应由函数返回,而是存储在数组
nums1中。为了应对这种情况,nums1的初始长度为m + n,其中前m个元素表示应合并的元素,后n个元素为0,应忽略。nums2的长度为n。
这道题我们是使用双指针分别指向两个数组的开头,依次比较,较小的元素赋值到新数组中,最后新数组再覆盖 nums1。
同样,对于 arr[head,mid] 和 arr[mid+1,tail] 的合并也可以使用双指针进行头部比较,较小的插入到新数组,然后新数组的元素覆盖 [head,tail]。
也可以反过来操作,先将 [head,tail] 赋值给新数组,双指针指向新数组进行头部比较,较小的插入到原数组 [head,tail] 位置。
这里采取反向操作:
// 将 arr[head,mid] 和 arr[mid+1,tail] 进行合并
private void merge(int[] arr, int head, int mid, int tail) {
// 新数组
int[] new_arr = new int[tail-head+1];
int head_index = head;
for(int i=0;i<new_arr.length;i++){
new_arr[i] = arr[head_index++];
}
// 双指针指向新数组
int left_head_index = 0; // 新数组左半部分遍历开始位置
int left_tail_index = mid-head; // 新数组 左半部分遍历结束位置
int right_head_index = mid-head+1; // 新数组 右半部分遍历开始位置
int right_tail_index = tail-head; // 新数组 右半部分遍历结束位置
for(int i=head;i<=tail;i++){
// 新数组左半部分遍历完了,右半部分 还有剩余
if(left_head_index > left_tail_index && right_head_index <= right_tail_index){
arr[i] = new_arr[right_head_index++];
continue;
}
// 右半部分遍历完了,左半部分 还有剩余
if(right_head_index > right_tail_index && left_head_index <= left_tail_index){
arr[i] = new_arr[left_head_index++];
continue;
}
// 左半部分指针指向的元素比较小
if(new_arr[left_head_index] <= new_arr[right_head_index]){
arr[i] = new_arr[left_head_index++];
}else{
arr[i] = new_arr[right_head_index++];
}
}
}
到此为止,合并结束。
回头再分析一下合并 merge 函数什么时候开始调用的。
当 arr[head,mid] 和 arr[mid+1,tail] 区间最多只有一个元素时,mergeSort 函数 return,开始合并,最开始合并成两个元素的数组,再两两合并,再四四合并....直至全部合并完成。
合并优化
merge 函数可以合并 arr[head,mid] 与 arr[mid+1,tail] 这两个有序数列,如果在合并操作前, arr[mid] 就已经小于 arr[mid+1] 了,也就是说 arr[head,tail] 已经是有序的了,那就不需要合并了吧。
// 对 数组 arr 的 [head,tail] 进行排序
public void mergeSort(int[] arr,int head,int tail){
if(head>=tail){
return; // 最小问题,[head,tail] 中只有一个元素或没有元素,无需排序
}
// 拆分数组
int mid = (head+tail) / 2 ;
// 对 arr[head,mid] 进行排序
mergeSort(arr,head,mid);
// 对 arr[mid+1,tail] 进行排序
mergeSort(arr,mid+1,tail);
if(arr[mid]>arr[mid+1]){
// 将 arr[head,mid] 和 arr[mid+1,tail] 进行合并
merge(arr,head,mid,tail);
}
}
如果数组原本就是有序的,那 merge 函数一次也不用执行,时间复杂度减低到 O(n) 。
合并操作不就是将两个有序子序列合并排序嘛,不一定非得使用创建新数组进行双指针头插法,用其他排序也是可以的,如果数据规模很小,用插入排序也许会更快一些。
// 对 数组 arr 的 [head,tail] 进行排序
public void mergeSort(int[] arr,int head,int tail){
// if(head>=tail){
// return; // 最小问题,[head,tail] 中只有一个元素或没有元素,无需排序
// }
if(tail-head<=20){
// 对数据规模小的序列,插入排序也许会比双指针头插法快一点
insertionSort(arr,head,tail);
return;
}
// 拆分数组
int mid = (head+tail) / 2 ;
// 对 arr[head,mid] 进行排序
mergeSort(arr,head,mid);
// 对 arr[mid+1,tail] 进行排序
mergeSort(arr,mid+1,tail);
if(arr[mid]>arr[mid+1]){
// 将 arr[head,mid] 和 arr[mid+1,tail] 进行合并
merge(arr,head,mid,tail);
}
}
再看创建新数组双指针头插法,每次调用 merge 函数合并,都要创建一个新数组,这涉及到内存的开辟和回收,很费性能。
如果在归并排序前,先创建一个大小和原数组容量相同的数组,每次 merge 操作都使用这个已经创建好的数组进行双指针头插,不就避免了新数组的创建与回收了嘛。
非递归实现
非递归实现就是不需要问题递出去,不需要压栈的过程,直接从最小问题出发,使用循环一步步将问题解决。
对于归并排序来说,最小问题是将两个大小为一的有序数组进行合并,整个循环要做的事情如下:
⨳ 第一次循环,将相邻的两个元素看作两个大小为 1 的有序数组,不断进行 merge 操作,直至整个数组每两个元素都是有序的
⨳ 第二次循环,将相邻的四个元素看作两个大小为 2 的有序数组,不断进行 merge 操作,直至整个数组每四个元素都是有序的
⨳ 第二次循环,将相邻的八个元素看作两个大小为 4 的有序数组,不断进行 merge 操作,直至整个数组每八个元素都是有序的
⨳ ....
循环从合并两个大小为 1 的有序数组开始,当有序数组的长度等于或大于整个数组的长度时,循环结束。
// 归并排序,非递归实现
public void mergeSort(int[] arr){
int arr_size = arr.length;//数组的长度
int merge_size = 1;// 合并区间的长度
while(merge_size< arr_size){
// 寻找合并区间的起始位置
int left_head = 0; // 左区间起止位置 [head,head+merge_size-1]
int right_head = left_head+merge_size;// 右区间起止位置 [head+merge_size,head+merge_size*2-1] 或者 [head+merge_size,arr_size-1]
// 当存在右区间时,才进行合并
while(right_head<arr_size){
int mid = left_head+merge_size-1;
int tail = Math.min(right_head+merge_size-1,arr_size-1);
// 将 arr[head,mide] 和 arr[mid+1,tail] 进行合并
merge(arr,left_head,mid,tail);
left_head = left_head+merge_size+merge_size; // 下一个合并区间的起始位置
right_head = left_head+merge_size;
}
merge_size=merge_size*2; // 合并区间的长度翻倍
}
}
交易中逆序对的总数
在股票交易中,如果前一天的股价高于后一天的股价,则可以认为存在一个「交易逆序对」。
请设计一个程序,输入一段时间内的股票交易记录
record,返回其中存在的「交易逆序对」总数。
举例,record = [9, 7, 5, 4, 6],逆序对为 (9, 7), (9, 5), (9, 4), (9, 6), (7, 5), (7, 4), (7, 6), (5, 4)。
最直观的解法就是两层暴力循环:
class Solution {
public int reversePairs(int[] nums) {
int res = 0;
for(int i = 0; i < nums.length; i ++)
for(int j = i + 1; j < nums.length; j ++)
if(nums[i] > nums[j]) res ++;
return res;
}
}
如果使用归并排序的思想,怎么解决呢?
归并的含义是将左右两个相邻的有序区间进行合并,假设左区间为[left_head,left_tail],右区间为 [right_head,right_tail],左区间头部指针指向的元素为 left_ele,右区间头部指针指向的元素为 right_ele,如果只考虑这两个相邻区间中的元素有哪些逆序对,该怎么做呢:
⨳ 左区间 left_head 到 left_tail,因为是有序的,所以这一部分不会产生逆序对;
⨳ 右区间 right_head 到 right_tail,因为是有序的,所以这一部分也不会产生逆序对;
⨳ 如果 left_ele 小于等于 right_ele ,则说明 left_ele 一定是两个有序区间中最小的元素,不会和任何元素组成逆序对;
⨳ 如果 left_ele 大于 right_ele ,则说明 right_ele一定是比整个 [left_head,left_tail] 区间中的元素小,也就是说,right_ele 可以和[left_head,left_tail] 区间中的任意元素构成逆序对。
对于两个有序区间进行 头指针比较 归并时,如果归并的是左区间的头部元素 left_ele,则不会产生逆序对,如果归并的是右区间的头部元素 right_ele,那左区间还剩几个元素,就会产生的几个与 right_ele 组成的逆序对。
对于两个有序区间,可以在归并排序中,顺带着可以将逆序对计算出来,那对于整个无序数组,这种解法好使吗?
更具体一点,当两个有序区间归并完了,逆序对也计算出来了,但归并完后这两个区间就有序了,这不会影响其他区间与该区间的逆序对的计算吗?
事实上是不会影响的,归并排序只会改变区间中元素的位置,而不改变与其他区间的相对位置,而且归并完成后,这个有序区间中的元素不会和其他同在这一区间的元素形成逆序对,也不会存在重复计算的问题。
那就改造一下归并排序代码呗。
class Solution {
public int reversePairs(int[] record) {
int head = 0;
int tail = record.length-1;
int[] tmp_arr = new int[record.length];
return mergeSort(record,head,tail,tmp_arr);
}
// 对 数组 arr 的 [head,tail] 进行排序
public int mergeSort(int[] arr,int head,int tail,int[] tmp_arr){
if(head>=tail){
return 0; // 最小问题,[head,tail] 中只有一个元素或没有元素,无需排序
}
int pairs = 0;
// 拆分数组
int mid = (head+tail) / 2 ;
// 对 arr[head,mid] 进行排序
pairs = pairs + mergeSort(arr,head,mid,tmp_arr);
// 对 arr[mid+1,tail] 进行排序
pairs = pairs + mergeSort(arr,mid+1,tail,tmp_arr);
if(arr[mid]>arr[mid+1]){
// 将 arr[head,mid] 和 arr[mid+1,tail] 进行合并
pairs = pairs + merge(arr,head,mid,tail,tmp_arr);
}
return pairs;
}
// 将 arr[head,mid] 和 arr[mid+1,tail] 进行合并
private int merge(int[] arr, int head, int mid, int tail,int[] tmp_arr) {
// 临时数组
int head_index = head;
for(int i=head;i<=tail;i++){
tmp_arr[i] = arr[head_index++];
}
// 双指针指向新数组
int left_head_index = head; // 新数组左半部分遍历开始位置
int left_tail_index = mid; // 新数组 左半部分遍历结束位置
int right_head_index = mid+1; // 新数组 右半部分遍历开始位置
int right_tail_index = tail; // 新数组 右半部分遍历结束位置
int pairs = 0;
for(int i=head;i<=tail;i++){
// 新数组左半部分遍历完了,右半部分 还有剩余(没有逆序对)
if(left_head_index > left_tail_index && right_head_index <= right_tail_index){
arr[i] = tmp_arr[right_head_index++];
continue;
}
// 右半部分遍历完了,左半部分 还有剩余(没有逆序对)
if(right_head_index > right_tail_index && left_head_index <= left_tail_index){
arr[i] = tmp_arr[left_head_index++];
continue;
}
// 左半部分指针指向的元素比较小
if(tmp_arr[left_head_index] <= tmp_arr[right_head_index]){
arr[i] = tmp_arr[left_head_index++]; // 没有逆序对
}else{
arr[i] = tmp_arr[right_head_index++];
pairs = pairs + (mid-left_head_index +1);
}
}
return pairs;
}
}