53. 最大子数组和 (maximum subarray)

3,903 阅读3分钟

"子序列、子数组、子串,傻傻分不清楚"

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第4天,点击查看活动详情

53. 最大子数组和 题目描述:给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。子数组 是数组中的一个连续部分

示例
输入: nums=[2,1,3,4,1,2,1,5,4]nums = [-2,1,-3,4,-1,2,1,-5,4]
输出: 66
解释: 连续子数组 [4,1,2,1][4,-1,2,1] 的和最大,为 66

题目要我们找出和最大的连续子数组的值是多少(说明元素中必有负数,要分开讨论),【连续】是关键字,连续很重要,不是子序列。

暴力解法

1. 暴力 O(N^3)

纯暴力很好理解啦,就是枚举出所有的可能,然后放在一起比大小。(💥妥妥超时

/**
 * 空间复杂度 O(1)
 * 时间复杂度 O(n^3),n是nums数组的长度
 */
function maxSubArray(nums: number[]): number {
    let ans = Number.MIN_SAFE_INTEGER;
    const len = nums.length;

    for(let i = 0; i < len; i++) {
        for(let j = i; j < len; j++) {
            ans = Math.max(ans, addRange(nums, i, j));
        }
    }

    return ans;
};

function addRange(nums: number[], left: number, right: number): number {
    let sum = 0;

    while(left <= right) {
        sum += nums[left];
        left += 1;
    }

    return sum;
}

2. 暴力 O(N^2)

暴力遍历算法的时间复杂度过高,可以优化:保存 [i,j][i,j] 连续区域的子数组元素之和,记为 sumi:jsum_{i:j},这样在计算 [i,j+1][i,j + 1] 连续区域的子数组元素之和时,只需要计算 sumi:j+nums[j+1]sum_{i:j} + nums[j + 1] 即可,即 sumi:j+1=sumi:j+nums[j+1]sum_{i:j + 1} = sum_{i:j} + nums[j + 1]。这样时间复杂度可从 O(n3)O(n^3) 降低值 O(n2)O(n^2),可惜 💥依然超时

/**
 * 空间复杂度 O(1)
 * 时间复杂度 O(n^2),n是nums数组的长度
 */
function maxSubArray(nums: number[]): number {
    let ans = Number.MIN_SAFE_INTEGER;
    const len = nums.length;

    for(let i = 0; i < len; i++) {
        let sum = nums[i];
        for(let j = i + 1; j < len; i++) {
            sum += nums[j];
            ans = Math.max(ans, sum);
        }
    }

    return ans;
};

中规中矩的动态规划

  • 最值问题?连续子数组 的元素之和最大

  • 无后效行? 没有要求指出哪个连续子数组的元素之和最大

  • 最值可以穷举? 暴力法就是最好的证明

  • 最优子结构? 假设我们知道以 nums[i]nums[i] 结尾(一定包括这个元素)的连续子数组的元素最大和为 dp[i]dp[i],通过 nums[i+1]nums[i+1] 正负性的判断,可以得知以 nums[i+1]nums[i+1] 结尾的连续子数组的元素最大和 dp[i+1]dp[i+1]

1、确定 dp 状态数组

dp[i]dp[i] 就是以 nums[i]nums[i] 结尾的子数组最大和,其中 i[0,n),n=nums.lengthi\in[0,n), n = nums.length

2、确定 dp 状态转移方程

对于 nums[i]nums[i] 的符号,一定有三种情况,分别是 nums[i]>0nums[i] \gt 0nums[i]<0nums[i] \lt 0 以及 nums[i]=0nums[i] = 0;对于 dp[i]dp[i] 的符号,也一定有三种情况,分别是 dp[i]>0dp[i] \gt 0dp[i]<0dp[i] \lt 0 以及 dp[i]=0dp[i] = 0。故我们对 99 种情况分别进行讨论:

nums[i]>0nums[i] \gt 0 时,

dp[i]={dp[i1]+nums[i],if dp[i1] > 0nums[i],if dp[i1] = 0nums[i],if dp[i1] < 0dp[i] = \begin{cases} dp[i - 1] + nums[i], & \text{if $dp[i-1]$ > 0} \\ nums[i], & \text{if $dp[i-1]$ = 0} \\ nums[i], & \text{if $dp[i-1]$ < 0} \\ \end{cases}

💥 NOTE:

  • dp[i1]>0dp[i-1]>0 时,nums[i]nums[i]dp[i1]dp[i-1] 相互正向影响,故要在 dp[i1]dp[i-1] 基础上累加! nums[i]nums[i],方能保证 dp[i]dp[i] 是以 nums[i]nums[i] 结尾的子数组的最大元素之和;

  • dp[i1]=0dp[i-1]=0 时,dp[i]=dp[i1]+nums[i]dp[i]=dp[i-1] + nums[i] 与上式等价;

  • dp[i1]<0dp[i-1]<0 时,dp[i1]dp[i-1]nums[i]nums[i] 是负向影响,故要将 dp[i1]dp[i-1] 舍去,仅保留 nums[i]nums[i] 保证连续,还能保证 dp[i]dp[i] 是以 nums[i]nums[i] 结尾的子数组的最大元素之和。


nums[i]=0nums[i] = 0 时,

dp[i]={dp[i1]+nums[i],if dp[i1] > 0dp[i1]+nums[i],if dp[i1] = 0nums[i],if dp[i1] < 0dp[i] = \begin{cases} dp[i - 1] + nums[i], & \text{if $dp[i-1]$ > 0} \\ dp[i-1] + nums[i], & \text{if $dp[i-1]$ = 0} \\ nums[i], & \text{if $dp[i-1]$ < 0} \end{cases}

💥 NOTE:

  • dp[i1]>0dp[i-1]>0 时,dp[i]=dp[i1]dp[i]=dp[i-1] 成立么❓ 答案是否定的,走到哪里都不能忘记初心,就是 dpdp 状态定义,dp[i]dp[i] 不能没有 nums[i]nums[i] 的参与;

  • dp[i1]=0dp[i-1]=0 时,两者都是 00,相当于白忙乎了,dp[i]=dp[i1]+nums[i]dp[i]=dp[i-1] + nums[i]dp[i]=nums[i]dp[i]=nums[i] 均可,同理 dp[i]dp[i] 不能没有 nums[i]nums[i] 的参与;

  • dp[i1]<0dp[i-1]<0 时,dp[i1]dp[i-1]nums[i]nums[i] 是负向影响,故要将 dp[i1]dp[i-1] 舍去,仅保留 nums[i]nums[i] 保证连续,还能保证 dp[i]dp[i] 是以 nums[i]nums[i] 结尾的子数组的最大元素之和。


nums[i]<0nums[i] < 0 时,

dp[i]={dp[i1]+nums[i],if dp[i1] > 0nums[i],if dp[i1] = 0nums[i],if dp[i1] < 0dp[i] = \begin{cases} dp[i - 1] + nums[i], & \text{if $dp[i-1]$ > 0} \\ nums[i], & \text{if $dp[i-1]$ = 0} \\ nums[i], & \text{if $dp[i-1]$ < 0} \end{cases}

💥NOTE:

  • dp[i1]>0dp[i-1]>0 时,dp[i1]dp[i-1]nums[i]nums[i] 是正向影响,故要在 nums[i]nums[i] 基础上累加 dp[i1]dp[i-1],方能保证 dpdp 是以 nums[i]nums[i] 结尾的子数组的最大元素之和;

  • dp[i1]=0dp[i-1]=0 时,dp[i]=dp[i1]+nums[i]dp[i]=dp[i-1] + nums[i]dp[i]=nums[i]dp[i]=nums[i],同理 dp[i]dp[i] 不能没有 nums[i]nums[i] 的参与;

  • dp[i1]<0dp[i-1]<0 时,dp[i1]dp[i-1]nums[i]nums[i] 是负向影响,故要将 dp[i1]dp[i-1] 舍去,仅保留 nums[i]nums[i] 保证连续,还能保证 dp[i]dp[i] 是以 nums[i]nums[i] 结尾的子数组的最大元素之和。

综上所述,

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

3、确定 dp 初始状态

dp[0]dp[0] 就是以 nums[0]nums[0] 结尾的子数组最大和,即 dp[0]=nums[0]dp[0]=nums[0]

4、确定遍历顺序

i=1i=1 遍历到 n1n-1 即可。

5、确定最终返回值

重温初心:dp[i]dp[i] 是以 nums[i]nums[i] 结尾的子数组最大和,故 dp[n1]dp[n-1] 仅仅是以 nums[n1]nums[n-1] 元素结尾的子数组的最大和,并不代表全局的最大子数组之和,故需要全局对比,即,max(...dp)max(...dp)

NOTE: ...dp...dp 代表将数组中所有元素按照其索引按顺序传到 maxmax 函数。

6、代码示例

/**
 * 空间复杂度 O(n),n是nums数组的长度
 * 时间复杂度 O(n)
 */
function maxSubArray(nums: number[]): number {
    const length = nums.length;
    const dp = Array.from({ length }, () => 0);
    dp[0] = nums[0];

    for(let i = 1; i < length; i++){
        dp[i] = Math.max(dp[i - 1] + nums[i], nums[i]);
    }

    return Math.max(...dp);
};

局部优化:一遍计算 dpdp 状态数组,一遍计算并保存最大值。

/**
 * 空间复杂度 O(n),n是nums数组的长度
 * 时间复杂度 O(n)
 */
function maxSubArray(nums: number[]): number {
    const length = nums.length;
    const dp = Array.from({ length }, () => 0);
    let ans = dp[0] = nums[0];

    for(let i = 1; i < length; i++){
        dp[i] = Math.max(dp[i - 1] + nums[i], nums[i]);
        ans = Math.max(ans, dp[i]);
    }

    return ans;
}

空间复杂度优化:dp[i]dp[i] 仅与 dp[i1]dp[i-1] 相关,故可将其状态数组进行压缩。

/**
 * 空间复杂度 O(1)
 * 时间复杂度 O(n),n是nums数组的长度
 */
function maxSubArray(nums: number[]): number {
    let dp = nums[0];
    let ans = nums[0];

    for(let i = 1, len = nums.length; i < len; i++){
        dp = Math.max(dp + nums[i], nums[i]);
        ans = Math.max(ans, dp);
    }

    return ans;
}

思维提升-前缀和

1. 暴力前缀和

观察【法1:纯暴力求解】思路,每次计算 nums[i:j]nums[i:j] 连续区域子数组元素之和时,都是通过循环遍历累加求得;后来发现可以缓存 nums[i:j]nums[i:j] 子数组元素之和,目的是可以 O(1)O(1) 时间级别计算出 nums[i:j+1]nums[i:j + 1] 子数组元素之和。

换一个思路,记数组 prefixSumsprefixSums,其中 prefixSums[i]prefixSums[i] 代表 nums[0:i]nums[0:i] 连续区域子数组元素之和。此时 nums[i:j]nums[i:j] 连续区域子数组元素之和为 prefixSum[j]prefixSim[i]+nums[i]prefixSum[j] - prefixSim[i] + nums[i]

其中,i[0,n),j[i,n)i \in [0, n),j \in [i, n)

/**
 * 空间复杂度 O(n),n是nums数组的长度
 * 时间复杂度 O(n^2)
 */
function maxSubArray(nums: number[]): number {
    const len = nums.length;
    const prefixSums = new Array(len).fill(nums[0]);
    for (let i = 1; i < len; i++) {
        prefixSums[i] = prefixSums[i - 1] + nums[i];
    }

    let maxSum = Number.MIN_SAFE_INTEGER;
    for(let i = 0; i < len; i++) {
        for(let j = i; j < len; j++) {
            maxSum = Math.max(maxSum, prefixSums[j] - prefixSums[i] + nums[i]);
        }
    }

    return maxSum;
 };

2. 时间优化

前缀和(暴力版)与【法1:纯暴力求解 O(n2)O(n^2) 那个算法】有异曲同工之妙,但是时间与空间复杂度依然没有得到优化。换一个角度思考问题:我们在计算前缀和时,可以使用两个临时变量,即

minPrefixSumminPrefixSum:前缀和最小值,计算方法:当前 minPrefixSumminPrefixSum 与当前前缀和 prefixSums[i1]prefixSums[i - 1] 的最小值,即,minPrefixSum=min(minPrefixSum,prefixSums[i1])minPrefixSum = min(minPrefixSum, prefixSums[i - 1])

maxPrefixSummaxPrefixSum:前缀和最大值,计算方法:当前 maxPrefixSummaxPrefixSum 与当前前缀和 prefixSums[i]prefixSums[i]minPrefixSumminPrefixSum 之差的最大值,即,maxPrefixSum=max(maxPrefixSum,prefixSums[i]minPrefixSum)maxPrefixSum = max(maxPrefixSum, prefixSums[i] - minPrefixSum)

/**
 * 空间复杂度 O(n),n是nums数组的长度
 * 时间复杂度 O(n)
 */
function maxSubArray(nums: number[]): number {
    const prefixSums = new Array(nums.length).fill(nums[0]);
    let maxPrefixSum = nums[0];
    let minPrefixSum = 0;

    for(let i = 1; i < nums.length; i++){
        prefixSums[i] = prefixSums[i - 1] + nums[i];
        minPrefixSum = Math.min(minPrefixSum,  prefixSums[i - 1]);
        maxPrefixSum = Math.max(maxPrefixSum, prefixSums[i] - minPrefixSum);
    }

    return maxPrefixSum;
};

3. 空间优化

空间复杂度优化:prefixSums[i]prefixSums[i] 仅与 prefixSums[i1]prefixSums[i-1] 相关,故可将其状态数组进行压缩。

/**
 * 空间复杂度 O(1)
 * 时间复杂度 O(n),n是nums数组的长度
 */
function maxSubArray(nums: number[]): number {
    let currSum = nums[0];
    let maxSum = nums[0];
    let minSum = 0;

    for(let i = 1; i < nums.length; i++){
        minSum = Math.min(minSum, currSum);
        currSum = currSum + nums[i];
        maxSum = Math.max(maxSum, currSum - minSum);
    }

    return maxSum;
};