最大子段和问题解析:从穷举法到分治法的算法之旅

153 阅读4分钟

引言

在刷算法题时,我们会遇到一种很常见的题目——求解最大子段问题。

即给定由 n 个整数(可能为负整数)组成的序列 a1, a2, ..., an,求该序列的子段和的最大值。当所有整数均为负整数时定义其最大子段和为0。(注意:子段是连续的)。例如,当 (a1, a2, a3, a4, a5, a6) = (-2, 11, -4, 13, -5, -2) 时, 最大子段和为:20。

接下来,我们将探讨两种不同的方法来解决这个问题:一种是直观但效率较低的穷举法,另一种是更高效且体现了递归思维的分治法。

正文

三种代码求解

在代码中,我们定义n是整数序列长度,a是数组名,besti是最大字段的左侧下标,bestj是最大字段的右侧下标。

首先我们放出最好理解的穷举法代码:

int MaxSum(int n, int *a, int &besti, int &bestj){
    int sum=0;
    for (int i=1; i<=n; i++){
        for (int j=i; j<=n; j++){
            int thissum=0;
            for (int k=i; k<=j; k++){
                thissum += a[k];
                if (thissum>sum){
                    sum = thissum;
                    besti = i;
                    bestj = j;
                }
            }
        }
    }
    return sum;
}

可以看到,该算法的时间复杂度为O(n^3)。

for (int i=1; i<=n; i++)的作用是从前往后遍历数组,使每个数字都可能作为最大序列的左侧数字,然后后面的两层循环便是在假设一个左侧数字后的前提下,遍历获得此种情况的最大字段,for (int j=i; j<=n; j++)便是通过递增j的值修改字段的长度(长度为i到j),for (int k=i; k<=j; k++)则是将新加入字段的数字和之前的字段求和。通过三层循环即可遍历得出最大字段的情况。

下面的代码则是优化后的穷举法代码:

int MaxSum(int n, int *a, int &besti, int &bestj){
    int sum=0;
    for (int i=1; i<=n; i++){
        int thissum=0;
        for (int j=i; j<=n; j++){
            thissum += a[j];
            if (thissum>sum){
                sum = thissum;
                besti = i;
                bestj = j;
            }
        }
    }
    return sum;
}

这里时间复杂度变成了O(n^2)。

for (int i = 1; i <= n; i++)的作用没有变化,for (int j=i; j<=n; j++)则是在i到n中找到“最大字段”(该字段左侧下标是i),这里对上述代码的循环进行了简化。

但是时间复杂度还是有点高,因此我们在分析一下分治法求解。

分治法即将一个父问题分成几个一样的子问题,然后求解子问题,最后汇总子问题的结果得出父问题的结果。

在这里我们的父问题是求解一个序列中的最大字段和,因此我们可以把这个序列从中间分为两个子序列,最后的结果就有了三种情况:①最大字段在左侧序列中。②最大字段在右侧序列中。③最大字段在中间,即包含左右两个序列。

对于前两种情况,我们可以直接递归。第三种情况我们可以先找到“左侧的最大字段”(该字段的右侧数字被限制为中间数字),然后再找到“右侧的最大字段”(该字段的左侧数字被限制为中间数字),然后把两段相加即可。因此我们可以编写出下述代码:

int MaxSubSum(int *a, int left, int right){
    int sum=0;
    if (left==right)
        sum=a[left] > 0 ? a[left]:0;
    int center=(left+right)/2;
    int leftsum=MaxSubSum(a, left, center);
    int rightsum=MaxSubSum(a,center+1,right );
    int s1=0; int lefts=0;
    for(int i=center; i>=left; i--){
        lefts+=a[i];
        if (lefts>s1)
            s1=lefts;
    }
    int s2=0; int rights=0;
    for(int i=center+1; i<=right; i++){
        rights+=a[i];
        if (rights>s2)
            s2= rights;
    }
    sum=s1+s2;
    if(sum<leftsum) sum=leftsum;
    if (sum< rightsum) sum=rightsum;
    return sum;

该算法将一个问题划分为两个子问题(即求出左右序列的最大字段),将问题规模n分为n/2,(分解部分代码为:int leftsum=MaxSubSum(a, left, center);int rightsum=MaxSubSum(a,center+1,right );)。

然后合并时需要从中间数字先遍历到最左侧数字,再从中间数字遍历到最右侧数字,即遍历了整个序列,所以合并的时间复杂度为O(n)。

因此可以建立递归方程:

根据公式法可以得出此递归方程的解的渐进阶为:T(n)=O(nlogn)。

公式法

分治法的时间复杂性所满足的递归关系,即一个规模为 n 的问题被分为规模均为 n/b 的 a 个子问题,递归地求解这 a 个子问题,然后通过对这 a 个子问题的解的综合,得到原问题的解。套用公式如下:

T(n)=aT(nb)+f(n)T(n) = aT\left(\frac{n}{b}\right) + f(n)

其中:

  • T(n) 是解决规模为 n 的问题所需的时间。
  • a是每个子问题的数量。
  • b 是每个子问题相对于原始问题的比例。
  • f(n) 是合并所有子问题结果所需的时间。

最后求解总体时间复杂度则需要将f(n)与nlogban^{\log_{b}a}进行对比,谁大取谁,相等乘logn。

结语

希望读完本文你有所收获,如有疑问,欢迎留言!