最大子段和详解(N种解法汇总)

问题的提出:给定有n个整数(可能为负整数)组成的序列a1,a2,...,an,求该序列连续的子段和的最大值。如果该序列的所有元素都是负整数时定义其最大子段和为0。

例如,当(a1,a2,a3,a4,a5)=(-5,11,-4,13,-4-2)时,最大子段和为11+(-4)+13=20。

 

解法一:穷举法,即把所有可能情况一一列举

穷举法是最直接的想法,把所有的情况列出来,再进行挑选。

同样是穷举法,下面两个写法优劣就不一样。有的人可能还会增加空间开销,使用一个数组来保存结果。

1)使用三层循环

下面的算法是这样的:(使用字典序的方式)从序列a[]的第一个开始,算a[0]的和,算a[0]~a[1]的和,算a[0]~a[2]的和

……算a[0]~a[n-1]的和,然后算a[1]的和,算a[1]~a[2]的和,算a[1]~a[3]的和,一直算到a[n-2]~a[n-1]的和、

算a[n-1]的和,在每次计算a[i]~a[j]的和后,都要和当前最大子段和sum比较,若发现更大的,就更新sum的值。

前两层循环就是完成字典序穷举,而第三层循环是计算a[i]~a[j]的和。


//begin,end分别记录最大子段和的开始和结尾位置的下标,下标从0开始
//a[]是待求数组,n是序列长度
int maxSum(int a[],int n,int &begin,int &end){
	int sum=0;//用来保存最大子段和的值
	for (int i=0;i<n;i++)
		for(int j=i;j<n;i++){
			int temSum=0;//temSum保存每一次a[i]~a[j]的和,然后和当前最大子段和比较
			for(int k=i;k<=j;k++)
				temSum+=a[k];//计算a[i]~a[j]的和
			if(temSum>sum){//如果发现更大的子段和,则更新sum的值,并保存当前最大子段和的开始和结尾下标
				sum=temSum;
				begin=i;
				end=j;
			}
		}
	return sum;
}


这算法很清晰,就是挨个列举,如果发现有比sum更大的值,就更新sum。但是重复做了很多工作,导致时间复杂度为O(n^3),每一次计算a[i]~a[j]的和都要从a[i]一直累加至a[j],其实我们是可以先保存a[i]~a[j-1]的和至一个变量temSum,那么a[i]~a[j]的和就等于temSum+a[j],这就是下面两层循环的写法

2)使用两层循环

int maxSum(int a[],int n,int &begin,int &end){
	int sum=0;//用来保存最大子段和的值
	for(int i=0;i<n;i++){
		int temSum=0;//保存从下表为i开始至j的和,当求a[i]~a[j+1]的和时,就可以变为求temSum+a[j+1]
		for(int j=i;j<n;i++){
			temSum+=a[j];
			if(temSum>sum){
				sum=temSum;
				begin=i;
				end=j;
			}
		}
	}
	return sum;	
}


可以看到,保存了a[i]~a[j-1]和的结果后,就可以省去一层循环,时间复杂度也降为O(n^2)。我们在写程序时要根据题目的要求而选择比较省时省空间的写法,这也需要多练习。


解法二:利用分治策略

先要明白分治策略基本思想是把问题规模分解为多个小规模问题,递归地解这些子问题,然后将各子问题的解合并得到原问题的解。一般采用二分法逐步分解(注意,很多算法都用到递归,当然这很耗空间)。分治法解题的一般步骤:

  (1)分解,将要解决的问题划分成若干规模较小的同类问题;

  (2)求解,当子问题划分得足够小时,用较简单的方法解决;

  (3)合并,按原问题的要求,将子问题的解逐层合并构成原问题的解。

本题目总的分治思想是:

如果将所给的序列a[1:n]分为长度相等的两段子序列a[1:n/2]和a[n/2+1:n],分别求出这两段子序列的最大子段和,则总序列的最大子段和有三种情况:1)与前段相同。2)与后段相同。3)跨前后两段。

(我想这解法比较难理解的地方是3)跨前后两段的情况(理解时可以简单列举一个序列按代码执行)。这里注意一下,跨前后两段是指一个连续的子序列跨越前后两段,而不是前后两段最大字段和的简单相加)

具体的分治做法是这样的:先把a[1:n]分成a[1:n/2]和a[n/2+1:n],分别求出两段子序列的最大子段和,而在求a[1:n/2]的最大子段和时,又把a[1:n/2]分成a[1:(n/2)/2]和a[(n/2)/2+1:n/2]两个子序列,照这样一直分,直到把每个子序列都只有一个或两个数未知,当子序列只有一个数时,它的最大子段和要么是自身或为0,而子序列有两个数时,其最大子段和要么为前一个数,要么为后一个数,要么为两个数的和,或者为0(当两个数都为负数时),当返回子序列的最大子段和时,子序列的最大子段和一个数就代表了一个子序列(这点很重要),那么后面每次处理的子序列都是只有或者两个数(因为子序列的最大子段和代表了这个序列)。可以举个例子照着程序执行一下,帮助理解。

 

//left是做端点下标,right是右端点下标
int maxSubSum(int a[],int left,int right){
	int sum=0;
	if(left==right)//这是递归调用必须要有的终值情况。
		sum=(a[left]>0?a[left]:0);
	else{
		int center=(left+right)/2;
		int leftSum=maxSubSum(a,left,center);//求出左序列最大子段和
		int rightSum=maxSubSum(a,center+1,right);//求出右序列最大子段和

//求跨前后两段的情况,从中间分别向两端扩展。
		//从中间向左扩展。这里注意,中间往左的第一个必然包含在内。
		int ls=0;int lefts=0;
		for(int i=center;i>=left;i--){
			lefts+=a[i];
			if(lefts>ls)
				ls=lefts;
		}
		//从中间向右扩展。中间往右的第一个必然包含在内
		int rs=0;int rights=0;
		for(i=++center;i<=right;i++){
			rights+=a[i];
			if(rights>rs)
				rs=rights;
		}
		sum=ls+rs;//sum保存跨前后两段情况的最大子段和
//求跨前后两段的情况完成

		if(sum<leftSum)
			sum=leftSum;//记住,leftSum表示前段序列的最大子段和
		if(sum<rightSum)
			sum=rightSum;//rightSum表示后段序列的最大字段和
	}
	return sum;
}


初学者要理解这个算法需要好好去举个例子。解法四的思想或许会对你理解有些帮助。

这个算法的时间复杂度为O(nlogn),分治算法在这主要是作为想法学习用,并不是这道题的最佳算法。
 
解法三:动态规划
先明白动态规划是把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解,创立了解决这类过程优化问题的新方法。建议去百度百科看一下“动态规划"词条里介绍的一些概念,明白它的思想。
本题目总的动态规划思想是这样的:
已知前n个数的最大子段和,那么前n+1个数的最大字段和有两种情况,一是包含前面的结果,二是不包含。
具体做法是这样的,序列a[]有n个数,我们就要做n次决策,从第一个数开始(下标从0开始),假设已经做好了前i个数的决策,并把做第i个数的最大子段和的结果保存到了tem(注意,前i个数的最大子段和sum和第i个数决策的子段和tem是不一样的,前者sum可能不包含第i个数,但第i个数决策的子段和tem一定包含tem,sum是当前最大子段的和,而tem是包含第i个数的子段和,并想办法使tem的值尽可能的大),当做第i+1个数的决策时,要做的工作就只是判断包含第i+1个数的子段和是否要把tem的值包进来,如果tem>0,就包括,否则不包括。
(再看一下总的想法)假设前n个数的最大子段和是tem,在决策前n+1个数的最大子段和时,判断tem的值,如果tem>0,那么前n+1个数的最大子段和为tem加上第n+1个数,否则就是第n+1个数自己。这里记住,你所求的是连续的几个数的和。代码比较简单:
 
 
//begin和end分别表示最大子段和的开始和结束位置的下标,下标从0开始。
int maxSum(int a[],int n,int &begin,int &end){
	int sum=0;//sum保存的是当前连续几个数的和的最大值,只是记录目前算得得最大值。
	int tem=0;//tem表示决策第i个数时所保存的第i-1个数决策状态。
	for(int i=0;i<n;i++){
		if(tem>0)
			tem+=a[i];//如果tem>0,说明tem可
		else{
			tem=a[i];
			begin=i;//如果tem小于等于零,说明重新计算最大字段和,记下开始位置
		}
		if(tem>sum){
			sum=tem;
			end=i;//如果tem>sum,说明刷新了最大子段和的值,记下结束位置
		}
	}
	return sum;
}

只需一次遍历,时间复杂度为O(n),动态规划里有一项很重要的内容就是保存各阶段的状态,有人会增加一个数组保存状态,但写程序可以根据题目要求做些改变,像这道题就只需要保存前一个状态就行。
解法四:
最大子段的左右两个数字必定为正数,最左边数字的左邻是负数,最右边数字的右邻是负数。假设a[i]~a[j]是最大子段和序列的一个子序列,则从a[i-1]逐个往左加,这个和如果在加到a[k]时变成一个正数,那就说明左端点i可以延伸到k,可以使这个子段的和更大一些,右边也同理扩展。我们要做的就是找到最大字段的两个端点。
我们可以先找出从右到左第一个正数作为寻找i的起点(如果一个正数都找不到那显然就是L=0,最大子段和=0),
然后按照上述原理不断向左延拓i;找j也是同理:先找从左到右第一个正数然后向右扩展。把代码贴上来
 
#include <stdio.h>
#define MAX 100//宏定义要寻找的序列个数最大值
int fineLeft(int d[],int n);//寻找最大子段的左下标
int fineRight(int d[],int n);//寻找最大子段的右下标
int main(){
	int d[MAX]={0};
	int n;
	int i;
	int left,right;
	scanf("%d",&n);
	for (i=0;i<n;i++)
		scanf("%d",&d[i]);
	left=fineLeft(d,n);//找出最大子段的左下表
	if(left<0){//如果left<0,说明没有找到正数
		printf("0/n");
		return 0;
	}
	right=fineRight(d,n);//找出最大子段的右下标
	if(left>right){//这种情况应该不会出现。只是保险起见而已。
		printf("haha/n");
		return 0;
	}
	n=0;//这是我写代码节省空间的一种方式,n下面将保存最大子段和
	for(i=left;i<=reft;i++)
		n+=d[i];
	printf("%d/nbegin=%d,end=%d/n",n,left+1,right+1);
	return 0;
}
int findRight(int d[],int n){
	int right=0;
	int sum=0;
	int i=0;
	
	while(right<n&&d[right]<=0)//找出第一个正数
		right++;
	while(right<n&&i<n){
		sum=0;
		for(i=right+1;i<n;i++){
			sum+=d[i];
			if(sum>0){//如果加到出现sum>0,说明可以扩展
				right=i;//把right定位到i后,继续寻找,看是否还能扩展
				break;
			}
		}
	}
	return right;
}
int fineLeft(int d[],int n){
	int left=n-1;
	int sum=0;
	int i=0;
	while(left>=0&&d[left]<=0)
		left--;
	while (left>=0&&i>=0){
		sum=0;
		for (i=left-1;i>=0;i--){
			sum+=d[i];
			if (sum>0){
				left=i;
				break;
			}
		}
	}
	return left;
}


//这里的扩展思想可以帮助理解分治策略的第三种情况(从中间往两边扩展)。
本方法至多只需三次遍历,一次往左,一次往右,一次最大子段求和。时间复杂度为O(n)。不过写法应该还可以再改进,找左右端点的函数应该可以抽象出一个模型。
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值