和吴昊一起玩推理 Round 11(增刊) —— 最大子段和的五重境界
如 图,为庐山的一角,又诗曰——“横看成岭侧成峰,远近高低各不同”,风流苏轼写的诗,木有错,至少说明了一个道理,解决一个问题可以有不同的途径,而不同 的途径,风景是不一样的,可以理解为时间效率和空间效率不一样,也可以理解为思想不同(比如在排序中有许多平均时间复杂度为O(nlogn)的算法,但是 由于其实现思想不同而导致了风景不一样)
Problem:
给定长度为n的整数序列,a[1...n], 求[1,n]某个子区间[i , j]使得a[i]+…+a[j]和最大。或者求出最大的这个和.例如(-2,11,-4,13,-5,2)的最大子段和为20,所求子区间为[2,4](这里必须指明子区间必须是连续的,否则,毫无人性的暴力可能会暴成O(2^n))
第一境:毫无人性的暴力——时间复杂度O(n^3)
这里实际上是要穷举所有的[1,n]之间的区间,所以我们用两重循环,可以很轻易地做到遍历所有子区间,一个表示起始位置,一个表示终点位置(也就是start和end)。
2 int start = 0;
3 //结束点
4 int end = 0;
5 //定义最大值
6 int max = 0;
7 for(int i = 1; i <= n; ++i)
8 {
9 for(int j = i; j <= n;++j)
10 {
11 int sum = 0;
12 for(int k = i; k <=j; ++k)
13 sum += a[k];
14 if(sum > max)
15 {
16 start = i;
17 end = j;
18 max = sum;
19 }
20 }
21 }
第二境:有人性的暴力——时间复杂度O(n^2)
我在吴昊系列第一季,也就是Round 7的熄灯问题中说过,如果可以合理的利用数据直接的关系的话,可以将毫无人性的暴力降低为有人性的暴力。这里,我们采取另外一种策略来弥补人性的缺失,就 是利用前面的一些数据作为铺垫(这里并不要将以前的数据全部存储),来解决一些新的问题。比如这里,我们考虑到并不需要每次都重新从起始位置求和加到终点位置.可以充分利用之前的计算结果,所以两个下标依然是start和end,但是我们在将end不断变动的时候,将其前面叠加的结果做一定程度的保留,并累加到后面。
2 int end = 0;//结束位置
3 int max = 0;
4 for(int i = 1; i <= n; ++i)
5 {
6 int sum = 0;
7 for(int j = i; j <= n;++j)
8 {
9 sum += a[j];
10 if(sum > max)
11 {
12 start = i;
13 end = j;
14 max = sum;
15 }
16 }
17 }
第三境:分而治之——时间复杂度O(nlogn)
《三国演义》中有这么一段,诸葛亮离开荆州之前问关羽,如果孙权打过来怎么办啊?攻击啊!如果曹操打过来怎么办啊?抵御啊!如果孙权和曹操一起打过来,怎么办呢?分治,木有错,分而治之,谓之分治。
在排序中,有一种方法叫归并排序,也就是分而治之的这种思想的体现,这种策略将问题又更进了一步。
考虑到所有子区间[start, end]只可能有以下三种可能性:
在[1, n/2]这个区域内
在[n/2+1, n]这个区域内
起点位于[1,n/2],终点位于[n/2+1,n]内
以上三种情形的最大者,即为所求. 前两种情形符合子问题递归特性,所以递归可以求出. 对于第三种情形,则需要单独处理. 第三种情形必然包括了n/2和n/2+1两个位置,这样就可以利用第二种穷举的思路求出:
以n/2为终点,往左移动扩张,求出和最大的一个left_max
以n/2+1为起点,往右移动扩张,求出和最大的一个right_max
left_max+right_max是第三种情况可能的最大值
2 {
3 //这里表示已经归并到尽头
4 if(right==left)
5 return a[left]>0 ? a[left]:0;
6 int center = (left+right)/2;
7
8 //左边区间的最大子段和
9 int leftMaxInterval = maxInterval(a,left,center);
10
11 //右边区间的最大子段和
12 int rightMaxInterval= maxInterval(a,center+1,right);
13
14 //以下求端点分别位于不同部分的最大子段和
15
16 //center开始向左移动
17 int sum = 0;
18 int left_max = 0;
19 //从中间往左边逐渐扫描
20 for(int i = center; i >= left; i--)
21 {
22 sum += a[i];
23 if(sum > left_max)
24 left_max = sum;
25 }
26 //center+1开始向右移动
27 sum = 0;
28 int right_max = 0;
29 //从中间往右边逐渐扫描
30 for(int i = center+1; i <= right; ++i)
31 {
32 sum += a[i];
33 if(sum > right_max)
34 right_max = sum;
35 }
36 //算出包含中间的情况的最大值,将这里的ret分别与左边和右边的最大值进行比较
37 int ret = left_max+right_max;
38 if(ret < leftMaxInterval)
39 ret = leftMaxInterval;
40 if(ret < rightMaxInterval)
41 ret = rightMaxInterval;
42 return ret;
43 }
第四境:动态规划——时间复杂度O(n)
DP的步骤:
令b[j]表示以位置 j 为终点的所有子区间中和最大的一个
如果b[j-1] >0, 那么显然b[j] = b[j-1] + a[j],用之前最大的一个加上a[j]即可,因为a[j]必须包含
如果b[j-1]<=0,那么b[j] = a[j] (这里为赋予),因为既然最大,前面的负数必然不能使你更大
DP的证明(来自算法导论中的剪切法):
令a[x,y]表示a[x]+…+a[y] , y>=x
假设以j为终点的最大子区间 [s, j] 包含了j-1这个位置,以j-1为终点的最大子区间[ r, j-1]并不包含其中
即假设[r,j-1]不是[s,j]的子区间
存在s使得a[s, j-1]+a[j]为以j为终点的最大子段和,这里的 r != s
由于[r, j -1]是最优解, 所以a[s,j-1]<a[r, j-1],所以a[s,j-1]+a[j]<a[r, j-1]+a[j]
与[s,j]为最优解矛盾
DP的实现:
2 int b[n+1];
3 int start = 0;
4 int end = 0;
5 //初始化数组b[]
6 memset(b,0,n+1);
7 for(int i = 1; i <= n; ++i)
8 {
9 if(b[i-1]>0)
10 {
11 b[i] = b[i-1]+a[i];
12 }
13 else
14 {
15 b[i] = a[i];
16 }
17 if(b[i]>max)
18 max = b[i];
19 }
20
第五境——空之境界!(转载)
任何一个游戏都可以无限地扩展,任何一个问题也如是,以下是一些基本的变式:
变形1:最大子矩阵和
例子:HOJ 2558 maxsum
所 谓万变不离其宗,这个的思路和上面是一样的. 维数增加了一维,所以可以考虑把它转化成一维的"基本问题". 我们可以先统计sum[i][j](以下假设下标从1开始) : 第i行,从开头到第j个元素的总值,这样, 第i行从第j个元素到第k个元素的总价值就是sum[i][k] - sum[i][j - 1]. 这个预处理的时间复杂度是O(N^2). 这时,这个问题就转化成了一维的最大子段和问题了: 枚举每一行中,第i到第j个元素(1 <= i <= j <= n),就可以把j - i + 1个元素的总和看成一个元素(转化的过程), 然后,对n个这样的元素求最大子段和即可. 这一部分的时间复杂度是O(N^3),因此总复杂度也为O(N^3).
变形2:最大子立方体
例子:HOJ 2555 Eating Watermelon
有 了上面的转化思路,这个问题也就很简单了,我们仍然应该进行转化. 这时,可以把平面上的一个子矩形看成一个元素,问题也轻松的转化成为一维的模型. 不过值得一提的是这时的预处理,可以设rec[x,y,z]表示z轴坐标为z的水平面中矩形(1,1,x,y)的数和。则z轴坐标为z的水平面中左上角为 (x1,y1)、右下角为(x2,y2)的矩阵的数和为rec[x2,y2,z] + rec[x1,y1,z] - rec[x2,y1,z] - rec[x1,y2,z](转自zhouguyue最大子段和问题的报告) 这个方法我是看了报告才知道的. 这个预处理过程是O(N^3),DP时的复杂度是O(N^5) (枚举一个平面上所有的子矩形,O(N^4); 求最大子段和,O(N); 相乘知总复杂度为O(N^5)),总复杂度也是O(N^5).
PS: 这个题我的代码跑了1.4X秒,差不多是最快的二倍,不知道这里有没有什么较好的优化方法!
变形3:最大m子段和
例子: HDU 1024 Max Sum Plus Plus
这个变形和前面的稍有不同. 前面的背包问题中曾提到,"在限制条件增加一维时,可以将状态也相应的增加一维,来进行状态转移". 我觉得这个问题正是利用了这种强大思想! 可以增加一维状态,以dp[i][j]表示以第i个元素为结尾,使用j个子段所能达到的最大值 (这一维的状态,正是对应了新的限制条件!) 这样就很容易写出状态转移方程: dp[i][j] = max{ dp[i - 1][j] + a[i] (把第i个元素包含在最后一个子段内), dp[i - k][j - 1] + a[i], j - 1 <= k < n - m + j(第i个元素单独为一子串).
不过,这里还有一个优化,是今天才学会的:为了避免重复计算(这也是DP的动机),可以令p[i][j]表示前i个元素的最大j子段和,这样p[i][j]
= max{p[i - 1][j], dp[i][j]}, dp[i][j] = max{f[i - 1][j], p[i - 1][j - 1]} + a[i]. 这个优化没有实践过,不知道效果如何!