最大子数组问题

1.问题描述
问题:一个有N个整数元素的一维数组(A[0],A[1],A[2],…A[n-1]),这个数组中子数组之和的最大值是多少?
该子数组是连续的。例如 数组:[1,-2,3,5,-3,2]返回8; 数组:[0,-2,3,5,-1,2]返回9。

网上有称之为最大子序列和,亦有称连续子数组最大和。个人觉得叫最大子序列和不太妥,数学上讲,子序列不一定要求连续,而这里我们的题目必然要求是连续的,如果不连续而求子序列最大和很显然就无意义了,这也是为啥又称连续子数组最大和。不过,莫要在意细节。

这题是很经典的一道面试题,也有各种解法,从算法分析上,时间复杂度也有很大差别,下面我就给出三种不同的解法。

2.解法一:暴力枚举法
此种方法最简单,我想应该也是每个人拿到题目想到的第一种解法了,学过一点编程的人都应该能编出此类程序。

记sum[i..j]为数组中第i个元素到第j个元素的和(其中0<=i

int maxSubArray(int *A,int n) {
    int maxium = -INF; //保存最大子数组之和
    for i=0 to n-1 do
        sum = 0; //sum记录第i到j的元素之和
        for j=i to n-1 do
            sum += A[j];
        if sum>maxium do //更新最大值
            maxium = sum;
    return maxium;
}

此种方法的时间复杂度为O(n2),显然不是一种很好的办法,也不是公司面试希望你写出这样的程序的。

3.解法二:分支界定
这里再介绍一种更高效的算法,时间复杂度为O(nlogn)。这是个分治的思想,解决复杂问题我们经常使用的一种思维方法——分而治之。

而对于此题,我们把数组A[1..n]分成两个相等大小的块:A[1..n/2]和A[n/2+1..n],最大的子数组只可能出现在三种情况:
A[1..n]的最大子数组和A[1..n/2]最大子数组相同;
A[1..n]的最大子数组和A[n/2+1..n]最大子数组相同;
A[1..n]的最大子数组跨过A[1..n/2]和A[n/2+1..n]
前两种情况的求法和整体的求法是一样的,因此递归求得。

第三种,我们可以采取的方法也比较简单,沿着第n/2向左搜索,直到左边界,找到最大的和maxleft,以及沿着第n/2+1向右搜索找到最大和maxright,那么总的最大和就是maxleft+maxright。
而数组A的最大子数组和就是这三种情况中最大的一个。
伪代码如下:

int maxSubArray(int *A,int l,int r) 
{
    if ldo
        mid = (l+r)/2;
        ml = maxSubArray(A,l,mid); //分治
        mr = maxSubArray(A,mid+1,r);
        for i=mid downto l do
            search maxleft;
        for i=mid+1 to r do
            search maxright;
        return max(ml,mr,maxleft+maxright); //归并
        then //递归出口
            return A[l];
}

4.解法三:动态规划(DP)
我们考虑最后一个元素arr[n-1]与最大子数组的关系,有如下三种情况:
(1)arr[n-1]单独构成最大子数组
(2)最大子数组以arr[n-1]结尾
(3)最大子数组跟arr[n-1]没关系,最大子数组在arr[0-n-2]范围内,转为考虑元素arr[n-2]

从上面我们可以看出,问题分解成了三个子问题,最大子数组就是这三个子问题的最大值,现假设:
(1) 以arr[n-1]为结尾的最大子数组和为End[n-1]
(2) 在[0-(n-1)]范围内的最大子数组和为All[n-1]
如果最大子数组跟最后一个元素无关,即最大和为All[n-2](存在范围为[0-n-2]),则解All[n-1]为三种情况的最大值,即All[n-1] = max{ arr[n-1],End[n-1],All[n-2] }。从后向前考虑,初始化的情况分别为arr[0],以arr[0]结尾,即End[0] = arr[0],最大和范围在[0,0]之内,即All[0]=arr[0]。根据上面分析,给出状态方程:
All[i] = max{ arr[i],End[i-1]+arr[i],All[i-1] }
代码如下:

/* DP base version*/
#define max(a,b) ( a > b ? a : b)

int Maxsum_dp(int * arr, int size)
{
    int End[30] = {-INF};
    int All[30] = {-INF};
    End[0] = All[0] = arr[0];

    for(int i = 1; i < size; ++i)
    {
        End[i] = max(End[i-1]+arr[i],arr[i]);
        All[i] = max(End[i],All[i-1]);
    }
    return All[size-1];
}

上述代码在空间上是可以优化为O(1)的

/* DP base version*/
#define max(a,b) ( a > b ? a : b)

int Maxsum_dp(int * arr, int size)
{
    nEnd = nAll = arr[0];

    for(int i = 1; i < size; ++i)
    {
        nEnd  = max(nEnd +arr[i],arr[i]);
        nAll = max(End[i],nAll);
    }
    return nAll ;
}

下面说一下由DP而导出的另一种O(N)的实现方式,该方法直观明了,个人比较喜欢,所以后续问题的求解也是基于这种实现方式来的。
仔细看上面DP方案的代码,End[i] = max{arr[i],End[i-1]+arr[i]},如果End[i-1]<0,那么End[i]=arr[i],什么意思?End[i]表示以i元素为结尾的子数组和,如果某一位置使得它小于0了,那么就自当前的arr[i]从新开始,且End[i]最初是从arr[0]开始累加的,所以这可以启示我们:我们只需从头遍历数组元素,并累加求和,如果和小于0了就自当前元素从新开始,否则就一直累加,取其中的最大值便求得解。
到这里其实就可以了,在《编程之美》中,作者故意没有按照这种推导来实现(我猜的),而是在End[i-1]<0时,让End[i]=0,从而留出了一个问题(元素全是负数怎么办),其实如果按照上面的推导直接实现的话,就不存在这个问题了;
基于上面的推导,代码如下:

/* DP ultimate version */
int Maxsum_ultimate(int * arr, int size)
{
    int maxSum = -INF;
    int sum = 0;
    for(int i = 0; i < size; ++i)
    {
        if(sum < 0)
        {
            sum = arr[i];
        }else
        {
            sum += arr[i];
        }
        if(sum > maxSum)
        {
            maxSum = sum;
        }
    }
    return maxSum;
}

其实上面的方法虽说是从DP推导出来的,但是写完发现也是很直观的方法,求最大和,那就一直累加呗,只要大于0,就说明当前的“和”可以继续增大,如果小于0了,说明“之前的最大和”已经不可能继续增大了,就从新开始,如此这样。

5.问题扩展:返回最大子数组始末位置
这个问题是《编程之美》2.14的扩展问题,返回始末位置还是比较容易的,我们知道,每当当前子数组和的小于0时,便是新一轮子数组的开始,每当更新最大和时,便对应可能的结束下标,这个时候,只要顺便用本轮的起始和结束位置更新始末位置就可以,程序结束,最大子数组和以及其始末位置便一起被记录下来了。

C++代码如下:

#include
using namespace std;

template<class T>
class my_data_type
{
    T max_sum;
    int start;
    int end;
public:
    void max_sub_sum_dp(T *a, int length);
    void my_type_printf(void);
};

template<class T>
void my_data_type::my_type_printf(void)
{
    cout << "max_sum is " << max_sum << endl;
    cout << "start is " << start << "   end is " << end << endl;
}

template<class T>
void my_data_type::max_sub_sum_dp(T *a, int length)
{
    T sum=a[0];
    start = 0;
    end = 0;
    max_sum = a[0];
    int start_temp = 0;
    for (int i = 1; i < length; i++)
    {   
        if (sum < 0)
        {
            sum = a[i];
            start_temp = i;
        }
        else
        {
            sum = sum + a[i];
        }

        if (sum > max_sum)
        {
            max_sum = sum;
            start = start_temp;
            end = i;
        }
    }
}

template<typename T>
void printf_data(T *a, int length)
{
    for (int i = 0; i < length; i++)
    {
        cout << a[i] << " ";
    }
    cout << endl;
}

int main()
{
    int a_int[6] = { 0, -2, 3, 5, -1, 2 };
    int a1_int[6] = { -9, -2, -3, -5, -3 };

    float a_float[6] = { 0.1, -2.2, 3.4, 5.6, -1.3, 2.8 };
    float a1_float[5] = { -9.0, -2.1, -3.1, -5.2, -3.6 };

    my_data_type<int> my_test_int;
    my_test_int.max_sub_sum_dp(a_int, 6);
    printf_data(a_int, 6);
    my_test_int.my_type_printf();

    my_test_int.max_sub_sum_dp(a1_int, 5);
    printf_data(a1_int, 5);
    my_test_int.my_type_printf();


    my_data_type<float> my_test_float;
    my_test_float.max_sub_sum_dp(a_float, 6);
    printf_data(a_float, 6);
    my_test_float.my_type_printf();

    my_test_float.max_sub_sum_dp(a1_float, 5);
    printf_data(a1_float, 5);
    my_test_float.my_type_printf();
}

测试结果:
最大子数组问题_第1张图片

6.问题扩展:允许数组首尾相连
这个也是2.14的扩展问题,如果数组arr[0],…,arr[n-1]首尾相邻,也就是允许找到一段数字arr[i],…,arr[n-1],arr[0],…,a[j],使其和最大,该如何?

编程之美解法:这个问题的解可以分为两种情况:

1) 解没有跨越arr[n-1]到arr[0] (原问题)

2) 解跨越arr[n-1]到arr[0]

对于第一种情况按照之前的方式计算即可,对于第二种情况我们可以巧妙地 进行问题转换。我们找最大子数组的对偶问题——最小子数组,有了最小子数组的值,总值减去它不就可以了么?但是我又想,这个对偶问题只能处理这种跨界的特殊情况吗?答案是肯定的,如果最大子数组跨界,那么剩余的中间那段和就一定是最小的,而且和必然是负的;相反,如果最大子数组不跨界,那么总值减去最小子数组的值就不一定是最大子数组和了,例如例子[8,-10,60,3,-1,-6],最大子数组为[8 | 60,3,-1,-6],而最小子数组和为[-10],显然不能用总值减去最小值。
故,在允许数组跨界(首尾相邻)时,最大子数组的和为下面的最大值
Maxsum={ 原问题的最大子数组和;数组所有元素总值-最小子数组和 }。

你可能感兴趣的:(算法学习,数学)