删除一次得到子数组最大和 图解题解
这道题到底在问什么
- 输入
- arr = [1,-2,0,3]
- 输出
- 4(删掉 -2 得 [1,0,3])
- 输入
- arr = [1,-2,-2,3]
- 输出
- 3(直接选 [3],一个都不删)
- 输入
- 本节演示 arr = [1,-2,3,-2,4,-1,2]
- 输出
- 8
最优解:为什么这么做
一句话答案:LeetCode 1186 删除一次得到子数组最大和用两状态动态规划:dp0 记不删的最大和、dp1 记删过一次的最大和,dp1 靠删当前接 dp0 或留当前接 dp1,全程取最大。时间 O(n)、空间 O(1)。
删一个元素,到底在所有子数组里挑什么
给数组 arr,选一段连续子数组,可删掉其中至多一个元素(可删可不删),删完不能空,求其中最大的元素和。答案得兼顾一个都不删、和恰好删一个。arr = [1,-2,0,3] 删掉 -2 得 [1,0,3]、和 4;arr = [1,-2,-2,3] 却别删、选 [3] 更好。
为什么把每个删除位置都试一遍会白算
删哪个元素有 n 种选择(含不删),每定一个还要从头求一遍最大连续子数组和,一层套一层就是 O(n²)(大 O 记号,描述规模变大时运算次数怎么涨),n 到 10^5 就跑不动。慢在每换位置,前面那段最大和都从零重算。
两行 dp 各记什么,dp1 那一维为什么是「删过一次」
开两行 dp,也就是动态规划(以第 i 位结尾、删或不删的最大和记表复用)的表。dp0[i] 记「以第 i 个位置结尾、不删的最大子数组和」,就是经典最大子数组(LeetCode 53 的 Kadane:每步在「接前段」和「另起一段」里挑大)。dp1[i] 在 dp0 上多挂一维「已删过一次」,记「以第 i 个位置结尾、恰好删过一个元素的最大和」。dp0、dp1 是两个状态(状态 = 描述当前一步局面的量),答案是两行里的最大值。
dp1 每格为什么在「删当前」和「留当前」两条里取大
dp0 就是 Kadane 那条老式子。新东西在 dp1 的转移(转移 = 由前一格答案推出当前格的式子):已删过一次的最大和有两条来路。一是删的正好是当前 arr[i],删完就退回前一格「还没删」的 dp0[i-1]——等于拿前面不删的一段跳过当前接上;二是当前 arr[i] 留着、删除在更早,接前一格「已删过」的 dp1[i-1] 再加 arr[i]。取大:dp1[i] = max(dp0[i-1], dp1[i-1] + arr[i])。dp1[0] 非法(删完为空),拿极小哨兵(哨兵=比任何真实和都小的不可能标记)兜住、别当 0。
拿 [1,-2,3,-2,4,-1,2] 把两行 dp 填到 8
拿题面演示的 arr = [1,-2,3,-2,4,-1,2] 填一遍。dp0 一路 Kadane 扫出 1、-1、3、1、5、4、6。dp1 逐格取两条来路的大者:dp1[1]=max(1, 非法)=1;dp1[2]=max(-1, 1+3)=4;dp1[3]=max(3, 4-2)=3;dp1[4]=max(1, 3+4)=7;dp1[5]=max(5, 7-1)=6;dp1[6]=max(4, 6+2)=8。两行最大 dp1[6]=8 即答案:删掉 [3,-2,4,-1,2] 中间的 -2、接成 [3,4,-1,2]。
dp1[0] 当成 0,全负数组的答案为什么会凭空变大
dp1[0] 是「一个元素还要删」,删完为空、非法。图省事写成 0 就出事:arr 全为负时答案本该是最大的负数,可 dp1[0]=0 会顺着转移把 0 一路抬上去,答案凭空变大。另一处:dp1「删当前」接 dp0[i-1] 而非 dp1[i-1],接错就成删两次。两行各扫一遍、每格常数运算,时间 O(n);每格只用前一格,两个滚动变量(只留最近一格)轮替,空间压到 O(1)。
▶ 动画逐步走查(共 22 步)——想跟着动画一帧帧对照就展开
- 3记住这两行:dp0 是不删的最大子数组,dp1 在它基础上多一维「已经删过一次」,dp1 要么删掉当前元素接 dp0,要么保留当前元素接 dp1。
- 4这是一张两行七列的 dp 表。上面一行 dp0 记「以这个位置结尾、一个都没删」的最大子数组和,下面一行 dp1 记「以这个位置结尾、已经删过一次」的最大和。列号 0 到 6 对应数组的每个位置。我们从左往右一列一列填,每填好一列就拿这两格去刷新全局答案。最终答案不是某个固定格子,而是整张表里所有数的最大值。
- 5先填第 0 列,也就是只看第一个数 1。不删的话,以它结尾的最大和就是它自己,dp0[0] = 1。删一次呢?这一段只有它一个元素,删掉就剩空了,题目不允许,所以 dp1[0] 是非法的,用一个极小的哨兵值占位,画面里用空集符号表示。答案先初始化成 dp0[0] = 1。
- 6来到第 1 列,当前数是 -2。先算不删的 dp0:要么把它接到前一格那段后面,得 dp0[0] + -2 = 1 + -2 = -1;要么从它自己重新起一段,得 -2。两者取大,dp0[1] = -1。前面那段还能带来正贡献,接上更好。
- 7再算删过一次的 dp1。两个来源:第一,把当前的 -2 删掉,那这段就退回到前一格不删的 dp0[0] = 1;第二,保留当前的 -2,说明删除发生在更早,接前一格已删的 dp1[0],也就是 非法。前一格的 dp1 还非法,所以只能走第一种、删掉当前元素。dp1[1] = 1,赢家是「删掉当前的 -2」。
- 8这一列两格都算好了,拿它们去刷新全局答案。原来的 ans 是 1,和 dp0[1] = -1、dp1[1] = 1 一起取最大,得 1。没有超过之前的 1,答案保持不变。
- 9来到第 2 列,当前数是 3。先算不删的 dp0:要么把它接到前一格那段后面,得 dp0[1] + 3 = -1 + 3 = 2;要么从它自己重新起一段,得 3。两者取大,dp0[2] = 3。前面那段是个累赘,丢掉、从当前位置重开更好。
- 10再算删过一次的 dp1。两个来源:第一,把当前的 3 删掉,那这段就退回到前一格不删的 dp0[1] = -1;第二,保留当前的 3,说明删除发生在更早,接前一格已删的 dp1[1],也就是 1 + 3 = 4。两者取大。dp1[2] = 4,赢家是「保留 3、删在更早处」。
- 11这一列两格都算好了,拿它们去刷新全局答案。原来的 ans 是 1,和 dp0[2] = 3、dp1[2] = 4 一起取最大,得 4。比之前更大,答案被刷新成 4。
- 12来到第 3 列,当前数是 -2。先算不删的 dp0:要么把它接到前一格那段后面,得 dp0[2] + -2 = 3 + -2 = 1;要么从它自己重新起一段,得 -2。两者取大,dp0[3] = 1。前面那段还能带来正贡献,接上更好。
- 13再算删过一次的 dp1。两个来源:第一,把当前的 -2 删掉,那这段就退回到前一格不删的 dp0[2] = 3;第二,保留当前的 -2,说明删除发生在更早,接前一格已删的 dp1[2],也就是 4 + -2 = 2。两者取大。dp1[3] = 3,赢家是「删掉当前的 -2」。
- 14这一列两格都算好了,拿它们去刷新全局答案。原来的 ans 是 4,和 dp0[3] = 1、dp1[3] = 3 一起取最大,得 4。没有超过之前的 4,答案保持不变。
- 15来到第 4 列,当前数是 4。先算不删的 dp0:要么把它接到前一格那段后面,得 dp0[3] + 4 = 1 + 4 = 5;要么从它自己重新起一段,得 4。两者取大,dp0[4] = 5。前面那段还能带来正贡献,接上更好。
- 16再算删过一次的 dp1。两个来源:第一,把当前的 4 删掉,那这段就退回到前一格不删的 dp0[3] = 1;第二,保留当前的 4,说明删除发生在更早,接前一格已删的 dp1[3],也就是 3 + 4 = 7。两者取大。dp1[4] = 7,赢家是「保留 4、删在更早处」。
- 17这一列两格都算好了,拿它们去刷新全局答案。原来的 ans 是 4,和 dp0[4] = 5、dp1[4] = 7 一起取最大,得 7。比之前更大,答案被刷新成 7。
- 18来到第 5 列,当前数是 -1。先算不删的 dp0:要么把它接到前一格那段后面,得 dp0[4] + -1 = 5 + -1 = 4;要么从它自己重新起一段,得 -1。两者取大,dp0[5] = 4。前面那段还能带来正贡献,接上更好。
- 19再算删过一次的 dp1。两个来源:第一,把当前的 -1 删掉,那这段就退回到前一格不删的 dp0[4] = 5;第二,保留当前的 -1,说明删除发生在更早,接前一格已删的 dp1[4],也就是 7 + -1 = 6。两者取大。dp1[5] = 6,赢家是「保留 -1、删在更早处」。
- 20这一列两格都算好了,拿它们去刷新全局答案。原来的 ans 是 7,和 dp0[5] = 4、dp1[5] = 6 一起取最大,得 7。没有超过之前的 7,答案保持不变。
- 21来到第 6 列,当前数是 2。先算不删的 dp0:要么把它接到前一格那段后面,得 dp0[5] + 2 = 4 + 2 = 6;要么从它自己重新起一段,得 2。两者取大,dp0[6] = 6。前面那段还能带来正贡献,接上更好。
- 22再算删过一次的 dp1。两个来源:第一,把当前的 2 删掉,那这段就退回到前一格不删的 dp0[5] = 4;第二,保留当前的 2,说明删除发生在更早,接前一格已删的 dp1[5],也就是 6 + 2 = 8。两者取大。dp1[6] = 8,赢家是「保留 2、删在更早处」。
- 23这一列两格都算好了,拿它们去刷新全局答案。原来的 ans 是 7,和 dp0[6] = 6、dp1[6] = 8 一起取最大,得 8。比之前更大,答案被刷新成 8。
- 24整张表填满了,所有 dp0、dp1 里最大的是 dp1[6] = 8,这就是答案 8。顺着它倒推:它来自保留末尾的 2、再往前一路保留,删除其实发生在那个 -2 上,把原来的 [3,-2,4,-1,2] 删掉中间的 -2,接成 [3,4,-1,2],和正好是 8。一个负数卡在两段正数中间,删掉它把左右接通,这就是删除带来的收益。
⚠️ 容易写错的地方
✗ 错:把「删一次」理解成「必须删」,只算 dp1 忘了 dp0
✓ 对:删除是可选的,答案要同时在 dp0 和 dp1 里取最大
数组全是正数时最优是一个都不删,只看 dp1 会漏掉这种情况算错
✗ 错:dp1[0] 当成 0,以为删掉唯一元素能得空数组的 0
✓ 对:dp1[0] 是非法的,设成极小哨兵
删完不能为空。若把它当 0,遇到 [-1,-1,-1,-1] 会错答成 0,正确答案是 -1
✗ 错:ans 初值设成 0 或正数
✓ 对:初值要设成极小的负数(负的 2 的 30 次方)或直接用 dp0[0]
数组可能全负,答案本身就是负数;初值设 0 会让全负输入错返 0
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
class Solution:
def maximumSum(self, arr: List[int]) -> int:
n = len(arr)
left = [0] * n
right = [0] * n
s = 0
for i, x in enumerate(arr):
s = max(s, 0) + x
left[i] = s
s = 0
for i in range(n - 1, -1, -1):
s = max(s, 0) + arr[i]
right[i] = s
ans = max(left)
for i in range(1, n - 1):
ans = max(ans, left[i - 1] + right[i + 1])
return ansC++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
class Solution {
public:
int maximumSum(vector<int>& arr) {
int n = arr.size();
int left[n];
int right[n];
for (int i = 0, s = 0; i < n; ++i) {
s = max(s, 0) + arr[i];
left[i] = s;
}
for (int i = n - 1, s = 0; ~i; --i) {
s = max(s, 0) + arr[i];
right[i] = s;
}
int ans = *max_element(left, left + n);
for (int i = 1; i < n - 1; ++i) {
ans = max(ans, left[i - 1] + right[i + 1]);
}
return ans;
}
};Java
import java.util.*;
class Solution {
public int maximumSum(int[] arr) {
int n = arr.length;
int[] left = new int[n];
int[] right = new int[n];
int ans = -(1 << 30);
for (int i = 0, s = 0; i < n; ++i) {
s = Math.max(s, 0) + arr[i];
left[i] = s;
ans = Math.max(ans, left[i]);
}
for (int i = n - 1, s = 0; i >= 0; --i) {
s = Math.max(s, 0) + arr[i];
right[i] = s;
}
for (int i = 1; i < n - 1; ++i) {
ans = Math.max(ans, left[i - 1] + right[i + 1]);
}
return ans;
}
}复杂度
时间
O(n)
不管是动画的一遍两状态扫,还是参考代码的正向、反向、枚举删除位三遍线性扫,都只跟数组长度成正比,n 到 10^5 也轻松
空间
O(n)
按峰值算:参考代码开了 left 和 right 两个长度 n 的数组,所以是 O(n)。两状态 DP 因为每格只依赖前一格,可以只留两个滚动变量压到 O(1)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 删除一次得到子数组最大和 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
1186 和最大子数组(LeetCode 53)是什么关系?+
1186 就是 53 加了一维「是否删过」。53 只求最大连续子数组和,一趟 Kadane(每步在「接前段」和「另起一段」里取大)扫到底即可。1186 允许删一个元素,于是在「不删的最大和」那行之上再挂一行「已删过一次的最大和」,前一行和 53 一模一样,后一行是加装的第二个状态。会了 53,1186 只需多想清删过那行的两条来路。
参考代码用正向、反向两遍扫,和这里的两状态写法是一回事吗?+
是等价的两种写法。参考代码先正向 Kadane 求 left[i](以 i 结尾的最大子数组和),再反向求 right[i](以 i 开头的最大和),最后枚举被删位置 i,用 left[i-1] + right[i+1] 把删掉 arr[i] 后左右两段接起来取大。两状态写法则一遍从左扫到右,把「删一次」直接编进删过那一行。思路相同、复杂度都是 O(n),前缀后缀写法直观对应「删中间、接两头」,两状态写法省一趟扫描。
空间能不能压到 O(1)?+
能。不删那行的每格只用到它自己的前一格,删过那行的每格用到不删的前一格和删过的前一格,都只看前一格,更早的用不上。所以不必开两个长度 n 的数组,各留一个变量存前一格的值,边扫边更新、顺手拿全局最大值刷答案,空间从 O(n) 降到 O(1)。参考代码的 left、right 数组为了「删中间接两头」要回看,才占 O(n);两状态写法没有回看,天然 O(1)。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 删除一次得到子数组最大和 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。