目录
一、剖析递归行为和递归行为时间复杂度的估算
用递归方法找一个数组中的最大值,系统上到底是怎么做的?
master公式使用:T(N) = a*T(N/b) + O(N^d)
- log(b,a) > d => 复杂度为O(N^log(b,a))
- log(b,a) = d => 复杂度为O(N^d*logN)
- log(b,a) < d => 复杂度为O(N^d)
将p(0,5)不断往下拆分,只有当所有的子节点的值求解出来后,才可以求解出最终的结果。每个节点都依赖自身的子节点来求解得到结果,栈的深度其实就是树的高度。
public static int getMax(int[] arr) {
return process(arr, 0, arr.length-1);
}
//arr[L...R]范围上求最大值
public static int process(int[] arr, int L, int R) {
if(L==R)return arr[L];
int mid=L+((R-L)>>1); //中点
int leftMax=process(arr,L,mid);
int rightMax=process(arr,mid+1,R);
return Math.max(leftMax, rightMax);
}
上述实现中,一个母问题拆分为2个规模为N/2的子问题,除了子问题拆分外的复杂度为O(1)。所以上述实现的master公式为:T(N) = 2*T(N/2) + O(1) 。a=2,b=2,log(b,a)=1, d=0。由于log(b,a)>d,所以时间复杂度为O(N)。等效于从左到右遍历数组找最大值。
二、归并排序
1.merge介绍
时间复杂度O(NlogN),空间复杂度O(N)
Merge函数:在p1和p2都不越界的情况下,比较p1和p2指向的数,将小的值copy到help[i]中,然后将指向较小值的指针往后移动(p1++或p2++),同时i++。
最后,p1或p2越界,将没枚举完的剩余部分copy到help数组后面。
若p1越界,将[p2,R]的内容接到help数组后面
若p2越界,将[p1,M]的内容接到help数组后面
public static void process(int[] arr, int L, int R) {
if(L==R) {
return;
}
int mid=L+((R-L)>>1);
process(arr,L,mid);
process(arr,mid+1,R);
merge(arr,L,mid,R);
}
public static void merge(int[] arr, int L, int M, int R) {
int[] help=new int[R-L+1];
int i=0;
int p1=L;
int p2=M+1;
while(p1<=M&&p2<=R)help[i++]=arr[p1]<=arr[p2]?arr[p1++]:arr[p2++];
while(p1<=M)help[i++]=arr[p1++];
while(p2<=R)help[i++]=arr[p2++];
for(i=0;i<help.length;i++)arr[L+i]=help[i];
}
2.master公式分析时间复杂度
归并往下拆分过程中,将母问题拆分成2个规模为原规模一半的子问题,同时merge只遍历数组一次,为O(N) => 写出归并排序的master公式:T(N) = 2*T(N/2) + O(N)
a=2, b=2,log(b, a)=1, d=1 => log(b, a) == d => 时间复杂度为O(NlogN)
3.为什么O(N^2)算法不好
O(N^2)的排序算法如选择排序,第一轮比较了N-1次确定放在0位置的数,第二轮比较了N-2次确定放在1位置的数,第三轮比较了N-3次确定放在2位置的数。可见,在上述选数的每一轮过程都是独立的,其中浪费了很多前几轮的比较信息,造成了比较大的浪费。相比之下,O(NlogN)的排序算法充分利用每次的比较信息,即比较行为没有被浪费,比较的信息被用去合并成了更大的有序整体。
4.归并排序的扩展
小和问题和逆序对问题
小和问题:在一个数组中,每一个数左边比当前数小的数累加起来,叫做这个数组的小和。求一个数组的小和。 例子:[1 3 4 2 5] 1左边比1小的数,没有;3左边比3小的数,1;4左边比4小的数,1、3;2左边比2小的数,1;5左边比5小的数,1、3、4、2。所以小和为1+1+3+1+1+3+4+2=16.
如上图所示,在合并【1 3 4】和【2 5】的过程中,
比较1和2,此时左组中的值小于右组中的值,发现小和数1。同时右组中还有【2 5】两个数,所以右组存在2个比1大的数,记录2个小和数1。
更新:左组【3 4】 右组【2 5】
比较3和2,此时右组中的值小于右组中的值,没有小和数出现,continue
更新:左组【3 4】 右组【5】
比较3和5,此时左组中的值小于右组中的值,发现小和 数3。同时右组中还有【5】一个数,所以右组存在1个比3大的数,记录1个小和数3。
更新:左组【4】 右组【5】
比较4和5,发现左组中的值小于右组中的值,发现小和数4。同时右组中还有【5】一个数,所以右组存在1个比4大的数,记录1个小和数4。
综上,本轮merge记录得小和数:2个1、1个3、1个4(其余merge过程记录小和数同理,最终将所有记录的小和数相加即为结果)
该方法既不遗漏也不重复:
不遗漏:merge过程中一定会将某个数的范围从单个数拓展成整体
不重复:已经变成一部分(一组)的东西,在这个部份内不会重复产生小和数,仅会在左组中的数比右组中的某个数小时才产生小和数。
排序不能省略:只有通过排序,才可以在O(1)的时间内求得右组中有多少个数比左组中的某个数要大。
给出小和问题代码如下:
public static int smallSum(int[] arr) {
if(arr==null||arr.length<2) {
return 0;
}
return process(arr,0,arr.length-1);
}
//arr[L...R]既要排好序,也要求小和
public static int process(int[] arr, int l, int r) {
if(l==r) {
return arr[l];
}
int mid=l+((r-l)>>1);
return process(arr,l,mid)+process(arr,mid+1,r)+merge(arr,l,mid,r);
}
public static int merge(int[] arr, int L, int m, int r) {
int[] help=new int[r-L+1];
int i=0;
int p1=L;
int p2=m+1;
int res=0;
while(p1<=m&&p2<=r) {
res+=arr[p1]<arr[p2]?(r-p2+1)*arr[p1]:0;
help[i++]=arr[p1]<arr[p2]?arr[p1++]:arr[p2++];
}
while(p1<=m)help[i++]=arr[p1++];
while(p2<=r)help[i++]=arr[p2++];
for(i=0;i<help.length;i++)arr[L+i]=help[i];
return res;
}
逆序对问题:在一个数组中,左边的数如果比右边的数大,则这两个数构成一个逆序对,请打印所有逆序对。
小和问题是求某个数右边有多少个数比当前数大,而逆序对问题是求某个数右边有多少个数比当前数小,所以原理其实是一样的。
逆序对问题代码如下:
package class02;
public class Code02_NiXu{
public static int niXu(int[] arr) {
if(arr==null||arr.length<2) {
return 0;
}
return process(arr,0,arr.length-1);
}
//arr[L...R]既要排好序,也要求小和
public static int process(int[] arr, int l, int r) {
if(l==r) {
return arr[l];
}
int mid=l+((r-l)>>1);
return process(arr,l,mid)+process(arr,mid+1,r)+merge(arr,l,mid,r);
}
public static int merge(int[] arr, int L, int m, int r) {
int[] help=new int[r-L+1];
int i=0;
int p1=L;
int p2=m+1;
int res=0;
while(p1<=m&&p2<=r) {
res+=arr[p1]>arr[p2]?(m-p1+1):0;
help[i++]=arr[p1]<arr[p2]?arr[p1++]:arr[p2++];
}
while(p1<=m)help[i++]=arr[p1++];
while(p2<=r)help[i++]=arr[p2++];
for(i=0;i<help.length;i++)arr[L+i]=help[i];
return res;
}
}
三.快速排序
1. 荷兰国旗问题一
给定一个数组arr,和一个数num,请把小于等于num的数放在数组的左边,大于num的数放在数组右边。要求额外空间复杂度O(1),时间复杂度O(N)。
实现<=num的数放左侧,>num的数放右侧:维护一个<=的左区间
情况一:若i位置的数值<=num,则将i位置值与<=区的下一个数交换,<=区间向右扩一个,同时位置指针i++
情况二:若i位置的数值>num,则直接i++
例如当前i位置的数值是4,比5小,所以将4与6位置互换,<=区间向右扩,同时i++
实质:<=区域是<=num的数值,<=区域和i之间的数是>num的数值。i在往右走的过程中,<=区域在把>区域推着往右走,当i越界是,<=区域外的部分就是>区域。
2. 荷兰国旗问题二
给定一个数组arr,和一个数num,请把小于num的数放在数组的做左边,等于num的数放在数组中间,大于num的数放在数组的右边。要求额外空间复杂度O(1),时间复杂度O(N)。
要将>num、=num、<num严格区分:维护一个<num左区间、一个>num右区间
情况一:若i位置数值<num,则i位置和<区域下一个位置交换,<区域右扩,i++
情况二:若i位置数值==num,则i++
情况三:若i位置数值>num,则i位置和>区域下一个位置交换,>区域左扩,i原地
情况一扩完i++是因为<区域的下一个位置的数值是确定的数值,要么是当前i位置数值,要么是==num的数值,不论哪种都没必要比较,直接i++。
情况三扩完i原地是因为>区域的下一个位置的数值是未知的数值,在交换完之后,i位置的数值就是一个未知数值,所以需要停留原地再次将i位置数值与num进行比较。
3.快排不同版本
快排1.0版本,每次选取区间最右侧的数作为基准,然后将<=基准的数放在左区间,>num的数值放在右区间,然后继续在<=基准的左区间和>基准的右区间重复上述过程,直至到最底层时整个数组完全有序。
快排2.0版本,选取区间最右侧的数作为基准,然后将<基准的数放在左边,==基准的数放在中间,>基准的数放在右边,最后将基准与>区域第一个数交换,就得到了三块区域。随后在<基准的区间和>基准的区间递归进行上述操作,直至最终数组完全有序。
上述两个版本最坏都是O(N^2),例如上述例子[1 2 3 4 5 6 7 8 9],第一次以9作划分,只有左区间没有右区间,第二次以8作为划分,还是只有左区间没有右区间……这样下来时间就是O(N^2)
=>划分值选的很极端就会让算法退化成O(N^2)
快排3.0 在待排区间随机选择一个数交换到最后,然后作为划分的基准,这样就做到将基准的选择成为等概率事件 => 在数学上证明,数学上累加的长期期望是O(N*logN)
//arr[l...r]排好序
public static void quickSort(int[] arr, int L, int R) {
if(L<R) {
swap(arr, L+(int)(Math.random())*(R-L+1),R);
int[] p=partition(arr, L, R);
quickSort(arr, L, p[0]-1); //《区
quickSort(arr, p[1]+1, R); //>区
}
}
//这里是一个处理arr[l..r]的函数
//默认以arr[r]作为划分,arr[r] -> p <p ==p >p
//返回等于区域(左边界、右边界),所以返回一个长度为2的数组res,res[0] res[1]
public static int[] partition(int[] arr, int L, int R) {
int less=L-1; // <区域有边界
int more=R; //>区域左边界
while(L<more) { //L表示当前数的位置 arr[R] -> 划分值
if(arr[L]<arr[R]) { //当前数 < 划分值
swap(arr,++less,L++);
}
else if(arr[L]>arr[R]) { //当前数 > 划分值
swap(arr,--more,L);
}
else { //当前数 == 划分值
L++;
}
}
swap(arr, more, R);
return new int[] {less+1, more};
}
public static void swap(int[] arr, int x, int y) {
int tmp=arr[x];
arr[x]=arr[y];
arr[y]=tmp;
}