前言:这个问题貌似在今年4月份腾讯招聘实习生的第一轮笔试中曾出现过,不过不是一道编写程序题, 而是相对简单些的程序填空题:题目给出了问题描述后,要求分别按照o(n^2)和o(n)的算法复杂度来填空。 最近几天正忙着看一个孩子推荐的书籍《编程珠玑》,巧合的是在本书第八章便是围绕这个问题层层推进来展开论述的。
问题描述:给定一个整数序列 a1, a2, a3, a4,a5......an, 求它的一个特殊子序列即:ai,a(i+1), a(i+2)......a(j) ;要求这个子序列的和在所有原序列的子序列中的和最大。补充说明:如果原序列全为负数,则所求特殊子序列为空,且规定,此时最大子序列的和为0。
问题分析:要求出子序列中和最大的那一个,初步的想法是:只要逐一比较所有子序列即可,即:求出所有的i,j∈[1,n] 并且逐一比较即可。由问题描述中空子串及其值的设定,可以在进行比较之前设置初始的最大子串和为0,程序(伪代码)如下:
input num_sequence[n + 1];
max = 0;
for (i = 1; i <= n ;i++) {
for (j = i; j <= n; j++) {
sum = 0;
k = i;
while (k <=j) {
sum += num_sequence[k];
k++;
}
if (sum > max)
max = sum;
}
}
一不小心,套了三次循环,算法复杂度o(n^3),如果这个序列长为1000000,估计有的你算的!《编程珠玑》给出了另外两个o(n^2)的方法:方法1、可以直接在把上述最外层的循环因子i看成是子序列起始位置的标示,第二层循环中的j仍标示子序列终止位置,然而与上述o(n^3)方法所不同的是这个方法不必根据i,j来重新循环计算i->j的子序列和来造成第三层循环,而是直接比较每一个固定的起始点i下,不同终点j的和得出局部最优,重而避免了重复计算前面的和,并且i循环到n后,自然就实现全局最优了,代码很显然,如下所示:
for (i = 1; i <= n ;i++) {
sum = 0;
for (j = i; j <= n; j++) {
sum += num_sequence[j];
if (sum > max)
max = sum;
}
}
方法2、我们可以把它称作"累加逐差法", 首先这种方法求出原序列的逐项累加序列num_accumulate[1~n], 且num_accumulate[i] = num_sequence[1] + num_sequence[2] ......+ num_sequence[i]; 根据这个序列,我们就能求出任意起始点为i,终止为j的子序列的和了:sum(i, j) = num_accumulate[j] - num_accumulate[i - 1]; 这里为了保证统一性,设置num_accumulate[0] = 0;这样程序如下:
/*初始化num_accumulate数组*/
num_accumulate[0] = 0;
for (i = 1; i <= n; i++) {
num_accumulate[i] = num_accumulate[i -1] + num_sequence[i];
}
max = 0;
sum = 0;
for (i = 1; i <= n; i++) {
for (j = i; j <= n; j++) {
sum = num_accumulate[j] - num_accumulate[i -1];
if (sum > max)
max = sum;
}
}
相较于最初的方法而言,这两个方法的算法复杂度都下降了一个等级,但是,离最优的O(N)算法还是差了一个数量级~我们稍后进一步来分析这个问题,分别用分治法的思想得到一个o(nlogn)算法以及全面分析这个问题的性质的前提下得到一个o(n)最优算法。
1.用分治法得到的一个o(nlogn)算法
分治法是算法设计的一个常用方法,比如最熟悉的合并排序算法就是利用这一思想。本问题也可以参照这个思想来考虑。我们可以像合并排序那样,将整个整数原序列分成两个,重而“分而治之”:求出每一半序列中的最优子序列然后比较,但是不幸的事情还是发生了:最优子序列恰恰可能“横跨”两个“分治区域”摆在中间。。。。。。我们必须设法解决这儿问题。一种解决方法是从两个“半序列”黏合的头部分别向扫描各自半个序列得到从“粘合”处开始的两个具有最大和的序列,然后将这两个和相加得到”最优子序列和候选和1号“,再将它与两个“半序列”产生的两个“最优子序列和”中的较大者进行比较,得到整个序列的最优子序列和。根据上面分析次算法的复杂度:首先设整个复杂度用T(n)表示,那么半个序列复杂度即为T(n/2), 考虑到最优子序列可能存在于中间而进行的扫描复杂度应该为o(n),于是:T(n) = 2*T(n/2) + o(n) 计算这个递推式不难得到T(n) = o(nlogn)。整个算法的伪代码如下:
input sequence[1~n];
int max_sum(int start, int end)
{
int mid = (start + end) /2;
int l_max, r_max,l_sum,r_sum, i;
if (start == end)
if (num_sequence[start] 0)
return 0;
else
return num_sequence[start];
i = mid;
l_sum = l_max = 0;
while (i >= start) {
l_sum += num_sequence[i];
if (l_sum > l_max)
l_max = l_sum;
i--;
}
i = mid + 1;
r_sum = r_max = 0;
while (i <= end) {
r_sum += num_sequence[i];
if (r_sum > r_max)
r_max = r_sum;
i++;
}
return max(max_sum(start, mid), max_sum(mid+1,end), l_max+r_max);
}
2.全面讨论此问题的性质,得到o(n)的最优算法
对于原序列a1,a2,a3......an的最优子序列ai, a(i+1)......aj;
将在以序列元素ai作为末端的子序列中,和最大的称作“ai为末端的最优子序列”记做"ai末最优";
将在以序列元素aj作为开始的子序列中,和最大的序列称作”aj为首端的最优子序列"记做"aj首最优";
有如下几条性质:
性质1: 对于任意的k < i, 均有ak + a(k + 1)......a(i-1) <= 0;对于任意m m > j && m <=n 有 a(j+1), a(j+2).....am <=0。
性质2: 设m ∈ 最优子区间[i,j], 那么 ai, a(i+1), a(i+2)......am 必然是"am末最优"; 同理, am, a(m+1)......a(j)必然是"am首最优"。
性质3:任何一个最优子序列,即是它首端元素的ai的"ai首最优", 同时也是其末端元素aj的"aj末最优"。
记"aj末最优"的序列和为sum_e(j), "ai首最优"的序列和为sum_s(i)则有如下递推公式:
sum_e(j) = aj + max(0, sum_e(j-1));
sum_s(i) = ai + max(0, sum_s(i+1));
由于最优子序列既是某一序列元素ai的"末最优",也是某一序列元素的"首最优", 因此,可以用上述两个公式得出两个不同方向扫描的o(n)算法,求得最优子序列和, 以"末最优"为例,代码如下:
input num_sequence[1~n]
max = 0; //最优子序列最大和
max_e = 0;//各个"末最优"最大和
//所谓最优序列和,可理解为所有"末最优"序列中的最大值
for (i = 1; i <= n; i++) {
max_e = num_sequence[i] + max(0, max_e);
if (max_e > max)
max = max_e;
}
上述代码是不是看上去很easy,其实很多问题想通之后也就那么回事。嘿嘿~上面的一些性质是我自己杜撰的理论,不知道描述恰当与否,还请各位批评指正啊。
算法思想总结:(书中总结了解决以上问题的几个思想)
1.保存状态,避免重复计算(貌似动态规划大都利用了这一思想)
2.计算前,可以预处理数据,保存在特定的数据结构中
3.可以采用分而治之的策略思想来解决问题
4.可以通过递归的思想将x(1~i)的解扩展成x(1~i+1)的解
5.累积表,类似于1
6.设计算法后估计算法复杂度下界
附言:这个问题实际上还可以继续扩展下面几个问题(可以利用解决上面问题的几个思想来解决)
1. 给定一个序列,求出一个子序列,其和最接近于0
2.给定一个子序列,求出一个序列,其和最小
3.给定一个子序列,其和的绝对值最大
4.给定一个子序列,其和最接近于一个给定的数r
5.给定一个子序列,其和的绝对值最接近一个给定的非负数r