欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

分治策略 - 最大子序列和问题

程序员文章站 2022-05-08 19:14:46
...

  最大子序列和问题的描述:从一组数中找出下标连续的几个数,这几个组成的数组的和是所有情况中值最大数组。比如:{13, -3, -25, 20, -3, -16, -23, 18, 20, -7, 12, -5, -22, 15, -4, 7},这么一组数中最大的为:{18, 20, -7, 12},这组数的和是最大的:43。如果想要把所有子数组都列出来找的话,子数组共有A(n,2)种,时间复杂度为Θ(n^2),这样很慢。而《数据结构与算法分析 第二版》书中的算法4给出的代码是这样的:

#include<stdio.h>

int MaxSubarraySum(int * nums, int n) {
    int ThisSum, MaxSum, i;

    ThisSum = MaxSum = 0;
    for(i = 0; i < n; i++) {

        ThisSum += nums[i];          //每次循环则累加
        if(ThisSum > MaxSum)         //每次循环,如果成立则MaxSum的值就会变为ThisSum
            MaxSum = ThisSum;
        else if(ThisSum < 0)         //每次循环,如果成立则ThisSum重新置为0
            ThisSum = 0;
    }

    return MaxSum;
}

int main()
{
    int arr[] = {-2, 11, -4, 13, -5, -2}, * nums = arr;
    int val;

    val = MaxSubarraySum(nums, sizeof(arr)/sizeof(int));
    printf("MaxSubarraySum = %d.\n", val);
    return 0;
}

  运行结果:

MaxSubarraySum = 20.

  其实并不难理解为什么会是正确的,用上面给出的数组{-2, 11, -4, 13, -5, -2}为例子来模拟一下:

  开始 ThisSum = 0,MaxSum = 0;进入循环,ThisSum = -2 < MaxSum,所以 ThisSum = 0,继续循环,ThisSum = 0 + 11 = 11 > MaxSum,所以 MaxSum = 11, 继续循环,ThisSum = 11 + (-4) = 7 < MaxSum, 因为ThisSum既不满足ThisSum > MaxSum,也不满足ThisSum < 0,ThisSum不变,所以继续循环,ThisSum = 7 + 13 = 20 > MaxSum, 所以 MaxSum = 20,继续循环,ThisSum = 20 + (-5) = 15,也不满足 ThisSum > MaxSum 和 ThisSum < 0,所以不变,继续循环,ThisSum = 15 + (-2) = 13,还是不满足两个条件判断,继续循环,然后 i > n,退出循环,返回MaxSum的值。最终MaxSum = 20。最坏时间复杂度为O(n)(ORZ...)...

  虽然上面这个算法很强...但本篇主要是讲解分治,所以顾不上膜拜了,然后再来看一下分治策略是如何解决这个问题的。

  先看代码:

#include<stdio.h>

static int Find_Max_Crossing_SubArray(int * nums, int start, int mid, int end) {

    int left = -65533, right = -65533;
    int sum;
    int i, j;

    sum = 0;
    for(i = mid; i >= start; i--) {
        sum += *(nums + i);
        if(sum > left) {
            left = sum;
        }
    }

    sum = 0;
    for(j = mid + 1; j < end; j++) {
        sum += *(nums + j);
        if(sum > right) {
            right = sum;
        }
    }

    return left + right;
}

int Find_Maximum_Subarray(int * nums, int start, int end) {

    int left;
    int right;
    int cross;
    int mid;

    if(start == end)
        return * nums;
    else
    {
        mid = (start + end)/2;
        left = Find_Maximum_Subarray(nums, start, mid);
        right = Find_Maximum_Subarray(nums, mid + 1, end);
        cross = Find_Max_Crossing_SubArray(nums, start, mid, end);
        
        if(left >= right && left >= cross)
            return left;
        else if(right >= left && right >= cross)
            return right;
        else
            return cross;
    }
}

int main()
{
    int arr[] = {13, -3, -25, 20, -3, -16, -23, 18, 20, -7, 12, -5, -22, 15, -4, 7}, * nums = arr;
    int val;

    val = Find_Maximum_Subarray(nums, 0, sizeof(arr)/sizeof(int));
    printf("MaxSubarraySum = %d.\n", val);
    return 0;
}

  运行结果:

MaxSubarraySum = 43.

  该如何理解这段代码呢?首先先来了解一下什么是分治策略。分治策略讲的是先用将问题分为几个部分,分出去的每个部分解决自己负责的那部分问题,然后再用来将所有分出去的部分合起来解决整个问题的一种策略。

  这样就好理解上面的这段代码了,那么该问题有几个子问题呢?答案是2个,cross只不过是负责了所有情况下分布在中间部分的附带问题,实际上它还是属于两个子问题中的一部分...详细说一下它负责的部分:第一次调用函数,它就开始负责一个完整的数组的中间部分,而left和right进入递归,第一次递归中cross负责了left-mid数组的部分的中间部分和right-end数组的部分的中间部分(注意,问题的规模被分为了两部分,2个子问题的规模各占一半),这样不断进入递归,从而最终left负责的部分的递归将得到left负责的这部分中的cross的返回值,right负责的部分的递归得到right负责的这部分中的cross的返回值,然后比较三个值(left、right、第一次函数调用时的cross)的大小,最大的作为函数的返回值返回。可能你还会问那其他子递归中的left和right怎么办?注意到还有个条件判断if(start == end) 如果满足则返回数组首地址的元素,这就说明了其他left、right部分的递归最终会满足这个条件,从而用来比较,如果都没有其他两个大肯定就被覆盖了...(不知道这样说得够不够清楚)。总之,还是得理解递归。

  然后我们来分析这个算法的时间复杂度,首先说一个分治策略问题的基本公式:T(N) = aT(N/b) +f(N) (其中a表示的是子问题个数,N/b表示的是子问题的规模)。

  上面的代码我们这样分析它的时间消费:

    1.函数Find_Max_Crossing_SubArray()中有两个循环start-mid,mid+1-end,数组nums[start...end]包含n个元素,所以(mid-start+1) + (end-mid) = n。初始sum赋值消费(1+1),不忽略循环条件中的判断和累加共消费(n/2+1+n/2+1),且每次循环sum都要累加共消费(n/2+n/2)和判断共消费(n/2+n/2),所以我们可得到整体共消费n+n+n+1+1+1+1 = 3n+4 = Θ(n),所以函数Find_Max_Crossing_SubArray()的时间复杂度为Θ(n)。

    2.①函数Find_Maximum_Subarray()中如果第一个判断成立消费(1)。对于n = 1的基本情况,T(1) = Θ(1)。

       ②n > 1时,第一个条件判断消费(1),两个函数自身调用,子问题规模为n/2,因此每个子问题的时间消费为T(n/2),有2个子问题,故总共消费2T(n/2),一个函数调用时间消费为Θ(n),后面的条件判断只消费了(1)。故可写出递归情况的消费为 T(n) = Θ(1)+2T(n/2)+Θ(n)+Θ(1) = 2T(n/2)+Θ(n)。

  综合①和②得到函数Find_Maximum_Subarray()的运行时间的递归式为:

T(1) = Θ(1)             n = 1;
T(n) = 2T(n/2) + Θ(n)      n > 1;

  然后,我们再重写一下递归式:

T(1) = 1             N = 1;
T(N) = 2T(N/2) + N      N > 1;

  现在我们再来求解这个递归式的时间复杂度:

T(1) = 1                N = 1;
T(N) = 2T(N/2) + N       N > 1;

T(1) = 1
T(2) = 2T(1) + 2 = 4 = 2*2
T(4) = 2T(2) + 4 = 12 = 3*4
...
T(N) = T(2^k) = 2T(n) = n*N = (k+1)*N (n = k+1, k = 0,1,2,3...)
当 N = 2^k 得到:k = lgN;

故 T(N) = (lgN + 1)*N = Θ(NlgN+N) = Θ(NlgN).

证毕.

  然后,要注意记号Θ()、Ω()、O()的不同之处,Θ()包含Ω()和O(),O()记号常用来描述最坏情况时间复杂的,是一个上界;Ω()用来描述最好情况时间复杂度,是一个下界。

  最后,说明一下,证明和计算是自己想的(因为书上要求自己求解一下递归式),可能会有不正确的地方,还望指正! 关于最大子序列问题我学习得到的暂时就这么多了,学习能力有限,还望不吝指教。

--------------------------------------------------------补充 2017.9.20  11:54--------------------------------------------------------

  今天发现自己对《数据结构与算法分析 第二版》的算法4的代码的算法时间复杂度分析是有错误的,当时我的原话是:把所有情况列出来会有A(n,n)种组合(包含了重复情况),太暴力了,输入规模很大时,时间复杂度为Θ(n!)。

  因为有错,为了不误导人,故修改了,并且在这里多补充一些。

  当时有点想当然了,得出了A(n,n)的结论,实际情况时是不用包含重复的情况的,比如:

Array:{-5,1,2,-3,-2,-1,3}

因为最大子序列问题要求必须下标连续
所以最大子数组的和为3:{1,2}or{3}.

我想当然的认为可以忽略重复便把情况都列出来,这将会出现的致命错误:
{1,2,3},{1,3,2},{2,1,3},{2,3,1},{3,1,2},{3,2,1}.
不仅出现最大子数组的和为6且有6种情况都等于6...

  所以,要注意的有两点:

1.不重复;
2.下标连续(也就是最大子数组中的所有数都在数组中的某一块)

比如:Array{-5,-9,7,-1,4,3};

它的最大子数组为{7,-1,4,3},而不是{7,4,3}.

  然后我们来分析一下为什么把所有情况的组合列出来找的时间复杂度为Θ(n^2).

知道了该问题的性质要满足——下标连续,就好办了。

因为我比较笨,而且组合学是我最大的坎...所以我就用归纳法来分析:

我们先假设第一组数据有3个数,它们的下标为:{1,2,3} 。
这组数中的子数组共有多少种情况呢:
{1},{2},{3},{1,2},{2,3},{1,2,3}
数一下:1,2,...共6种.

再假设第二组数据有4个数,它们的下标为:{1,2,3,4}。

这组数中的子数组共有这些情况:
{1},{2},{3},{4},{1,2},{2,3},{3,4},{1,2,3},{2,3,4},{1,2,3,4}
共10种.

我们发现规律:
假设n个数的数组,它们的下标为:{1,2,...,n}.
只有一个数组成的子数组共有情况:n种;
有两个数组成的子数组共有情况:n-1种;
...(依次类推)
有n个数组成的子数组就是数组本身,共有情况:1种

把所有情况相加可得到所有情况共有:
n + (n - 1) + ... + 1 = n(1 + n)/2
也就是一个首项为1,公差为1的n项的等差数列的和。

故这种暴力枚举的算法的时间复杂度为 n(1 + n)/2 = θ(n^2).
证毕.

  认真分析过后我感觉畅快很多,因为A(n,2) =  θ(n^2)的结论是书上给出的,当时没想为什么,直接搬了过来,现在终于知道为什么时间复杂度为θ(n^2)了。但我还是不明白这个A(n,2)是怎么得到的...

  然后,昨晚断网以后我发现了该问题的分治算法的Bug...导致我认为这个算法是存在问题的。先举个例子:

我发现错误是在这组数据的基础上发现的:
{13, -3, -25, 20, -3, -16, -23, 18, 20, -7, 12, -5, -22, 15, -4, 7}

因为算法的意思是三个部分各自的累加后的结果的比较,最大的返回。
我把上面的数组中的部分数据改一下:

{13, -3, -25, 20, -3, -16, -23, 18, 20, -7, 12, -25, -22, 15, 14, 21}

问题就出现了:
程序对下面这组数的运算结果:
MaxSubarraySum = 46.

显然我们肉眼后心算得到的最大值为:
MaxSubarraySum = 50.
因为很显然的,最大子数组为:{15,14,21}。

那程序为什么会得出46的结果呢,原因就在于它的累加...
从数据18开始,一直累加到21,然后46(子数组{18,20,-7,12,-25,-22,15,14,21})比之前的43(子数组{18,20,-7,12})大,因而返回了...因为它是累加的,当累加到43再继续时-25、-22也被继续累加上去了,这部分的比43小所以不在意,再累加15、14、21,循环结束得到46。

  也许只是我的这段分治的代码存在这个问题,其它写法的我还没有测试过,我打算先自己想想解决办法,然后再试试其它写法的分治算法...

 --------------------------------补充 2017.9.28 10:23:26---------------------------------

  今天突然想到这个问题,才发现应该是我自己把伪代码翻译错了,错误在哪呢?请看下面注释部分:

static int Find_Max_Crossing_SubArray(int * nums, int start, int mid, int end) {

    int left = -65533, right = -65533;
    int sum;
    int i, j;

    sum = 0;
    for(i = mid; i >= start; i--) {
        sum += *(nums + i);
        if(sum > left) {
            left = sum;
        }
    }

    sum = 0;
    for(j = mid + 1; j < end; j++) {
        sum += *(nums + j);
        if(sum > right) {
            right = sum;
        }
    }
    sum = left > right ? left : right;              //比较找到最大的

    return sum > left + right ? sum : left + right;       //返回最大的,而不是left + right,他不一定是最大的
}

  运行结果:

MaxSubarraySum = 50.

  原先的错误就是并没有比较大小,我认为 left+right 就是最大的,这肯定是有问题的。