#032. 编程二:花束、跳跃与拆分 DP

先用一个小例走通

一步或两步爬3阶,有111、12、21共3条路径。令dp[i]表示恰到第i阶方案数,最后一步来自\(i-1\)或\(i-2\),因此\(dp[i]=dp[i-1]+dp[i-2]\),\(dp[0]=1\)代表还没走的空路径。动态规划不是“出现数组就填表”,而是先定状态含义、分类最后一步、给边界,再决定顺序和空间压缩。

动态规划状态设计模式示意图:花束、跳跃与拆分

#学习目标:把陌生的 DP 拆成熟悉的模板

源题库的编程部分里,动态规划题占比最高,而且题面千姿百态:花店扎花束、硬币上滴水、上楼梯、跳跃数组、对元素做 k 次操作、把数组切成 n 段、子集能否凑出目标数、电影排片、文件占位……逐道硬背是不可能的。本章的做法是给它们一个共同的分析框架——「状态设计三问」——然后把十三道源题全部放进框架里过一遍。读完之后你再遇到陌生的 DP 题时的反应应当是:先问「最后一步是什么」,而不是盯着题面发呆。

本章同时是复杂度意识的训练场。同样一道楼梯题,朴素递归、记忆化、递推、矩阵快速幂的时间复杂度分别是指数、线性、线性、对数级;同样一道跳跃题,\(O(n^2)\) 的 DP 与 \(O(n)\) 的贪心都正确,但面试官会追问「能不能更快」。能在写代码之前先把复杂度阶梯说清楚,是通过编程轮的关键信号。

状态(state)

「问题的哪个局部快照足以决定后续」。设计状态的标准语言是描述最后一步:最后扎的一束花、最后跨的一级台阶、最后切的一刀。

转移(transition)

从更小的状态推出当前状态的全部途径,要求互斥且完备(不重不漏),并且无后效性——未来的决策只依赖当前状态,不依赖到达路径。

边界与答案(base case)

最小的、不用转移就能确定的状态;以及最终答案落在哪个状态上(常见坑:答案不是 \(dp_n\) 而是全体状态的 max)。

#知识讲解:DP 状态设计三问

拿到任何一道可能是 DP 的题,依次问三个问题。第一问:最后一步是什么?以花束题为例,最后一步是「在某个前缀上扎完最后一束花」;以切段题为例,最后一步是「在某个位置切下最后一刀」。把最后一步用一句话说清楚,状态的维数和含义就自动浮现。第二问:化为子问题后,需要哪些信息?只保留会影响后续决策的信息(无后效性)。楼梯题里「还剩几阶」就够;跳跃题里「当前位置 \(i\)」就够,因为能跳多远由 \(arr_i\) 决定。第三问:边界在哪里、答案在哪里?楼梯题 \(f_0 = 1\)(原地不动算一种);LIS 的答案取所有 \(f_i\) 的最大值而不是 \(f_n\)——第三问是最容易翻车的一问。

\[dp_i = \mathrm{opt}\bigl(\{dp_j + \mathrm{cost}(j, i) : j \to i \ \text{是合法转移}\}\bigr).\]

怎么读:状态 \(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 阶,三类互斥且完备:

\[f_n = f_{n-1} + f_{n-2} + f_{n-3}, \qquad f_0 = 1,\; f_1 = 1,\; f_2 = 2.\]

怎么读:到第 \(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\) 朵花」能取得的最大价值,转移即为三者取最大:

\[dp_i = \max\bigl(dp_{i-1},\; dp_{i-2} + q \cdot [\text{末两朵一 A 一 B}],\; dp_{i-3} + p \cdot [\text{末三朵全 A}]\bigr),\]

方括号指示项不满足条件时取 \(-\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 上记录每步的选择再回溯即可。

推导补全与自检。定义dp[i]为前i朵的最优收益,最后一朵不使用时\(dp[i-1]\);末三朵AAA且选该束时\(dp[i-3]+p\);末两朵AB合法时\(dp[i-2]+q\),取三者最大。用半开前缀长度可避免把花重复用在两束。比如0001可选前三朵AAA或后两朵AB,各与剩余合法前缀独立;若BA也算同束,匹配条件应写相邻不同,而不是只认01。

例题 2(硬币上的水滴):若干列硬币按行堆叠成高度数组,向其上滴水。每枚水滴会停在两列硬币形成的凹处并不断堆高,直到水面与两侧较矮的硬币齐平后溢出。求一共能存住多少滴水(源题示例:能存 2 滴)。

思路(建模是本题主角)。把第 \(i\) 列硬币的高度记为 \(h_i\)。水滴落下后,位置 \(i\) 处的水位不会超过「左侧最高硬币」与「右侧最高硬币」的较小者,因为超过就会从矮的一侧溢出。于是每列能稳定停留的水滴数为

\[d_i = \max\bigl(0,\; \min(L_i, R_i) - h_i\bigr), \qquad L_i = \max_{j \le i} h_j,\; R_i = \max_{j \ge i} h_j,\]

总滴数即 \(\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. 编程三:滑动窗口与前缀和的例题里。

面试怎么讲。先用「两侧较矮者决定水位」这句物理直觉立住公式,再说明为什么逐列求和是安全的(每列独立)。主动提接雨水变体与双指针优化,是漂亮的收尾。

推导补全与自检。每列水高由左右最高柱中较矮者限制,水量\(\max (0,\min (L_{i},R_{i})-h_{i})\)。[2,0,2]只有中间可存2滴;[2,0,1]则只存1滴,不能取两侧较高者。双指针每步处理“当前左右最高值较小”的一端,其限制已确定,不需知道另一侧最终多高。柱宽若非1,水高还必须乘宽度。

#例题详解:楼梯与跳跃

例题 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 必须配取模。

面试怎么讲。「最后一步跨几阶」这句分类语言是全部内容;复杂度分析按「朴素递归 → 填表 → 滚动 → 矩阵幂」四档报出,展示你知道每档存在的理由,而不是背复杂度。

C4:独立实现Fibonacci。它与允许跨1、2、3阶的tribonacci不是同一个递推。约定F₀=0、F₁=1,只将前两项相加:

unsigned long long fibonacci(int n) {
    if (n < 0 || n > 93) throw invalid_argument("uint64 Fibonacci requires 0 <= n <= 93");
    if (n == 0) return 0;
    unsigned long long previous = 0, current = 1;
    for (int i = 1; i < n; ++i) {
        unsigned long long following = previous + current;
        previous = current;
        current = following;
    }
    return current;
}

F₀到F₇依次为0、1、1、2、3、5、8、13。该实现O(n)时间、O(1)额外空间,并显式限制64位范围;任意大n须用大整数或取模。朴素无记忆递归重复求解子问题,时间呈指数增长。

推导补全与自检。\(f(0)=1\)表示空走法,\(f(1)=1\)、\(f(2)=2\),然后\(f(n)=f(n-1)+f(n-2)+f(n-3)\)。\(n=3\)时4种:111、12、21、3,顺序不同应分别算。若设\(f(0)=0\)会抹掉一步直达的方案。递归不记忆会重复指数多次子问题,滚动三状态即可O(n)时间、O(1)额外空间。

例题 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 分层」这个视角。能讲清贪心为什么对(每层是最少步数相同的位置集合)比背代码重要。

推导补全与自检。最短跳跃DP可令\(d[0]=0\),依次松弛i能到的所有j:\(d[j]=\min (d[j],d[i]+1)\)。正向贪心把同一跳数能达范围当BFS层,扫描完当前层再把步数加1,右界扩到该层所有点的最远可达。若最远界未推进而尚未到末尾,应返回不可达;[0,1]不能错误返回1。

#例题详解:切分、背包与排片

例题 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]\) 三段。PDF示例宣称答案2,但按写出的自由切分规则最优为3;等长切段或最小段长限制可以产生2,不过这些限制原文没有给出,不能当成已知事实;这正好说明面试时先澄清切分约束的必要。

代码。

int maxParts(const vector<int>& a) {          // 自由切分口径下的最大段数
    int n = (int)a.size();
    if (n == 0) return 0;
    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 的口径差,展示澄清习惯。

推导补全与自检。在i处切段的充要条件是max(a[0…\(i])\le \min (a[i+1\)…\(n-1])\)。预处理后缀最小值、扫描前缀最大值,每个满足点都可同时切,因而取得最多段。[3,2,1,4,6,5]可切[3,2,1]|[4]|[6,5]共3段。对有重复值用\(\le \);“前缀最大值等于下标”只适用于0…\(n-1\)排列的特殊版本。

例题 6(子集和判定):给定一个列表和一个整数,判断该整数能否写成列表中若干元素之和(每个元素至多用一次)。

思路。0/1 背包可达性:设 \(\mathrm{reach}(s)\) 为「和 s 能否被凑出」,逐件物品决策「不拿」或「拿」。第 \(x\) 件物品加入后,所有 \(s - x\) 可达的和使 \(s\) 可达:

\[\mathrm{reach}_k(s) = \mathrm{reach}_{k-1}(s) \;\vee\; \mathrm{reach}_{k-1}(s - x_k).\]

实现上用一维布尔数组、容量从大到小枚举(倒序保证每件物品只用一次——正序会允许同一件被拿多次,变成完全背包)。目标 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。倒序枚举的原因务必能讲清。

C25未限制非负整数。一般带负数版本可直接维护逐件可达和,每件先从旧集合生成新和再合并,防止同一件反复使用。下面返回是否可达;最坏2ⁿ个不同和,时间与空间均为指数级,所有部分和须放得进long long。

bool canReachIntegers(const vector<int>& items, long long target) {
    unordered_set<long long> reachable{0};
    for (int x : items) {
        vector<long long> additions;
        additions.reserve(reachable.size());
        for (long long sum : reachable) additions.push_back(sum + x);
        reachable.insert(additions.begin(), additions.end());
    }
    return reachable.count(target) != 0;
}

例如[-3,5]能凑出2,也能凑出−3;非负容量数组版不能用于这个接口。目标0允许空子集,是否要求至少一件应另外约定。

推导补全与自检。非负整数\(0/1\)子集和,dp[s]表示是否可达,处理值v从目标T向下更新。例只有一个3、目标6时,正向更新会先由dp[0]得到dp[3],再用同一枚3得到dp[6],错误变成可重复。负数输入不能直接使用0…T数组,需要偏移或集合状态;O(nT)依赖金额数值,是伪多项式时间。

例题 7(源题 C29):电影可任意组合,每天总时长不超过3小时,求最少天数

题面与方法。PDF没有限制每天最多两部,因此这是容量为3小时的装箱问题。将时长精确换成整数分钟(有限小数可统一到更细单位),每天容量180。小规模用分支限界得到精确最优:先按时长降序,逐部尝试放进每个装得下的已有天,或新开一天。相同已用时长的天是对称状态,只尝试一个。每一种合法排片都在这个搜索树中;当前天数已不小于最好答案时可以剪枝,因为后续不会减少已开天数。

int minDaysMinutesExact(vector<int> minutes, int capacity = 180) {
    if (capacity <= 0) throw invalid_argument("positive daily capacity required");
    for (int length : minutes) {
        if (length <= 0) throw invalid_argument("movie duration must be positive");
        if (length > capacity) return -1;
    }
    if (minutes.empty()) return 0;
    sort(minutes.rbegin(), minutes.rend());
    int best = minutes.size();
    vector<int> used;
    function<void(int)> search = [&](int i) {
        if ((int)used.size() >= best) return;
        if (i == (int)minutes.size()) { best = used.size(); return; }
        set<int> triedLoads;
        for (int day = 0; day < (int)used.size(); ++day) {
            if (minutes[i] > capacity - used[day] || !triedLoads.insert(used[day]).second)
                continue;
            used[day] += minutes[i];
            search(i + 1);
            used[day] -= minutes[i];
        }
        used.push_back(minutes[i]);
        search(i + 1);
        used.pop_back();
    };
    search(0);
    return best;
}

核验。[60,60,60,60,60]可分成3部和2部,两天完成;“每天至多两部”的双指针会错误给三天。[120,120,120]总时长360、容量下界2天,但任意两部不能同日,实际三天。因此\(\lceil\text{总时长}/180\rceil\)只是下界。

规模与条件。一般装箱是NP难问题,此精确搜索最坏指数级(粗上界O(nⁿ)),排序O(n log n),负载数组与递归栈为O(n),各层对称去重集合合计最坏O(n²)额外空间;应对照输入规模选用。大规模可用首次适应递减FFD近似,必须标记不保证最优;“每天至多两部”的排序双指针是另一道受限题,不能冒充本题。任何单片超过容量返回−1,空列表返回0。

推导补全与自检。这里先保留原题允许任意多部的规则,再用显式容量和整数时间单位给精确实现。近似算法、固定整数小时闭式解、至多两部版本分别是不同条件下的方案;不以没有证明的贪心替换最小天数目标。

例题 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)只是把结论翻译成代码。

与设计题统一。本例countFiles按无地址对齐要求解释;若增加对齐约束,不能只看空段长度,须看起始地址。完整双模式实现与反例见036章文件填充,其minFiles默认同样采用无对齐模式。

推导补全与自检。长度L的连续空闲段独立填充,若不要求起始对齐,两个同大小\(2^j\)文件可合并为\(2^{j+1}\)而减少一个;合并到各位至多一份即二进制展开,最少数为popcount(L)。\(L=13=8+4+1\)需3份。若文件必须按自身长度对齐,起点也影响可行性,例如从位置1起长度4不能直接放对齐的4块。

例题 9(矩阵最大全 1 正方形):给定 m×n 的 0/1 矩阵,找出只包含 1 的最大正方形并返回其面积(源题库里这一题出现两次,一次只提示用 DP,一次是标准题面,合并讲解)。

思路。设 \(dp_{i,j}\) 为「以 \((i,j)\) 为右下角的最大全 1 正方形边长」。若 \(g_{i,j} = 1\),这个正方形向左上三个方向同时受限,转移为三邻取最小再加一:

\[dp_{i,j} = 1 + \min\bigl(dp_{i-1,j},\; dp_{i,j-1},\; dp_{i-1,j-1}\bigr) \quad (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」时,用反例回应:右上方向的空缺无法被三个邻居中任何一个看到。

推导补全与自检。dp[i][j]表示以(i,j)为右下角的全1正方形边长;当前为1时取左、上、左上三者最小值再加1,为0则0。缺任何一侧都无法补满正方形。\(2\times 2\)的[[1,1],[1,0]]最大边长1,面积1;返回时别把边长当面积。滚动数组左上旧值要在覆盖dp[j]之前保存。

#例题详解:交换、操作与划分

例题 10(相邻交换至多一次):数组 Arr,你可以交换两个相邻元素的值,且每个元素至多被交换一次。如何最大化 \(\sum_i Arr_i \times (i+1)\)(i 从 0 起)?

思路。先算清一次交换的收益:交换相邻位置 \(i\) 与 \(i+1\)(权重 \(i+1\) 与 \(i+2\)),目标函数变化

\[\Delta = \bigl[b\,(i+1) + a\,(i+2)\bigr] - \bigl[a\,(i+1) + b\,(i+2)\bigr] = a - b,\quad a = Arr_i,\ b = Arr_{i+1}.\]

即交换「降序对」(\(a \gt b\))才有正收益——直觉上要让大数尽快拿到大权重,这与「整体排序后最大」一致。若题意是「总共至多交换一次」,答案就是 \(\max(0, \max_i (Arr_i - Arr_{i+1}))\) 加上原加权和。更常考的口径是每个元素至多参与一次交换:交换两两不相交,各对收益独立可加,问题变成「选出收益和最大的不相邻对」,即打家劫舍式的一维 DP:

\[g_i = \max\bigl(g_{i-1},\; g_{i-2} + \max(0,\; Arr_{i-1} - Arr_i)\bigr).\]

手算 \([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\) 这一步初等代数(展示不慌),再指出「不相交交换 → 不相邻选择 → 打家劫舍模板」这条归约链。模板识别能力正是这类题的考点。

推导补全与自检。交换相邻\(a_{i},a_{i+1}\)带来的目标增量\(=a_{i}-a_{i+1}\),所以只需在相邻边上选不共享端点的正增益边。\(dp[i]=\max (dp[i-1],dp[i-2]+\max (0,a_{i-1}-a_{i}))\)。不是把所有下降对都交换:三个元素[3,2,1]的两条边共享中间元素,只能选一条。原目标加上最大匹配增益才是最终答案。

例题 11(源题 C13):对数组元素做k次操作,使总和最小;操作定义缺失

原题无法唯一求解。PDF没有定义操作,不能直接给一个确定答案。例如[5]操作一次,减1得4、向下折半得2、向上折半得3。以下只是两个明确标注的补充模型,不声称还原了原题未写出的规则。

非负整数折半模型。向下折半的节省为\(x-\lfloor x/2\rfloor=\lceil x/2\rceil\);向上折半的节省为\(x-\lceil x/2\rceil=\lfloor x/2\rfloor\)。每个元素反复折半的边际节省单调不增,因此每步选择当前最大节省是最优;两种模型的节省都随当前值非减,可用最大堆按当前值选取。roundUp=false对应向下取整,true对应向上取整。

long long minSumAfterOps(const vector<long long>& a, long long k, bool roundUp = false) {
    if (k < 0) throw invalid_argument("k must be nonnegative");
    priority_queue<long long> pq;
    long long total = 0;
    for (long long x : a) {
        if (x < 0) throw invalid_argument("halving model requires nonnegative entries");
        pq.push(x); total += x;
    }
    if (pq.empty() && k > 0) throw invalid_argument("no entry to operate on");
    while (k-- > 0 && !pq.empty()) {
        long long x = pq.top(); pq.pop();
        long long y = x / 2 + (roundUp && x % 2);
        if (x == y) break;  // Remaining operations cannot improve the sum.
        total -= x - y;
        pq.push(y);
    }
    return total;
}

O(n log n+k log n)时间、O(n)空间;提前进入全无收益状态时,剩余操作可以在0或1等不变元素上消耗。例[5]、k=1,两种开关分别返回2与3。总和须在long long范围。若真实操作不是折半,必须重新设计状态/转移,不能沿用这个堆作为“原题答案”。

推导补全与自检。题面缺条件的完成方式是指出无法唯一解、列清额外假设并给相应实现,而不是在正文和补充里分别偷偷选不同取整规则。本实现的参数、节省公式、例子与代码使用同一约定。

例题 12(源题 C19):跨数组交换k次,使A中不同值最多

先说明优化目标。原文只问k次后有多少不同值,未明确最大化;若不指定策略,结果依赖具体交换。以下解最大化版本,允许任意跨数组下标、重复使用下标,也允许相同值交换。下标按0…n−1理解。

设A原有b种值、重复占位d=n−b,B里A没有的新值有f种。一交换最多增加一种值,且必须用A里的重复副本换入新值;因此上界是\(b+\min(k,d,f)\)。不断做这种交换即可达到。

恰好k次也能达到该式。若有多余次数:当A仍有重复但B已无新值,可在两数组中选同值交换;当A全是不同值,B有同值则交换同值,否则用任一B新值替掉A任一旧值,不改变种类数。因此在当前允许的操作下,总能消耗额外次数且不降目标,不能宣称“恰好多次必然变差”。

int maxDistinctAfterK(const vector<int>& A, const vector<int>& B, int k) {
    if (A.size() != B.size() || k < 0) throw invalid_argument("equal lengths, nonnegative k required");
    if (A.empty() && k > 0) throw invalid_argument("no elements to swap");
    unordered_set<int> sa(A.begin(), A.end()), sb(B.begin(), B.end());
    int fresh = 0;
    for (int value : sb) if (!sa.count(value)) ++fresh;
    return (int)sa.size() + min({k, (int)A.size() - (int)sa.size(), fresh});
}

例A=[1,1]、B=[2,2]、k=2:先把A₀=1与B₀=2交换,得A=[2,1]、B=[1,2];再交换A₀与B₁,两者同为2,最终仍有两种值。若禁止同值交换、限制每个下标只能用一次或规定i=j,就不再是此题的操作集合,须另解。哈希平均O(n)时间、O(n)空间。

推导补全与自检。公式对应的是明确给出的最大化模型,并在允许重复/同值交换时同时适用于至多k次与恰好k次。上述两步序列直接否定“恰好两次只能剩一种值”的错误反例。若只是给出k而不要求最优,也不提供交换记录,则无法确定单一结果。

例题 13(源题 C17):分成两个列表,第一个更短且元素和更大

PDF未要求连续切分。应区分“从某处切成前后段”和“任意把元素分进两个组、组内保留原顺序”。前者枚举前缀长度m,检查\(2m\lt n\)和\(P_m\gt S-P_m\),O(n)。后者并非一般子集和判定:只要求和超过另一组,没有指定精确目标;固定大小m的最大和就是最大的m个元素之和。因此按值降序,枚举\(1\le m\lt n/2\)即可,O(n log n)。这种论证对带负数的输入也成立。

int splitShortGreaterPrefix(const vector<long long>& a) {
    long long total = accumulate(a.begin(), a.end(), 0LL), prefix = 0;
    for (int length = 1; 2LL * length < (long long)a.size(); ++length) {
        prefix += a[length - 1];
        if (prefix > total - prefix) return length;
    }
    return -1;
}

vector<int> shorterGreaterSubset(const vector<long long>& a) {
    vector<pair<long long, int>> order;
    for (int i = 0; i < (int)a.size(); ++i) order.push_back({a[i], i});
    sort(order.rbegin(), order.rend());
    long long total = accumulate(a.begin(), a.end(), 0LL), sum = 0;
    for (int length = 1; 2LL * length < (long long)a.size(); ++length) {
        sum += order[length - 1].first;
        if (sum > total - sum) {
            vector<int> indices;
            for (int j = 0; j < length; ++j) indices.push_back(order[j].second);
            sort(indices.begin(), indices.end());
            return indices;
        }
    }
    return {};  // No nonempty shorter group works.
}

第二个函数返回所选元素的原下标,按下标递增保证组内原顺序;其余元素构成第二组。例[1,9,1]:没有合法的更短且更大和的前缀,但任意分组可选中间的9,对其余[1,1]。两组非空、长度严格不等;n≤2不存在第一组更短的非空二分。各总和假定不溢出long long。

推导补全与自检。不能未经说明把“分两个列表”缩成前缀切分,也不能把任意分组笼统判作NP难。这里枚举组大小时,最大和由选最大的m个数直接给出;只有额外加入“必须恰等于某个目标和”等约束才回到子集和的困难版本。

#误区与边界

四个高频翻车点

一、状态设计漏掉「不用/不选」分支:花束题漏掉「末尾的花不扎」、子集和漏掉「这件不拿」,都会错过最优解。二、答案位置想当然:LIS 与最大正方形的答案是全体状态的 max,不是最后一个状态。三、口径不澄清就写代码:交换「至多一次」的两种读法、电影「每天几部」、k 次操作的「操作是什么」,三道题各有两个口径、两套答案。四、溢出:加权和、前缀和、tribonacci 都能轻松超过 int,统一用 long long。

DP 与贪心的分界

本章既有 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,并能说出贪心成立的单调性条件。