删除一次得到子数组最大和 图解题解
这道题到底在问什么
- 输入
- 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
最优解:一步一步想明白
- 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++ 跟着练。
面试官可能追问
状态怎么设,转移是什么?+
给每个结尾位置记两种状态:dp0[i] 是以 arr[i] 结尾、没删过的最大和,dp0[i] = max(dp0[i-1] + arr[i], arr[i]),就是 Kadane;dp1[i] 是以 arr[i] 结尾、删过一次的最大和,dp1[i] = max(dp0[i-1], dp1[i-1] + arr[i]),第一项删当前、第二项保留当前删更早。答案是所有 dp0[i]、dp1[i] 的最大值,复杂度 O(n)。
能不能把空间压到 O(1)?+
能。dp0[i] 只依赖 dp0[i-1],dp1[i] 只依赖 dp0[i-1] 和 dp1[i-1],都是上一格,所以不必开数组,留两个滚动变量边扫边更新即可,空间从 O(n) 降到 O(1)。参考代码为了清晰用了 left、right 两个数组,是 O(n)。
这题和普通的最大子数组 lc53 是什么关系?+
dp0 这一行就是 lc53 的 Kadane,lc53 是本题去掉删除能力的特例。本题在它之上加了一维「是否已经用掉删除机会」,变成 dp0、dp1 两行一起推。很多带一次操作的题都能套这个加一维的套路。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄声线把 删除一次得到子数组最大和 一步步讲透(全站 0 节语音精讲,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。