分治法
最大子数组问题
方法一:暴力求解方法
我们可以很容易地设计出一个暴力方法来求解本问题:简单地尝试没对可能的子数组,共有O(n2)种
#include<iostream>
using
namespace
std;
#define INT_MIN 0x80000000
int
main()
{
int
arr[10]={9,8,-3,-5,7,-39,79,-37,8,9};
int
i,j;
int
sum=0,maxsum=INT_MIN;
int
imax;
for
(i=0;i<10;i++)
{
sum=0;
for
(j=i;j<10;j++)
{
sum+=arr[j];
if
(maxsum<sum)
{
maxsum=sum;
imax=j;
}
}
}
cout<<
"maxsum: "
<<maxsum<<
" imax:"
<<imax<<endl;
}
方法二:使用分治策略的求解方法(O(nlgn))
我们来思考如何用分治技术来求解最大子数组问题。假定我们要寻找子数组A[low,high]的最大子数组。使用分治技术意味着我们要将子数组划分为两个规模尽量相等的子数组。也就是说,找到子数组的中央位置,比如mid,然后考虑求解两个子数组A[low,mid]和A[mid+1,high]。则A[low,high]的任何连续子数组A[i..j]所处的位置必定是以下3种情况之一:
- 完全位于子数组A[low..mid]中,因此low<=i<=j<=mid
- 完全位于子数组A[mid+1..high]中,因此mid<i<=j<=high;
- 跨越了中点,因此low<=i<=mid<j<=high;
因此,A[low..high]的一个最大子数组所处的位置必然是这三种情况之一。实际上,A[low,high]的一个最大子数组必然是完全位于A[low..mid]中,完全位于A[mid+1..high]中或者跨越中点的所有子数组中和最大者。我们可以递归地求解A[low,mid]和A[mid+1,high]的最大子数组,因为这两个子问题仍然是最大子数组的问题,只是规模更小。因此,剩下的全部工作就是寻找跨越中点的最大子数组,然后在三种情况中选取和最大者。
算法如下:
#include<iostream>
#include<tuple>
using
namespace
std;
#define INT_MIN 0x80000000
tuple<
int
,
int
,
int
> FindMaxCrossingSubarry(
int
arr[],
int
low,
int
mid,
int
high)
{
int
leftsum=INT_MIN,rightsum=INT_MIN;
int
sum=0;
int
maxleft=low,maxright=mid+1;
int
i;
for
(i=mid;i!=low;i--)
{
sum+=arr[i];
if
(leftsum<sum)
{
leftsum=sum;
maxleft=i;
}
}
sum=0;
for
(i=mid+1;i!=high;i++)
{
sum+=arr[i];
if
(rightsum<sum)
{
rightsum=sum;
maxright=i;
}
}
cout<<
"maxleft: "
<<maxleft<<
" maxright: "
<<maxright<<
" leftsum+rightsum: "
<<leftsum+rightsum<<endl;
return
tuple<
int
,
int
,
int
>(maxleft,maxright,leftsum+rightsum);
}
tuple<
int
,
int
,
int
> FindMaximumSubarry(
int
arr[],
int
low,
int
high)
{
if
(low==high)
return
tuple<
int
,
int
,
int
>(low,high,arr[low]);
else
{
int
mid=(low+high)/2;
tuple<
int
,
int
,
int
> left=FindMaximumSubarry(arr,low,mid);
tuple<
int
,
int
,
int
> right=FindMaximumSubarry(arr,mid+1,high);
tuple<
int
,
int
,
int
> middle=FindMaxCrossingSubarry(arr,low,mid,high);
if
(get<2>(left)>=get<2>(right)&&get<2>(left)>=get<2>(middle))
return
left;
else
if
(get<2>(right)>=get<2>(left)&&get<2>(right)>=get<2>(middle))
return
right;
else
return
middle;
}
}
int
main()
{
int
arr[10]={9,8,-3,-5,7,-39,79,-37,8,9};
tuple<
int
,
int
,
int
> result=FindMaximumSubarry(arr,0,9);
cout<<get<0>(result)<<
" "
<<get<1>(result)<<
" "
<<get<2>(result)<<endl;
}
方法三:使用动态规划的算法(O(n))
#include<iostream>
using
namespace
std;
int
MaxArraySum(
int
arr[],
int
n)
{
int
sum,maxSum,maxi;
int
i;
maxSum=0;
maxi=0;
sum=0;
for
(i=0;i<n;i++)
{
sum+=arr[i];
if
(sum<0)
{
sum=0;
continue
;
}
if
(maxSum<sum)
{
maxSum=sum;
maxi=i;
}
}
cout<<
"max sum: "
<<maxSum<<
" index: "
<<maxi<<endl;
return
maxSum;
}
int
main()
{
int
arr[10]={9,8,-3,-5,7,-39,79,-37,8,9};
cout<<MaxArraySum(arr,10);
}
分治法总结
在计算机科学中,分治法是一种很重要的算法。字面上的解释是“分而治之”,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。这个技巧是很多高效算法的基础,如排序算法(快速排序,归并排序),傅立叶变换(快速傅立叶变换)……
任何一个可以用计算机求解的问题所需的计算时间都与其规模有关。问题的规模越小,越容易直接求解,解题所需的计算时间也越少。例如,对于n个元素的排序问题,当n=1时,不需任何计算。n=2时,只要作一次比较即可排好序。n=3时只要作3次比较即可,…。而当n较大时,问题就不那么容易处理了。要想直接解决一个规模较大的问题,有时是相当困难的。
--------------------------------------------------------------------------------
二、基本思想及策略
分治法的设计思想是:将一个难以直接解决的大问题,分割成一些规模较小的相同问题,以便各个击破,分而治之。
分治策略是:对于一个规模为n的问题,若该问题可以容易地解决(比如说规模n较小)则直接解决,否则将其分解为k个规模较小的子问题,这些子问题互相独立且与原问题形式相同,递归地解这些子问题,然后将各子问题的解合并得到原问题的解。这种算法设计策略叫做分治法。
如果原问题可分割成k个子问题,1<k≤n,且这些子问题都可解并可利用这些子问题的解求出原问题的解,那么这种分治法就是可行的。由分治法产生的子问题往往是原问题的较小模式,这就为使用递归技术提供了方便。在这种情况下,反复应用分治手段,可以使子问题与原问题类型一致而其规模却不断缩小,最终使子问题缩小到很容易直接求出其解。这自然导致递归过程的产生。分治与递归像一对孪生兄弟,经常同时应用在算法设计之中,并由此产生许多高效算法。
--------------------------------------------------------------------------------
三、分治法适用的情况
分治法所能解决的问题一般具有以下几个特征:
1) 该问题的规模缩小到一定的程度就可以容易地解决
2) 该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质。
3) 利用该问题分解出的子问题的解可以合并为该问题的解;
4) 该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子子问题。
第一条特征是绝大多数问题都可以满足的,因为问题的计算复杂性一般是随着问题规模的增加而增加;第二条特征是应用分治法的前提它也是大多数问题可以满足的,此特征反映了递归思想的应用;、
第三条特征是关键,能否利用分治法完全取决于问题是否具有第三条特征,如果具备了第一条和第二条特征,而不具备第三条特征,则可以考虑用贪心法或动态规划法。
第四条特征涉及到分治法的效率,如果各子问题是不独立的则分治法要做许多不必要的工作,重复地解公共的子问题,此时虽然可用分治法,但一般用动态规划法较好。
--------------------------------------------------------------------------------
四、分治法的基本步骤
分治法在每一层递归上都有三个步骤:
step1 分解:将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题;
step2 解决:若子问题规模较小而容易被解决则直接解,否则递归地解各个子问题
step3 合并:将各个子问题的解合并为原问题的解。
它的一般的算法设计模式如下:
Divide-and-Conquer(P)
1. if |P|≤n0
2. then return(ADHOC(P))
3. 将P分解为较小的子问题 P1 ,P2 ,...,Pk
4. for i←1 to k
5. do yi ← Divide-and-Conquer(Pi) △ 递归解决Pi
6. T ← MERGE(y1,y2,...,yk) △ 合并子问题
7. return(T)
其中|P|表示问题P的规模;n0为一阈值,表示当问题P的规模不超过n0时,问题已容易直接解出,不必再继续分解。ADHOC(P)是该分治法中的基本子算法,用于直接解小规模的问题P。因此,当P的规模不超过n0时直接用算法ADHOC(P)求解。算法MERGE(y1,y2,...,yk)是该分治法中的合并子算法,用于将P的子问题P1 ,P2 ,...,Pk的相应的解y1,y2,...,yk合并为P的解。
--------------------------------------------------------------------------------
五、分治法的复杂性分析
一个分治法将规模为n的问题分成k个规模为n/m的子问题去解。设分解阀值n0=1,且adhoc解规模为1的问题耗费1个单位时间。再设将原问题分解为k个子问题以及用merge将k个子问题的解合并为原问题的解需用f(n)个单位时间。用T(n)表示该分治法解规模为|P|=n的问题所需的计算时间,则有:T(n)= k T(n/m)+f(n)
通过迭代法求得方程的解:
递归方程及其解只给出n等于m的方幂时T(n)的值,但是如果认为T(n)足够平滑,那么由n等于m的方幂时T(n)的值可以估计T(n)的增长速度。通常假定T(n)是单调上升的,从而当 mi≤n<mi+1时,T(mi)≤T(n)<T(mi+1)。
--------------------------------------------------------------------------------
六、可使用分治法求解的一些经典问题
(1)二分搜索
(2)大整数乘法
(3)Strassen矩阵乘法
(4)棋盘覆盖
1,快速排序
快排是C.R.A.Hoare于1962年提出的一种划分交换排序。它采用了一种分治的策略,该方法的基本思想是:
1).先从数列末尾取出一个数作为基准元。
2).分区过程,将比这个数大的数全放到它的右边,小于或等于它的数全放到它的左边。
3).再对左右区间重复第二步,直到各区间只有一个数。
- //手动快速默写快排:先划界,再分治.... int *arr, low, high)
- pos = rand() % (high - low + 1) + low;
- key = arr[high];
- i = low - 1;
- ( j = low; j <= high - 1; j++)
- (arr[j] <= key)
- i;
- void *arr, low, high)
- (low < high)
- mid = quickPartion(arr, low, high);
- }
2,归并排序
归并排序是把序列递归地分成短序列,递归出口是短序列只有1个元素(认为直接有序)或者2个序列(1次比较和交换),然后把各个有序的段序列合并成一个有序的长序列,不断合并直到原序列全部排好序。可以发现,在1个或2个元素时,1个元素不会交换,2个元素如果大小相等也没有人故意交换,这不会破坏稳定性。那么,在短的有序序列合并的过程中,稳定是是否受到破坏?没有,合并过程中我们可以保证如果两个当前元素相等时,我们把处在前面的序列的元素保存在结果序列的前面,这样就保证了稳定性。所以,归并排序也是稳定的排序算法。
核心函数:
- //分治法的合并函数 //arr[low...mid]与arr[mid+1...high]相合并 void *arr, low, mid, high)
- leftlen = mid - low + 1;
- rightlen = high - mid;
- *L = [leftlen + 1];
- *R = [rightlen + 1];
- i = 0;
- (; i < leftlen; i++)
- j = 0;
- (; j < rightlen; j++)
- ( k = low; k <= high; k++)
- (L[i] <= R[j])
- [] L; L = NULL;
- [] R; R = NULL;
- //合并排序法(分治法) void *arr, low, high)
- (low < high)
- mid = (low + high) / 2;
- }
九,Leetcode实例
题目:
在一个未排序的数组中找到第k大的元素,注意此言的第k大就是排序后的第k大的数,
注意:给定k总是安全有效的。
分析:
设数组下表从low开始,至high结束。
1、 总是取要划界的数组末尾元素为划界元x,开始划界:
a) 用j从low遍历到high-1(最后一个暂不处理),i=low-1,如果nums[j]比x小就将nums[++i]与nums[j]交换位置
b) 遍历完后再次将nums[i+1]与nums[high]交换位置(处理最后一个元素);
c) 返回划界元的位置i+1,下文称其为midpos
这时的midpos位置的元素,此时就是整个数组中第N-midpos大的元素,我们所要做的就像二分法一样找到K=N-midpos的“中间位置”,即midpos=N-K
2、 如果midpos==n-k,那么返回该值,这就是第k大的数。
3、 如果midpos>n-k,那么第k大的数在左半数组
4、 如果midpos<n-k,那么第k大的数在右半数组
- //思路首先:
- //快排划界,如果划界过程中当前划界元的中间位置就是k则找到了
- //time,o(n*lg(k)),space,o(1)
- class Solution {
- public:
- //对数组vec,low到high的元素进行划界,并获取vec[high]的“中间位置”
- int quickPartion(vector<int> &vec, int low,int high)
- {
- int x = vec[high];
- int i = low - 1;
- for (int j = low; j <= high - 1; j++)
- {
- if (vec[j] <= x)//小于x的划到左边
- swap(vec,++i,j);
- }
- swap(vec,++i,high);//找到划界元的位置
- return i;//返回位置
- }
- //交换数组元素i和j的位置
- void swap(vector<int>& nums, int i, int j){
- int temp = nums[i];
- nums[i]=nums[j];
- nums[j]=temp;
- }
- int getQuickSortK(vector<int> &vec, int low,int high, int k)
- {
- if(low >= high) return vec[low];
- int midpos = quickPartion(vec, low,high); //对原数组vec[low]到vec[high]的元素进行划界
- if (midpos == vec.size() - k) //如果midpos==n-k,那么返回该值,这就是第k大的数
- return vec[midpos];
- else if (midpos < vec.size() - k) //如果midpos<n-k,那么第k大的数在右半数组
- return getQuickSortK(vec, midpos+1, high, k);
- else //如果midpos>n-k,那么第k大的数在左半数组
- return getQuickSortK(vec, low, midpos-1, k);
- }
- int findKthLargest(vector<int>& nums, int k) {
- return getQuickSortK(nums,0,nums.size()-1,k);
- }
- };
分治法
这篇文章将讨论:
1) 分治策略的思想和理论
2) 几个分治策略的例子:合并排序,快速排序,折半查找,二叉遍历树及其相关特性。
说明:这几个例子在前面都写过了,这里又拿出来,从算法设计的策略的角度把它们放在一起来比较,看看分治是如何实现滴。
由于内容太多,我将再花一篇文章来写4个之前没有写过的分治算法:1,大整数乘法 2,矩阵乘法的分治策略 3,最近点对 4,凸包问题,请见下一篇。
好了,切入正题。
---------------------------------------------------------------------------------------------------------------------------------------------------
分治算法是按照下列方案来工作的:
1)将问题的实例划分为几个较小的实例,最好具有相等的规模(事实上,一般来说就是这样来分的,而且分为2个实例的居多,注意是递归的分!!!)
2)对这些较小的实例求解(一般使用递归的方法,但在问题规模足够小的时候也可以采用采用另一个算法(停止递归))
3)如果有必要的话,合并这些较小问题的解,以得到原始问题的解(事实上,一个分治算法的精华就在于合并解的过程)
不要忽视这三句话!!!它是许多分治算法经验的总结,有助于在分析问题中考虑如何去使用分治算法,提请注意括号里我的注释!!!
形象的表示一下,截张图:
大多数都是规模为n的问题,被划分为2个规模为n/2的问题,更一般的情况下,从理论上分析一下:
一个规模为n的实例可以划分为b个规模为n/b的实例,其中a个实例是需要求解的,为了简化分析,我们假设n是b的幂(每次都可以整的划分),对算法的运行时间,下面的递推关系式是显然的:
其中,a,b的含义已经说过了,f(n)表示将求解得到的a个子问题的解合并起来所需要的时间复杂度。
如何根据a,b以及f的阶来确定这个算法的时间复杂度呢?有下列主定理:(证明参见算法导论)
看起来似乎是有一点复杂,证明又比较复杂,这样的式子又不好记。看到这个我也觉得茫然。好吧,根据自己的理解,我来做一点解析,挖掘一下这个式子:
1)b,a,f(n)的含义,这个要弄清楚,要不然恐怕你是无法记住这个结论的。
2)这个式子是一个通用的数学表达式,在计算机的常用算法策略中,它太概括了,我们往往用到的只是它范围很小的一部分。
分析1:a和b的关系,其实a绝大多数时候都是等于b的吧(因为规模n划分为了b个子规模,需要处理的是a个,参见a,b的含义),a,b的含义告诉我们a <= b(当这是最基本要满足的条件)。常常要么是 a==b(分成的子规模都要处理,然后去合并),要么是a==1(实际上这是减治的思想,分成了b个子规模,但最终却可以排除其他的,只在其中一个子规模中去处理)。一般来说就是这样,所以a,b并不是随意的。
分析2:其实b要么为2(几乎所有情况)要么为3(极少数情况)吧,这个分治思想里面就说啦,一般来说都是分为2个规模相等的子规模(当然谁都想分的越多越好,这样算法就更快,但是现实是问题往往没有那么高效的算法,找到一个3的分治就已经很不错了)
分析3:由上面2条可知,a,b的值几乎就那么几个(当然我说的是几乎所有书上可以看到的常见算法案例),所以不用那么担心。
分析4:f(n)是线性的的情况很多(即d=1的情况是最多的)。再来看看a和b^d比较大小的关系说明了什么:f(n)代表的是合并的复杂度,1<=a <= b,定性的分析,可以知道:
第一种情况:a < b^d
因为1<=a <= b,所以只要d>1,不管a,b是什么(不管怎么划分规模,也不关需要处理几个规模),总是第一种情况,时间复杂度是n^d。
如果d=1呢,只要a < b(处理的比划分的少),那么还是第一种情况,时间复杂度也是n^d = n
第二种情况:a = b^d
因为1<=a <= b,所以如果a=b(划分多少处理多少),那么d只能为1才能是这种情况。-----常见的归并排序都是这样
而如果a < b,那b就只能<1才能是这种情况,一般很少见。
第三种情况:a > b^d
非常少见,我还木有见过这样的算法,一开始我认为这种情况不可能,但在理论上它是存在的。因为1<=a <= b,所以要满足这个式子d必须<1 。
从这也可以看出这三个参数之间的关系,事实上是划分的复杂度和合并的复杂度在争抢复杂度的控制权。
说了这么多,感觉越分析越复杂了吗,其实不是,把这些分析想清楚,对递推式的理解就更进一步了,有了上述分析,其实下面几个常见的递推式就包含了大多数的算法:
T(n) = a * T(n/b) + f(n)的常见式子:
1) T(n) = 2 * T(n/2) + O(n) 时间复杂度n*log(n)
一般来说分治算法就是这样,分成2个子规模的问题,需要处理的也是2个,对这两个子规模合并又是线性的
a = b = 2, d = 1; a == b^d 由主定理得n*log(n)
只要a=b,d=1,就都是这个复杂度
2) T(n) = T(n/2) + O(n) 时间复杂度为n,线性的
a = 1,b = 2,d=1(分为2个子规模,但只对一个子规模处理,合并也是线性的)
a < b^d, 时间复杂度是n^d = n
其实质押a < b,d=1,都是这个。
感觉对一个定理解读了这么多,确实让它变得更加复杂了,但如果你做了上述思考,相信对这个式子认识也更加深刻了一点。当然,如果你觉得直接记住上面这个公式就可以了,可以无视以上解读。
----------------------------------------------------------------------------------------------------------------------------------------------------
4.1 合并排序
数据结构里面写过了,再从分治实现策略的角度想一想:
合并排序递归的将规模n分为2个子规模,对2个子规模分别排好序后(划分并处理),再来合并这2个子规模为有序(合并解)。
其递推关系式是这样的:
合并解的过程是线性的,即:
由主定理得,时间复杂度是n * logn的。
几道习题的思考:
7,合并排序稳定吗?yes
10,也可以不用递归的方法来实现合并排序,合并排序是采用递归从上往下构造,我们也可以合并2个相邻序列从底向上构造。这样就需要不断地申请一个大的数组,把当前相邻的2个部分合并起来,直到整个合并。
--------------------------------------------------------------------------------------------------------------------------------------------------
4.2 快速排序
前面数据结构里写过了,从分治实现策略的解读来看看:
归并排序是按照元素在数组里的位置来对它们进行划分的,而快速排序是按照元素的值对它们进行划分。
递推关系式:
根据主定理得知,时间复杂度和归并排序一样。
1)其关键在于寻找分裂位置!!!!(2个指针左右扫描,交换。注意这种方法)
2)学会快排采用的分区这种思想。
几道习题的思考:
3,快排稳定吗? ---- no
8,将一个乱序数组排成所有负数在正数之前。-----借用快排2个指针左右扫描交换的思想,线性时间内完成
9,荷兰国旗问题------分区思想
---------------------------------------------------------------------------------------------------------------------------------------------------
4.3 折半查找
前面数据结构写过了,从分治策略的角度看看:
对有序数组采用折半查找:递归和非递归实现都比较简单。
分治的递推式:
根据主定理,它的复杂度是logn。
实际上分治算法一般是分成了几个子规模就对这几个子规模处理然后合并结果,然而折半查找分成了2个子规模,每次却总能排除一个子规模,只对一个子规模进行处理,准确的说,它是一种减治策略。或者说可以把它看做是分治算法的退化。
--------------------------------------------------------------------------------------------------------------------------------------------------
4.4 二叉树遍历及相关特性
很多算法也在前面写过了,还是从分治策略的角度再来看看它的一些特性:
二叉树的定义本身(参见一般的数据结构书),把一颗二叉树递归的定义为了左右子树构成的树。这意味着,分治法是典型可以用于解决二叉树相关问题的策略。
来看看可以做些什么:
1)计算二叉树的高度
注意初始条件是判断是否为空集返回一个 -1,后面有一个习题说是否可以写成返回0,然后else里面不加1。这是不行的!!这样没有考虑单节点的情况。
它的递推关系式:
你能由这个递归关系给出它的时间复杂度吗?想想~
事实上,可以简化它为A(n) = 2 * A(n / 2) + 1 -------把左右的处理当做一个数量级,不会影响复杂度,根据主定理,它是O(n)的。
来看看怎么证明它吧----》
参见第 2)点
2)应当指出,加法并不是上述算法中执行最多的次数,每得到一个左子树和右子树,才执行一次加法(先求max,再执行+1),而比较次数(指检查树是否为空,即if语句)显然不止一次(要判断根节点是否为空,还要判断左右子树是否为空)。
这里引入内部节点和外部结点的概念(定义略)来分析问题!!!:
显然对于每个内部节点都要计算一次加法,对于内部节点和外部结点,都要执行一次比较(判断是否为空)。
可以证明,有n个结点的二叉树的外部结点个数为 x = n + 1;(如上图,用数学归纳法或者图论相关知识都很好证明,证明略)
根据上述分析,对一个n个顶点的二叉树求高度,
加法的操作次数为内顶点个数:n
比较判断是否为空的操作次数为内部点加外部点的个数:n + (n + 1) = 2n + 1;
无论怎么说,该算法是线性的!
3)其他一些如三种经典的遍历算法,都可以递归的来定义,这些都是数据结构课程的标准组成部分,前面已写,不再详述。
最后指出一点,并不是所有的二叉树算法都要遍历左右子树,例如二叉查找树的插入查找删除只需要遍历一棵(每次总能排除一棵),这种策略应该归于减可变规模的策略(参见下一章减治策略)。
几道习题的思考:
1)设计一个分治算法来计算二叉树的层数。-----------递归的返回左右子树层数较大值+1,注意空树和单节点时的情况
最后,扩展一下本节最开始的计算二叉树高度的算法:
一道腾讯面试题:怎么求二叉树上最远的2个节点的距离?-------左子树高度+右子树高度+2,注意特殊情况(只有一边右子树)就行了。
--------------------------------------------------------------------------------------------------------------------------------------------------
总结:
1)分治的思想:一般递归来实现,划分子问题,合并子问题的解。
2)主定理,要很熟悉,常见的递推式应该一眼判断其复杂度。
3)合并排序,快速排序,折半查找,二叉遍历树相关特性,这些都是数据结构的经典内容,之前也都写过了,代码参见前面的相关文章。
这里再次复习,从不同的视角来看它们都是如何用用到了分治的策略。这些内容应当非常熟练。