YAZONG 我的开源

(数据结构) 第一讲-基本概念(1.3应用实例:最大子列和问题)

  , ,
0 评论0 浏览

image.png

这是一个求”从Ai到Aj连续的一段子列的和”的函数。

对N个整数来说,有很多这样的连续的子列。

这里要求的是,所有连续子列和里面最大的那个。

如果这个和是负数的话,那么我们最后返回0作为结束。

一个最直接、最暴力的办法就是:把所有的连续子列和全部都算出来,然后从中找到最大的那一个。

算法1:O(N^3)

//输入整数序列:int A[]
//输入整数序列里面元素的个数:int N
int maxSubSeqSum1(int A[],int N){

	int ThisSum,MaxSum = 0;
	int i,j,k;
	//i是子列左端位置
	for(i = 0;i < N;i++){
		//j是子列右端位置
		for(j = i;j < N;j++){
			//ThisSum是从A[i]到A[j]的子列和
			ThisSum = 0;
			for(k = i;k <= j;k++){
				ThisSum += A[k];
			}
			//如果刚得到的这个子列和更大,则更新结果.
			if(ThisSum > MaxSum){
				MaxSum = ThisSum;
			}
		}//j循环结束
	}//i循环结束

	return MaxSum;

}

总的来说,有两重for循环。

最外面的这层for循环i对应的是子列左端的位置,是从A[i]开始。

然后里面的for循环j对应的是子列和右边的位置,一直到A[j]开始。

所以j总是大于等于i的,从i开始计数。

在这个双重for循环里面,我们开始暴力的去求这个子列和,

所以我们还需要做一重for循环k,k从i一直加到j。

=分割线=

现在考虑一下,这个算法的复杂度是多少?T(N)=O(N^3)。

很直观的理由就是,这里有三层嵌套的for循环。

虽然说,如果每一层都是从0到N的,那么很显然,三个乘在一起就是N立方,但是这里没那么明显。

因为这里只有第一重for循环是从0到N的,后面的for循环一个是从i到N的,一个是从i到j的.

但是,无论如何,用一下我们中学的证明技巧,仍然可以证明,整个的运算量是N立方乘以一个常数。

T(N)=O(N^3)

其实可以说,这个算法真的还挺笨的,N立方也是一个很恐怖的函数了,比如N等于10W、100W、1000W。

image.png

它到底不聪明在哪儿呢?

主要在于最里层的k循环。

当i=0时,最后一个k循环是从0一直加到N-1。

当i=1时,最后一个k循环是从1一直加到N-1。

等等。

中间有一个很大的问题,

当我们知道当前从i到j的”结果部分和”的时候,要计算下一个j的时候,根本没必要从头开始往后加,所以当j增加了1的时候,其实只要在前面那个i到j的”结果部分和”后面加1个元素就好了,这个k循环完全是多余的。

算法2:O(N^2)

//输入整数序列:int A[]
//输入整数序列里面元素的个数:int N
int maxSubSeqSum2(int A[],int N){

	int ThisSum,MaxSum = 0;
	int i,j;
	//i是子列左端位置
	for(i = 0;i < N;i++){
		//ThisSum是从A[i]到A[j]的子列和
		ThisSum = 0;
		//j是子列右端位置
		for(j = i;j < N;j++){
			//对于相同的i,不同的j,只要在j-1次循环的基础上累加1项即可.
			ThisSum += A[j];
			//如果刚得到的这个子列和更大,则更新结果.
			if(ThisSum > MaxSum){
				MaxSum = ThisSum;
			}
		}//j循环结束
	}//i循环结束

	return MaxSum;

}

image.png

这里,在外面仍然有一个i循环,i是子列的左端位置。

当j每增加一个值的时候,只是在当前这个部分和的基础上,加一个A[j]就可以了。

很显然,有两套for循环的嵌套,所以这里的时间复杂度是O(N^2)。

所以说,这个算法已经比前面的O(N^3)要聪明许多了,但是呢,它还不是最好的。

一个专业的程序猿,设计了一个算法,当发现这个算法是O(N^X)的时候,应该下意识地、本能地就应该想想,有没有可能把它改进成NlogN呢?那还能快很多呢。

算法3:分而治之O(NlogN)

大概的思路就是把一个比较大的复杂的问题切分成小的块,然后分头去解决它们,最后再把结果合并起来,这就是”分而治之”。

那么这个思想在解决这个问题的时候,是如何分工的呢?

image.png

首先,假设我们的问题是放在这么一个数组里面的。

这个算法的第一步是”分”,也就是把这个数组从中间一分为二,然后递归的去解决左右两边的问题。

递归的去解决左边的问题,那么会得到左边的一个最大子列和。

递归的去解决右边的问题,那么会得到右边的一个最大子列和。

那么,现在不能说这两个数中间最大的一个就一定是结论了。

还有一种情况就是跨越边界的最大子列和。

把这个三个结果都找到了以后,那么我们最后的结果一定是这三个数中间最大的那一个。

所以这个叫做”分”,

最后的合成就叫做”治”,

总起来说叫做”分而治之”。

=分割线=

案例:

8个数字,第一步,从中间一分为二,先递归的解决左半边,在递归的进入左半边的时候,继续把它一分为二,继续的递归到左半边,继续把它一分为二。

注:以下计算,先求最大子列和,再求跨越边界最大子列和。

个人理解:最大子列和为一个或某2N连续值的计算得到的值,而跨越边界最大子列和把前后相邻的”最大子列和”再次相加,每次少了1/2的重复计算量。

最后得到了O(NlogN)的时间复杂度。

image.png

先看左边的4和-3(从4和-3中间画条线):

分的时候,从这个左边我们会得到最大子列和,是4。而右边是负数-3,返回0(如果序列中所有整数皆为负数,则输出0。再比如,最后一个是负数了,就不用在执行加操作了)。

那么,跨越这个边界的最大和也是4。

再看右边的5和-2(从5和-2中间画条线):

最大子列和与跨越边界的最大和都是5。

红线右边的数字计算同理。

image.png

再次算跨越边界的最大和。

从-3与5中间画条线:

从线往左边扫描两个格:得-3,然后(-3+4)=1,最大子列和为1。

从线往右边扫描两个格:得5,然后(5-2)=3,最大子列和为5。

所以跨越边界的这个最大子列和(1+5)=6,即红色线左边四个数的递归函数返回值为6。

从2与6中间画条线:

从线往左边扫描两个格:得2,然后(2-1)=1,最大子列和为2。

从线往右边扫描两个格:得6,然后(6-2)=4,最大子列和为6。

所以跨越边界的这个最大子列和(2+6)=8,即红色线右边四个数的递归函数返回值为8。

image.png

再次算跨越边界的最大和。

从-2与1中间画条线:

从线往左边扫描四个格:(-2)+5+(-3)+4=4

从线往右边扫描四个格:(-1)+2+6=7(最后一个格再减值就不是最大值了,就没意义了)

所以跨越边界的这个最大子列和(4+7)=11。

image.png

这里的难点在于,如何分析此算法的时间复杂度?

当解决的这个问题中有N个数字的时候,如果时间复杂度为T(N),那么得到的这半边数字的时间复杂度为T(N/2),因为规模减半了。

image.png

而这里是如何得到中间这个跨越边界最大子列和的呢?

做法就是:从中间开始,往左边扫描,然后往右边扫描, 每个元素都被扫描了一次(扫描的每个元素的时间) ,所以,得到这个结果的复杂度应该是N的一个常数倍。

由此,就得到了关于T(N)的一个递归公式,

T(N)是由”算出左边的复杂度”加上”算出右边的复杂度”,再加上”跨越中间的复杂度”,这三个部分组成的,也就是两倍的T(N/2)加上一个N的常数倍(当N=1时为常数)。

image.png

这里如何递推呢?

可以把这个N换成N/2,然后代到代码中去。

T(N/2)按照公式递推地展开成2T(NB/4)加上N/2的常数倍。

跟原来的cN加在一起,得到一个2倍的cN。

image.png

如果觉得上述规律不够清楚的话,可以进一步展开,直到T里面的这个数等于1为止。

过了K步以后,一共乘以K个2,中间项变成1,所以变成常数O(1)。

每展开一层就多了一个cN,所以展开K步以后就是ckN。

那么K和N是什么关系呢?

N/2除了K次以后,最终会得到1(理想情况)。

两边乘以2的K次方,这一项是得到N的,2的K次方就是1(T和常数c同理)。

image.png

什么是K呢?

K就是log以2为底N的对数:log2N。

所以在分析复杂度的时候,我们就有了两项:O(N)与O(NlogN)。

那么,当两个复杂度在一起的时候,取得是比较大的那一项,所以,就得到了这整个算法的复杂度为O(NlogN)。

算法3:源码-分而治之O(NlogN)

//分而治之O(NlogN)
//输入整数序列:int A[]
//输入整数序列里面元素的个数:int N
int maxSubSeqSum3(int A[],int N){
	return DivideAndConquer3(A, 0, N - 1);
}
//分治法求List[left]到List[right]的最大子列和。此方法被递归调用。
int DivideAndConquer3(int List[],int left,int right){
	//存放左右子问题的解
	int MaxLeftSum, MaxRightSum;
	//存放跨越分界线的结果
	int MaxLeftBorderSum, MaxRightBorderSum;
	int LeftBorderSum, RightBorderSum;
	int center,i;

	//递归的终止条件,子列只有1个数字。
	if(left == right){
		if(List[left] > 0){
			return List[left];
		}else{
			return 0;
		}
	}

	//下面是分的过程
	center = (left + right)/2;//找到中分点
	//递归求得两边子列的最大和
	MaxLeftSum = DivideAndConquer3(List, left, center);
	MaxRightSum = DivideAndConquer3(List, center + 1, right);

	//求跨越分界线的最大子列和
	//开始从中间向左边扫描
	MaxLeftBorderSum = 0;
	LeftBorderSum = 0;
	for(i = center; i >= left;i--){
		LeftBorderSum += List[i];
		if(LeftBorderSum > MaxLeftBorderSum){
			MaxLeftBorderSum = LeftBorderSum;
		}
	}
	//结束从中间向左边扫描
	//开始从中间向右边扫描
	MaxRightBorderSum = 0;
	RightBorderSum = 0;
	for(i = center + 1;i <= right;i++){
		RightBorderSum += List[i];;
		if(RightBorderSum > MaxRightBorderSum){
			MaxRightBorderSum = RightBorderSum;
		}
	}
	//结束从中间向右边扫描


	//返回"治"的结果/返回3个整数中的最大值
	return Max3(MaxLeftSum, MaxRightSum, MaxLeftBorderSum + MaxRightBorderSum);

}
//返回"治"的结果/返回3个整数中的最大值
int Max3(int A, int B, int C){
	return A > B ? A > C ? A : C : B > C ? B : C;
}

算法4:在线处理O(N)

//输入整数序列:int A[]
//输入整数序列里面元素的个数:int N
int maxSubSeqSum4(int A[],int N){

	int ThisSum,MaxSum = 0;
	int i;
	for(i = 0; i < N;i++){
		//向右累加
		ThisSum += A[i];
		if(ThisSum > MaxSum){
			//发现更大和则更新当前结果.
			MaxSum = ThisSum;
		}else if(ThisSum < 0){
			//如果当前子列和为负,则不可能使后面的部分和增大,抛弃之.
			ThisSum = 0;
		}
	}

	return MaxSum;
}

这个算法的复杂度是O(N),是线性的。

其中for循环以及里面的if-else都是常数数量级的复杂度。

这个必须是可能得到的最快的一个算法了。

因为无论如何都需要把每个元素都过一遍,当读入的时候也需要O(N)的时间,所以,解决整个问题居然可以用O(N)的速度,这已经是可以想象的、最快、最好的算法了。

当然,一个算法效率如此的高,一般来说是有副作用的,正确性不是那么的明显,也就是说,别人要理解这个程序是怎么工作的,略微有点困难。

=分割线=

再结合案例 :

(

为什么这个算法叫”在线处理”呢?

在线 ”的意思是指每输入一个数据就进行 即时处理 ,在任何一个地方中止输入,算法都能正确给出当前的解。

)

image.png

第一件事:把当前子列和把当前的这个数加进来。

-1加进来,-1并不能使当前的最大和变大,所以这块就被跳过去了。

如果当前的子列和是负的,那么就直接把它抛弃掉,把当前子列和重新归零。

为什么可以这么做呢?因为我们是要求连续的子列和,所以如果当前子列和到这里,下一步我们是要顺着往后去求和的。

如果现在求和是负数的话,往后面不管加什么数,它都只能让后面的数越变越小,不可能越变越大,所以呢,还不如从头开始。

image.png

发现3比MaxSum要大,所以当前的最大子列和记为3。

image.png

发现加上-2后的总体值是+1,总有可能让后面的和变得更大,所以继续加。

image.png

发现4比MaxSum要大,所以当前的最大子列和记为1+4=5。

对于前四个数字而言,这个结果是对的。

这个就叫做”在线处理”。

image.png

发现加上-6后的总体值是负数-1,那么直接把它抛弃掉。

重新归零,开始考虑下一个。

image.png

这里继续1+6=7。

到最后的-1,输入就结束了,那么最终结果就是7。

所以这个算法为什么快呢? 一旦发现当前的子列和是负数的话,它其实就没有用了,直接就把抛弃掉,扫描后面的就可以

image.png

这种表是在某一台机器跑出来的结果。

可以把这个算法都实现一下。

N^3的这个算法跑到N=10000的时候就已经跑不动了。

NA的意思是Not Available,就是不算了。

当然这个结果里面,并没有算输入和输出的时间。

算法源码汇总

#include<stdio.h>
//O(N^3)
int maxSubSeqSum1(int A[],int N);
//O(N^2)
int maxSubSeqSum2(int A[],int N);
//分而治之O(NlogN)
int maxSubSeqSum3(int A[],int N);
int DivideAndConquer3(int A[],int left,int right);
int Max3(int A, int B, int C);
//在线治理O(N)
int maxSubSeqSum4(int A[],int N);
//程序入口
int main(void){
	return 0;
}

//O(N^3)
//输入整数序列:int A[]
//输入整数序列里面元素的个数:int N
int maxSubSeqSum1(int A[],int N){

	int ThisSum,MaxSum = 0;
	int i,j,k;
	//i是子列左端位置
	for(i = 0;i < N;i++){
		//j是子列右端位置
		for(j = i;j < N;j++){
			//ThisSum是从A[i]到A[j]的子列和
			ThisSum = 0;
			for(k = i;k <= j;k++){
				ThisSum += A[k];
			}
			//如果刚得到的这个子列和更大,则更新结果.
			if(ThisSum > MaxSum){
				MaxSum = ThisSum;
			}
		}//j循环结束
	}//i循环结束

	return MaxSum;

}

//O(N^2)
//输入整数序列:int A[]
//输入整数序列里面元素的个数:int N
int maxSubSeqSum2(int A[],int N){

	int ThisSum,MaxSum = 0;
	int i,j;
	//i是子列左端位置
	for(i = 0;i < N;i++){
		//ThisSum是从A[i]到A[j]的子列和
		ThisSum = 0;
		//j是子列右端位置
		for(j = i;j < N;j++){
			//对于相同的i,不同的j,只要在j-1次循环的基础上累加1项即可.
			ThisSum += A[j];
			//如果刚得到的这个子列和更大,则更新结果.
			if(ThisSum > MaxSum){
				MaxSum = ThisSum;
			}
		}//j循环结束
	}//i循环结束

	return MaxSum;

}

//在线治理O(N)
//输入整数序列:int A[]
//输入整数序列里面元素的个数:int N
int maxSubSeqSum4(int A[],int N){

	int ThisSum,MaxSum = 0;
	int i;
	for(i = 0; i < N;i++){
		//向右累加
		ThisSum += A[i];
		if(ThisSum > MaxSum){
			//发现更大和则更新当前结果.
			MaxSum = ThisSum;
		}else if(ThisSum < 0){
			//如果当前子列和为负,则不可能使后面的部分和增大,抛弃之.
			ThisSum = 0;
		}
	}

	return MaxSum;
}

//分而治之O(NlogN)
//输入整数序列:int A[]
//输入整数序列里面元素的个数:int N
int maxSubSeqSum3(int A[],int N){
	return DivideAndConquer3(A, 0, N - 1);
}
//分治法求List[left]到List[right]的最大子列和。此方法被递归调用。
int DivideAndConquer3(int List[],int left,int right){
	//存放左右子问题的解
	int MaxLeftSum, MaxRightSum;
	//存放跨越分界线的结果
	int MaxLeftBorderSum, MaxRightBorderSum;
	int LeftBorderSum, RightBorderSum;
	int center,i;

	//递归的终止条件,子列只有1个数字。
	if(left == right){
		if(List[left] > 0){
			return List[left];
		}else{
			return 0;
		}
	}

	//下面是分的过程
	center = (left + right)/2;//找到中分点
	//递归求得两边子列的最大和
	MaxLeftSum = DivideAndConquer3(List, left, center);
	MaxRightSum = DivideAndConquer3(List, center + 1, right);

	//求跨越分界线的最大子列和
	//开始从中间向左边扫描
	MaxLeftBorderSum = 0;
	LeftBorderSum = 0;
	for(i = center; i >= left;i--){
		LeftBorderSum += List[i];
		if(LeftBorderSum > MaxLeftBorderSum){
			MaxLeftBorderSum = LeftBorderSum;
		}
	}
	//结束从中间向左边扫描
	//开始从中间向右边扫描
	MaxRightBorderSum = 0;
	RightBorderSum = 0;
	for(i = center + 1;i <= right;i++){
		RightBorderSum += List[i];;
		if(RightBorderSum > MaxRightBorderSum){
			MaxRightBorderSum = RightBorderSum;
		}
	}
	//结束从中间向右边扫描


	//返回"治"的结果/返回3个整数中的最大值
	return Max3(MaxLeftSum, MaxRightSum, MaxLeftBorderSum + MaxRightBorderSum);

}
//返回"治"的结果/返回3个整数中的最大值
int Max3(int A, int B, int C){
	return A > B ? A > C ? A : C : B > C ? B : C;
}

标题:(数据结构) 第一讲-基本概念(1.3应用实例:最大子列和问题)
作者:yazong
地址:https://blog.llyweb.com/articles/2023/06/14/1686754374999.html