绝妙的算法——最大子序列和问题

问题的引入

    给定(可能有负数)整数序列A1, A2, A3..., An, 求这个序列中子序列和的最大值。(为方便起见,如果所有整数均为负数,则最大子序列和为0)。例如:输入整数序列: -2, 11, 8, -4, -1, 16, 5, 0,则输出答案为35,即从A2~A6。

    这个问题之所以有吸引力,主要是因为存在求解它的很多算法,而这些算法的性能差异又很大。这些算法,对于少量的输入差别都不大,几个算法都能在瞬间完成,这时若花费大量的努力去设计聪明的算法恐怕就不太值得了;但是如果对于大量的输入,想要更快的获取处理结果,那么设计精良的算法显得很有必要。

切入正题

    下面先提供一个设计最不耗时间的算法,此算法很容易设计,也很容易理解,但对于大量的输入而言,效率太低:

    算法一:

?


1

2

3

4

5

6

7

8

9

10

11

12

13

public static int maxSubsequenceSum(int[] a) {

    int maxSum = 0;

    for(int i=0; i<a.length; i++) {        //i为子序列的左边界

        for(int j=i; j<a.length; j++) {    //j为子序列的右边界

            int thisSum = 0;

            for(int k=0; k<=j; k++)        //迭代子序列中的每一个元素,求和

                thisSum += a[k];

            if(thisSum > maxSum)

                maxSum = thisSum;

        }

    }

    return maxSum;

}

    上述设计很容易理解,它只是穷举各种可能的结果,最后得出最大的子序列和。毫无疑问,这个算法能够正确的得出和,但是如果还要得出是哪个子序列,那么这个算法还需要添加一些额外的代码。

    现在来分析以下这个算法的时间复杂度。运行时间的多少,完全取决于第6、7行,它们由一个含有三重嵌套for循环中的O(1)语句组成:第3行上的循环大小为N,第4行循环大小为N-i,它可能很小,但也可能是N。我们在判断时间复杂度的时候必须取最坏的情况。第6行循环大小为j-i+1,我们也要假设它的大小为N。因此总数为O(1*N*N*N)=O(N3)。第2行的总开销为O(1),第8、9行的总开销为O(N2),因为它们只是两层循环内部的简单表达式。

    我们可以通过拆除一个for循环来避免3次的运行时间。不过这不总是可能的,在这种情况下,算法中出现大量不必要的计算。纠正这种低效率的改进算法可以通过观察Sum(Ai~Aj) = Aj + Sum(Ai~A[j-1])而看出,因此算法1中第6、7行上的计算过分的耗费了。下面是在算法一的基础上改进的一种算法:

    算法二:

?


1

2

3

4

5

6

7

8

9

10

11

12

public static int maxSubsequenceSum(int[] a) {

    int maxSum = 0;

    for(int i=0; i<a.length; i++) {

        int thisSum = 0;

        for(int j=i; j<a.length; j++) {

            thisSum += a[j];

            if(thisSum > maxSum)

                maxSum = thisSum;

        }

    }

    return maxSum;

}

    对于此算法,时间复杂度显然是O(N2),对它的分析甚至比前面的分析还要简单,就是直接使用穷举法把序列中i后面的每个值相加,如果发现有比maxSum大的,则更新maxSum的值。

    对于这个问题,有一个递归和相对复杂的O(NlogN)解法,我们现在就来描述它。要是真的没有出现O(N)(线性的)解法,这个算法就会是体现递归为例的极好的范例了。该方法采用一种“分治”策略。其想法就是吧问题分成两个大致相等的子问题,然后递归地对它们求解,这是“分”的阶段。“治”阶段就是将两个子问题的解修补到一起并可能再做些少量的附加工作,最后得到整个问题的解。

    在我们的例子中,最大子序列的和只可能出现在3个地方:

  1. 出现在输入数据的左半部分
  2. 出现在输入数据的右半部分
  3. 跨越输入数据的中部而位于左右两个部分

    前两种情况可以递归求解,第三种情况的最大和可以通过求出前半部分(包含前半部分的最后一个元素)的最大和以及后半部分(包括后半部分的第一个元素)的最大和,再将二者相加得到。作为例子,考虑以下输入:

?


1

2

3

4

5

-----------------------------------------

    前半部分           后半部分  

-----------------------------------------

-2118, -4,    -11650   

-----------------------------------------

    其中,前半部分的最大子序列和为19(A2~A3),而后半部分的最大子序列和为21(A6~A7)。前半部分包含其最后一个元素的最大和是15(A2~A4),后半部分包含第一个元素的最大和是20(A5~A7)。因此,跨越这两部分的这个子序列才是拥有最大和的子序列,和为15+20=35(A2~A7)。于是出现了下面这种算法:

    算法三:

?


1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

public static int maxSubsequenceSum(int[] a, int left, int right) {

    if(left == right) { //Base case

        if(a[left] > 0) {

            return a[left];

        else {

            return 0//保证最小值为0

        }

    }

     

    int center = (left+right)/2;

    int maxLeftSum = maxSubsequenceSum(a, left, center); //递归调用,求左部分的最大和

    int maxRightSum = maxSubsequenceSum(a, center+1, right);//递归调用,求右部分的最大和

     

    int leftBorderSum = 0, maxLeftBorderSum = 0;//定义左边界子序列的和

    for(int i=center; i>=left; i--) {

        leftBorderSum += a[i];

        if(leftBorderSum > maxLeftBorderSum) {

            maxLeftBorderSum = leftBorderSum;

        }

    }

     

    int rightBorderSum = 0, maxRightBorderSum = 0;//定义左边界子序列的和

    for(int i=center+1; i<=right; i++) {

        rightBorderSum += a[i];

        if(rightBorderSum > maxRightBorderSum) {

            maxRightBorderSum = rightBorderSum;

        }

    }

     

    //选出这三者中的最大值并返回(max3(int a, int b, int c)的实现没有给出)

    return max3(maxLeftSum, maxRightSum, maxLeftBorderSum + maxRightBorderSum);

}

    有必要对算法三的程序进行一些说明。递归过程调用的一般形式是传递输入的数组和左右边界,它们界定了数组要被处理的部分。第2~8行处理基准情况,让递归调用有退出的机会。如果left==right,那么只有一个元素,并且当该元素非负时,它就是最大子序列。第11、12行执行两个递归调用。我们可以看到,递归调用总是对小于原问题的问题进行,不过程序中的小扰动有可能破坏这个特性。14~20行,22~28行分界处左右两边的最大子序列和,这两个值的和就有可能是整个序列中的最大子序列和。第31行调用max3方法,求出这三种情况下的最大值,该值即为整个序列的最大子序列和。

    显然,算法三需要比设计前两种算法付出更多的编程努力,看上去前面两种算法的代码量要比算法三少许多,然而,程序短并不意味着程序好。测试表明,除较小的输入量外,算法三比前两个算法明显要快。现在来分析以下算法三的时间复杂度。

    令T(N)是求解大小为N的最大子序列和问题所花费的时间。如果N=1,则算法3执行程序第2~8行花费某个常数时间,我们称之为一个时间单位。于是T(1)=1,否则,程序必须执行两个递归调用,即在14~28行之间的两个for循环以及几个小的簿记量,如10、14行。这两个for循环总共接触到A1~An中的每个元素,而在循环内部的工作量是常量,于是14~28行花费的时间为O(N)。从2~10、14、22和31行上的程序的工作量是常量,从而与O(N)相比可以忽略。其余就剩下11~12行上运行的工作。这两行求解大小为N/2的子序列问题(假设N为偶数)。因此,这两行每行花费T(N/2)个时间单元,共花费2T(N/2)个时间单元。因此,算法三花费的总时间为2T(N/2)+O(N)。于是我们得到方程组:

?


1

2

T(1) = 1

T(N) = 2T(N/2) + O(N)

    为了简化计算,我们可以用N代替上面方程中的O(N)项;由于T(N)最终还是要用大O表示,因此这么做并不影响答案。现在,如果T(N) = 2T(N/2) + N,且T(1) = 1,那么T(2) = 4 = 2*2;T(4) = 12 = 4*3;T(8) = 32 = 8*4;T(16) = 80 = 16*5。用数学归纳法可以证明若N=2^k,那么T(N) = 2^k * (k+1) = N * (k+1) = N(logN + 1) = NlogN + N = O(NlogN)。即算法三的时间复杂度为O(NlogN),这明显小于算法二的复杂度O(N2),因此算法三会更快的得出结果。

    这个分析假设N是偶数,否则N/2就不确定了。通过该分析的递归性质可知,实际上只有当N是2的幂时结果才是合理的,否则我们最终要得到大小不是偶数的子问题,方程就是无效的了。当N不是2的幂时,我们多少需要更加复杂一些的分析,但是大O的结果还是不变的。

更优秀的算法

    虽然算法三已经足够优秀,将时间复杂度由O(N2)降低为O(NlogN),但是,这并不是最优秀的,下面介绍针对这个问题更优秀的解法。

    算法四:

?


1

2

3

4

5

6

7

8

9

10

11

public static int maxSubsequenceSum(int[] a) {

    int maxSum = 0, thisSum = 0;;

    for(int i=0; i<a.length; i++) {

        thisSum += a[i];

        if(thisSum > maxSum)

            maxSum = thisSum;

        else if(thisSum < 0)

            thisSum = 0;

    }

    return maxSum;

}

    很显然,此算法的时间复杂度为O(N),这小于算法三中的时间复杂度O(NlogN),因此,此算法比算法三更快!方法固然已给出,但是要明白为什么此方法能用,还需多加思考。

    在算法一和算法二中,i代表子序列的起点,j代表子序列的终点。碰巧的是,我们不需要知道具体最佳的子序列在哪里,那么i的使用可以从程序上被优化,因此在设计算法的时候假设i是必需的,而且我们想改进算法二。一个结论是:如果a[i]是负数,那么它不可能代表最优序列的起点,因为任何包含a[i]的作为起点的子序列都可以通过使用a[i+1]作为起点得到改进。类似的,任何负的子序列也不可能是最优子序列的前缀(原理相同)。如果在内循环中检测到从a[i]到a[j]的子序列的和是负数,那么可以向后推进i。关键的结论是:我们不仅能够把i推进到 i+1,而且实际上我们还可以把它一直推进到 j+1。为了看清楚这一点,我们令 p 为 i+1 和 j 之间的任意一下标。开始于下标 p 的任意子序列都不大于在下标i开始并包含从 a[i] 到 a[p-1] 的子序列的对应的子序列,因为后面这个子序列不是负的(即j是使得从下标 i 开始,其值成为负值的序列的第一个下标)。因此,把 i 推进到 j+1 是没有风险的:我们一个最优解也不会错过。

    这个算法是许多聪明算法的典型:运行时间是明显的,但正确性却不那么容易就能看出来。对于这些算法,正式的正确性证明(比上面分析更加正式)几乎总是需要的;然而,及时到那时,许多人仍然还是不信服。此外,许多这类算法需要更有技巧的编程,这导致更长的开发过程。不过,当这些算法正常工作时,它们运行得很快!而我们将它们和一个低效但容易实现的蛮力算法通过小规模的输入进行比较可以测试到大部分的程序原理。

    该算法的一个附带优点是:它值对数据进行一次扫描,一旦a[i]被读入并处理,它就不再需要被记忆。因此,如果数组在磁盘上活通过网络传送,那么它就可以被按顺序读入,在主存中不必存储改数组的任何部分。不仅如此,在任意时刻,算法都能对它已经读入的数据给出子序列问题的正确答案(其他算法不具备这个特性)。具有这种特性的算法叫做“联机算法”。仅需要常量空间并以线性时间运行的联机算法几乎是完美的算法。

时间: 2024-12-15 04:46:43

绝妙的算法——最大子序列和问题的相关文章

算法系列(六)最长公共子序列(LCS)问题(连续子序列)的三种解法

最长公共子序列(LCS)问题有两种方式定义子序列,一种是子序列不要求不连续,一种是子序列 必须连续.上一章介绍了用两种算法解决子序列不要求连续的最终公共子序列问题,本章将介绍要求 子序列必须是连续的情况下如何用算法解决最长公共子序列问题. 仍以上一章的两个字符串 "abcdea"和"aebcda"为例,如果子序列不要求连续,其最长公共子序列为"abcda",如果子序列 要求是连续,则其最长公共子序列应为"bcd".在这种情况下

找出单向链表的倒数第m个元素

链表节点: class Node{public:    int        data;    Node*    next;public:    Node(int n) : data(n), next(0)    {    }    Node() : data(0), next(0)    {    }    Node* InsertAfter( int _data )    {        Node* newnode = new Node(_data);        newnode->ne

算法研究:最大子序列求和问题的解决方案

The maximum subarray problem is the task of finding the contiguous subarray within a one-dimensional array of numbers (containing at least one positive number) which has the largest sum. For example, for the sequence of values ?2, 1, ?3, 4, ?1, 2, 1,

最大子序列和问题从O(N^3)到线性的算法

算法复杂度,从开始学习算法分析之后就一直在讨论着这个问题,很多人都认为,计算机相关人才只是"高级蓝领","技术民工",那为什么计算机的大牛们依然乐此不疲呢?我想,是因为他们发现了思考的乐趣. 有时候,稍加思考,你所做的事情就会变得格外的美妙,有时候,更简短的代码带来的却是更高的执行效率,生活,恰是需要这样的点睛之笔. 好了,前奏铺垫的有点长,下面进入正题,通过对大家所熟知的一道题的分析,最大子序列和的问题,让我们来初步感受一下编程的美丽: 题目是这样描述的,给定指定

Ruby实现的最长公共子序列算法

  这篇文章主要介绍了Ruby实现的最长公共子序列算法,本文直接给出实现代码,需要的朋友可以参考下 最长公共子序列,LCS,动态规划实现. ? 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 #encoding: utf-8 #author:

[算法系列之十九]最长公共子序列

题目 最长公共子序列 分析 有两个字符串S1和S2,求一个最长公共子串,即求字符串S3,它们同时是S1和S2的子串,且要求它们的长度最长,并确定这个长度.这个问题我们称之为最长公共子序列问题. 与求最长递增子序列一样,我们首先将原问题分割成一些子问题,我们用dp[i][j]表示S1中前i个字符和S2中前j个字符分别组成的两个前缀字符串的最长公共子串长度.显然的,当i,j较小时我们可以直接给出答案,如dp[0][j] 必等于0.那么,假设我们已经求的dp[i][j](0 <= i < x,0 &

算法知识之最长公共子序列问题(动态规划)

最近朋友让帮做个关于动态规划的最长公共子序列的问题,翻看以前的笔记并完成该题后,顺便写这样一篇文章,希望对大家有所帮助,同时也帮助自己回顾该知识点. 一.最长公共子序列的定义 子序列:若给定序列X={x1,x2,-,xm},则另一序列Z={z1,z2,-,zk},是X的子序列是指存在一个严格递增下标序列{i1,i2,-,ik}使得对于所有j=1,2,-,k有:zj=xij.公共子序列:给定2个序列X和Y,当另一序列Z既是X的子序列又是Y的子序列时,称Z是序列X和Y的公共子序列.最长公共子序列:给

经典算法题每日演练——第四题 最长公共子序列

一: 作用        最长公共子序列的问题常用于解决字符串的相似度,是一个非常实用的算法,作为码农,此算法是我们的必备基本功. 二:概念      举个例子,cnblogs这个字符串中子序列有多少个呢?很显然有27个,比如其中的cb,cgs等等都是其子序列,我们可以看出 子序列不见得一定是连续的,连续的那是子串.      我想大家已经了解了子序列的概念,那现在可以延伸到两个字符串了,那么大家能够看出:cnblogs和belong的公共子序列吗? 在你找出的公共子序列中,你能找出最长的公共子

支配点递推关系-求教下字符串最长公共子序列中支配点算法中几点问题。

问题描述 求教下字符串最长公共子序列中支配点算法中几点问题. 求支配点之间的递推关系 资料地址:http://www.doc88.com/p-140662466336.html