最大交替子序列和 图解题解
这道题到底在问什么
- 输入
- nums = [4,2,5,3]
- 输出
- 7 最优子序列 [4,2,5] = (4 + 5) - 2 = 7
- 输入
- nums = [6,2,1,2,5]
- 输出
- 10 最优子序列 [6,1,5] = (6 + 5) - 1 = 10
最优解:为什么这么做
一句话答案:LeetCode 1911 最大子序列交替和:双状态动态规划,g 记加项收尾、f 记减项收尾的最大交替和,加减必须轮流所以两行交叉转移,每个数进表或照抄,答案取 max(f[n],g[n]),时间 O(n)、空间 O(1)。
交替和的加减号,到底跟着哪个下标走
给数组 nums,挑一个子序列(按原顺序跳着选),重新从 0 编号,偶数位相加、奇数位相减叫交替和,求最大值。加减号跟重编号后的位置走:[6,2,1,2,5] 里挑 [6,1,5],1 原来排第 2 位(原数组也从 0 数),重编号后落到第 1 位成了减项,(6+5)-1=10,与示例二一致。
子序列有 2ⁿ 条,挨条算交替和为什么走不通
每个数选或不选,n 个数就是 2ⁿ 条子序列,几十个数就数不完。而且前面一段怎么选最好,在后面每条分支里被重算,这份成绩不随后面的数改变,存下来复用就是动态规划(本题存「加项或减项收尾」两行成绩,后面的数直接在表上接)。
为什么一行状态记不住,非得拆成 f、g 两行
只记「前 i 个数的最大交替和」推不下去:下一个数该加该减,取决于已选的最后一个是加项还是减项。于是把状态(描述算到哪一步的那组量)按收尾拆成两行:g[i] 记前 i 个数里加项收尾的最大交替和,f[i] 记减项收尾的最大。空前缀 f[0]=g[0]=0 代表一个数都没选,第一个数走 f[0]+x 就能进场当加项。
想让加项进场,为什么必须从 f 行接过来
转移(由上一列推出这一列的式子)每行两条来路:想让当前数 x 当加项,前一个被选的数必须是减项,所以 g[i]=max(f[i-1]+x, g[i-1]),后一项是不选、照抄左边;f 行对称,f[i]=max(g[i-1]-x, f[i-1])。加号行的来源在减号行、反过来也一样,「交替」说的就是这件事。减一个数当下变小,这笔亏存在 f 行里,等大数出现接回加号行就赚回来。
拿示例 [6,2,1,2,5] 把两行表逐列填一遍
f[0]=g[0]=0。来 6:g[1]=max(0+6,0)=6,f[1]=max(0-6,0)=0。来 2:g[2]=max(0+2,6)=6,f[2]=max(6-2,0)=4。来 1:g[3]=max(4+1,6)=6,f[3]=max(6-1,4)=5。来 2:g[4]=max(5+2,6)=7,f[4]=max(6-2,5)=5。来 5:g[5]=max(5+5,7)=10,f[5]=max(7-5,5)=5。
答案 max(f[5],g[5])=10,和示例二对上。倒着追:g[5]=f[4]+5,f[4] 照抄 f[3],f[3]=g[2]-1,g[2] 照抄 g[1],即加 6、减 1、加 5,正是子序列 [6,1,5]。
Java 用 int 存交替和,为什么会悄悄得出负数
一遍循环、每个数两次取较大,时间 O(n)(操作次数随数组长度线性涨);两个 n+1 长的数组占 O(n),每列只靠上一列,换成两个滚动变量(只留最新一列)可压到 O(1)。交替和能累到十的十次方量级(n 和每个数都能到十万,十万×十万),超出 Java int 约 2.1×10⁹ 的上限就变负数,得用 long,C++ 用 long long,Python 不受限。边界:单个数就返回它本身(g[1] 把它当加项收下);收尾取 f[n]、g[n] 较大者,本题全是正数、g[n] 不低于 f[n],只回 g[n] 碰巧也对,按 max 写最稳。
▶ 动画逐步走查(共 24 步)——想跟着动画一帧帧对照就展开
- 3记牢这两行状态:g 是加号收尾、f 是减号收尾。想加就从 f 接过来加,想减就从 g 接过来减,不想动就照抄上一列。下面从空前缀开始,一列一列往右填。
- 4先搭表。上面一行是 g 加结尾,下面一行是 f 减结尾,列头是空加上五个数 6、2、1、2、5。最左边这一列代表空前缀,也就是一个数都还没选,这时候不管说加号收尾还是减号收尾,值都是 0,先把这两格填 0。接下来从左往右,每来一个数就填一列。
- 5轮到第 1 个数 6,先填上面的 g 加结尾这格。想让 6 当加号进来,它前面必须是减号收尾,所以看下面那格 f[0] 等于 0,加上 6 得 6。要是不选 6,就照抄左边的 g[0] 等于 0。两个黄格就是它的两个来源,取较大的那个。
- 66 和 0 里取较大,g[1] 落子 6。这一步真的把 6 当加号选了进来,加号收尾的成绩涨到了 6。
- 7同一个数 6,再填下面的 f 减结尾这格。想让 6 当减号进来,它前面必须是加号收尾,所以看上面那格 g[0] 等于 0,减去 6 得 -6。要是不选它,就照抄左边的 f[0] 等于 0。还是两个来源取较大。
- 8-6 和 0 里取较大,f[1] 落子 0。减掉 6 反而更亏,不如保留原来的 0,所以这格不动。
- 9轮到第 2 个数 2,先填上面的 g 加结尾这格。想让 2 当加号进来,它前面必须是减号收尾,所以看下面那格 f[1] 等于 0,加上 2 得 2。要是不选 2,就照抄左边的 g[1] 等于 6。两个黄格就是它的两个来源,取较大的那个。
- 102 和 6 里取较大,g[2] 落子 6。不选 2 更划算,加号收尾的成绩保持在 6 没动。
- 11同一个数 2,再填下面的 f 减结尾这格。想让 2 当减号进来,它前面必须是加号收尾,所以看上面那格 g[1] 等于 6,减去 2 得 4。要是不选它,就照抄左边的 f[1] 等于 0。还是两个来源取较大。
- 124 和 0 里取较大,f[2] 落子 4。这一步把 2 当减号减掉了,尽管当下变小,却给后面再接一个大加号铺好了路。
- 13轮到第 3 个数 1,先填上面的 g 加结尾这格。想让 1 当加号进来,它前面必须是减号收尾,所以看下面那格 f[2] 等于 4,加上 1 得 5。要是不选 1,就照抄左边的 g[2] 等于 6。两个黄格就是它的两个来源,取较大的那个。
- 145 和 6 里取较大,g[3] 落子 6。不选 1 更划算,加号收尾的成绩保持在 6 没动。
- 15同一个数 1,再填下面的 f 减结尾这格。想让 1 当减号进来,它前面必须是加号收尾,所以看上面那格 g[2] 等于 6,减去 1 得 5。要是不选它,就照抄左边的 f[2] 等于 4。还是两个来源取较大。
- 165 和 4 里取较大,f[3] 落子 5。这一步把 1 当减号减掉了,尽管当下变小,却给后面再接一个大加号铺好了路。
- 17轮到第 4 个数 2,先填上面的 g 加结尾这格。想让 2 当加号进来,它前面必须是减号收尾,所以看下面那格 f[3] 等于 5,加上 2 得 7。要是不选 2,就照抄左边的 g[3] 等于 6。两个黄格就是它的两个来源,取较大的那个。
- 187 和 6 里取较大,g[4] 落子 7。这一步真的把 2 当加号选了进来,加号收尾的成绩涨到了 7。
- 19同一个数 2,再填下面的 f 减结尾这格。想让 2 当减号进来,它前面必须是加号收尾,所以看上面那格 g[3] 等于 6,减去 2 得 4。要是不选它,就照抄左边的 f[3] 等于 5。还是两个来源取较大。
- 204 和 5 里取较大,f[4] 落子 5。减掉 2 反而更亏,不如保留原来的 5,所以这格不动。
- 21轮到第 5 个数 5,先填上面的 g 加结尾这格。想让 5 当加号进来,它前面必须是减号收尾,所以看下面那格 f[4] 等于 5,加上 5 得 10。要是不选 5,就照抄左边的 g[4] 等于 7。两个黄格就是它的两个来源,取较大的那个。
- 2210 和 7 里取较大,g[5] 落子 10。这一步真的把 5 当加号选了进来,加号收尾的成绩涨到了 10。
- 23同一个数 5,再填下面的 f 减结尾这格。想让 5 当减号进来,它前面必须是加号收尾,所以看上面那格 g[4] 等于 7,减去 5 得 2。要是不选它,就照抄左边的 f[4] 等于 5。还是两个来源取较大。
- 242 和 5 里取较大,f[5] 落子 5。减掉 5 反而更亏,不如保留原来的 5,所以这格不动。
- 25表填满了。最后一列两格,g[5] 等于 10,f[5] 等于 5。最终答案要在这两个里取较大,因为子序列既可以加号收尾也可以减号收尾。这里 10 更大,所以答案是 10。加号收尾几乎总是更划算,因为最后多加一个正数不会吃亏。
- 26回头看这 10 是哪几个数凑的。g[5] 的 10 来自 f[4] 加 5,f[4] 一路照抄到 f[3] 的减 1,f[3] 的 5 来自 g[2] 减 1,g[2] 照抄到 g[1] 的加 6。串起来就是:先加 6,再减 1,最后加 5,正好对应子序列 [6,1,5],交替和 (6 + 5) - 1 = 10。填表其实就是在悄悄帮我们挑这三个数。
⚠️ 容易写错的地方
✗ 错:以为要枚举所有子序列再算交替和
✓ 对:用 f、g 两状态一趟线性 DP
子序列有指数多个,枚举会超时;只跟踪加号收尾和减号收尾两种最优,每个数常数时间决策即可
✗ 错:把结果一直存进 int
✓ 对:Java 用 long、C plus plus 用 long long
数组最长十的五次方、每个数最大十的五次方,交替和量级到十的十次方,超出 int 上限会溢出成负数
✗ 错:以为返回 g[n] 会漏掉减号收尾可能更优的解
✓ 对:正数约束下 g[n] 恒不小于 f[n],返回 g[n] 与取 max 等价,这里统一取 max 保持两状态对称
nums 每个数都是正数,任何减号收尾的子序列删掉最后一个被减的正数都会更大,所以最优解必以加号收尾;取 max 只是口径统一、更保守
✗ 错:算 g[i] 时用了本列刚更新的 f[i]
✓ 对:f[i] 和 g[i] 都只依赖上一列 i-1
两个转移读的都是 f[i-1]、g[i-1],若误用本列新值会把还没成立的状态提前算进去,结果偏大
完整代码(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 maxAlternatingSum(self, nums: List[int]) -> int:
n = len(nums)
f = [0] * (n + 1)
g = [0] * (n + 1)
for i, x in enumerate(nums, 1):
f[i] = max(g[i - 1] - x, f[i - 1])
g[i] = max(f[i - 1] + x, g[i - 1])
return max(f[n], g[n])C++
#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:
long long maxAlternatingSum(vector<int>& nums) {
int n = nums.size();
vector<long long> f(n + 1), g(n + 1);
for (int i = 1; i <= n; ++i) {
f[i] = max(g[i - 1] - nums[i - 1], f[i - 1]);
g[i] = max(f[i - 1] + nums[i - 1], g[i - 1]);
}
return max(f[n], g[n]);
}
};Java
import java.util.*;
class Solution {
public long maxAlternatingSum(int[] nums) {
int n = nums.length;
long[] f = new long[n + 1];
long[] g = new long[n + 1];
for (int i = 1; i <= n; ++i) {
f[i] = Math.max(g[i - 1] - nums[i - 1], f[i - 1]);
g[i] = Math.max(f[i - 1] + nums[i - 1], g[i - 1]);
}
return Math.max(f[n], g[n]);
}
}复杂度
时间
O(n)
n 是数组长度。只从左到右扫一遍,每个数在 f 和 g 两行各做一次取较大、常数操作,总量随 n 线性增长
空间
O(n)
本节代码开了两个长度 n 加 1 的数组 f、g,空间随 n 线性增长;又因为每列只依赖上一列的 f[i-1]、g[i-1],可以只留两个滚动变量,把空间进一步压到 O(1)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 最大交替子序列和 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
参考代码先算 f[i] 再算 g[i],顺序换一下会错吗?+
不会。f[i] 和 g[i] 都只读上一列的 f[i-1]、g[i-1],不读本列刚算出的值,两行谁先算都一样。但压成两个滚动变量后要小心:必须先把旧值同时取出再更新,Python 里可写 f, g = max(g-x, f), max(f+x, g) 这类同时赋值,否则先更新的那个会把另一个要用的旧值覆盖掉。
f[0]=0 说的是「减项收尾」,可空前缀一个数都没选,这不矛盾吗?+
把 f[i]、g[i] 理解成「以该种方式收尾,或者干脆什么都不选」的最大值就顺了。空前缀的交替和是 0,它躺在两行里当保底:第一个数想当加项,走 f[0]+x 这条路就能直接进场;而任何把成绩压到 0 以下的选择(比如开局就减一个数)都会被 max 里的旧值挡掉,所以 f、g 全程不会是负数。
听说这题有一行贪心解法,和双状态 DP 是什么关系?+
等价。把交替和拆开看,答案等于 nums[0] 加上所有相邻上涨的差之和,即每逢 nums[i] 大于 nums[i-1] 就收下差值,和买卖股票的最佳时机 II 收集每段上涨的思路同源,g、f 两行正好对应「持有加项」「已减出场」两种持仓状态。DP 版胜在通用:不依赖「收益可拆成相邻差」这条性质,约束换个花样也照样能推。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 最大交替子序列和 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。