1.问题描述
问题:一个有N个整数元素的一维数组(A[0],A[1],A[2],…A[n-1]),这个数组中子数组之和的最大值是多少?
该子数组是连续的。例如 数组:[1,-2,3,5,-3,2]返回8; 数组:[0,-2,3,5,-1,2]返回9。
网上有称之为最大子序列和,亦有称连续子数组最大和。个人觉得叫最大子序列和不太妥,数学上讲,子序列不一定要求连续,而这里我们的题目必然要求是连续的,如果不连续而求子序列最大和很显然就无意义了,这也是为啥又称连续子数组最大和。不过,莫要在意细节。
这题是很经典的一道面试题,也有各种解法,从算法分析上,时间复杂度也有很大差别,下面我就给出三种不同的解法。
2.解法一:暴力枚举法
此种方法最简单,我想应该也是每个人拿到题目想到的第一种解法了,学过一点编程的人都应该能编出此类程序。
记sum[i..j]为数组中第i个元素到第j个元素的和(其中0<=i
int maxSubArray(int *A,int n) {
int maxium = -INF; //保存最大子数组之和
for i=0 to n-1 do
sum = 0; //sum记录第i到j的元素之和
for j=i to n-1 do
sum += A[j];
if sum>maxium do //更新最大值
maxium = sum;
return maxium;
}
此种方法的时间复杂度为O(n2),显然不是一种很好的办法,也不是公司面试希望你写出这样的程序的。
3.解法二:分支界定
这里再介绍一种更高效的算法,时间复杂度为O(nlogn)。这是个分治的思想,解决复杂问题我们经常使用的一种思维方法——分而治之。
而对于此题,我们把数组A[1..n]分成两个相等大小的块:A[1..n/2]和A[n/2+1..n],最大的子数组只可能出现在三种情况:
A[1..n]的最大子数组和A[1..n/2]最大子数组相同;
A[1..n]的最大子数组和A[n/2+1..n]最大子数组相同;
A[1..n]的最大子数组跨过A[1..n/2]和A[n/2+1..n]
前两种情况的求法和整体的求法是一样的,因此递归求得。
第三种,我们可以采取的方法也比较简单,沿着第n/2向左搜索,直到左边界,找到最大的和maxleft,以及沿着第n/2+1向右搜索找到最大和maxright,那么总的最大和就是maxleft+maxright。
而数组A的最大子数组和就是这三种情况中最大的一个。
伪代码如下:
int maxSubArray(int *A,int l,int r)
{
if ldo
mid = (l+r)/2;
ml = maxSubArray(A,l,mid); //分治
mr = maxSubArray(A,mid+1,r);
for i=mid downto l do
search maxleft;
for i=mid+1 to r do
search maxright;
return max(ml,mr,maxleft+maxright); //归并
then //递归出口
return A[l];
}
4.解法三:动态规划(DP)
我们考虑最后一个元素arr[n-1]与最大子数组的关系,有如下三种情况:
(1)arr[n-1]单独构成最大子数组
(2)最大子数组以arr[n-1]结尾
(3)最大子数组跟arr[n-1]没关系,最大子数组在arr[0-n-2]范围内,转为考虑元素arr[n-2]
从上面我们可以看出,问题分解成了三个子问题,最大子数组就是这三个子问题的最大值,现假设:
(1) 以arr[n-1]为结尾的最大子数组和为End[n-1]
(2) 在[0-(n-1)]范围内的最大子数组和为All[n-1]
如果最大子数组跟最后一个元素无关,即最大和为All[n-2](存在范围为[0-n-2]),则解All[n-1]为三种情况的最大值,即All[n-1] = max{ arr[n-1],End[n-1],All[n-2] }。从后向前考虑,初始化的情况分别为arr[0],以arr[0]结尾,即End[0] = arr[0],最大和范围在[0,0]之内,即All[0]=arr[0]。根据上面分析,给出状态方程:
All[i] = max{ arr[i],End[i-1]+arr[i],All[i-1] }
代码如下:
/* DP base version*/
#define max(a,b) ( a > b ? a : b)
int Maxsum_dp(int * arr, int size)
{
int End[30] = {-INF};
int All[30] = {-INF};
End[0] = All[0] = arr[0];
for(int i = 1; i < size; ++i)
{
End[i] = max(End[i-1]+arr[i],arr[i]);
All[i] = max(End[i],All[i-1]);
}
return All[size-1];
}
上述代码在空间上是可以优化为O(1)的
/* DP base version*/
#define max(a,b) ( a > b ? a : b)
int Maxsum_dp(int * arr, int size)
{
nEnd = nAll = arr[0];
for(int i = 1; i < size; ++i)
{
nEnd = max(nEnd +arr[i],arr[i]);
nAll = max(End[i],nAll);
}
return nAll ;
}
下面说一下由DP而导出的另一种O(N)的实现方式,该方法直观明了,个人比较喜欢,所以后续问题的求解也是基于这种实现方式来的。
仔细看上面DP方案的代码,End[i] = max{arr[i],End[i-1]+arr[i]},如果End[i-1]<0,那么End[i]=arr[i],什么意思?End[i]表示以i元素为结尾的子数组和,如果某一位置使得它小于0了,那么就自当前的arr[i]从新开始,且End[i]最初是从arr[0]开始累加的,所以这可以启示我们:我们只需从头遍历数组元素,并累加求和,如果和小于0了就自当前元素从新开始,否则就一直累加,取其中的最大值便求得解。
到这里其实就可以了,在《编程之美》中,作者故意没有按照这种推导来实现(我猜的),而是在End[i-1]<0时,让End[i]=0,从而留出了一个问题(元素全是负数怎么办),其实如果按照上面的推导直接实现的话,就不存在这个问题了;
基于上面的推导,代码如下:
/* DP ultimate version */
int Maxsum_ultimate(int * arr, int size)
{
int maxSum = -INF;
int sum = 0;
for(int i = 0; i < size; ++i)
{
if(sum < 0)
{
sum = arr[i];
}else
{
sum += arr[i];
}
if(sum > maxSum)
{
maxSum = sum;
}
}
return maxSum;
}
其实上面的方法虽说是从DP推导出来的,但是写完发现也是很直观的方法,求最大和,那就一直累加呗,只要大于0,就说明当前的“和”可以继续增大,如果小于0了,说明“之前的最大和”已经不可能继续增大了,就从新开始,如此这样。
5.问题扩展:返回最大子数组始末位置
这个问题是《编程之美》2.14的扩展问题,返回始末位置还是比较容易的,我们知道,每当当前子数组和的小于0时,便是新一轮子数组的开始,每当更新最大和时,便对应可能的结束下标,这个时候,只要顺便用本轮的起始和结束位置更新始末位置就可以,程序结束,最大子数组和以及其始末位置便一起被记录下来了。
C++代码如下:
#include
using namespace std;
template<class T>
class my_data_type
{
T max_sum;
int start;
int end;
public:
void max_sub_sum_dp(T *a, int length);
void my_type_printf(void);
};
template<class T>
void my_data_type::my_type_printf(void)
{
cout << "max_sum is " << max_sum << endl;
cout << "start is " << start << " end is " << end << endl;
}
template<class T>
void my_data_type::max_sub_sum_dp(T *a, int length)
{
T sum=a[0];
start = 0;
end = 0;
max_sum = a[0];
int start_temp = 0;
for (int i = 1; i < length; i++)
{
if (sum < 0)
{
sum = a[i];
start_temp = i;
}
else
{
sum = sum + a[i];
}
if (sum > max_sum)
{
max_sum = sum;
start = start_temp;
end = i;
}
}
}
template<typename T>
void printf_data(T *a, int length)
{
for (int i = 0; i < length; i++)
{
cout << a[i] << " ";
}
cout << endl;
}
int main()
{
int a_int[6] = { 0, -2, 3, 5, -1, 2 };
int a1_int[6] = { -9, -2, -3, -5, -3 };
float a_float[6] = { 0.1, -2.2, 3.4, 5.6, -1.3, 2.8 };
float a1_float[5] = { -9.0, -2.1, -3.1, -5.2, -3.6 };
my_data_type<int> my_test_int;
my_test_int.max_sub_sum_dp(a_int, 6);
printf_data(a_int, 6);
my_test_int.my_type_printf();
my_test_int.max_sub_sum_dp(a1_int, 5);
printf_data(a1_int, 5);
my_test_int.my_type_printf();
my_data_type<float> my_test_float;
my_test_float.max_sub_sum_dp(a_float, 6);
printf_data(a_float, 6);
my_test_float.my_type_printf();
my_test_float.max_sub_sum_dp(a1_float, 5);
printf_data(a1_float, 5);
my_test_float.my_type_printf();
}
6.问题扩展:允许数组首尾相连
这个也是2.14的扩展问题,如果数组arr[0],…,arr[n-1]首尾相邻,也就是允许找到一段数字arr[i],…,arr[n-1],arr[0],…,a[j],使其和最大,该如何?
编程之美解法:这个问题的解可以分为两种情况:
1) 解没有跨越arr[n-1]到arr[0] (原问题)
2) 解跨越arr[n-1]到arr[0]
对于第一种情况按照之前的方式计算即可,对于第二种情况我们可以巧妙地 进行问题转换。我们找最大子数组的对偶问题——最小子数组,有了最小子数组的值,总值减去它不就可以了么?但是我又想,这个对偶问题只能处理这种跨界的特殊情况吗?答案是肯定的,如果最大子数组跨界,那么剩余的中间那段和就一定是最小的,而且和必然是负的;相反,如果最大子数组不跨界,那么总值减去最小子数组的值就不一定是最大子数组和了,例如例子[8,-10,60,3,-1,-6],最大子数组为[8 | 60,3,-1,-6],而最小子数组和为[-10],显然不能用总值减去最小值。
故,在允许数组跨界(首尾相邻)时,最大子数组的和为下面的最大值
Maxsum={ 原问题的最大子数组和;数组所有元素总值-最小子数组和 }。