1. 归并排序
- 基本思想:采用了分治再合并的思想,具体而言就是对于一个待排序数组,首先切一半,对得到的左右两组数组重复上述操作,直到分到不能再分,最后自底向上的左右两两进行合并操作,合并操作是对左右两个子数组依次进行取小操作放到一个新数组中,最后新数组拷贝到旧数组,表示一次合并结束。全部合并结束即归并结束,至此整个数组有序。
- 时间复杂度和空间复杂度
归并排序不同于简单、冒泡和插入排序。后面三者进行了大量的重复比较操作,而归并排序每次进行比较操作(合并)都是有利于下一步的合并(不会作无用重复的比较),所以归并是快于前面三种排序的,具体快多少,来分析一下归并的递推公式,对于一个规模为N的母问题,切一半相当于2个子问题都是N/2规模,子问题再切一半类似。此外两个子问题合并的操作显然是O(n)(取小操作需要遍历一遍左右子数组),所以归并的递推式为:T(N)=2T(N/2)+O(n).这里用主方法可以得到时间复杂度为O(N*logN).
归并的空间复杂度为O(N),因为额外开了一个数组。 - 代码实现
public static void process(int arr[],int l,int r){
if(l == r)return;
int mid = l + ((r-l)>>2);//防止溢出
process(arr,l,mid);
process(arr,mid+1,r);
Merge(arr,l,mid,r);
}
//合并
private static void Merge(int[] arr, int l, int mid, int r) {
int p = l;
int q = mid+1;
int tmp[] = new int[r-l+1];
int i = 0;
while(p <= mid && q <= r){
tmp[i++] = arr[p] <= arr[q] ? arr[p++] : arr[q++];
}
while(p <= mid){
tmp[i++] = arr[p++];
}
while(q <= r){
tmp[i++] = arr[q++];
}
for (int j = 0; j < i; j++) {
arr[l++] = tmp[j];
}
}
- 归并扩展
归并排序的思想能运用到很多地方。例如小和问题,逆序数对问题等等。
小和问题:对于一组数,对其中每个数其左边小于这个数的和的累加,比如1 3 5 2 4,对于1来说左边没有小于它的数,对于3有个1,对于5是1+3,对于2是1,对于4是1+2+3,那么这组数的小和就是1+4+1+6=12.这种算法可以采取暴力解决,即每次遍历这个数的左边找到比它小的数,不过操作数是一个等差数列,也即时间复杂度为O(n²),
归并思路:上述时间复杂度能不能快点?显然是可以的,这里不妨对小和问题做一个转换,每次看这个数右边有几个数比它大就产生几个这个数的小和,即对于1来说有4个比它大即41,对于3有2个比它大即23,对于5是0,对于2是1*2,对于4是0,即小和为:4+6+2=12.这个算法与前一种类似,不过可以采取归并排序的思路,且可以做到不重也不漏,时间复杂度为O(nlogn)。具体而言就是在归并排序左右子数组合并的过程中左边每个数与右边数比较时可以直接得到右子数组后面有几个比它大(左右子数组是有序的),而不需要遍历,且每次合并完成左子数组的数不会再和右子数组比较,因为合并在一起了,下次合并是以一个新左子数组整体出现,这样就不会重算或者漏算,整体合并完,数组每个数也就比较完了,小和也就求出来了。
小和问题代码实现:
public static int process(int arr[],int l,int r){
if(l == r)return 0;
int mid = l + ((r-l)>>2);
//注意这里分为3部分:左半部分小和+右半部分小和+整体左右合并的小和
return process(arr,l,mid)+process(arr,mid+1,r)+Merge(arr,l,mid,r);
}
private static int Merge(int[] arr, int l, int mid, int r) {
int p = l;
int q = mid+1;
int tmp[] = new int[r-l+1];
int i = 0;
int res = 0; //小和
while(p <= mid && q <= r){
if(arr[p] < arr[q]){ //这里与归并的区别就是左右比较的时候,如果相等的情况是先拿右子数组放进去
res += (r-q+1)*arr[p]; //求出后面有几个数比它大,并加上小和
tmp[i++] = arr[p++];
}else{
tmp[i++] = arr[q++];
}
}
while(p <= mid){
tmp[i++] = arr[p++];
}
while(q <= r){
tmp[i++] = arr[q++];
}
for (int j = 0; j < i; j++) {
arr[l++] = tmp[j];
}
return res;
}
可以看出就是归并排序小小的改写。
逆序数对问题,与小和问题类似,这里不做赘述。
直接上代码:
public static int process(int arr[],int l,int r){
if(l == r)return 0;
int mid = l + ((r-l)>>2);
return process(arr,l,mid)+process(arr,mid+1,r)+Merge(arr,l,mid,r);
}
private static int Merge(int[] arr, int l, int mid, int r) {
int p = l;
int q = mid+1;
int tmp[] = new int[r-l+1];
int i = 0;
int res = 0;
while(p <= mid && q <= r){
if(arr[p] > arr[q]){
res ++; //发现一组逆序数
tmp[i++] = arr[q++];
}else{
tmp[i++] = arr[p++];
}
}
while(p <= mid){
tmp[i++] = arr[p++];
}
while(q <= r){
tmp[i++] = arr[q++];
}
for (int j = 0; j < i; j++) {
arr[l++] = tmp[j];
}
return res;
}
2. 快速排序
- 基本思想
快排分三个阶段解读,更加深刻。
先来看一个问题:给定一个数组arr,给定一个数num,要求将arr分成左右两部分,左边的数都小于等于num,右边的数都大于num。
具体实现算法:划分一个左边界,左边界内的都是小于等于num的(开始左边界在数组第一个数下标减1的位置),用一个指针指向数组第一个元素,遍历这个数组,对于每个数判断与num的关系,分为两种情况:1)小于等于num的数,将这个数与下标=左边界+1的数交换,左边界向外阔一个单位(左边界下表加1,就是将这个小于等于num的数阔进去),指针后移;2)大于num的数,此时不将这个数处理,将指针后移一位即可,根据这两种情况将数组处理完,当指针越界,整个数组被分为两部分。
快排第一阶段:根据上述问题及实现,快排是将数组最后一个数看作num,将数组分成左右两部分,即左边的数<=num<右边的数(结束标志是指针指向num,随后将num与左边界后面一位的数交换)。再分别对左右两部分递归重复上述操作(每次操作都会有一个num在排序好),最后整体有序。
再来看一个问题(荷兰国旗问题):给定一个数组arr,给定一个数num,要求将arr分成三部分,左边的数小于num,中间部分的数等于num,右边的数大于num。
实现算法:划分左右两个边界,左边界还是在数组最左边数的前一位下标,右边界是数组最右边数下标,即:左边界)数组(右边界。准备一个指针指向数组第一个元素,遍历数组,对于每一个数,有以下三种情况,1)当前数小于num,将这个数与下标等于=左边界+1的数进行交换,左边界扩充一个单位,指针后移;2)当前数等于num,此时这个数不做任何处理,指针后移一位;3)当前数大于num,将当前数与下标=右边界-1的数交换,右边界左移一位(扩充),指针后移。当指针撞上右边界代表算法结束,至此整个数组被分为了三部分。
快排第二阶段:根据上述问题,还是将数组最后一个数看作num,将数组分成左右中三部分,即左边的数<num,num…<右边的数。再分别对左右两部分递归进行上述重复操作(结束标志与第一阶段一样),与第一阶段不同的是,每次会有一批num排序好,所以会比第一阶段快一些,最后整体有序。
快排第三阶段:可以看出不论是第一阶段还是第二阶段都是将最后一个数作为num,考虑一种最坏的情况,例如123456789,那么以9作为num的话,分完一遍发现只搞定了一个数,完全走下来操作次数是一个等差数列,显然时间复杂度为O(N²),这就是选的数太偏(左偏或者右偏)的结果(最好的情况num能将数组进行均分)。所以考虑每次选取num时候要求等概率选取,这样好坏情况是一个均等的概率。 - 时间复杂度和空间复杂度
对于第三阶段的快排,根据等概率选取的num不同,递推公式也不一样,例如num刚好将数组分为1:2,或者1:3,2:3等等都有可能,对应的递推公式可能是T(N)=T(N/3)+T(2N/3)+O(n),T(N)=T(N/4)+T(3N/4)+O(n)等等(其中O(n)就是partition的过程,因为要遍历一次数组所以为O(n)),但综合所有的情况可以求一个时间复杂度期望值,可以得到最终为O(N*logN),具体就不赘述了,有兴趣的可以自行了解,只要知道它是怎么来的即可。
空间复杂度求解类似于时间复杂度的求解,因为也分为好的情况和坏的情况(同时间复杂度),由于采取的是递归调用(开辟栈空间),所以在最坏的情况(所选的数在最右边或者在最左边)开辟的栈空间是O(n)级别(因为要递归调用n次),在最好的情况(所选的数能均分数组),递归分配的栈空间类似于一个二叉树,左边递归完释放的栈空间可以继续给右边使用(所以左和右递归实质上只用分配一次栈空间),最终栈空间的分配是log(n)级别。同理还有很多其他情况,综合最好和最坏以及其他情况可以得出最终的空间复杂度为log(n)(具体过程可以自行了解)。 - 代码实现
public static void QuickSort(int arr[],int l,int r){
if(l == r)return;
swap(arr,l + (int)(Math.random()*(r-l+1)),r);//随机取一个元素作分类并放在最后
//***************partition start****************
int p = l-1; //左边界
int q = r; //右边界
int i = l;
while(i<q){
if(arr[i] < arr[r]){
swap(arr,i++,++p); //先将当前数与左边界+1的位置交换,然后左边界扩充
}else if(arr[i] > arr[r]){
swap(arr,i,--q); //先将当前数与右边界-1的位置交换,然后右边界扩充
}else i++;
}
swap(arr,q++,r);//将标志元素与右边界第一个元素交换,使得标志元素在左右边界中间
//***************partition end****************
if(l<=p){
QuickSort(arr,l,p);
}
if(q<=r){
QuickSort(arr,q,r);
}
}
3. 主方法(此方法用于子问题是同规模的)
对于形如:T(n)=a(T/b)+O(n^d)这样的递归式,根据以下三种情况得出
1)n ^ log(b)a < n ^ d,时间复杂度为O(n^d)
2) n ^ log(b)a = n ^ d,时间复杂度为O(n^d * logn)
3)n ^ log(b)a > n ^ d,时间复杂度为O(n ^ log(a)b)
以上述归并排序的递推式为例,归并:T(n)=2T(n/2)+O(n) (a=2,b=2,d=1)
显然n ^ log(b)a = n ^ d = n,那么时间复杂度为O(n*logn)
本文深入解析了归并排序的分治合并策略,展示了其时间复杂度O(N*logN)的优势,并介绍了快速排序的划分与递归过程,探讨了其随机化选择枢轴优化后的期望时间复杂度。同时,涉及小和问题和逆序数对问题的归并排序应用实例。
1181

被折叠的 条评论
为什么被折叠?



