1、最大子段和问题
给定n个整数(可能为负数)组成的序列a[1],a[2],a[3],…,a[n],求该序列如a[i]+a[i+1]+…+a[j]的子段和的最大值。
一个简单的做法,暴力解决:把1~1、1~2 ······ 1~n、2~2、2~3 ······ 2~n ······ n-1~n、n~n所有可能的子区间和全部算一遍然后比较肯定能得出答案,遍历所有子区间需要两重循环,计算子区间的和需要一重循环,总共三重循环,时间复杂度为。
考虑对上述暴力解法进行优化,我们知道解决区间求和问题可以使用前缀和数组(不知道的读者点这里),那么求和的过程只需要O(1)的时间复杂度,最终优化为。
在前缀和数组的基础上继续思考有无可能优化,注意前缀和求区间和的公式:i到j的区间和=前缀和数组[j] - 前缀和数组[i-1],如果将j固定,那么该区间和的最大值仅取决于i,也就是让前缀和数组[i-1]最小,所以对每个位置j找到它之前位置最小的前缀和就能得到以它为结尾的最大子段和区间,最后比较所有以j为结尾的最大子段和,最大的就是整个区间的最大子段和。乍一看这个思路好像也需要双重循环,第一重循环需要遍历所有的j(范围为1~n),第二重循环要遍历i-1(范围为0~j-1)找到最小的sum[i-1],但是实际上能发现,在遍历j的时候已经遍历了0~j-1的范围,所以这个最大值和最小值能在遍历j的时候同时维护,所以最终只用一重循环即可。
public int max_sum(int[] array) {
//转为前缀和数组
int len = array.length, k=0;
int[] sum = new int[len+1]; sum[0]=0;
for(int i=1;i<=len;++i)
sum[i]=sum[i-1]+array[k++];
//当j确定时,要使sum[j]-sum[i-1]最大,只需要让sum[i-1]最小
int ans=Integer.MIN_VALUE, min_i=0;
for(int j=1;j<=array.length;++j) {
ans = Math.max(ans,sum[j]-min_i);
//这里有个十分关键的点,就是这个比较sum[i-1]的最小值不能放到比较ans最大值的前面!
//至于为什么你可以交换这两行代码,然后输入一个只有-1一个元素的数组试试
//原因在于sum[i-1]的最小值必须取到sum[j]之前的位置,不能正好是sum[j],放在后面就避免了这样的情况
min_i = Math.min(min_i,sum[j]);
}
return ans;
}
由代码可知,最终时间复杂度被降为O(n)。
该问题同时还存在着另一种O(n)级别的解法,即利用动态规划(不熟悉动态规划的读者建议先看看这篇博客:动态规划入门(一):从爬楼梯开始),借助上面求每个以j结尾的最大子段和的思想,我们定义dp[j]就是以array[j]结尾的最大子段和来表示状态(在题目要求序列连续的时候,在状态表示时就要考虑到如何保证连续性),那么最终答案还是max{dp[j] | 0<j<=n}。该问题每阶段的决策就是对于array[j],是否将其加入前一阶段的最大子段和,而因为该问题要求连续性,所以状态dp[j]的前一个阶段的状态就是dp[j-1]。有了状态和决策,下面开始分析本题的状态转移过程:
对于状态dp[j],其上一阶段的决策有:
- array[j]加入最大子段和:则区间1~j的最大子段和等于区间1~j-1的最大子段和+j位置的值,即dp[j]=dp[j-1]+array[j];
- array[j]不加入最大子段和:因为序列必须连续,array[j]不加入那就只能以array[j]重新开始,dp[j]=array[j];
最终状态转移方程为:dp[j]=max(dp[j-1]+array[j], array[j]);
注意到我们比较的是dp[j-1]+array[j]与array[j]的大小,即dp[j-1]+array[j]-array[j]=dp[j-1]与0的大小,所以状态转移方程可以改写为:
此外,由于dp[j]的值是由dp[j-1]更新而来,我们不用同时存储,所以用滚动数组进行空间优化,可以不使用数组:if(dp<0) dp=array[j]; else dp=dp+array[j];
public int maxsum(int[] array) {
int dp = 0, ans = Integer.MIN_VALUE;
for(int i=0;i<array.length;++i) {
if(dp<0) dp=array[i];
else dp+=array[i];
ans = Math.max(ans, dp);
}
return ans;
}
2、最大子矩阵和问题
与最大子段和问题相比,最大子矩阵和就是将一维的情况升至二维,那么此时一个常见的思路就是降维。
如何降维?我们可以看看子矩阵求和的过程:假设有子矩阵,求其和可以是(9+2)+(-4+1)这是先求出每一行的和再逐行相加;也可以是(9+-4)+(2+1),这是先求一列的和再逐列相加,我们来重点看这种情况:求每列的和后,矩阵变成了[5 3],矩阵从二维变成了一维,所以先求每列的和其实就是一个降维的方法。降维后如何求最大子矩阵?我以下面的矩阵为例分析:
如果最终的最大子矩阵只有一行,那么要分别求出[0 -2 7 0] [9 2 - 6 2] [-4 1 -4 1] [1 8 0 -2]的最大子矩阵,因为只有一行,所以就是求最大子段和,最后最大的就是最终的最大子矩阵。
如果最终的最大子矩阵有二行,那么要分别求出的最大子矩阵,利用降维的方式将它们分别压缩至一维,就可以利用求最大子段和的方法求出最大子矩阵了,最后取最大的。
同理,如果最终的最大子矩阵有三行,分别求的最大子矩阵,同上降至一维,分别求最大子段和取最大的。
如果最终的最大子矩阵有四行,那就是求原矩阵的最大子矩阵,降至一维直接求出即可。
子矩阵一共只有这几种情况,最终的最大子矩阵就是其中最大的那个。
降维是每列求和的过程,而求和可以用前缀和数组优化,可以O(1)完成降维,所以最终只要枚举所有列的情况,时间复杂度为,之后分别求最大子段和,时间复杂度为O(n),一共的时间复杂度。
有些题目可能不问最大的子矩阵和而是要你给出最后的最大子矩阵的范围,代码里做了相应实现:
public int[] getMaxMatrix(int[][] array) {
int lenC = array.length, lenR = array[0].length;
//前缀和数组优化
int[][] sum = new int[lenC+1][lenR];
for(int i=1;i<=lenC;++i)
for(int j=0;j<lenR;++j)
sum[i][j]=sum[i-1][j]+array[i-1][j];
//rest数组用来记录最终得到的最大子矩阵的范围,rest[0]rest[1]为矩阵左上角位置,rest[0]rest[1]为右下角位置
int ans=Integer.MIN_VALUE; int[] rest = new int[4];
for(int i=1;i<=lenC;++i) {
for(int j=i;j<=lenC;++j) {
int dp =0, start = 0;
for(int t=0;t<lenR;++t) {
if(dp<0) {
dp=sum[j][t]-sum[i-1][t];
//这里可以得到最大子段和的起始位置
start=t;
}
else dp+=sum[j][t]-sum[i-1][t];
//最后进入该if条件的就是最大子段和的终止位置
if(dp > ans) {
ans = dp;
rest[0]=i-1; rest[1]=start;
rest[2]=j-1; rest[3]=t;
}
}
}
}
return rest;
}