1 问题
最大子数组问题(问题来源算法导论)
对于一个数组A,寻找A的和最大的非空连续子数组,称这样的连续子数组为最大子数组(maximum subarray)。当然A中可能有负数,不然A的最大子数组就是A本身。
例如在以下数组中:
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|
-16 | -23 | 18 | 20 | -7 | 12 | -5 | -22 |
最大子数组为下标3-6的子数组即[18 20 -7 12]其和为43。
2 解题思路
2.1 穷举法
从左至右,找出所有以i(i=1 2…n)下标为初始下标的子数组,此举需要O(n)时间复杂度,每个以i为下标的子数组可以递增自身长度j次(j<n)此举需要O(n)时间复杂度;而这样的i有k个(k<n),所以总时间复杂度为O(n)*O(n)*O(n)=O(n^3)
2.2 分治策略
先定义一个线性时间复杂度为O(n)的函数,用于找出包含“中间位置”的最大子数组。然后定义递归函数,递归地找出左端、右端的最大子数组,最后将递归得出的左端、右端最大子数与左端、右端合并的最大子数组进行比较,得出其较大值就为问题的解,而因为是对原数组折半递归,其递归树深度为 l o g 2 n log{_{2}}{n} log2n,所以其时间复杂度为O(lgn),因此总的时间复杂度为O(n)O(lgn)=O(nlgn)。
2.3 线性策略
因为本文侧重通过分治策略解决问题,对于问题本身的线性更优解先不作讨论,线性方法详细如下:
点此跳转
3 Matlab代码实现
3.1穷举法实现
A=[-16 -23 18 20 -7 12 -5 -22];
maxResult=0; %maxResult保存最大子数组的和
for i=1:size(A,2) %以下标i开始的所有子数组
for j=i:size(A,2) %子数组的长度每次递增1
tmp=[];
for k=i:j %从i开始的子数组以j结束
tmp=[tmp,A(k)];
end
if sum(tmp)>maxResult
maxResult=sum(tmp);
left_index=i;
right_index=k;
end
end
end
disp(left_index);
disp(right_index);
disp(maxResult);
3.2.分治策略实现
find_maximum_subarray.m(递归定义自身找出最大子数组)
为了下文讨论方便,下文将find_maximum_subarray() 简称为f1。
function [a,b,c]=find_maximum_subarray(A,low,high)
if high==low %基线条件:数组中只有一个元素
a=low;
b=high;
c=A(low);
return
else
mid=floor((low+high)/2);
[left_low,left_high,left_sum]=find_maximum_subarray(A,low,mid); %递归找出最大左子数组
[right_low,right_high,right_sum]=find_maximum_subarray(A,mid+1,high); %递归找出最大右子数组
[cross_low,cross_high,cross_sum]=find_max_cross_subarray(A,low,mid,high);
if left_sum>=right_sum && left_sum>=cross_sum %最大子数组在左端取得
a=left_low;
b=left_high;
c=left_sum;
return
elseif right_sum>=left_sum && right_sum>=cross_sum %最大子数组在右端取得
a=right_low;
b=right_high;
c=right_sum;
return
else %最大子数组在中间取得
a=cross_low;
b=cross_high;
c=cross_sum;
return
end
end
find_max_cross_subarray.m(找出包含“中间位置”的最大子数组)
为了下文讨论方便,下文将find_max_cross_subarray()简称为f2。
%求出给定子数组跨越中线(mid)的最大连续和
%A为给定子数组
%low为下标
%mid为中线
%high为上标
%该函数的事件复杂度为O(n)
function [max_left,max_right,lrsum]=find_max_cross_subarray(A,low,mid,high)
%% 求出左半部分的最大子数组
left_sum=-10^(10); %保存目前为止找到的最大和
sum=0; %保存A[i...mid]中所有值的和
i=mid;
while i>=low
sum=sum+A(i);
if sum>left_sum
left_sum=sum;
max_left=i; %记录A[i...mid]中最大和的下标i
end
i=i-1;
end
%% 求出右半部分的最大子数组
right_sum=-10^(10); %负无穷
sum=0;
for j=mid+1:high
sum=sum+A(j);
if sum>right_sum
right_sum=sum;
max_right=j;
end
end
lrsum=left_sum+right_sum;
命令行调用以下命令输出结果
find_maximum_subarray(A,1,size(A,2))
输出结果:
4 讨论
递归树(右边子树部分没有表述全)
数据用描述问题中的例示数据。 整个递归树的调用过程类似于满二叉树的先序遍历…
由递归树也可以容易看出,分治策略的时间复杂度。因为递归树的深度为
l
o
g
2
n
log{_{2}}{n}
log2n,即一共有
l
o
g
2
n
log{_{2}}{n}
log2n层需要计算,而计算每层的时间复杂度都为O(n),因此总的时间复杂度为
O
(
l
o
g
2
n
)
O({log{_{2}}{n}})
O(log2n)*O(n)=
O
(
n
∗
l
o
g
2
n
)
O({n*log{_{2}}{n}})
O(n∗log2n)。
在上图中,
f1(A,low,high)指示find_maximum_subarray.m函数,用于找出在数组A的low到high范围内的最大子数组;开头调用f1(A,1,8)实际上就是想要求出原问题的解,而求解f1(A,1,8)则需要求解 f1(A,1,4), f2(A,1,4,8)、f1(A,4,8)三个的问题的解(分治策略),分别表示求出f(A,1,8)的左端最大子数组、跨越“中间”的左右端合并最大子数组、以及右端最大子数组,因此每层f1的调用都返回对应的3个值。简单地说,最大子数组肯定是当前数组的左端,右端或者是左右两端合并的的子数组中取得,递归求解就能得到最后的结果。
f2(A,low,mid,high)指示find_max_cross_subarray.m函数,用于找出low到high范围内跨越mid的A中最大子数组。
f1、f2傍边的黑色圆圈里面的值表示该函数执行时的顺序号,1| f1(A,1,8)前的1表示这个函数最先被调用,而21 | f2(A,1,4,8)表示这个函数是第21个被调用的函数,在上例中也是最后一个被调用的函数。红色圆圈内的值为调用函数的返回值,红色矩形框为调用f2求出的最大子数组。
分治三部曲
- 分解(Divide), 将问题划分为一些子问题,子问题与原问题一样,只是规模更小;
- 解决(Conquer), 如果子问题的规模足够小,则停止递归,直接求解;
- 合并(Combine), 将子问题的解合成原问题的解;
在本例中,求解f1(A,low,high)需要将问题分为f1(A,low,mid)、f1(A,mid+1,high),以及f2(A,low,mid,hgih)3部分,而前两部分f1(A,low,mid)、f1(A,mid+1,high)实际上与原问题一样,只是问题规模变小了可以进一步求解,体现了3部曲中的第1部——分解(Divide)。
而递归程序不能够永远执行下去,即需要有一个递归出口,本例的递归出口就是A的low==high时,即当前数组只有一个元素,因此最大子数组为其本身,直接求解,返回元素本身作为最大子数组,也就是3部曲中的第2部——解决(Conquer)。
在本例中,求出了数组左端的最大子数组、右端的最大子数组、以及包含中间的最大子数组后需要在这3个中选择出一个最优解,也就是分解子问题得到的解合并成为原问题的解——合并(Comnine)。
分治与递归
虽然采用分治策略编写的递归程序程序简洁,但是却不好理解,原因在于递归栈的调用过程都是隐式调用,给人一种看不出程序是如何执行的感觉,对此,可以多手动模拟递归栈的调用过程,加深理解。
动态规划与分治法
适合用分治法求解的问题通常在递归的每一步都生成全新的子问题;动态规划因为具有重叠子问题性质,所以一直都是在反复求解相同的子问题,而不是一直生成新的子问题。