写这篇文章的目的是分享自己在解Leetcode题目“最大子数组问题”时候,用到的Kadane算法,这是一个典型的动态规划算法示例。

为了更好的理解Kadane算法,本文章首先介绍暴力破解的方法,然后通过对暴力破解算法的改进,详细介绍Kadane算法的思路,其中会简单介绍动态规划算法。 希望能够帮助读者更好地理解Kadane算法,从而对动态规划算法有更深的理解。

目录

1. 问题描述

LeetCode上面的题目描述:

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

示例:

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

来源:力扣(LeetCode) 链接:https://leetcode-cn.com/problems/maximum-subarray

在解释Kadane算法之前,我们先从简单的方法入手,首先介绍一下暴力破解的方法。本篇文章就以LeetCode题目中的示例数组为例来介绍算法。

2. 暴力破解

首先观察题目可以很自然地想到一个明显的解决方法:遍历所有的子数组并计算子数组的和,从所有子数组和中找到最大的和,即为结果。这个方法很符合我们的直觉,那么如何遍历所有的子数组呢?直观的办法是从第一个元素开始,查找所有以nums[0]起始的子数组,然后在依次查找以num[1],nums[2]…nums[n-1]起始的所有子数组,具体如下:

nums[0]起始子数组:                  nums[1]起始子数组:
-2, 1, -3, 4, -1, 2, 1, -5, 4      -2, 1, -3, 4, -1, 2, 1, -5, 4
-2                                     1,
-2, 1                                  1, -3
  .                                       .                       ...
  .                                       .
  .                                       .
                                       1, -3, 4, -1, 2, 1, -5
-2, 1, -3, 4, -1, 2, 1, -5             1, -3, 4, -1, 2, 1, -5, 4
-2, 1, -3, 4, -1, 2, 1, -5, 4

上面介绍的方法就可以遍历给定数组的所有子数组了,不过为了方便下文介绍从暴力破解推导Kadane算法的过程,我们对这个遍历方法做个改变,改成从后往前遍历。即:从最后一个元素开始,查找所有以nums[n-1]结束的子数组,为了方便描述用subarray[n-1]表示,然后依次查找以nums[n-2], nums[n-3]…nums[0]结束的子数组(subarray[n-2], subarray[n-3]…subarray[0]),具体如下:

subarray[n-1]:                      subarray[n-2]:
-2, 1, -3, 4, -1, 2, 1, -5, 4      -2, 1, -3, 4, -1, 2, 1, -5, 4
                            4                              -5
                        -5, 4                           1, -5
                          .                               .       ...
                          .                               .
                          .                               .
    1, -3, 4, -1, 2, 1, -5, 4      -2, 1, -3, 4, -1, 2, 1, -5
-2, 1, -3, 4, -1, 2, 1, -5, 4,

显然,这种办法也可以遍历到nums数组的所有可能的子数组,通过这种暴力破解的方法就很容易得到答案了。下面放出我的代码,这里我使用递归,能更直观的理解第二种遍历方法:

def maxSubArray(self, nums: list, n: int) -> int:
    if n==0:
        return 0 # in case there is no item in nums
    if n==1:
        return nums[0]
    max_sum, idx = self.maxSubArray(nums[0:n-1], n-1), n-1
    s = 0
    while idx >= 0:
        s += nums[idx]
        max_sum = max(s, max_sum)
        idx -= 1
    return max_sum

相信你也看到了,这种算法并不高效,随着数组规模扩大(n增大),子数组的数量也会快速增加,从而使的算法的时间复杂度快速增加。这个算法的时间复制度是$O(n^2)$, 是种比较低效的方法。

那么我们可以如何提高这个算法的效率呢?相信你也想到了,从我的递归程序里面可以看出,这个题目是可以用动态规划来优化的,接下来我们来看看如何使用动态规划解决这个问题。

3. 从暴力破解到Kadane算法的思路过程

动态规划是一种利用空间换时间的算法思想,本质上是通过将一个复杂的求解最优方案的问题,分解成若干个子问题,而问题最终解可以通过子问题的解推导得到。因而我们可以保存子问题的解,在遇到相同子问题时直接从存储中读取解,而不是从新求解子问题,从而达到利用空间换时间的效果。

可以用动态规划求解的问题一般有两个性质:最优子结构和重复子问题。

让我们来分析一下这个题目是否可以使用动态规划的方法求解,首先回忆一下刚刚的递归过程,对于元素i,我们不妨定义subarray[i]为以i元素结尾的所有子数组集合,S[i]表示以i为结尾所有子数组和的集合,sum[i][j]表示以j开头i结束的子数组和($S[i] \in sum[i][j]$),进而可以定义max_sum[i]表示以i结尾的最大子数组和:

  -2, 1, -3, 4, -1, 2, 1, -5, 4     |   -2, 1, -3, 4, -1, 2, 1, -5, 4
                                  |                     2    2    sum[5][5]
              -1   -1   sum[4][4] |                 -1, 2    1    sum[5][4]
           4, -1    3   sum[4][3] |              4, -1, 2    5    sum[5][3]
       -3, 4, -1    0   sum[4][2] |          -3, 4, -1, 2    2    sum[5][2]
    1, -3, 4, -1    1   sum[4][1] |       1, -3, 4, -1, 2    3    sum[5][1]
  -2, 1, -3, 4, -1   -1   sum[4][0] |   -2, 1, -3, 4, -1, 2    1    sum[5][0]
  ------  -------   -  -            |   --------  ---------   -  -
      \/           \/             |           \/             \/
    subarray[4]    S[4]           |        subarray[5]       S[5]
        max_sum[4] = 3                         max_sum[5] = 5

递归公式如下:

max_sum[i] = max(sum[i][0], sum[i][1] ...sum[i][i])
final_max = max(max_sum[i], max_sum[i-1])

再来观察一下sum[i][j],可以得到如下规律:

sum[i][i] = nums[i]
sum[i][i-1] = nums[i] + sum[i-1][i-1]
sum[i][i-2] = nums[i] + sum[i-1][i-2]
        ...
sum[i][0] = nums[i] + sum[i-1][0]

我们可以看出sum[i][j]是可以通过sum[i-1][j]推导得到,其中计算sum[i][j]的过程中包含了大量重复计算的子问题sum[i-1][j],到此我们可以看出这个问题符合动态规划的两个特征,可以用动态规划来解决这个问题,具体如何优化呢?

我们来观察一下递归公式中的max_sum[i]以及上面推导的sum[i][j]的计算过程,很容易可以得到以下公式:

max_sum[i] = max(nums[i], max[i-1]+nums[i])

即所有以i结尾的子数组的和的最大值max_sum[i]可以从nums[i]和max[i-1]+nums[i]这两个数中选最大的一个,从而最大子数组和的问题可以转化为递归地计算$max_sum$的子问题上来,而且可以很容易的得出max_sum[0]=nums[0]。因此,可以很自然的得到以下动态规划状态转移方程:

max_sum[i] = max(nums[i], max[i-1]+nums[i])
final_max = max(max_sum[i], final_max)

4. Python代码

至此,我们已经分析了这个问题从暴力破解到动态规划算法的优化过程,下面我贴出我自己的python代码,仅供参考:

def maxSubArray(self, nums: List[int]) -> int:
    if len(nums) <= 0:
        return 0
    final_max = nums[0]
    max_sum = 0
    for n in nums:
        max_sum = max(n, max_sum+n)
        final_max = max(max_sum, final_max)
    return final_max

这里我没有用数组存储max_sum[i],而是直接用了一个变量来存储,由于状态转移过程只需要用到上一个位置的最优结果,因此只存储上一个max_sum是可行的,这样也节约了存储。可以看出优化后的程序,我们只需要遍历一次nums数组,时间复杂度为$O(n)$,比暴力求解了较好的提高。

5. 总结

由于问题的最终解final_max通过分解成max_sum[0]….max_sum[n-1]这些子问题来求解,其中包含了很多重复子问题,这个问题可以用动态规划来优化。通过对Kadane算法的分析,我们可以更好地理解动态规划的思想。