前言
最大子数列和是初入算法和数据结构所要学习的内容,也是一个了解什么是数据结构,不同算法之间的差别的很好的例子,我们接下来将会以枚举法,分治法,Kadane算法,和动态规划四种不同的算法来简要说明该问题,当然除了这四个方法以外还有很多算法(如二分法)可以解决最大子数列和这个问题。
问题引入
什么是最大子数列?
在计算机科学中,最大子数列问题的目标是在数列的一维方向找到一个连续的子数列,使该子数列的和最大。例如,对一个数列 【】,其连续子数列中和最大的是 【】, 其和为6。
按照这样的理解,我们可以很容易的想到用两个“指针”,一前一后的遍历数组所有的子数列,比较所有的子数列和即可求得最大解。当然,如果数列中所有的元素均为非负数,我们默认最大子片段是可以为空的, 空片段的和值是0。
算法分析
枚举法
我们只需要找寻从第n个元素开始的最大子串,并比较这些最大子串的最大值即可。
其C++代码如下:
int Maxsum(const int A[],int N)
{
int ThisSum,Maxsum=0;
for(int i=0;i<N;i++)
{
ThisSum=0;
for(int j=i;j<N;j++)
{
ThisSum+=A[j];
if(ThisSum>Maxsum)
Maxsum=ThisSum;
}
}
return Maxsum;
}
这种方式虽然速度较慢,但是优点在于很容易想到,并且代码简单易写,在的值不太大的情况下是非常稳定的(目前主流CPU每秒可以执行约数量级的循环计算,因此在的情况下还是很快会得到结果的)。
分治法
首先应当清楚的是分治法和二分法之间的关系。
分治算法的基本思想是将一个规模为N的问题分解为K个规模较小的子问题,这些子问题相互独立且与原问题性质相同。求出子问题的解,就可得到原问题的解。
也就是说,二分法仅仅是最简单的分治,事实上很多问题的求解都更容易联想到二分,但分治却不容易想到,最大子数列之和既可以通过二分的分治法求解,也可以通过非二分的分治法求解,由于二分算法思维较为简单易懂,且较代码为复杂,且网络上基本都是通过二分法求解该算法的,我在这里就不过多赘述,具体的C++代码实现如下:
int Findmaxsum(int box[],int size,int left,int right)//数组名,数组大小,左边界,右边界
{
int mid = (right + left) / 2;
if(left == right) //分治递归要注意出口条件
return box[left];
int leftsum = Findmaxsum(box,size,left,mid ); //求出左半区最大子序列和 ,要有递归信任,不要纠结层层深入,假设该函数是正确的。
int rightsum = Findmaxsum(box,size,mid + 1,right); //求出右半区最大子序列和
int leftbordersum = rightbordersum = thissum = 0;
for(int i = mid + 1 ;i <= right;i++) //求出含有中间分界点的右半区最大子序列和
{
thissum += box[i];
if(rightbordersum < thissum)
rightbordersum = thissum;
}
thissum = 0;
for(i = mid ;i >= left;i--) //求出含有中间分界点的左半区最大子序列和
{
thissum += box[i];
if(leftbordersum < thissum)
leftbordersum = thissum;
}
int midsum = leftbordersum + rightbordersum; //横跨左右半区最大子序列和
return MAX(midsum,leftsum,rightsum); //左半区最大子序列和,右半区最大子序列和,跨半区最大子序列和,三者中最大的为所求者
}
int MAX (int a,int b,int c);
需要特别注意的是递归出口条件和最大子数列可能同时跨越左边界和右边界的情况,该算法的时间复杂度计算方式如下:
()
其中为程序运行的时间,k为迭代次数。由于迭代终点序列长度为1,我们可以得到
因此该算法的时间复杂度为:
Kadane算法
我们试着换一种方式来思考这样的问题,最大子数列的性质都有什么?
我们假设已经找到这样的一串最大子数列,那么它必然会有以下的性质:
-
否则完全可以去掉这个元素形成更大的最大子数列。 -
否则完全可以加上这个元素形成更大的最大子数列。 -
否则完全可以删去这一串子数列形成更大的最大子数列。
因此,这个节点是一个分界点,所要求的最大子序列要么在之前,要么在之后。
最大子数列一定不会包含,否则就不满足第三条性质了。
因此,我们只需要找到这样的分界点即可将整个数组划分成多个小子数列进行逐个求解,然后比较这些子数列中的最大值即可。
那么如何找到这样的分解点呢?
根据性质3,我们可以得出恒成立,因此我们只需从起始位置逐个向后加和,当加和sum变为负数的时候的位置就是分界点。然后将sum清零,继续求和寻找下一个分界点。
我们举一个例子:有这样的一个数组,根据上面的描述,我们可以找到分界点为-4,那么这个最大子数列一定在-9的前面或者后面,这里很明显是在后面,如果将第一数字2变成200,那么这个最大子数列就在分界点的前面。
这种方法的思想是,我们仔细的分析了最大子数列的性质,并根据这种最大子数列特有的性质,将整个数组划分成多个子数列,而且这些子数列的起始位置也已经固定,只需从开始逐个加和至下一个“”之前,即可用线性的时间复杂度求解最大子数列的最大值,因此这个算法的时间复杂度为:
具体c++代码如下:
int Maxsum(const int A[],int N)
{
int Sum=A[0],Maxsum=A[0];
for(int i=0;i<N;i++)
{
Sum+=A[i];
if(sum<0) //分界点
Sum=0;
else
if(Sum>Maxsum)
Maxsum=Sum;
}
return Maxsum;
}
这个算法极大的简化了代码的长度,提高了容错率,更降低了时间复杂度,但缺点是思维量较前者比更大。这个算法也称在线处理算法,因为它可以随输入的变化而随时计算和更新最大子数列的答案。
动态规划
更常规的思想是动态规划的思想,该算法的时间复杂度也是线性的,且想法更为常规,适用于多种不同问题的解决方案。
动态规划算法通常用于求解具有某种最优性质的问题。其核心思想是自底向上(这里是从前往后)的求解,把多阶段过程转化为一系列单阶段问题逐个求解。对于本问题的重点便是定义目标函数和确定状态转移方程。
首先建立一个与数组同样长度的数组, 表示与第i个数组合时,所能得到的最大值 ;接下来考虑的两种可能:
- 当 时,自己独立成为一个长度为1的子串。
- 当 时,是否要和相加。
由此便可列出该算法的动态转移方程:
需要注意的是:这里的 并不代表前i个数据中最大子串和,只代表包含第i个数的子数列所能得到的最大值。
具体C++代码如下:
int Maxsum(const int A[],int N)
{
int dp[N];
int Maxsum=A[0];
dp[0]=A[0];
for(int i=0;i<N;i++)
{
if(dp[i-1]<0)
dp[i]=A[i];
else
dp[i]=a[i]+dp[i-1];
Maxsum=max(dp[i],Maxsum);
}
return Maxsum;
}
该算法的时间复杂度依旧为.
总结
对于同一个问题,我们通过不同的思维方式,不同的算法都可以很好的解决这样的问题,这就是算法和数据结构的魅力所在。我们很注重时间复杂度和空间复杂度的消耗。同样的,通常情况下,时间复杂度和空间复杂度越小的算法,代码越简单,但所需的思维量就越大。
对于本题,Kadane算法是这些解决方案中最好的,因为它的时间复杂度为,并且没有额外占用其他空间,相比之下dp算法虽然时间复杂度相同,但是额外开辟了一个长度为n的数组空间。
通过这四个算法的比较,我们也对优化代码的思想有所初步了解,如何优化代码也是学习算法的核心思想之一。
参考文献:
[1] https://baike.baidu.com/item/%E6%9C%80%E5%A4%A7%E5%AD%90%E6%95%B0%E5%88%97%E9%97%AE%E9%A2%98/22828059 最大子数列问题
[2] https://zh.wikipedia.org/wiki/%E6%AF%8F%E7%A7%92%E6%B5%AE%E9%BB%9E%E9%81%8B%E7%AE%97%E6%AC%A1%E6%95%B8
[3] https://baike.baidu.com/item/%E5%88%86%E6%B2%BB%E7%AE%97%E6%B3%95/3263297
[4] 动态规划问世以来,在经济管理、生产调度、工程技术和最优控制等方面得到了广泛的应用。例如最短路线、库存管理、资源分配、设备更新、排序、装载等问题,用动态规划方法比用其它方法求解更为方便
[5] https://baike.baidu.com/item/%E5%8A%A8%E6%80%81%E8%A7%84%E5%88%92#ref_3_28146
记得点个赞再走啊!
另外,感兴趣的读者也可以参看我的以下文章:
tonyl:微积分,离散,计算机笔记入口zhuanlan.zhihu.com