01 实例1.1 最大子列和问题《PTA浙大版《数据结构(第2版)》题目集》
1.原题链接
2.题目描述
给定 K K K个整数组成的序列{ N 1 , N 2 , . . . , N K N_1, N_2, ..., N_K N1,N2,...,NK },“连续子列”被定义为{ N i , N i + 1 , . . . , N j N_i, N_i+1, ..., N_j Ni,Ni+1,...,Nj},其中 1 ≤ i ≤ j ≤ K 1≤i≤j≤K 1≤i≤j≤K。“最大子列和”则被定义为所有连续子列元素的和中最大者。例如给定序列{ -2, 11, -4, 13, -5, -2 },其连续子列{ 11, -4, 13 }有最大的和20。现要求你编写程序,计算给定整数序列的最大子列和。
本题旨在测试各种不同的算法在各种数据情况下的表现。各组测试数据特点如下:
- 数据1:与样例等价,测试基本正确性;
- 数据2:102个随机整数;
- 数据3:103个随机整数;
- 数据4:104个随机整数;
- 数据5:105个随机整数;
输入格式:
输入第1行给出正整数 K K K (≤100000);第2行给出 K K K个整数,其间以空格分隔。
输出格式:
在一行中输出最大子列和。如果序列中所有整数皆为负数,则输出0。
输入样例:
6
-2 11 -4 13 -5 -2
输出样例:
20
3.参考答案
最优解
#include<stdio.h>
int main(){
int K;
scanf("%d",&K);
int a[K];
int i,sum_temp=0,sum_max=0;
for(i=0;i<K;i++){
scanf("%d",&a[i]);
sum_temp+=a[i];
if(sum_temp>sum_max)sum_max=sum_temp;
if(sum_temp<0)sum_temp=0;
}
printf("%d",sum_max);
return 0;
}
4.解题思路
第一种算法
每一个子列和都独立计算,计算所有的子列和,找到其中的最大值。这种算法只能通过前三个测试点,后面两个测试点会超时。三重循环,时间复杂度 T ( N ) = O ( N 3 ) T(N)=O(N^3) T(N)=O(N3)
第二种算法
对于起点相同的子列,在计算子列和的时候在前面相同起点的子列和的基础上累加,找到其中的最大值。二重循环,时间复杂度 T ( N ) = O ( N 2 ) T(N)=O(N^2) T(N)=O(N2)
第三种算法
分治算法。要理解分治算法首先要充分理解调用递归函数的程序是如何执行的。
- 分(
divide
):调用递归函数把一个比较大的复杂的问题一级一级地切割分成较小的子问题,每个子问题都是原问题的一部分,如果分割到了最基本的情况就不再继续调用递归函数进行分割。 - 治(
conquer
):从子问题的解构建原问题的解。
采用分治算法把数列分为左右两部分,分别递归地解决左右两边的问题,得到左右两边各自的最大子列和,再找到跨越左右两边中间边界的最大子列和,数列的最大子列和一定是上述三者中的最大值。
时间复杂度 T ( N ) = O ( N log N ) T(N)=O(N \log N) T(N)=O(NlogN)。
令 T ( N ) T(N) T(N) 是求解大小为 N N N 的最大子序列和问题所花费的时间。假如数列只有1个元素,花费的时间记为一个时间单元,即 T ( 1 ) = 1 T(1)=1 T(1)=1。对于一般情况,数列被分割为前后两部分,求解前后两部分则各自的最大子列和花费 T ( N / 2 ) T(N/2) T(N/2)的时间,计算跨越前后两部分中间边界的最大子列和花费的时间为 O ( N ) O(N) O(N)。得到 T ( N ) = 2 T ( N / 2 ) + O ( N ) T(N) =2 T(N / 2)+O(N) T(N)=2T(N/2)+O(N),这是一个标准的递归关系, 它可以用多种方法求解。我们将介绍两种方法。
为了简便起见,以下计算过程 O ( N ) O(N) O(N)直接用 N N N表示,并且假设 N N N是 2 2 2的幂,如果 N N N不是 2 2 2的幂,因为问题规模的变化趋势是相同的,所以时间复杂度是不变的。
第一种方法
用 N N N 去除递归关系的两边,相除后得到
T ( N ) N = T ( N / 2 ) N / 2 + 1 \frac{T(N)}{N}=\frac{T(N / 2)}{N / 2}+1 NT(N)=N/2T(N/2)+1
该方程对任意的 N N N (其是 2 的幂)都是成立的,我们还可以写成
T ( N / 2 ) N / 2 = T ( N / 4 ) N / 4 + 1 T ( N / 4 ) N / 4 = T ( N / 8 ) N / 8 + 1 ⋮ T ( 2 ) 2 = T ( 1 ) 1 + 1 \begin{aligned} \frac{T(N / 2)}{N / 2} & =\frac{T(N / 4)}{N / 4}+1\\ \frac{T(N / 4)}{N / 4} & =\frac{T(N / 8)}{N / 8}+1 \\ & \vdots \\ \frac{T(2)}{2} & =\frac{T(1)}{1}+1 \end{aligned} N/2T(N/2)N/4T(N/4)2T(2)=N/4T(N/4)+1=N/8T(N/8)+1⋮=1T(1)+1
将所有这些方程相加,将等号左边的所有各项相加并使结果等于右边所有各项的和。项 T ( N / 2 ) N / 2 、 T ( N / 4 ) N / 4 、 T ( N / 8 ) N / 8 、 … \frac{T(N / 2)}{N / 2} 、\frac{T(N / 4)}{N / 4}、\frac{T(N / 8)}{N / 8}、\dots N/2T(N/2)、N/4T(N/4)、N/8T(N/8)、…出现在等号两边,可以消去,最后的结果为:
T ( N ) N = T ( 1 ) 1 + log N \frac{T(N)}{N}=\frac{T(1)}{1}+\log N NT(N)=1T(1)+logN
将 T ( 1 ) = 1 T(1)=1 T(1)=1带入上式化简得到
T ( N ) = N log N + N = O ( N log N ) T(N)=N \log N+N=O(N \log N) T(N)=NlogN+N=O(NlogN)
第二种方法
在 T ( N ) = 2 T ( N / 2 ) + O ( N ) T(N) =2 T(N / 2)+O(N) T(N)=2T(N/2)+O(N)右边连续地代入递归关系。
T ( N ) = 2 T ( N / 2 ) + N ⋯ ( 1 ) T ( N / 2 ) = 2 T ( N / 4 ) + N / 2 ⋯ ( 2 ) T(N)=2 T(N / 2)+N\cdots (1)\\ T(N/2)=2 T(N / 4)+N/2\cdots (2) T(N)=2T(N/2)+N⋯(1)T(N/2)=2T(N/4)+N/2⋯(2)
将 ( 2 ) 式 × 2 (2)式\times 2 (2)式×2
2 T ( N / 2 ) = 2 ( 2 ( T ( N / 4 ) ) + N / 2 ) = 4 T ( N / 4 ) + N ⋯ ( 3 ) 2 T(N / 2)=2(2(T(N / 4))+N / 2)=4 T(N / 4)+N\cdots (3) 2T(N/2)=2(2(T(N/4))+N/2)=4T(N/4)+N⋯(3)
将 ( 3 ) (3) (3)代入 ( 1 ) (1) (1)中
T ( N ) = 2 T ( N / 2 ) + N = 4 T ( N / 4 ) + N + N = 4 T ( N / 4 ) + 2 N T(N)=2 T(N / 2)+N=4 T(N / 4)+N+N=4 T(N / 4)+2N T(N)=2T(N/2)+N=4T(N/4)+N+N=4T(N/4)+2N
以此类推
T ( N ) = 8 T ( N / 8 ) + 3 N ⋮ T ( N ) = 2 k T ( N / 2 k ) + k ⋅ N T(N)=8T(N / 8)+3 N\\ \vdots \\ T(N)= 2^{k} T\left(N / 2^{k}\right)+k \cdot N T(N)=8T(N/8)+3N⋮T(N)=2kT(N/2k)+k⋅N
当 N / 2 k = 1 \N / 2^{k}=1 N/2k=1 时 k = log N k=\log N k=logN,化简上式得
T ( N ) = N T ( 1 ) + N log N = N log N + N = O ( N log N ) T(N)=N T(1)+N \log N=N \log N+N=O(N \log N) T(N)=NT(1)+NlogN=NlogN+N=O(NlogN)
第四种算法
在线处理算法。
从左向右累加,如果累加的结果让当前计算的子列和比之前的最大子列和更大,则更新最大子列和,如果当前子列和为负,则不可能使后面的部分和增大,抛弃当前计算的子列,从后一元素开始选择新的子列计算当前的子列和。这个算法只对数据进行一次扫描,时间复杂度 T ( N ) = O ( N ) T(N)=O(N) T(N)=O(N)
5.答案详解
#include <stdio.h>
#define MAXN 100000
//第一种算法计算最大子列和
int MaxSubseqSum1( int List[], int N ){
int i, j, k;
int ThisSum, MaxSum = 0;
for ( i=0; i<N; i++ ) {
/* i是子列左端位置 */
for ( j=i; j<N; j++ ) {
/* j是子列右端位置 */
ThisSum = 0; /* ThisSum是从List[i]到List[j]的子列和 */
for ( k=i; k<=j; k++ )
ThisSum += List[k];
if ( ThisSum > MaxSum ) /* 如果刚得到的这个子列和更大 */
MaxSum = ThisSum; /* 则更新结果 */
} /* j循环结束 */
} /* i循环结束 */
return MaxSum;
}
//第二种算法计算最大子列和
int MaxSubseqSum2( int List[], int N ){
int i, j;
int ThisSum, MaxSum = 0;
for( i=0; i<N; i++ ) {
/* i是子列左端位置 */
ThisSum = 0; /* ThisSum是从List[i]到List[j]的子列和 */
for( j=i; j<N; j++ ) {
/* j是子列右端位置 */
/*对于相同的i,不同的j,只要在j-1次循环的基础上累加1项即可*/
ThisSum += List[j];
if( ThisSum > MaxSum ) /* 如果刚得到的这个子列和更大 */
MaxSum = ThisSum; /* 则更新结果 */
} /* j循环结束 */
} /* i循环结束 */
return MaxSum;
}
//第三种算法,分治算法,计算最大子列和
/* 返回3个整数中的最大值 */
int Max3( int A, int B, int C ){
return A > B ? A > C ? A : C : B > C ? B : C;
}
/* 分治法求List[left]到List[right]的最大子列和 */
int DivideAndConquer( int List[], int left, int right ){
int MaxLeftSum, MaxRightSum; /* 存放左右子问题的解 */
int MaxLeftBorderSum, MaxRightBorderSum; /*存放跨分界线的结果*/
int LeftBorderSum, RightBorderSum;
int center, i;
if( left == right ) /* 递归的终止条件,子列只有1个数字 */
if( List[left] > 0 )
return List[left];
else return 0;
/* 下面是"分"的过程 */
center = ( left + right ) / 2; /* 找到中分点 */
/* 递归求得两边子列的最大和 */
MaxLeftSum = DivideAndConquer( List, left, center );
MaxRightSum = DivideAndConquer( 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;
} /* 右边扫描结束 */
/* 下面返回"治"的结果 */
return Max3( MaxLeftSum, MaxRightSum, MaxLeftBorderSum + MaxRightBorderSum );
}
int MaxSubseqSum3( int List[], int N ){
/* 保持与前2种算法相同的函数接口 */
return DivideAndConquer( List, 0, N-1 );
}
//第四种算法,在线处理算法,计算最大子列和
int MaxSubseqSum4( int List[], int N ){
int i;
int ThisSum, MaxSum;
ThisSum = MaxSum = 0;
for ( i=0; i<N; i++ ) {
ThisSum += List[i]; /* 向右累加 */
if ( ThisSum > MaxSum )
MaxSum = ThisSum; /* 发现更大和则更新当前结果 */
else if ( ThisSum < 0 ) /* 如果当前子列和为负 */
ThisSum = 0; /* 则不可能使后面的部分和增大,抛弃之 */
}
return MaxSum;
}
int main(){
int K, list[MAXN], i;
scanf("%d", &K);
for (i=0; i<K; i++)
scanf("%d", &list[i]);
// printf("%d\n", MaxSubseqSum1( list, K ));
// printf("%d\n", MaxSubseqSum2( list, K ));
// printf("%d\n", MaxSubseqSum3( list, K ));
printf("%d\n", MaxSubseqSum4( list, K ));
return 0;
}
6.知识拓展
本题旨在让初学数据结构与算法分析的同学理解不同的算法在运行时间上的差异。
算法的优劣
一个算法的优劣应该从以下四方面来评价。
-
正确性。
- 在合理的数据输入下,能够在有限的运行时间内得到正确的结果。
-
可读性。
-
一个好的算法,首先应便于人们理解和相互交流,其次才是机器可执行性。
-
可读性强的算法有助于人们对算法的理解,而难懂的算法易于隐藏错误,且难于调试和修改。
-
-
健壮性。
- 当输入的数据非法时,好的算法能适当地做出正确反应或进行相应处理,而不会产生一些莫名其妙的输出结果。
-
高效性。
- 高效性包括时间和空间两个方面。
- 时间高效是指算法设计合理,执行效率高,可以用时间复杂度来度量。
- 空间高效是指算法占用存储容量合理,可以用空间复杂度来度量。
- 时间复杂度和空间复杂度是衡量算法的两个主要指标。
- 算法效率分析的目的是看算法实际是否可行,并在同一问题存在多个算法时,可进行时间和空间性能上的比较,以便从中挑选出较优算法。
- 衡量算法效率的方法主要有两类:事后统计法和事前分析估算法。
- 事后统计法需要先将算法实现,然后测算其时间和空间开销。
- 事后统计法法的缺陷很显然,一是必须把算法转换成可执行的程序,二是时空开销的测算结果依赖于计算机的软硬件等环境因素,这容易掩盖算法本身的优劣。
- 所以我们通常采用事前分析估算法,通过计算算法的渐进复杂度来衡量算法的效率。所谓渐进复杂度指当 n n n 逐步增大时,系统资源开销 T ( n ) T(n) T(n) 的增长趋势。
问题规模(Problem scale
)
- 不考虑计算机的软硬件等环境因素,影响算法时间代价的最主要因素是问题规模。
- 问题规模是算法求解问题输入量的多少,是问题大小的本质表示,一般用整数 n n n 表示。
- 问题规模 n n n 对不同的问题含义不同,例如,在排序运算中n为参加排序的记录数,在矩阵运算中n为矩阵的阶数,在多项式运算中n为多项式的项数,在集合运算中n为集合中元素的个数,在树的有关运算中 n n n为树的结点个数,在图的有关运算中 n n n为图的顶点数或边数。显然, n n n越大算法的执行时间越长。
语句频度(Frequency Count
)
- 一个算法的执行时间大致上等于其所有语句执行时间的总和。
- 语句的执行时间则为该条语句的重复执行次数和执行一次所需时间的乘积。
- 一条语句的重复执行次数称作语句频度(
Frequency Count
)。 - 由于语句的执行要由源程序经编译程序翻译成目标代码,目标代码经装配再执行,因此语句执行一次实际所需的具体时间是与机器的软、硬件环境(如机器速度、编译程序质量等)密切相关的。
- 所谓的算法分析并非精确统计算法实际执行所需时间,而是针对算法中语句的执行次数做出估计,从中得到算法执行时间的信息。
- 设每条语句执行一次所需的时间均是单位时间,则一个算法的执行时间可用该算法中所有语句频度之和来度量。
【例】求两个 n n n 阶矩阵的乘积算法。
for(i=1;i<=n;i++) //频度为 n+1
for(j=1;j<=n;j++){
//频度为 n*(n+1)
c[i][j]=0; //频度为 n2
for(k=1;k<=n;k++) //频度为 n2 * (n+1)
c[i][j]=c[i][j]+a[i][k]*b[k][j]; //频度为 n3
}
该算法中所有语句频度之和,是矩阵阶数n的函数,用f(n)表示之。换句话说,上例算法的执行时间与*f(n)*成正比。
f ( n ) = 2 n 3 + 3 n 2 + 2 n + 1 f(n)=2 n^{3}+3 n^{2}+2 n+1 f(n)=2n3+3n2+2n+1
以下是可执行的C语言代码,可以测出两个 n n n 阶矩阵的乘积运行时间。
#include<stdio.h>
#include<time.h>
clock_t start,stop;
//clock_t是clock()函数返回的变量类型
double duration;
//记录被测函数运行时间,以秒为单位
void matrix_product();
int main(){
int n;
printf("如果n的值太小,运行时间可能小于1个CLK_TCK,\n");
printf("如果n的值太大,所需运行内存可能超过可用内存而导致异常结束。\n");
printf("请输出n:");
scanf("%d",&n);
start =clock();
//记录阶乘函数运行前的时刻
matrix_product(n);
stop =clock();
//记录阶乘函数运行后的时刻
duration=((double)(stop-start))/CLK_TCK;
//计算阶乘函数运行时间
printf("阶乘函数运行时间:%f",duration);
return 0;
}
void matrix_product(int n){
int i,j,k,a[n][n],b[n][n],c[n][n];
for(i=1;i<=n;i++)
for(j=1;j<=n;j++){
c[i][j]=0;
for(k=1;k<=n;k++)
c[i][j]=c[i][j]+a[i][k]*b[k][j];
}
}
clock ()
:捕捉从程序开始运行到clock()
被调用时所耗费的时间。- 这个时间单位是
clock tick
, 即“时钟打点”。 - 常数
CLK_TCK
:机器时钟每秒所走的时钟打点数。
时间复杂度(Time complexity
)
-
对于较简单的算法,可以直接计算出算法中所有语句的频度,但对于稍微复杂一些的算法,则通常是比较困难的,即便能够给出,也可能是个非常复杂的函数。
-
为了客观地反映一个算法的执行时间,可以只用算法中的「基本语句」的执行次数来度量算法的工作量。
-
所谓「基本语句」指的是算法中重复执行次数和算法的执行时间成正比的语句,它对算法运行时间的贡献最大。
-
通常,算法的执行时间是随问题规模增长而增长的,因此对算法的评价通常只需考虑其随问题规模增长的趋势。
-
我们只需要考虑当问题规模充分大时,算法中基本语句的执行次数在渐进意义下( n n n趋于无穷)的阶。
-
如两个矩阵的乘积算法,当n趋向无穷大时
lim n → ∞ f ( n ) / n 3 = lim n → ∞ ( 2 n 3 + 3 n 2 + 2 n + 1 ) / n 3 = 2 \displaystyle\lim _{n \rightarrow \infty} f(n) / n^{3}=\lim _{n \rightarrow\infty}\left(2 n^{3}+3 n^{2}+2 n+1\right) / n^{3}=2 n→∞limf(n)/n3=n→∞lim(2n3+3n2+2n+1)/n3=2
-
即当n充分大时, f ( n ) f(n) f(n)和 n 3 n^3 n3 之比是一个不等于零的常数。即 f ( n ) f(n) f(n)和 n 3 n^3 n3 是同阶的,或者说 f ( n ) f(n) f(n)和 n 3 n^3 n3的数量级(
Order of Magnitude
)相同。 -
我们用「O」来表示数量级,也被称为函数的阶数(Order),记作 T ( n ) = O ( f ( n ) ) = O ( n 3 ) T(n)=O(f(n))=O(n^3) T(n)=O(f(n))=O(n3)。
算法时间复杂度的定义:
一般情况下,算法中基本语句重复执行的次数是问题规模 n n n的某个函数 f ( n ) f(n) f(n),算法的时间量度记作
T ( n ) = O ( f ( n ) ) T(n)=O(f(n)) T(n)=O(f(n))
它表示随问题规模n的增大,算法执行时间的增长率和 f ( n ) f(n) f(n)的增长率相同,称做算法的渐进时间复杂度,简称时间复杂度(Time Complexity
)。
数学符号「O」的严格定义为:
若 T ( n ) T(n) T(n)和 f ( n ) f(n) f(n)是定义在正整数集合上的两个函数,则 T ( n ) = O ( f ( n ) ) T(n)=O(f(n)) T(n)=O(f(n))表示存在正的常数 C C C 和 n 0 n_0 n0,使得当 n ≥ n 0 n≥n_0 n≥n0时都满足 0 ≤ T ( n ) ≤ C f ( n ) 0≤T(n)≤Cf(n) 0≤T(n)≤Cf(n)。
该定义说明了函数 T ( n ) T(n) T(n)的增长至多趋向于函数 f ( n ) f(n) f(n)的增长。
符号「O」用来描述增长率的上限,它表示当问题规模n>n0时,算法的执行时间不会超过 f ( n ) f(n) f(n)。
分析算法时间复杂度的基本方法为:
- 找出所有语句中语句频度最大的那条语句作为基本语句;
- 计算基本语句的频度得到问题规模 n n n的某个函数 f ( n ) f(n) f(n);
- 取其数量级用符号「O」表示。
空间复杂度(Spatial complexity
)
- 类似于算法的时间复杂度,我们采用渐近空间复杂度(
Space Complexity
)作为算法所需存储空间的量度,简称空间复杂度,它也是问题规模n的函数,记作: S ( n ) = O ( f ( n ) ) S(n)=O(f (n)) S(n)=O(f(n))。 - 一般情况下,一个程序在机器上执行时,除了需要寄存本身所用的指令、常数、变量和输入数据外,还需要一些对数据进行操作的辅助存储空间。
- 对于输入数据所占的具体存储量取决于问题本身,与算法无关,这样只需分析该算法在实现时所需要的辅助空间就可以了。
- 若算法执行时所需要的辅助空间相对于输入数据量而言是个常数,则称这个算法为原地工作,辅助空间为 O ( 1 ) O(1) O(1)。
- 有的算法需要占用临时的工作单元数与问题规模 n n n有关。