#032. 编程二:花束、跳跃与拆分 DP
#学习目标:把陌生的 DP 拆成熟悉的模板
源题库的编程部分里,动态规划题占比最高,而且题面千姿百态:花店扎花束、硬币上滴水、上楼梯、跳跃数组、对元素做 k 次操作、把数组切成 n 段、子集能否凑出目标数、电影排片、文件占位……逐道硬背是不可能的。本章的做法是给它们一个共同的分析框架——「状态设计三问」——然后把十三道源题全部放进框架里过一遍。读完之后你再遇到陌生的 DP 题时的反应应当是:先问「最后一步是什么」,而不是盯着题面发呆。
本章同时是复杂度意识的训练场。同样一道楼梯题,朴素递归、记忆化、递推、矩阵快速幂的时间复杂度分别是指数、线性、线性、对数级;同样一道跳跃题,\(O(n^2)\) 的 DP 与 \(O(n)\) 的贪心都正确,但面试官会追问「能不能更快」。能在写代码之前先把复杂度阶梯说清楚,是通过编程轮的关键信号。
「问题的哪个局部快照足以决定后续」。设计状态的标准语言是描述最后一步:最后扎的一束花、最后跨的一级台阶、最后切的一刀。
从更小的状态推出当前状态的全部途径,要求互斥且完备(不重不漏),并且无后效性——未来的决策只依赖当前状态,不依赖到达路径。
最小的、不用转移就能确定的状态;以及最终答案落在哪个状态上(常见坑:答案不是 \(dp_n\) 而是全体状态的 max)。
#知识讲解:DP 状态设计三问
拿到任何一道可能是 DP 的题,依次问三个问题。第一问:最后一步是什么?以花束题为例,最后一步是「在某个前缀上扎完最后一束花」;以切段题为例,最后一步是「在某个位置切下最后一刀」。把最后一步用一句话说清楚,状态的维数和含义就自动浮现。第二问:化为子问题后,需要哪些信息?只保留会影响后续决策的信息(无后效性)。楼梯题里「还剩几阶」就够;跳跃题里「当前位置 \(i\)」就够,因为能跳多远由 \(arr_i\) 决定。第三问:边界在哪里、答案在哪里?楼梯题 \(f_0 = 1\)(原地不动算一种);LIS 的答案取所有 \(f_i\) 的最大值而不是 \(f_n\)——第三问是最容易翻车的一问。
怎么读:状态 \(i\) 的值,从所有能一步到达它的状态 \(j\) 中取最优;\(\mathrm{opt}\) 是 max 或 min,合法转移集合由题面的约束决定。几乎所有一维 DP 都能套进这个壳,差别只在「合法转移」长什么样。
状态只依赖前面常数个状态:楼梯(跨 1/2/3 阶)、斐波那契。转移 \(O(1)\),总复杂度 \(O(n)\),空间可滚动到 \(O(1)\)。
每个位置「选或不选」「用哪种方式选」:花束(末三位扎 AAA、末两位扎 AB、不扎)、子集和。转移枚举选择,总复杂度 \(O(n \times W)\) 或 \(O(n)\)。
枚举「最后一段」的起点:数组切段、两子表划分。转移 \(O(n)\) 个起点、配合前缀和 \(O(1)\) 查区间和,总复杂度 \(O(n^2)\) 或借助贪心/单调性降为 \(O(n)\)。
#知识讲解:线性递推的复杂度阶梯
「上 n 阶楼梯,每次跨 1、2 或 3 阶,有多少种走法」与「实现斐波那契并分析复杂度」是同一族题,把它们放在一起正好展示复杂度的四个档位。设走法数为 \(f_n\),最后一步跨 1、2 或 3 阶,三类互斥且完备:
怎么读:到第 \(n\) 阶的最后一步只能从第 \(n-1\)、\(n-2\)、\(n-3\) 阶跨过来,三类途径数直接相加。只允许跨 1 或 2 阶的版本就是斐波那契(\(f_n = f_{n-1} + f_{n-2}\)),三阶版本叫三阶斐波那契(tribonacci)。
朴素递归:直接按定义递归,\(O(\varphi^n)\) 级别的指数时间(\(\varphi \approx 1.618\),tribonacci 的底约 1.839)——重叠子问题被反复计算,n 到 40 就明显卡顿。
记忆化递归:递归加缓存,每个状态只算一次,\(O(n)\) 时间、\(O(n)\) 空间。
自底向上递推:循环填表,\(O(n)\) 时间;状态只依赖前三个值,空间滚动到 \(O(1)\)。这是面试的默认落点。
矩阵快速幂:把递推写成矩阵形式后用快速幂,\(O(\log n)\);对 n 达到 \(10^{18}\) 且要求取模的题面才是必需。
「到第 \(i\) 阶的走法数」这个状态不需要知道你是怎么到达第 \(i\) 阶的——任何一条到 \(i\) 的路径,对「从 \(i\) 继续往上走」的机会完全相同。反过来,如果跳跃游戏的代价取决于「上一跳跨了几步」,那状态就必须扩成二维 \((位置, 上一跳长度)\)。判断要不要扩维,就是在判断「未来会看哪些历史」。
#例题详解:花束与水滴
例题 1(花束 DP):给定长度为 n 的 0/1 数组,0 代表 A 型花、1 代表 B 型花。花束只能由数组中相邻的花扎成,类型有两种——AAA(三朵相邻的 A,售价 p)与 AB(一朵 A 一朵 B 相邻,售价 q)。花不必全部用完,求能扎出的最大总价值。
思路。最后一步只有三种:最后一束是 AAA(用了末三朵、且它们都是 0)、是 AB(用了末两朵、一 A 一 B,顺序不限)、或者末尾若干朵花根本不进任何花束。设 \(dp_i\) 为「只用前 \(i\) 朵花」能取得的最大价值,转移即为三者取最大:
方括号指示项不满足条件时取 \(-\infty\)(不合法)。关键在于「不扎」这一支保证了花可以剩下不用,且扎花的花必须相邻这一约束被「末 k 朵整体成束」编码进状态截断里。手算 \(f = [0,0,0,0,1]\)、\(p=10\)、\(q=6\):\(dp_3 = 10\)(前三朵扎 AAA),\(dp_5 = \max(dp_4, dp_3 + q) = 16\)——前三朵扎 AAA、第四五朵(A、B 相邻)扎 AB,组合优于任何单一方案。
代码。
long long maxBouquetValue(const vector<int>& f, long long p, long long q) {
int n = (int)f.size();
vector<long long> dp(n + 1, 0); // dp[i]:只用前 i 朵花的最大收益
for (int i = 1; i <= n; ++i) {
dp[i] = dp[i - 1]; // 第 i 朵不进任何花束
if (i >= 3 && !f[i-1] && !f[i-2] && !f[i-3])
dp[i] = max(dp[i], dp[i - 3] + p); // 末三朵 0,0,0 扎 AAA
bool ab = (f[i-1] == 0 && f[i-2] == 1) || (f[i-1] == 1 && f[i-2] == 0);
if (i >= 2 && ab)
dp[i] = max(dp[i], dp[i - 2] + q); // 末两朵一 A 一 B 扎 AB
}
return dp[n];
}
复杂度。时间 \(O(n)\),空间 \(O(n)\)(只需最近三个值,可滚动到 \(O(1)\))。
边界。\(n \lt 2\) 时无法扎任何花束,返回 0;AB 是否区分顺序(A 在前还是 B 在前)建议向面试官确认,本实现两种都允许;p、q 可为 0 或负(亏本的花束不扎,dp 自然处理)。
面试怎么讲。先把「最后一步」说出来:最后一束花要么 AAA、要么 AB、要么末尾的花不用。这一句就是状态设计的全部;剩下的转移只是机械展开。追问「能不能输出扎法」时,在 dp 上记录每步的选择再回溯即可。
例题 2(硬币上的水滴):若干列硬币按行堆叠成高度数组,向其上滴水。每枚水滴会停在两列硬币形成的凹处并不断堆高,直到水面与两侧较矮的硬币齐平后溢出。求一共能存住多少滴水(源题示例:能存 2 滴)。
思路(建模是本题主角)。把第 \(i\) 列硬币的高度记为 \(h_i\)。水滴落下后,位置 \(i\) 处的水位不会超过「左侧最高硬币」与「右侧最高硬币」的较小者,因为超过就会从矮的一侧溢出。于是每列能稳定停留的水滴数为
总滴数即 \(\sum_i d_i\)。源题的「能存 2 滴」对应最小非平凡例子:高度 \([2, 0, 2]\),中间列水位被两侧夹到 2,存 \(2 - 0 = 2\) 滴。\(L\) 与 \(R\) 各一次线性扫描即可预计算,这正是「前缀最值 + 后缀最值」的 DP。
代码。
long long totalDrops(const vector<int>& h) { // h[i]:第 i 列硬币叠放的高度
int n = (int)h.size();
if (n == 0) return 0;
vector<int> L(n), R(n); // 左/右两侧(含自身)最高的一列
L[0] = h[0];
for (int i = 1; i < n; ++i) L[i] = max(L[i-1], h[i]);
R[n-1] = h[n-1];
for (int i = n - 2; i >= 0; --i) R[i] = max(R[i+1], h[i]);
long long total = 0;
for (int i = 0; i < n; ++i)
total += max(0, min(L[i], R[i]) - h[i]); // 水位由两侧较矮者决定
return total;
}
复杂度。时间 \(O(n)\)(三次线性扫描),空间 \(O(n)\)。
边界。单调不降或单调不增的高度数组存不了水(每列 \(\min(L_i,R_i) = h_i\));\(n \le 2\) 必然为 0;高度为 0 的「空列」也照样参与计算——它只是矮墙。这道题与源题库另一道「接雨水」(海拔图存水)是同一模型的不同皮,后者还有双指针 \(O(1)\) 空间解法,完整推导放在 033. 编程三:滑动窗口与前缀和的例题里。
面试怎么讲。先用「两侧较矮者决定水位」这句物理直觉立住公式,再说明为什么逐列求和是安全的(每列独立)。主动提接雨水变体与双指针优化,是漂亮的收尾。
#例题详解:楼梯与跳跃
例题 3(楼梯):n 阶楼梯,每步可以跨 1、2 或 3 阶,共有多少种上法?另有一道源题要求实现斐波那契并分析时间复杂度——两题合并讲解。
思路。最后一步分类:从第 \(n-1\) 阶跨 1、从第 \(n-2\) 阶跨 2、从第 \(n-3\) 阶跨 3,互斥完备,得 tribonacci 递推(见知识讲解)。斐波那契正是「只允许跨 1 或 2 阶」的特例。复杂度问题的标准答案:朴素递归是指数级(斐波那契约 \(O(1.618^n)\),因为递归树每层近似分裂成两个子问题);递推填表是 \(O(n)\) 时间;只依赖前三个状态,空间滚动到 \(O(1)\);若 n 极大还要取模并用矩阵快速幂 \(O(\log n)\)。
代码。
long long countWays(int n) { // f(n) = f(n-1) + f(n-2) + f(n-3)
if (n <= 1) return 1; // 0 阶:站着不动,也是 1 种
vector<long long> f(n + 1);
f[0] = 1; f[1] = 1; f[2] = 2; // 小情形直接给边界
for (int i = 3; i <= n; ++i) f[i] = f[i-1] + f[i-2] + f[i-3];
return f[n];
}
复杂度。\(O(n)\) 时间、\(O(n)\) 空间;滚动后 \(O(1)\) 空间。
边界。\(f_0 = 1\) 的口径(空走法算一种)要在写代码前说出口;\(f_2 = 2\)(1+1 或 2)。tribonacci 增长底约 1.839,\(n = 80\) 已接近 long long 上限,大 n 必须配取模。
面试怎么讲。「最后一步跨几阶」这句分类语言是全部内容;复杂度分析按「朴素递归 → 填表 → 滚动 → 矩阵幂」四档报出,展示你知道每档存在的理由,而不是背复杂度。
例题 4(跳跃游戏·最小步数):数组 arr,站在下标 i 时可以跳到 \([i+1,\; i + arr_i]\) 中任意位置,求从下标 0 跳到最后一个位置的最少步数(源题给出了 DP 递推 \(\,dp_i = \min(dp_i, dp_{i+j}+1)\),\(j \in [1, arr_i]\))。
思路(DP 版)。设 \(dp_i\) 为到达下标 \(i\) 的最少步数,\(dp_0 = 0\)。正向转移:每个 \(i\) 把它能跳到的所有 \(j\) 松弛 \(dp_j = \min(dp_j, dp_i + 1)\)。源题的写法 \(\min(dp_i, dp_{i+j} + 1)\) 是从右往左倒推的同一件事(从终点往回看)。\(O(n^2)\),正确但慢。
思路(贪心版)。把「一跳之内能到的位置」看成一层:维护当前层的右边界 end 与下一层最远可达 farthest,遍历到层边界时步数加一并进入下一层。每层只需一遍扫描,总 \(O(n)\)。本质是 BFS 的分层思想,因为每步代价相同,最先铺满的一定步数最少。
代码(贪心)。
int minJumps(const vector<int>& arr) { // 贪心:按「一跳能到的层」逐层扩张
int n = (int)arr.size();
int jumps = 0, end = 0, farthest = 0;
for (int i = 0; i < n - 1; ++i) {
farthest = max(farthest, i + arr[i]);
if (i == end) { // 当前层的候选位置耗尽
if (farthest <= i) return -1; // 原地卡死,到不了终点
++jumps; // 跨入下一层
end = farthest;
}
}
return jumps;
}
手算 \([2, 3, 1, 1, 4]\):i=0 时 farthest=2,触到层边界,jumps=1、end=2;i=1 时 farthest=max(2, 4)=4;i=2 触到 end,jumps=2、end=4;end 已越过终点,返回 2。
复杂度。DP 版 \(O(n^2)\) 时间、\(O(n)\) 空间;贪心版 \(O(n)\) 时间、\(O(1)\) 空间。
边界。首元素为 0 且 \(n \gt 1\) 时不可达(返回 −1 或按题意报错);\(n = 1\) 起点即终点,0 步;\(arr_i\) 可能为 0,卡死检测(farthest 不再前进)必须要有。
面试怎么讲。先给 DP(对齐源题给的递推,说明等价),再主动升级到贪心并点破「同权边最短路 → BFS 分层」这个视角。能讲清贪心为什么对(每层是最少步数相同的位置集合)比背代码重要。
#例题详解:切分、背包与排片
例题 5(切段排序):给定列表(如 \([3,2,1,4,6,5]\)),把它切成尽量多的连续段,使每段各自排序后拼接起来整体非降。
思路。「每段排序后拼接非降」当且仅当相邻两段之间,左段的最大值不超过右段的最小值(段内部排序解决,段之间靠这个条件)。于是位置 \(i\) 与 \(i+1\) 之间能切一刀,等价于 \(\max(a_1..a_i) \le \min(a_{i+1}..a_n)\)。从左到右维护前缀最大、从右到左维护后缀最小(即源题口径的「从右往左前缀 min」),一刀一刀数出来。对 \([3,2,1,4,6,5]\):后缀最小数组为 \([1,1,1,4,5,5]\),前缀最大扫描到下标 3 时为 4,检查 \(4 \le \min(6,5)=5\),下标 3 与 4 之间可切;下标 2 与 3 之间 \(3 \le 4\) 也可切——自由切分下其实能切成 \([3,2,1]\)、\([4]\)、\([6,5]\) 三段。源题宣称答案是 2(\([3,2,1]\) 与 \([4,6,5]\)),对应的是「等长切段」或「每段长度至少为 2」的口径;这正好说明面试时先澄清切分约束的必要。
代码。
int maxParts(const vector<int>& a) { // 自由切分口径下的最大段数
int n = (int)a.size();
vector<int> sufMin(n); // 从右往左维护的后缀最小值
sufMin[n-1] = a[n-1];
for (int i = n - 2; i >= 0; --i) sufMin[i] = min(a[i], sufMin[i+1]);
int parts = 1, preMax = a[0];
for (int i = 0; i + 1 < n; ++i) { // 考察 i 与 i+1 之间能否切一刀
preMax = max(preMax, a[i]);
if (preMax <= sufMin[i+1]) ++parts; // 左段全部 <= 右段全部
}
return parts;
}
复杂度。时间 \(O(n)\),空间 \(O(n)\)。
边界。已排序数组每处皆可切,答案是 n;严格递减数组只能整段,答案 1;等长口径下需要检查每个 n 的因子,复杂度变为 \(O(n \cdot d(n))\)。
面试怎么讲。把「相邻段 max ≤ min」这个充要条件先证出来(两个方向各一句),代码就是查每个切位。主动指出源题答案 2 与自由切分答案 3 的口径差,展示澄清习惯。
例题 6(子集和判定):给定一个列表和一个整数,判断该整数能否写成列表中若干元素之和(每个元素至多用一次)。
思路。0/1 背包可达性:设 \(\mathrm{reach}(s)\) 为「和 s 能否被凑出」,逐件物品决策「不拿」或「拿」。第 \(x\) 件物品加入后,所有 \(s - x\) 可达的和使 \(s\) 可达:
实现上用一维布尔数组、容量从大到小枚举(倒序保证每件物品只用一次——正序会允许同一件被拿多次,变成完全背包)。目标 0 恒可达(空子集)。
代码。
bool canReach(const vector<int>& items, long long target) { // 0/1 背包可达性
if (target < 0) return false;
vector<char> reach(target + 1, 0);
reach[0] = 1; // 空子集的和为 0
for (int x : items)
for (long long s = target; s >= x; --s) // 倒序:每件物品至多用一次
if (reach[s - x]) reach[s] = 1;
return reach[target];
}
复杂度。时间 \(O(n \cdot T)\)、空间 \(O(T)\),\(T\) 为目标值——这是伪多项式(pseudo-polynomial):复杂度对目标值的数值线性、对输入比特数是指数级。用 bitset 可把时间除以机器位宽 64。
边界。默认元素非负;若允许负数,可达和不再是 \([0, T]\) 的区间,改用有序集合维护可达和。target 为 0 返回 true;元素可重复出现但每件仍只用一次。
面试怎么讲。先声明「这是子集和问题,0/1 背包的判定版,伪多项式可解、一般化的维度下是 NP 完全的判定问题」——一句话定位问题的难度坐标,然后给 DP。倒序枚举的原因务必能讲清。
例题 7(电影排片):给定每部电影的时长列表,求最少需要多少天看完;每天观影不超过 3 小时,同一天可以连看多部。
思路(先澄清口径)。「每天至多两部」是这道题最常见的面试口径:此时它就是载重量 3 的摆渡船问题——排序后双指针,最长的一部能否与最短的一部同天,能则配对,不能则最长者独占一天。配对策略的最优性可用交换论证:若最优解里最长的一部没和最短的同天,把它的同伴与最短的同伴交换不劣。若每天可连看任意多部,问题变成装箱(bin packing),是 NP 难问题,标准答复是首递减装箱(FFD)启发式:按时长从大到小依次放入第一个装得下的天,实践中接近最优。
代码(每天至多两部的双指针口径)。
int minDays(vector<int> d) { // 口径:每天 <= 3 小时、至多两部
sort(d.rbegin(), d.rend()); // 从最长到最短安排
int i = 0, j = (int)d.size() - 1, days = 0;
while (i < j) {
if (d[i] + d[j] <= 3) --j; // 最长的一部带上一部最短的
++i; ++days; // 当天看完第 i 部(可能捎带了 j)
}
if (i == j) ++days; // 落单的最后一部
return days;
}
手算 \([3.0, 2.5, 1.8, 1.5, 1.2, 0.9]\)(小时):3.0 独占一天、2.5 独占一天、1.8 与 0.9 同天、1.5 与 1.2 同天,共 4 天;下界 \(\lceil 10.9 / 3 \rceil = 4\),达到下界即最优。
复杂度。双指针 \(O(n \log n)\)(排序主导);FFD 为 \(O(n \log n + nD)\),D 为天数上界。
边界。任何一部超过 3 小时则无解(或按题意单独处理);单部恰好 3 小时必须独占;用整数分钟存时长避免浮点比较误差。
面试怎么讲。第一句先问「每天最多几部」,这一句同时展示了澄清习惯与对问题难度谱系的判断(2 部 → 多项式,任意部 → NP 难)。然后用总时长下界去验证你的方案,是快速自查的好习惯。
例题 8(2 的幂文件填充):文件上传场景中,一段存储区从左到右有 n 个位置,其中部分位置已被占用。要求用大小为 \(2^n\)(即 1、2、4、8……)的文件恰好填满所有空闲位置,求最少需要几个文件。
思路。连续的空闲位置构成一段空隙;两个被占位置之间的空隙互不影响,可以各自填充。关键观察是:长度为 \(L\) 的空隙恰好能被 \(2\) 的幂铺满,且最省文件数的方案唯一——就是 \(L\) 的二进制分解。从大到小贪心(先放 \(2^{\lfloor \log_2 L \rfloor}\),再递归余量)与二进制分解等价,因为每个 \(2^k\) 至多用一次(用了两次该幂就应合并成 \(2^{k+1}\)),而任何小于等于 \(L\) 的幂集合若要不重复地恰好凑出 \(L\),只能是二进制表示里那些位。于是答案 = 各空隙长度的二进制中 1 的个数之和(popcount)。
代码。
long long countFiles(const string& slot) { // '#' 已占用,'.' 空闲
long long files = 0, run = 0; // run:当前连续空隙长度
for (char c : slot) {
if (c == '.') ++run;
else { files += __builtin_popcountll(run); run = 0; }
}
files += __builtin_popcountll(run); // 收尾的空隙别漏
return files;
}
例如 "#..##...#":两段空隙长度 2 与 3,分别需要 \(2\)、\(2+1\) 个文件,共 3 个。
复杂度。时间 \(O(n)\),空间 \(O(1)\)。
边界。全占用返回 0;长度 0 的空隙 popcount 为 0 自动跳过;若空隙极长,popcount 用 64 位内建函数即可,或按位循环统计。
面试怎么讲。把「贪心从大到小」与「二进制分解唯一性」的等价说清楚是核心得分点;位运算(popcount)只是把结论翻译成代码。
例题 9(矩阵最大全 1 正方形):给定 m×n 的 0/1 矩阵,找出只包含 1 的最大正方形并返回其面积(源题库里这一题出现两次,一次只提示用 DP,一次是标准题面,合并讲解)。
思路。设 \(dp_{i,j}\) 为「以 \((i,j)\) 为右下角的最大全 1 正方形边长」。若 \(g_{i,j} = 1\),这个正方形向左上三个方向同时受限,转移为三邻取最小再加一:
怎么读:边长为 \(d\) 的正方形以 \((i,j)\) 为右下角,等价于它的左边一格、上边一格、左上一格分别能撑起边长至少 \(d-1\) 的正方形;三者取最小就是瓶颈。答案是所有 \(dp_{i,j}\) 的最大值的平方。状态定义里的「右下角」与 Kadane 的「以 i 结尾」是同一种定语技术:让全局最优可枚举。
代码。
int maximalSquareArea(const vector<vector<int>>& g) { // 返回全 1 最大正方形面积
int m = (int)g.size(), n = (int)g[0].size(), best = 0;
vector<vector<int>> dp(m, vector<int>(n, 0));
for (int i = 0; i < m; ++i)
for (int j = 0; j < n; ++j)
if (g[i][j] == 1) {
if (i == 0 || j == 0) dp[i][j] = 1; // 边界:只能自成一格
else dp[i][j] = 1 + min({dp[i-1][j], dp[i][j-1], dp[i-1][j-1]});
best = max(best, dp[i][j]);
}
return best * best;
}
复杂度。时间 \(O(mn)\),空间 \(O(mn)\),滚动一行后 \(O(n)\)。
边界。全 0 矩阵返回 0;单行单列时最大正方形至多边长 1;矩阵为空直接返回 0。追问「最大全 1 矩形(不是正方形)」时需要单调栈按行处理直方图,复杂度同为 \(O(mn)\),可作为延伸提一句。
面试怎么讲。先画图说明三邻取 min 的含义(木桶效应),再给转移。被问「为什么不是 max+1」时,用反例回应:右上方向的空缺无法被三个邻居中任何一个看到。
#例题详解:交换、操作与划分
例题 10(相邻交换至多一次):数组 Arr,你可以交换两个相邻元素的值,且每个元素至多被交换一次。如何最大化 \(\sum_i Arr_i \times (i+1)\)(i 从 0 起)?
思路。先算清一次交换的收益:交换相邻位置 \(i\) 与 \(i+1\)(权重 \(i+1\) 与 \(i+2\)),目标函数变化
即交换「降序对」(\(a \gt b\))才有正收益——直觉上要让大数尽快拿到大权重,这与「整体排序后最大」一致。若题意是「总共至多交换一次」,答案就是 \(\max(0, \max_i (Arr_i - Arr_{i+1}))\) 加上原加权和。更常考的口径是每个元素至多参与一次交换:交换两两不相交,各对收益独立可加,问题变成「选出收益和最大的不相邻对」,即打家劫舍式的一维 DP:
手算 \([3,1,2,5,4]\):各对收益 \(2, -1, -3, 1\),选 \((3,1)\) 与 \((5,4)\) 两个不相交对,总增益 3;原加权和 \(51\),优化后 \(54\)。
代码。
long long maxWeightedSum(vector<int> a) { // 口径:每个元素至多交换一次
int n = (int)a.size();
long long base = 0;
for (int i = 0; i < n; ++i) base += (long long)a[i] * (i + 1);
if (n < 2) return base;
vector<long long> g(n, 0); // g[i]:只考虑前 i+1 个位置的最大增益
for (int i = 1; i < n; ++i) {
long long gain = max(0LL, (long long)a[i-1] - a[i]); // 交换 (i-1,i) 的收益
g[i] = g[i-1]; // 不动这一对
if (i >= 2) g[i] = max(g[i], g[i-2] + gain);
else g[i] = max(g[i], gain);
}
return base + g[n-1];
}
复杂度。时间 \(O(n)\),空间可滚动到 \(O(1)\)。
边界。n 小于 2 无交换可言;所有相邻对都升序时增益为 0。面试第一句话应当是澄清「至多一次」是总共一次还是每个元素一次——两种口径答案不同,前者平凡、后者才需要 DP。
面试怎么讲。先推 \(\Delta = a - b\) 这一步初等代数(展示不慌),再指出「不相交交换 → 不相邻选择 → 打家劫舍模板」这条归约链。模板识别能力正是这类题的考点。
例题 11(k 次操作后的最小和):给定整数列表与参数 k:对某个元素施加一次操作,共施加 k 次,使最终数组总和最小。求这个最小总和。
思路。源题对「操作」的定义语焉不详,面试第一步是澄清。取最常见的口径——操作为「把某元素折半(向下取整)」:每次操作的节省量是 \(x - \lfloor x/2 \rfloor = \lceil x/2 \rceil\),它随元素变小而单调不增。于是贪心成立:每次都把操作花在「当前节省量最大」的元素上,用大根堆维护;交换论证——若最优解某次没操作堆顶元素,把它与堆顶交换后总节省不减。若口径是「元素减 1」,答案退化为 \(\sum a_i - k\);若操作另有定义,贪心是否成立取决于节省量的单调性,这一点要在嘴上说明。
代码。
long long minSumAfterOps(vector<long long> a, long long k) { // 操作:把某元素折半
priority_queue<long long> pq(a.begin(), a.end());
long long sum = 0;
for (long long x : a) sum += x;
while (k-- > 0 && !pq.empty()) {
long long m = pq.top(); pq.pop();
sum -= m - m / 2; // 本次操作省下 m - m/2
pq.push(m / 2);
if (m == 0) break; // 全部折到 0,再做也无收益
}
return sum;
}
例如 \([10, 6, 5]\)、\(k = 2\):先折 10(省 5),再折 5(省 3),总和从 21 降到 13。
复杂度。\(O((n + k) \log n)\)。若元素取值有界,可用计数数组代替堆做到近似线性。
边界。k 大于「有收益的操作总数」时提前收工(元素全为 0);元素可为 0 或 1(折半仍可能有益:1 折到 0 省 1);负数元素的折半语义要先澄清——通常这类题限定非负。
面试怎么讲。把「先澄清操作定义」放在最前面,然后讲贪心的单调性条件与交换论证。题面残缺不是陷阱,是送分:看你有没有工程沟通的常识。
例题 12(k 次跨数组交换后 A 里的不同数):两个长度为 n 的数组 A、B。一次操作是取下标 i、j 并交换 A[i] 与 B[j]。做 k 次这样的操作后,A 中最多能有多少个不同的数?
思路(可达集合分析)。每次交换恰好让 A「一进一出」:A 的元素个数不变,变化的只是值的集合。设 \(S_A\)、\(S_B\) 为两数组的不同值集合,\(d = n - |S_A|\) 是 A 中「多余副本」的个数(重复占位),\(f = |S_B \setminus S_A|\) 是 B 中 A 没有的值个数。要让 A 的不同值 +1,一次交换必须同时满足:换入一个不在 \(S_A\) 的新值、且被换出的元素是某个值的重复副本(换出唯一副本会让集合大小不变)。因此
三个瓶颈的含义:k 次操作的上限、重复副本的供给上限、新值的供给上限,且上限可达(每次挑一个重复副本与一个新值交换即可)。反过来问「最少能有多少个不同数」时,被迫交换可以逐个踢掉重数小的值,答案按「用 k 次把哪些值清出 A」贪心讨论。这道题考的不是代码而是集合进出的推理,白板上画两个圆(\(S_A\)、\(S_B\))讲最清楚。
代码。
int maxDistinctAfterK(const vector<int>& A, const vector<int>& B, int k) {
unordered_set<int> sa(A.begin(), A.end()), sb(B.begin(), B.end());
int base = (int)sa.size(); // A 原有的不同值个数
int dup = (int)A.size() - base; // A 中的多余副本数
int fresh = 0; // B 中 A 没有的值个数
for (int v : sb) if (!sa.count(v)) ++fresh;
return base + min({k, dup, fresh}); // 三个瓶颈取最小
}
复杂度。时间 \(O(n)\)(哈希均摊),空间 \(O(n)\)。
边界。k 为 0 返回 \(|S_A|\);A 全是重复值且 B 有大量新值时上限由 k 决定;若题目要求「恰好 k 次」,多出的被迫交换可能降低答案,口径又不同——照例先澄清。
面试怎么讲。「一进一出」四个字是全部建模。先给上界论证(每类瓶颈各一段),再给可达性构造,最后才落代码。这题代码极短,代码写长反而说明建模没做透。
例题 13(分两个子表):把一个列表在某个位置分成前后两个子表,要求前一个更短(元素个数少)且元素和更大。找出这样的划分。
思路。「前缀 + 划分点」:预处理总和,再从短到长枚举前缀长度 p(要求 \(p \lt n - p\)),用滚动的前缀和比较两边。找到的第一个满足条件的 p 即一个答案;若遍历完仍无,返回不存在。之所以从短前缀枚举,是因为「更短」的约束把 p 限制在前一半位置,天然剪枝。
代码。
// 返回前缀长度 p,使前缀更短(p < n-p)且和更大;不存在返回 -1
int splitShortGreaterPrefix(const vector<long long>& a) {
int n = (int)a.size();
long long total = 0, pre = 0;
for (long long x : a) total += x;
for (int p = 1; 2 * p < n; ++p) { // 前缀长 p、后缀长 n-p,要求 p < n-p
pre += a[p - 1];
if (pre > total - pre) return p;
}
return -1;
}
例如 \([8, 3, 2, 1, 1]\):p=1 时前缀和 8 已大于后缀和 7,返回 1。而 \([1,2,3]\) 枚举 p=1(约束 \(2 \times 1 \lt 3\))失败后循环结束,不存在这样的划分。
复杂度。时间 \(O(n)\),空间 \(O(1)\)。
边界。\(n \le 1\) 无意义;全正递增数组常无解(前半又短又小);注意题目若改为「任意划分子集(不要求连续)」则是子集和搜索,复杂度完全不同——先确认是前缀划分还是子集划分。
面试怎么讲。指出「前缀和 + 划分点」是这个题的模板名,一句话即可;把无解情形主动举例(递增正数组)体现边界意识。
#误区与边界
一、状态设计漏掉「不用/不选」分支:花束题漏掉「末尾的花不扎」、子集和漏掉「这件不拿」,都会错过最优解。二、答案位置想当然:LIS 与最大正方形的答案是全体状态的 max,不是最后一个状态。三、口径不澄清就写代码:交换「至多一次」的两种读法、电影「每天几部」、k 次操作的「操作是什么」,三道题各有两个口径、两套答案。四、溢出:加权和、前缀和、tribonacci 都能轻松超过 int,统一用 long long。
本章既有 DP 正解(花束、楼梯、正方形),也有贪心正解(跳跃、排片、折半操作)。判断标准是「局部最优是否蕴含全局最优」:折半操作的节省量单调不降于元素值,贪心安全;花束的收益互相纠缠(共享花朵),必须 DP。面试时先说一句「这里贪心不安全,因为……」,比直接写 DP 更能展示判断力。
两道与本章擦边的源题去向:矩阵最大全 1 正方形在本章例题 9 完整解答(它在源题库里以「提示 DP」与「标准题面」两种措辞各出现一次,已合并);硬币水滴的孪生题「接雨水」的双指针优化解法在 033. 编程三:滑动窗口与前缀和。本章所有题目都默认 0 下标、区间闭口,若面试官口径不同,转换即可。
#检查清单
- 我能对一道陌生 DP 依次回答「最后一步是什么、需要哪些信息、边界与答案在哪里」三问。
- 我能写出花束 DP 的三个转移分支,并解释「不扎」分支为什么不可省略。
- 我能推导硬币水滴的逐列水位公式 \(\max(0, \min(L_i, R_i) - h_i)\),并说明它为什么是线性 DP。
- 我能给出楼梯/斐波那契的四档复杂度:指数递归、\(O(n)\) 记忆化、\(O(n)\) 递推加 \(O(1)\) 滚动、\(O(\log n)\) 矩阵幂。
- 我能分别实现跳跃游戏的 \(O(n^2)\) DP 与 \(O(n)\) 分层贪心,并解释贪心正确的理由。
- 我能证明「切段排序」与「相邻段 max ≤ min」的等价,并用前后缀最值 \(O(n)\) 计数。
- 我能解释 0/1 背包为什么倒序枚举容量、子集和为什么是伪多项式复杂度。
- 面对题面残缺的源题(如 k 次操作),我会先澄清口径再选择贪心或 DP,并能说出贪心成立的单调性条件。