最大子数组问题

Maximum Subarray

由于简书不支持latex语法,所以可以到下面的作业部落去看。
https://www.zybuluo.com/LIUHUAN/note/363545
标签(空格分隔): algorithm


这个问题我们先看下问题的描述:

问题描述

Find the contiguous subarray within an array (containing at least one number) which has the largest sum.
For example, given the array [−2,1,−3,4,−1,2,1,−5,4],
the contiguous subarray [4,−1,2,1] has the largest sum = 6.

问题来自于Leetcode:Maximum Subarray


问题分析

简单来说,就是在一个数组A_{1...n}中找到一个子数组A_{i...j}使得
\sum_{k=i}^j A_k最大,也有找最小值的(可以转化为找最大值的问题,不再详述)

  • 那么最直接的想法,就是对于每一个(i,j),i \le j 遍历整个数组,用一个最大值标记一下,就能都找到最大值了。对于每一个i,j组合总共有 \frac{n(n+1)}{2}个子数组,都遍历一次数组,那么可以看出来整个的复杂度为O(n^3)

解决方案

1.按着上面的思路,我们可以写出如下的程序来

int maxSubArray(vector<int>& nums) {
        int n = nums.size();
        if(n == 0)
            return 0;
        if(n == 1)
            return nums[0];
        int max = 0x80000001;//最小的32位整数
        int sum = 0;
        for(int i =0;i<n;++i){
             for(int j=0;j<n;++j){
                 sum = 0;
                 /*计算 A[i,j] 的和*/
                 for(int k=i;k<=j;++k){
                    sum += nums[k]; 
                 }
                /**更新最大值max*/
                if(sum > max)
                    max = sum;
             }
        }
        return max;
    }
  • 但是这种方法在Leetcode上面没有通过,因为超时了。时间复杂度太高了。如果数据很大,那么会很慢。

2.上面的解决方案1需要重复计算每个子数组的部分过程

  • 上面的算法我们每次是按着(i,j)对来计算的,如果我们当纯来想如何求所有的子数组的过程,可以发现,对于一个特定的i,我们可以计算(i,i),(i,i+1),(i,i+2) \dots (i,n-1)的和,
    \sum_{k=i}^{j+1}A_k = A_{j+1} + \sum_{k=i}^{j}A_k可以充分利用前面计算出来的 \sum_{k=i}^{j}A_k,来降低时间复杂度。
  • 那么就不用对于每一个(i,j)都从ij遍历数组,那么时间复杂度可以降低为O(n^2)
  • 所以可以有如下优化的代码
int maxSubArray(vector<int>& nums) {
        int n = nums.size();
        if(n == 0)
            return 0;
        if(n == 1)
            return nums[0];
        int max = 0x80000001;//最小的32位整数
        int sum = 0;
        for(int i=0;i<n;++i){
            sum = 0;
            /**对于某个特定的i 分别计算A[i,i+1],A[i,i+2],...A[i,n-1]的和*/
            for(int j=i;j<n;++j){
                sum += nums[j];
                /**更新最大值max*/
                if(sum > max )
                    max = sum;
            }
        }
        return max;
}
  • 噩耗再次传来,⊙﹏⊙b汗
  • 没有通过leetcode测试,还是超时了
  • 那么看样子时间复杂度还需要降低才可以。不然找不到工作了。。。

2.下面采用的分治的算法,从最大子数组出现的位置来考虑的。可以参考<算法导论>的第4章内容

  • 分治的思想是,把数组A_{i \dots j},i \le j看成两个部分,可以认为是从数组中间分割成
    A_{i \dots k}A_{k+1 \dots j},k = \frac{i+j}{2}两个数组,那么我们的目标就是通过求这两个子数组的最大值,然后求得目前这个数组A_{i \dots j}的最大子数组和的值。那么问题来了,如果你知道了A_{i \dots k}的最大子数组的和max\_leftA_{k+1 \dots j}的最大子数组的和max\_right,你怎么求解目前这个数组A_{i \dots j}的最大子数组的和?⊙﹏⊙b汗
  • 可以分析下,如果知道了max\_leftmax\_right,那么我们分析下max\_leftmax\_right的构成。
  • max\_left = \sum_{t=start}^{end}A_t,start \ge i , end \lt k
  • max\_right = \sum_{t=start}^{end}A_t,start \gt k , end \le j
  • 从上面的表达式可以看出来max\_leftk左边的某个子数组的和, max\_rightk右边的某个子数组的和,具体是什么我们可以先不用管了,因为,这两个值都是假设已经知道的。
  • 那么整个A_{i \dots j} 最大子数组的和,出现的子数组的位置还有一种可能,那就是,在左边有一部分,右边也有一部分,并且包含A_k这个元素。也就是子数组和的形式为\sum_{t=start}^{k-1}A_t + A_k + \sum_{t=k+1}^{end}A_t,哎呦这样看来不就是和之前\sum_{t=i}^ {j}A_t形式一致了么?有神马意义⊙﹏⊙b汗
  • 客官,请慢!!我明天再写吧。
  • 那么,我们就先分别递归求的左边和右边的最大子数组和的值,然后考虑下和当前的跨越中点的那个最大子数组和进行比较,获取他们三个当中最大的那一个。
  • 那么如何求的跨越中点的子数组的最大值呢?
  • 跨越中点有一个特点,就是左边是以中点k = \frac{i+j}{2}所在元素结尾,右边是以这个元素为开始,那么由第二种解决方案的思路,我们就可以分别从k开始,向左边和右边进行遍历找到最大的那个。其实这种遍历是O(n)的复杂度的⊙﹏⊙b汗
  • 那么我们就写下来代码看看。
int maxSubArray(vector<int>& nums) {
    int n = nums.size();
    if(n == 0)
        return 0;
    if(n == 1)
        return nums[0];
    return maxSubArray_help(nums,0,n-1);
}

int maxSubArray_help(vector<int>& nums,int begin,int end){
    if(begin == end)
        return nums[end];
    int mid = (begin + end)>>1;
    /**左边子数组的最大值子数组值*/
    int max_left = maxSubArray_help(nums,begin,mid);
    /**右边子数组的最大值子数组值*/
    int max_right = maxSubArray_help(nums,mid + 1,end);
    /**跨越中点的最大值子数组值*/
    int max_cross = maxCrossMid(nums,begin,mid,end);
    return max(max(max_left,max_right),max_cross);
}

/*maxCrossMid函数的时间复杂度实际为O(n)*/
int maxCrossMid(vector<int>& nums,int begin,int mid ,int end){
    int left_max = 0x80000001;//最小的32位整数
    int right_max = 0x80000001;
    int sum = 0;
    /*计算以mid结尾的最大的子数组和,左边子数组*/
    for(int i = mid ;i>=begin;--i){
        sum += nums[i];
        if(sum > left_max)
            left_max = sum;
    }
    sum = 0;
    /*计算以mid+1开始的最大的子数组和,右边子数组*/
    for(int i=mid+1;i<=end;++i){
        sum += nums[i];
        if(sum > right_max)
            right_max = sum;
    }
    return left_max + right_max;
}
  • 提交到Leetcode,如果还通不过,那么,你觉得我能找到工作么?黔驴技穷了都⊙﹏⊙b汗(还是参考算法导论的内容)
  • 好消息是通过了测试,坏消息是,运行的速度很慢。很慢。。
  • 我们来分析下这个算法慢在哪里,这个算法是一个分治的算法,那么我们按着分治的思想列出时间复杂度的计算表达式T(n) = 2T(\frac{n}{2}) + O(n),为什么最后面一项是O(n),这项就表示我们计算跨越中点的最大子数组和的时间复杂度。 maxCrossMid这个函数的最坏情况下是最大子数组就是从begin开始到end结束的和,那么begin和end最坏的情况就是0到n 所以时间复杂度是O(n)
  • 那么这个表达式的结果是什么呢?
  • 根据主定理,我们可以知道这个解的下界是O(nlgn)也就是\Theta(nlgn),当然这个算法比O(n^2)要快,不然也通不过测试。。
  • 那么他们怎么运行的那么快呢?有没有线性时间的算法呢?

3.线性时间的算法,思想参考算法导论的第4章的习题

  • 线性算法的思想是基于动态规划,把问题转化为一个较小的子问题。思考是这么想的,但是实际求解的过程还是从子问题逐渐到整个问题的过程。我也不知道我在说神马⊙﹏⊙b汗
  • 对于数组从左到右处理,记录到目前为止,他的意思是到你遍历的某个元素为止的已经处理过的最大子数组的,基于下面的观察,如果已知A[i \dots j]的最大子数组,那么可以根据如下的性质将解扩展到为A[i \dots j+1]的最大子数组:A[i \dots j+1]的最大子数组,要么是A[i \dots j]的最大子数组,要么是某个子数组A[m \dots j+1],i \le m \le j+1。以A_{j+1}结尾的子数组。
  • 那么我们可以想到,如果A[i \dots j+1]的最大子数组,和A[i \dots j]的最大子数组一样,那很好实现,但是这只是一种情况。其实重点在A[i \dots j+1]的子数组是A[m \dots j+1],i \le m \le j+1,如果是这种情况怎么确定A[m \dots j+1]呢?也就是求解以A_{j+1}结尾的最大子数组,按着解决方案三的思想,我们可以从A_{j+1}向左遍历,求解一个最大的子数组,但是这种很明显就提高了复杂度,对于每一个j你都要向左遍历,那么时间复杂度就成为O(n^2)了⊙﹏⊙b汗
  • 其实我们忘了一个假设,那就是当你处理到A_{j+1}的时候,我们已经知道以A_{i}结尾的最大子数组,对应于算法导论中提到的记录目前为止处理过的最大子数组。那么假设sum_j=\sum_{t=m}^{j}A_t记录以A_j结尾的最大子数组,那么可以得到
    sum_{j+1}=\left\{ \begin{array}{rcl} \sum_{t=m}^{j}A_t + A_{j+1} & & {\sum_{t=m}^{j}A_t + A_{j+1} \gt A_{j+1} } \\ A_{j+1} & & {\sum_{t=m}^{j}A_t + A_{j+1} \le A_{j+1} } \end{array} \right.
  • 这样就把以A_{j+1}结尾的最大子数组找出来了,那么到A_{j+1}为止的最大子数组(可能不是以A_{j+1}结尾),还没有找出来,这就需要我们从开始记录一个到A_{j+1}为止的最大子数组,假设为max_j表示到A_{j}为止的最大子数组,然后和以A_{j+1}结尾的最大子数组进行比较,取最大的那个,
    max_{j+1} = \left\{ \begin{array}{rcl} max_{j} & & max_{j} \gt sum_{j+1}\\ sum_{j+1} & & max_{j} \le sum_{j+1} \end{array} \right.
  • 这样就可以完成求解到A_{j+1}为止,最大的子数组。把过程弄明白之后,那就开始写程序。
int maxSubArray(vector<int>& nums) {
    int n = nums.size();
    if(n == 0)
        return 0;
    if(n == 1)
        return nums[0];
    vector<int> Max(n,0x80000001);
    vector<int> sum(n,0);
    sum[0] = nums[0];
    Max[0] = nums[0];
    for(int i=1;i<n;++i){
        /*以nums[i]结尾的最大子数组*/
        sum[i] = max(sum[i-1] + nums[i],nums[i]);
        /*到i为止的最大子数组*/
        Max[i] = max(sum[i],Max[i-1]);
    }
    /*返回到n-1为止的最大子数组,也就是整个数组的最大子数组*/
    return Max[n-1];
}
  • Leetcode提交通过,速度任然很慢,这是怎么一回事⊙﹏⊙b汗
  • 分析下复杂度,时间复杂度为O(n)只扫描一遍数组。
  • 空间复杂度为O(2n)因为用到了两个额外的数组Max和sum难道这个是慢的原因(有可能)
  • 那么下面的思路就是对这个进行优化。

4.解决方案三的优化

  • 我们看着方案三的代码,可以看出来,其实我们没有必要把全局的最后求解的最大值和每次以A_{j}为止的最大值分开来求,只要存放在一个变量里面就可以了,因为到A_{j}为止的最大值只使用了一次就结束了,就在和以A_{j}结尾的最大值进行比较,所以可以有下面的优化一步的代码。
  int maxSubArray(vector<int>& nums) {
        int n = nums.size();
        if(n == 0)
            return 0;
        if(n == 1)
            return nums[0];
        vector<int> sum(n,0);
        sum[0] = nums[0];
        int max_sum = nums[0];
        for(int i=1;i<n;++i){
            /*以nums[i]结尾的最大子数组*/
            sum[i] = max(sum[i-1] + nums[i],nums[i]);
            /*到i为止的最大子数组*/
            max_sum = max(max_sum , sum[i]);
        }
        /*返回到n-1为止的最大子数组,也就是整个数组的最大子数组*/
        return max_sum;
}

5.解决方案四的进一步优化

  • 在上一步的优化当中我们可以看出来,其实以A_{j}结尾的最大子数组的值也是在求解以A_{j+1}结尾的最大子数组的时候用到一次,其他的时候并不会用到,所以我们可以把这个sum数组也优化了,具体的可以用变量sum\_cur,表示以A_{i}结尾的最大子数组的和,然后比较sum\_cur + A_{j+1}A_{j+1}的大小,取最大的那个就代表到以A_{j+1}结尾的最大子数组的和。
  • 这样我们就可以写出来,网上一般会直接给出的动态规划最后的结果。
  • 当然还有其他的写法,可以主要思想就是这样。
int maxSubArray(vector<int>& nums) {
        int n = nums.size();
        if(n == 0)
            return 0;
        if(n == 1)
            return nums[0];
        int sum_cur = nums[0];
        int max_sum = nums[0];
        for(int i=1;i<n;++i){
            /*以nums[i]结尾的最大子数组*/
            if(sum_cur < 0)
                sum_cur = nums[i];
            else 
                sum_cur += nums[i];
            /*到i为止的最大子数组*/
            if(sum_cur > max_sum)
                max_sum = sum_cur;
        }
        /*返回到n-1为止的最大子数组,也就是整个数组的最大子数组*/
        return max_sum;
}

6.总结

  • 对于一个问题首先要想到它最为直接,一般的方法,比如这个题的方案1和方案2,首先想到这些直接的算法,然后观察对这种方法进行改进。
  • 我很不赞成直接给出最后一种优化后的结果的答案,因为这个比较难于理解,至少是对我来说,如果你能直接看懂这个优化后的结果,那么可以用这种思想去做下Leetcode的Maximum Product Subarray这个题的思路和本题类似,后续的内容也会写到这个题。
  • 曾经发生过两行代码来解决这个问题的争论,确实优化之后的核心代码确实只有两行,哈哈。
  • 对于没见过的题,弄懂别人的解法是学习,能够应用之前学习到的方法解决问题是能力

7.参考内容

  • 算法导论第4章
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,569评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,499评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,271评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,087评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,474评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,670评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,911评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,636评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,397评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,607评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,093评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,418评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,074评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,092评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,865评论 0 196
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,726评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,627评论 2 270

推荐阅读更多精彩内容