leetcode实战——最大子序列的和(动态规划,分治法,Kadane算法)

这个题目需要使用到动态规划,还不清楚什么是动态规划的同学,可以看我的另一篇文章的解释

题目

给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

示例

输入: [-2,1,-3,4,-1,2,1,-5,4],
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。

进阶:

如果你已经实现复杂度为 O(n) 的解法,尝试使用更为精妙的分治法求解。

解法

初看这道题,思路基本就是用遍历的方法,就这个例子来说,一共有9个数字,我们把所有的排列组合列出来,然后求出最大值。按照排列组合的数学算法,一共有45个组合,如果有n个数字,时间复杂度是O(n^2),这样的时间复杂度是明显不能接受的。

解法1 动态规划

于是我们把目光落到动态规划上面来,首先需要把这个问题分解成最优子问题来解。最主要的思路就是将上面的45个组合进行分类,分解成数量较少的几个子问题。在这里我们一共有9个数字,顺理成章的我们把组合分解成9个小组的组合。

  1. 第一个子组合是以第一个数字结尾的连续序列,也就是[-2],最大值-2

  2. 第二个子组合是以第二个数字结尾的连续序列,也就是[-2,1], [1],最大值1

  3. 第三个子组合是以第三个数字结尾的连续序列,也就是[-2,1,3], [1,3], [3],最大值4

  4. 以此类推。。。

如果我们能够得到每一个子组合的最优解,也就是子序列的最大值,整体的最大值就可以通过比较这9个子组合的最大值来得到了。现在我们找到了最优子问题,重叠子问题在哪呢?那就得细心比较一下每个子问题。

从第二个子组合和第三个子组合可以看到,组合3只是在组合2的基础上每一个数组后面添加第3个数字,也就是3,然后增加一个只有第三个数字的数组[3]。这样两个组合之间的关系就出现了,可是我们不关心这个序列是怎么生成的,只是关心最大值之间的关系。我们将子组合三分成两种情况:

  1. 继承子组合二得到的序列,也就是[-2,1,3], [1,3] (最大值 = 第二个组合的最大值 + 第三个数字)
  2. 单独第三个数字的序列,也就是[3] (最大值 = 第三个数字)

如果第二个序列的最大值大于0,那么最大值1就比最大值2要大,反之最大值2较大。这样,我们就通过第二个组合的最大值和第三个数字,就得到了第三个组合的最大值。因为第二个组合的结果被重复用到了,所以符合这个重叠子问题的定义。通俗来讲这个问题就变成了,第i个子组合可以通过第i-1个子组合的最大值和第i个数字获得,如果第i-1个子组合的最大值没法给第i个数字带来正增益,我们就抛弃掉前面的子组合,自己就是最大的了。
\begin{aligned} &如果Max(i-1) > 0, Max(i) = Max(i-1) + Nums(i) \\ &如果Max(i-1) <= 0, Max(i) = Nums(i) \end{aligned}

来看看代码,我们只需要一个变量subMax保存前面子组合的最大值,另外一个max保存全局最大值。

    public int maxSubArray(int[] nums) {
        if (nums == null) {
            return 0;
        }

        int max = nums[0];    // 全局最大值
        int subMax = nums[0];  // 前一个子组合的最大值
        for (int i = 1; i < nums.length; i++) {
            if (subMax > 0) {
                // 前一个子组合最大值大于0,正增益
                subMax = subMax + nums[i];
            } else {
                // 前一个子组合最大值小于0,抛弃前面的结果
                subMax = nums[i];
            }
            // 计算全局最大值
            max = Math.max(max, subMax);
        }

        return max;
    }

解法2 分治法

分治法是将整个数组切分成几个小组,每个小组然后再切分成几个更小的小组,一直到不能继续切分也就是只剩一个数字为止。然后每个小组会计算出最优值,汇报给上一级的小组,一级一级汇报,上级拿到下级的汇报找到最大值,得到最终的结果。和合并排序的算法类似,先切分,再合并结果。

这个问题中的关键就是如何切分这些组合才能使每个小组之间不会有重复的组合(有重复的组合意味着有重复的计算量),这个问题应该困扰了不少的同学,我在学习理解的时候也花了不少时间。

首先是切分分组方法,就这个案例中的例子来,我们有一个数组[-2,1,-3,4,-1,2,1,-5,4],一共有9个元素,我们center=(start + end) / 2这个原则,得到中间元素的索引为4,也就是-1,拆分成三个组合:

  • [-2,1,-3,4,-1]以及它的子序列(在-1左边的并且包含它的为一组)
  • [2,1,-5,4] 以及它的子序列(在-1右边不包含它的为一组)
  • 任何包含-1以及它右边元素2的序列为一组(换言之就是包含左边序列的最右边元素以及右边序列最左边元素的序列,比如[4,-1,2,1],这样就保证这个组合里面的任何序列都不会和上面两个重复)

以上的三个组合内的序列没有任何的重复的部分,而且一起构成所有子序列的全集,计算出这个三个子集合的最大值,然后取其中的最大值,就是这个问题的答案了。

然而前两个子组合可以用递归来解决,一个函数就搞定,第三个跨中心的组合应该怎么计算最大值呢?

答案就是先计算左边序列里面的包含最右边元素的的子序列的最大值,也就是从左边序列的最右边元素向左一个一个累加起来,找出累加过程中每次累加的最大值,就是左边序列的最大值。同理找出右边序列的最大值,就得到了右边子序列的最大值。左右两边的最大值相加,就是包含这两个元素的子序列的最大值。

在计算过程中,累加和比较的过程是关键操作,一个长度为n的数组在递归的每一层都会进行n次操作,分治法的递归层级在logN级别,所以整体的时间复杂度是O(nlogn),在时间效率上不如动态规划的O(n)复杂度。

代码如下

    public int maxSubArray(int[] nums) {
        return maxSubArrayDivideWithBorder(nums, 0, nums.length-1);
    }

    private int maxSubArrayDivideWithBorder(int[] nums, int start, int end) {
        if (start == end) {
            // 只有一个元素,也就是递归的结束情况
            return nums[start];
        }

        // 计算中间值
        int center = (start + end) / 2;
        int leftMax = maxSubArrayDivideWithBorder(nums, start, center); // 计算左侧子序列最大值
        int rightMax = maxSubArrayDivideWithBorder(nums, center + 1, end); // 计算右侧子序列最大值

        // 下面计算横跨两个子序列的最大值

        // 计算包含左侧子序列最后一个元素的子序列最大值
        int leftCrossMax = Integer.MIN_VALUE; // 初始化一个值
        int leftCrossSum = 0;
        for (int i = center ; i >= start ; i --) {
            leftCrossSum += nums[i];
            leftCrossMax = Math.max(leftCrossSum, leftCrossMax);
        }

        // 计算包含右侧子序列最后一个元素的子序列最大值
        int rightCrossMax = nums[center+1];
        int rightCrossSum = 0;
        for (int i = center + 1; i <= end ; i ++) {
            rightCrossSum += nums[i];
            rightCrossMax = Math.max(rightCrossSum, rightCrossMax);
        }

        // 计算跨中心的子序列的最大值
        int crossMax = leftCrossMax + rightCrossMax;

        // 比较三者,返回最大值
        return Math.max(crossMax, Math.max(leftMax, rightMax));
    }

解法3 Kadane算法

Kadane全名叫Joseph "Jay" Born Kadane,是卡耐基梅隆大学的统计学方面的教授,于1984年提出提出了线性解决这个问题的办法。

国内网上有很多材料提到了Kadane算法,并且将Kadane算法和动态规划并列到了一起,表明是两个不同的算法,但是当搜索外文网站的时候,大家用的Kadane算法和动态规划的思路是一模一样的,参考我的第一个参考文章,这里贴一下Wikipedia的代码,感觉和我们的动态规划的算法似乎不太一样

def max_subarray(numbers):
    """Find a contiguous subarray with the largest sum."""
    best_sum = 0  # or: float('-inf')
    current_sum = 0
    for x in numbers:
        current_sum = max(0, current_sum + x)
        best_sum = max(best_sum, current_sum)
    return best_sum

我们把这个代码放到网站跑一边,发现没有通过,因为当这个数组全是负数的时候,它的结果是0,改进方法是把max(0, current_sum + x)换成max(x, current_sum + x),这样就没有问题了:

def max_subarray(numbers):
    """Find a contiguous subarray with the largest sum."""
    best_sum = 0  # or: float('-inf')
    current_sum = 0
    for x in numbers:
        current_sum = max(x, current_sum + x)
        best_sum = max(best_sum, current_sum)
    return best_sum

现在大家有没有感觉这段代码很眼熟,没错!这就是我们的动态规划的解法。和原来的一样,核心思路都是判断以前一个元素结尾的子序列的最大值能不能给当前元素结尾的序列提供增益。当初布朗大学的Ulf Grenander教授在1977年提出这个问题的时候是为了展示数字图像中一个简单的最大似然估计模型,可能没有过多考虑道负数的问题,所以后来在解决的时候就没有考虑到全是负数的情况。

延伸——获取最大序列的起始和结束位置

可以使用我们的第一种方法也就是动态规划的方法来找到这个位置,将以这个元素结尾的的最大子序列的位置找出来,然后每次比较最大值的时候更新一下最大值的位置就行了

    public int maxSubArrayPosition(int[] nums) {
        if (nums == null) {
            return 0;
        }

        int start = 0;
        int end = 0;
        int subStart = 0;
        int subEnd = 0;
        int max = nums[0];    // 全局最大值
        int subMax = nums[0];  // 前一个子组合的最大值
        for (int i = 1; i < nums.length; i++) {
            if (subMax > 0) {
                // 前一个子组合最大值大于0,正增益,更新最后元素位置
                subMax = subMax + nums[i];
                subEnd++;
            } else {
                // 前一个子组合最大值小于0,抛弃前面的结果,更新当前最大值位置
                subMax = nums[i];
                subStart = i;
                subEnd = i;
            }
            // 计算全局最大值,更新位置,将全局最优解的位置更新
            if (subMax > max) {
                max = subMax;
                start = subStart;
                end = subEnd;
            }
        }

        System.out.println("[" + start + ","+ end +"]");
        return max;
    }

参考

Kadane’s Algorithm Explained
Maximum subarray problem
求最大连续子序列的和,两种解法:动态规划 & Kadane算法
分治策略结合递归思想求最大子序列和
Kadane’s Algorithm — (Dynamic Programming) — How and Why does it Work?

更多内容请看我的个人博客

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

推荐阅读更多精彩内容