关于「动态规划」解决问题的基本思想,下面哪种说法正确?
规则:动态规划 = 把问题拆成重叠的子问题,用状态数组存答案,用转移方程递推,用「存」换「重复算」。
考点:动态规划基本思想的识别。
解析:DP 的核心动作有三个:定义状态(dp[i] 表示什么)、写转移方程(从更小的子问题推当前的)、定边界初值。它和「拆成独立小问题」的分治、「每步选局部最优」的贪心、「全枚举」的暴力是四条不同的路。
排除法:「相互独立的小问题」是分治的特征(归并排序),DP 的子问题恰恰大量重叠;「每步选最优」是贪心;「枚举所有方案」是暴力,DP 正是为了不枚举而生的。
走楼梯问题:上到第 级台阶,每次可走 1 级或 2 级,问有多少种不同走法。有人想用贪心「每次都尽量走 2 级」。为什么贪心求不出正确的走法数?
01int ways(int n) { 02 if (n == 0 || n == 1) return 1; 03 return ways(n - 1) + ways(n - 2); 04}
规则:贪心解决的是「最优解」问题(最短路、最少步数);走楼梯问的是「方案数」,是计数问题,贪心只能输出一种方案,天然数不出总量。
考点:DP 与贪心的适用范围分野。
解析: 时走法有 5 种(1111、112、121、211、22)。贪心「尽量走 2 级」只会给出「22」这一种,答非所问。想数全走法,必须按「最后一步跨 1 级还是 2 级」分类递归,这正是 DP 的雏形。
排除法:「越界」与贪心无关,走法数是逻辑问题;「变 」没有任何依据;「死循环」更无从谈起。
下面哪种情况存在明显的「重叠子问题」?
规则:重叠子问题 = 同一个子问题被多个「父问题」需要,递归树里出现多次。
考点:重叠子问题的判断。
解析: 的递归树里, 既被 (因为 )需要,也被 ()需要,出现两次;、 出现得更多。这种「重复计算」正是 DP 要消除的对象。
排除法:累加和每个子问题只被用一次;归并排序两半互不相干;二分查找每次只进一半、状态不重复。
同一句 f(40),下面两个版本一个慢到跑不完、一个瞬间出结果。为什么差距这么大?
01int f(int n) { 02 if (n <= 1) return n; 03 return f(n - 1) + f(n - 2); 04} 05 06int g[100]; 07int main() { 08 g[0] = 0; g[1] = 1; 09 for (int i = 2; i <= 40; i++) g[i] = g[i - 1] + g[i - 2]; 10}
规则:朴素递归的时间复杂度是 (每个子问题被重复求解),递推是 (每个状态只算一次)。
考点:重叠子问题导致的重复计算。
解析: 的递归树里 、……每个子问题都被反复求,调用总次数超过 量级;而递推循环从 一路推到 ,每次计算都是 的加法,总共 40 次。同一数学定义,两种实现差了天文数字——这正是「把答案存下来」的价值。
排除法:压栈开销只是常数差距,改变不了量级;溢出不会变慢只会错值;递归版没有任何输出。
运行下面的程序,cnt 最终是多少?
01#include <iostream> 02using namespace std; 03int cnt = 0; 04int f(int n) { 05 cnt++; 06 if (n <= 1) return n; 07 return f(n - 1) + f(n - 2); 08} 09int main() { 10 cout << f(6) << " " << cnt << endl; 11 return 0; 12}
规则: 朴素递归共调用 函数 次( 的输出另说,cnt 记的是调用次数)。
考点:朴素递归调用次数的追踪(递归树)。
解析:记 为调用次数,,。逐层算:、、、、。程序先输出 f(6) = 8,再输出 cnt = 25。
排除法: 是 的值不是调用次数; 是 ; 没有依据。
下面哪个描述最准确地表达了「最优子结构」?
规则:最优子结构 = 大问题的最优解包含(能由)子问题的最优解(构造)。
考点:最优子结构的定义。
解析:打家劫舍里,「前 家的最大金额」要么等于「前 家的最大金额」,要么等于「前 家的最大金额加第 家的钱」——不管哪条,都只用到了更小规模的「最优解」。「算过一次不变」是状态记录的特性;「只能一个解」是误解;「中间位置」是无稽之谈。
排除法:「算过不变」说的是记忆化/递推的表,不是最优子结构本身;「只能一个解」与最优子结构无关,子问题可以有多个并列最优;「中间位置」是典型胡思乱想。
动态规划里的「无后效性」指的是什么?
规则:无后效性 = 未来的决策只依赖当前状态的值,不依赖「这个值是怎么来的」;状态本身已经包含了做决策所需的全部信息。
考点:无后效性的定义与含义。
解析:爬楼梯算 dp[5] 只需要 dp[4] 和 dp[3],不需要知道 dp[4] 具体是「先 2 级再 2 级」还是「先 1 级再 1 级再 2 级」——那些历史细节不影响 dp[5]。一旦某个问题的最优决策必须知道历史路径,简单一维 DP 就会失效(这是许多进阶问题的难点来源)。
排除法:「不会更新」误解了状态的含义(dp 数组在递推过程中本来就被逐格填值);「每格只能用一次」没有这回事;「非负整数」与无后效性毫无关系。
有一个最优化问题,「当前最优」完全取决于之前具体走了哪条路径(比如走过的每一步都会改变未来的收益系数)。这时用一个简单的 dp[i] 一维数组求解,为什么可能出错?
规则:DP 成立的两个前提是「最优子结构」和「无后效性」;当未来决策依赖历史路径时,无后效性被破坏,状态里必须加入路径信息(通常导致状态维度爆炸)。
考点:无后效性被破坏时 DP 失效。
解析:标准的 DP 状态要求「dp[i] 的值一旦确定,就能作为独立信息参与更大家伙的决策」。若最优决策还依赖于「之前怎么选的」,那么同样停在 的两种走法对未来的影响不同,一个 dp[i] 装不下两种未来——强行用一个数递推,结果必然失真。
排除法:「数组太小」不是本质原因,本质是信息不够;「字符串」无关;「超时」和正确性无关。
分治(如归并排序、快速排序)和动态规划都「把大问题拆成小问题」。两者最本质的区别是什么?
规则:分治的子问题「互不相交」,做完合并即可;DP 的子问题「互相重叠」,所以要存答案避免重算。
考点:DP 与分治的区分。
解析:归并排序把数组对半切开,左半、右半互不相干,各自排好后合并;斐波那契的两个递归分支共享了大量子问题。分治里「存答案」没有意义(每个子问题只用一次),DP 里「存答案」是关键(同一子问题反复被用)。子问题是否重叠是两者最简单的判别器。
排除法:DP 也能写递归(记忆化),分治也能用循环模拟;速度由算法本身决定与类别无关;DP 也处理最小化、计数、可行性问题。
一个 DP 的状态空间大小为 (状态数),每个状态要枚举 种转移。这个 DP 的时间复杂度大约是?
规则:DP 总时间 状态数 每状态转移数,即 。
考点:DP 复杂度的通用口径。
解析:每个状态都要枚举一遍它的转移并取最优,所以总工作量 = 状态数乘每状态的转移数。爬楼梯 、,总 ;0/1 背包 、,总 ;LIS 、,总 。
排除法: 漏掉了「每个状态都做 M 次」; 是把 M 强行当 S; 是搜索的复杂度。
0/1 背包: 件物品、容量 ,用二维表 dp[i][j]( 行每行 格),每格从「不选第 件」和「选第 件」两种转移里取较大值。时间复杂度是?
规则:状态数 ,每状态 2 种转移,总 。
考点:用「状态数 转移数」估复杂度。
解析:状态是「前几件 + 容量多少」,共 个;每个状态只做一次 max 二选一,常数时间。所以总 。、 时约 次操作,轻松可过;如果写成枚举选哪些物品,就是 了。
排除法: 把 误当成 ; 漏了 维; 是暴力子集枚举的复杂度。
一个问题想用动态规划求解,下面哪个性质不是必需的?
规则:DP 需要最优子结构与无后效性;重叠子问题决定「是否值得用 DP」(有重叠才赚);「数据是正整数」纯属虚构。
考点:DP 适用条件的辨析。
解析:最优子结构和无后效性是 DP 正确性的硬前提;重叠子问题是 DP 能提速的软前提(没有重叠也能 DP,只是和递归一样慢);数据正负与 DP 无关,打家劫舍照样处理负收益。
排除法:「最优子结构」「无后效性」是必需;「重叠子问题」虽然不决定正确性,但它正是 DP 区别于分治的价值所在,通常也算前提——严格讲本题问的是「不是必需」,唯一确定错误的是「正整数」。
走楼梯问题(一段 级台阶,每次走 1 级或 2 级,问走到第 级有几种走法)、打家劫舍问题(每家有现金,相邻两家不能同时偷,求最大收益),这类问题为什么应该想到用 DP 而不是硬套贪心?
规则:贪心适合「每步的局部最优能推出全局最优」的问题;DP 适合「解由若干子问题的最优组合而成」的问题,它用状态 + 转移覆盖所有可能。
考点:贪心与 DP 的选型判断。
解析:走楼梯的每种走法互不覆盖,贪心选一种就丢掉了其他;打家劫舍「偷第 家」与「不偷」是互斥选项,必须比较两条子问题路径。这类「每条路都要考虑」的问题正是 DP 的主场:状态存每个子问题的最优,转移枚举分支,从底到顶拼出全局最优。
排除法:「代码短」不是选型理由;「整数/实数」与算法类别无关;「不用定义状态」恰恰相反,状态定义是 DP 第一步。
爬楼梯问题(每次走 1 级或 2 级,求走到第 级的走法数)要算到第 级,状态数组开 int dp[105]。下面哪种说法正确?
规则:状态是「到达第几级」,从 0 到 n 共 个;数组大小就是状态数,开 n+1。
考点:状态数组大小的确定。
解析:dp[0] 表示站在地面,dp[n] 表示到第 n 级,都是合法状态,所以数组长度至少 。竞赛里常开大一点(如 )防边界手滑,但原理上状态数决定数组大小。
排除法:开到 会越界访问 dp[n]; 没有必要;「随便开」违背了状态数 = 数组大小的基本对应。
爬楼梯问题(每次走 1 级或 2 级,求走到第 级的走法数)里,状态 dp[i] 应该表示什么?
规则:状态定义回答「dp[i] 存什么」——这里存「到第 级的走法数」。
考点:一维 DP 的状态定义。
解析:题目要的是「走法数量」,所以 dp[i] 存的是数量。「最少步数」是另一类问题(贪心/最短路)的答案口径,走法数不需要最少。状态定义一旦定错,后面转移方程、初值全都会跟着错。
排除法:「最少步数」答非所问;「总长度」「高度」是物理量,与走法数无关。
爬楼梯问题(每次走 1 级或 2 级,求走到第 级的走法数)的状态转移方程是?
规则:转移方程 = (斐波那契式递推)。
考点:一维 DP 转移方程的写法。
解析:到第 级的走法分成两类:最后一步跨 1 级(来自第 级)或跨 2 级(来自第 级),两类相加即可,因为「到第 级的每种走法后面接 1 级」和「到第 级的每种走法后面接 2 级」互不重复。
排除法:+1 是「最后一步只有一种」的错误理解;\times 2 是「每一步两种选择」的错误叠乘;max 是求最优不是数方案。
下面的爬楼梯代码(每次走 1 级或 2 级,求走法数)把初值写错了:dp[1] 应该是 1,却误写成了 2。对 n = 3 运行,输出多少?(正确的 应为 3)
01#include <iostream> 02using namespace std; 03int main() { 04 int n = 3; 05 int dp[6] = {0}; 06 dp[0] = 1; dp[1] = 2; 07 for (int i = 2; i <= n; i++) 08 dp[i] = dp[i - 1] + dp[i - 2]; 09 cout << dp[n] << endl; 10 return 0; 11}
规则:dp[1] = 2 使整个递推链偏大:、,比正确值 3 大 2。
考点:边界初值错导致全盘错。
解析:正确的 1/2 阶爬楼梯 (走法:111、12、21 三种)。误把 dp[1] 写成 2 后,(正确应为 2)、。初值错一格,误差沿着递推向下游传播。
排除法: 是正确版本; 是只错一处的中间值; 是把 也写错的更大偏差。
运行下面的程序,输出多少?
01#include <iostream> 02using namespace std; 03int main() { 04 int n = 7; 05 int dp[10] = {0}; 06 dp[0] = 1; dp[1] = 1; 07 for (int i = 2; i <= n; i++) 08 dp[i] = dp[i - 1] + dp[i - 2]; 09 cout << dp[n] << endl; 10 return 0; 11}
规则: 就是斐波那契数列:、、、、、。
考点:爬楼梯递推的数值追踪。
解析:;;;;;。7 级台阶共 21 种走法。
排除法: 是 ; 是 ; 是常见手算错误()。
为什么 dp[i] = dp[i-1] + dp[i-2] 能把所有走法数全,既不多算也不漏算?
01dp[0] = 1; dp[1] = 1; 02for (int i = 2; i <= n; i++) 03 dp[i] = dp[i - 1] + dp[i - 2];
规则:分类计数的两条纪律——「不重」:按最后一步分类,两类没有交集;「不漏」:任何到达第 级的走法,最后一步只能是 1 或 2,必属于其中一类。
考点:DP 转移方程正确性的论证(分类计数)。
解析:任取一种到第 级的走法,看它最后一步:若跨 1 级,前面必然是「到第 级」的某种走法;若跨 2 级,前面必然是「到第 级」的某种走法。一个走法不可能既最后跨 1 级又最后跨 2 级,所以不重;每种走法都有确定的最后一步,所以不漏。相加即全部。
排除法:「数字恰好相加」是巧合不是理由;「背规律」没解释为什么;「自动去重」DP 不会自动去重,不重是因为分类互斥。
走楼梯问题:一段 级台阶,每次可走 1、2 或 3 级,问走到第 级共有多少种走法。运行下面的程序输出多少?
01#include <iostream> 02using namespace std; 03int main() { 04 int n = 6; 05 int dp[10] = {0}; 06 dp[0] = 1; dp[1] = 1; dp[2] = 2; 07 for (int i = 3; i <= n; i++) 08 dp[i] = dp[i - 1] + dp[i - 2] + dp[i - 3]; 09 cout << dp[n] << endl; 10 return 0; 11}
规则:三步阶梯推 ,。
考点:转移方程随「走法集合」扩展。
解析:最后一步可以是 1、2、3 级三种,所以三类相加。;;;。
排除法: 是 ; 是 ; 是错误的手算。
走楼梯问题:一段 级台阶,每次可走 1、2 或 3 级,问走到第 级共有多少种走法。补全下面的转移方程。
01int n = 6; 02int dp[10] = {0}; 03dp[0] = 1; dp[1] = 1; dp[2] = 2; 04for (int i = 3; i <= n; i++) 05 dp[i] = ____; 06cout << dp[n] << endl;
规则:能走 1、2、3 级,最后一步有三类,转移是三项相加。
考点:转移方程与走法集合的对应。
解析:最后一步跨 1 级来自 ,跨 2 级来自 ,跨 3 级来自 ,三类互斥且完备,所以三项相加。
排除法:两项相加漏掉了跨 3 级的走法;乘法变成「每步乘起来」的错解;max 是求最优不是数方案。
打家劫舍问题:一条街 家,第 家有现金 ,相邻两家不能同时偷,求能偷到的最大金额(以 a = {6, 2, 8, 4} 四家为例)。状态 dp[i] 应该表示什么?
规则:状态定义要「自洽且方便转移」——dp[i] = 前 家能偷的最大金额,第 家偷不偷由转移决定。
考点:最优类 DP 的状态定义。
解析:定义成「第 家必须偷」会让转移被迫「跳一家偷一家」,还要比较两种方案,很不方便;定义成「前 家的最优值」后,转移只需要在「偷第 家」「不偷第 家」间取大。
排除法:「第 家必须偷」限制了状态、写不出干净转移;「第 家现金」是输入数据;「方案数」是计数问题的口径,本题求最大金额。
打家劫舍(第 家有现金 ,相邻两家不能同时偷,求最大金额)。为什么打家劫舍不能写成 dp[i] = dp[i-1] + a[i](每家都在上一家基础上直接加)?用数据 a = {6, 2, 8, 4} 说明。
01dp[0] = a[0]; 02for (int i = 1; i < 4; i++) 03 dp[i] = dp[i - 1] + a[i];
规则:dp[i] = dp[i-1] + a[i] 意味着「第 家和第 家都偷」,直接违反相邻约束。
考点:转移方程必须编码全部约束。
解析:dp[i-1] 的最优方案里很可能已经偷了第 家,再加第 家就相邻了。用 {6,2,8,4} 验算:正确解是偷 6 和 8,共 (或偷 2+8+4 非法、偷 6+8=14 最优);错误写法算出 ,显然不可行。转移必须把「偷第 家就跳过第 家」写进去。
排除法:「结果偏小」方向反了,这个错误结果是偏大(虚高);「负数」「越界」与相邻约束无关。
运行下面的程序,输出多少?
01#include <iostream> 02using namespace std; 03int main() { 04 int a[4] = {6, 2, 8, 4}; 05 int dp[4]; 06 dp[0] = a[0]; 07 dp[1] = max(a[0], a[1]); 08 for (int i = 2; i < 4; i++) 09 dp[i] = max(dp[i - 1], dp[i - 2] + a[i]); 10 cout << dp[3] << endl; 11 return 0; 12}
规则:、、、,输出 。
考点:打家劫舍转移的数值追踪。
解析:;;(偷 6 和 8);(偷 6 和 8 仍最优,偷 2 和 4 只有 6)。
排除法: 是错误写法(每家都偷)的结果; 漏算了 8; 是 的误加(相邻非法)。
打家劫舍(每家有现金 ,相邻两家不能同时偷,求最大金额)。为什么打家劫舍的转移是 dp[i] = max(dp[i-1], dp[i-2] + a[i])?
01dp[0] = a[0]; 02dp[1] = max(a[0], a[1]); 03for (int i = 2; i < n; i++) 04 dp[i] = max(dp[i - 1], dp[i - 2] + a[i]);
规则:对第 家只有「偷 / 不偷」两种决策,分别对应两个子问题,最优 = 两者取大。
考点:决策类 DP 的「选 / 不选」二分支。
解析:不偷第 家:问题退化成「前 家怎么偷最优」,即 。偷第 家:第 家不能偷,最优是「前 家的最优 + 第 家」,即 。两者覆盖全部可能且互斥,取大即答案。
排除法:「先加起来」等于让相邻两家都偷;max 与负数无关(打家劫舍金额本来就是非负);「平均值」没有任何最优性保证。
最小花费爬楼梯(爬到第 级要付 cost[i-1],每次从第 或 级跳上来,到顶即可):dp[i] = min(dp[i-1], dp[i-2]) + cost[i-1]。为什么最后返回 min(dp[n], dp[n-1]) 而不是只返回 dp[n]?
01int dp[6] = {0}; 02dp[1] = cost[0]; 03for (int i = 2; i <= n; i++) 04 dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i - 1]; 05return min(dp[n], dp[n - 1]);
规则:楼顶在第 级之后,最后一步可以从第 级(已付 )跨上来,也可以从第 级(已付 )跨上来;两者取小。
考点:最小花费爬楼梯的终点口径。
解析:dp[i] 表示「到达第 级并付清第 级费用」。楼顶不收费,站在第 级或第 级都能一步到顶(一次跨 1 级或 2 级)。所以答案是到达这两个位置的最小花费,即 min(dp[n], dp[n-1])。若只返回 dp[n],当 dp[n] > dp[n-1] 时就丢了更优方案。
排除法:dp[n] 并不必然更大(要看数据);「下标从 0」与返回逻辑无关;溢出与 min 无关。
最小花费爬楼梯(每级有花费 cost[],每次从 或 级跳上来)里,为什么必须显式写 dp[1] = cost[0],不能依赖数组默认的 0?
01int dp[6] = {0}; 02dp[1] = cost[0]; 03for (int i = 2; i <= n; i++) 04 dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i - 1];
规则:dp[1] 是「到达第 1 级的最小费用」,第 1 级的费用是 cost[0],必须显式付;默认 0 相当于第 1 级免费。
考点:边界初值的正确设定。
解析:整个递推从 dp[1] 出发:dp[2] = min(dp[1], dp[0]) + cost[1]。若 dp[1] 误为 0,则 dp[2] 也偏小,逐级传递下去,最终答案整体偏小。初值错一,全盘皆错,且程序不报错。
排除法:漏写这行照样能编译(数组已初始化);默认 0 不等于 cost[0](除非数据恰好为 0);dp[1] 被 dp[2]、dp[3]…… 一路用到,不是只被用一次。
题目:给定长度为 的数组 cost,每爬一级台阶要付对应的费用。从地面(第 0 级)出发,每次可以跨 1 级或 2 级,问到达楼顶(越过第 级)所需的最小总费用。
本题数据:n = 5,cost = {6, 3, 9, 2, 8}。
01#include <iostream> 02using namespace std; 03int compute(int cost[], int n) { 04 int dp[6] = {0}; 05 dp[1] = cost[0]; 06 for (int i = 2; i <= n; i++) 07 dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i - 1]; 08 return min(dp[n], dp[n - 1]); 09} 10int main() { 11 int cost[5] = {6, 3, 9, 2, 8}; 12 cout << compute(cost, 5) << endl; 13 return 0; 14}
本题第一问:程序最终输出多少?
(1 分)本题第二问:dp[4] 和 dp[5] 分别是多少?
本题第三问:为什么 dp[0] 保持默认值 0 是必要的?
本题第四问:若把 dp[i - 2] 误写成 dp[i - 3],当 i = 2 时会怎样?
本题第五问:若把最后一行改成 return dp[n](不取 min),程序输出多少?
斐波那契递推只用到前两项,于是可以不存数组、只维护两个变量 a、b 滚动前进。这属于哪种优化?
01int a = 0, b = 1; 02for (int i = 2; i <= n; i++) { 03 int c = a + b; 04 a = b; 05 b = c; 06}
规则:滚动数组把空间从 压到 ,时间仍是 (每个状态还是要算一次)。
考点:滚动数组的优化维度。
解析:空间优化的原理是「当前状态只依赖少数几个更早状态」,其它更早的值用完就丢。斐波那契只用前两个,两个变量足够。注意它不省时间——每个 仍然要算一次,只是不存下来而已。
排除法:时间没变,还是 ;「时间和空间都优化」错在时间项;「写法不同」错,空间确实从 变成常数。
运行下面的程序,输出多少?
01#include <iostream> 02using namespace std; 03int main() { 04 int a = 0, b = 1; 05 for (int i = 2; i <= 7; i++) { 06 int c = a + b; 07 a = b; 08 b = c; 09 } 10 cout << b << endl; 11 return 0; 12}
规则:两变量滚动模拟斐波那契,循环结束时 b 等于 。
考点:滚动数组的数值追踪。
解析:初值 a=0, b=1 表示 。每轮 c = a+b 算下一个数,a=b、b=c 后移。i=2 后 b=1();i=3 后 b=2();i=4 后 b=3;i=5 后 b=5;i=6 后 b=8;i=7 后 b=13。输出 13。
排除法: 是 (少跑一轮); 是 ; 是 。
为什么斐波那契递推可以不存整个数组,只保留两个变量?
01int a = 0, b = 1; 02for (int i = 2; i <= n; i++) { 03 int c = a + b; 04 a = b; 05 b = c; 06}
规则:空间能否压缩取决于「依赖窗口」——只依赖最近 个状态就能用 个变量滚动。
考点:滚动数组的适用条件。
解析:算 时, 及更早的已经不可能再被任何未来状态用到( 只需 、),所以可以随时丢弃。这就是滚动数组的本质:依赖关系决定了需要保留的窗口大小。
排除法:数值大小与存储方式无关;「数组浪费内存」不是理论依据;编译器不会自动做这种优化。
题目:一条街上 家店,第 家有现金 ,相邻两家不能同时被偷。求能偷到的最大金额。
本题数据:n = 5,a = {2, 7, 9, 3, 1}。
01#include <iostream> 02using namespace std; 03int main() { 04 int a[5] = {2, 7, 9, 3, 1}; 05 int n = 5; 06 int dp[5]; 07 dp[0] = a[0]; 08 dp[1] = max(a[0], a[1]); 09 for (int i = 2; i < n; i++) 10 dp[i] = max(dp[i - 1], dp[i - 2] + a[i]); 11 cout << dp[n - 1] << endl; 12 return 0; 13}
本题第一问:程序最终输出多少?
(1 分)本题第二问:dp[2]、dp[3]、dp[4] 分别等于多少?
本题第三问:为什么 dp[1] = max(a[0], a[1]) 不能简化成 dp[1] = a[1]?
本题第四问:若数据改成 a = {1, 3, 1, 3, 100},输出多少?
本题第五问:若把 max(dp[i-1], dp[i-2] + a[i]) 误写成 max(dp[i-2], dp[i-2] + a[i])(两个分支都从 i-2 转移),会怎样?
数字三角形从顶到底,每一步可以往左下或右下走,求路径上的最大和。为什么不能用贪心「每步都选大的那个方向」?
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
规则:贪心在每层只留一条「看起来最好」的路,可能把真正的最优路丢在路口。
考点:局部最优不等于全局最优。
解析:顶点 7 往下看,右下的 8 比左下的 3 大,贪心会走 8 那支。但最优路线 7→3→8→7→5 的和是 30,而 7→8 那支整条最多 29(7→8→1→7→5 或 7→8→1→7→5=28、7→8→0→4→5=24……)。贪心在第一层就丢了最优解。DP 的做法是「每层每个位置都算到达它的最大和」,把所有路都保留下来。
排除法:行数奇偶与算法无关;「漏最后一行」贪心恰恰会走到最后一行,问题是路径不对;「方向相反」无稽之谈。
数字三角形(从顶到底每步往左下或右下走,求路径最大和)自底向上 DP 中,dp[i][j] 应该表示什么、怎么转移?
规则:自底向上把 dp[i][j] 定义为「从 出发到最后一行的最大和」:dp[i][j] += max(dp[i+1][j], dp[i+1][j+1])。
考点:数字三角形的状态定义与转移方向。
解析:从 只能去左下 或右下 ,所以到达末行的最大和 = 本格数字 + 两个子位置的最大和取大。逐层从下往上填,最后 dp[0][0] 就是全图最大和。
排除法:「从顶点走到 」是自顶向下的定义;「数字本身」是输入;「最少步数」是另一类问题。
运行下面的程序,输出多少?
01#include <iostream> 02using namespace std; 03int a[5][5] = { 04 {7}, {3, 8}, {8, 1, 0}, {2, 7, 4, 4}, {4, 5, 2, 6, 5} 05}; 06int main() { 07 for (int i = 3; i >= 0; i--) 08 for (int j = 0; j <= i; j++) 09 a[i][j] += max(a[i + 1][j], a[i + 1][j + 1]); 10 cout << a[0][0] << endl; 11 return 0; 12}
规则:自底向上逐层合并,a[0][0] = 30。
考点:数字三角形自底向上的数值追踪。
解析:第 4 行原样 {4,5,2,6,5};第 3 行 {2,7,4,4} 各加下方两格较大值:、、、;第 2 行 {8,1,0} 变 、、;第 1 行 {3,8} 变 、;第 0 行 。
排除法:、 是贪心路径的答案; 漏了一条大支路。
对下面这个数字三角形做自底向上 DP,程序输出多少?
2 3 4 6 5 7 4 1 8 3
01#include <iostream> 02using namespace std; 03int a[4][4] = { 04 {2}, {3, 4}, {6, 5, 7}, {4, 1, 8, 3} 05}; 06int main() { 07 for (int i = 2; i >= 0; i--) 08 for (int j = 0; j <= i; j++) 09 a[i][j] += max(a[i + 1][j], a[i + 1][j + 1]); 10 cout << a[0][0] << endl; 11 return 0; 12}
规则:小三角自底向上合并得到 21。
考点:自底向上 DP 的手算追踪。
解析:末行 {4,1,8,3};第 2 行 {6,5,7} 变 、、;第 1 行 {3,4} 变 、;第 0 行 。路径 2→4→7→8。
排除法: 是另一条小路的和; 是 a[1][1] 的中间值; 是常见手算漏项。
把数字三角形自底向上 DP 的 max 全部改成 min,对下面数据输出多少?
7 3 8 8 1 0 2 7 4 4 4 5 2 6 5
01int a[5][5] = { 02 {7}, {3, 8}, {8, 1, 0}, {2, 7, 4, 4}, {4, 5, 2, 6, 5} 03}; 04int main() { 05 for (int i = 3; i >= 0; i--) 06 for (int j = 0; j <= i; j++) 07 a[i][j] += min(a[i + 1][j], a[i + 1][j + 1]); 08 cout << a[0][0] << endl; 09 return 0; 10}
规则:把 max 换成 min 就变成「最小路径和」,同一框架改一个函数名即可。
考点:DP 求最大/最小只差目标函数(max/min)。
解析:末行 {4,5,2,6,5};第 3 行 {2,7,4,4} 变 、、、;第 2 行 {8,1,0} 变 、、;第 1 行 {3,8} 变 、;第 0 行 。
排除法: 少算一格;、 是最大路径方向的结果。
补全记忆化搜索版数字三角形:dfs(i, j) 返回从 到末行的最大和。
01int memo[5][5]; 02int dfs(int i, int j) { 03 if (i == 4) return a[4][j]; 04 if (memo[i][j] != -1) return memo[i][j]; 05 memo[i][j] = a[i][j] + max(dfs(i + 1, j), dfs(i + 1, j + 1)); 06 return ____; 07}
规则:记忆化返回前先落盘(存进 memo),函数返回的就是 memo 里的值。
考点:记忆化搜索的「判空 + 落盘 + 返回」三件套。
解析:memo[i][j] != -1 判空(-1 表示还没算过);算完先 memo[i][j] = ... 存起来,再 return memo[i][j]。这样下次再问同一个 直接命中缓存,不再递归。
排除法:返回 a[i][j] 丢掉了下方的最大和;返回 memo + a 重复加了本格;返回 dfs(i-1,j) 方向反了(自底向上才往上走)。
记忆化搜索里用 memo[i][j] = -1 表示「还没算过」。为什么不能用默认的 0 表示?
01int memo[5][5]; 02int dfs(int i, int j) { 03 if (i == 4) return a[4][j]; 04 if (memo[i][j] != -1) return memo[i][j]; 05 memo[i][j] = a[i][j] + max(dfs(i + 1, j), dfs(i + 1, j + 1)); 06 return memo[i][j]; 07}
规则:判空标记必须是一个「合法结果永远取不到」的值;负数在本题(全正数)不会出现,是安全标记。
考点:记忆化判空标记的选择。
解析:若结果可能为 0,用 0 当标记就会造成「已经算过且答案是 0」的状态被当成没算,导致重复递归甚至死循环。数字三角形全为正数,答案至少是某个数字,所以 -1 安全。
排除法:快慢与 -1/0 无关;数组 int memo[5][5] 若不初始化是垃圾值,正确代码会先 memset 或显式初始化;-1 当然能存数值,它就是「未算过」这个特殊值。
对 5 行数字三角形跑记忆化搜索 dfs(0,0),有多少个状态会真正执行到 memo[i][j] = ... 那一行(不含底层直接返回、不含命中缓存直接返回)?
01int memo[5][5]; 02int dfs(int i, int j) { 03 if (i == 4) return a[4][j]; 04 if (memo[i][j] != -1) return memo[i][j]; 05 memo[i][j] = a[i][j] + max(dfs(i + 1, j), dfs(i + 1, j + 1)); 06 return memo[i][j]; 07}
规则:非底层状态 个,每个状态第一次被问到时才真正计算,之后全部命中缓存。
考点:记忆化把「每个状态只算一次」落实在具体数字上。
解析:第 0 行 1 个、第 1 行 2 个、第 2 行 3 个、第 3 行 4 个,共 10 个非底层格子需要算 memo[i][j] = ...;第 4 行 5 个直接 return a[4][j] 不走计算行;任何格子算过一次后,再被上层要时都从 memo[i][j] != -1 直接返回。
排除法:15 是把底层直接返回也算成「计算」;25 是朴素递归的调用量级;5 是只数了底层。
数字三角形(每步往左下或右下走,求最大路径和)用记忆化搜索求解,时间复杂度是多少?
规则:状态数 = 三角形格子数 ,每状态 2 种转移,总 。
考点:记忆化搜索的复杂度口径。
解析: 行三角形有约 个格子,每个格子的 dfs 只真正计算一次,每次做两次递归调用(其中第二次大概率命中缓存)。所以总时间 。若用朴素递归不加 memo,是 。
排除法: 是朴素递归; 低估了(每个格子都要算); 没有出处——状态 、每状态常数次转移。
数字三角形用「自底向上的循环 DP」和「自顶向下的记忆化递归」结果相同。为什么它们是等价的?
循环版本:
01for (int i = 3; i >= 0; i--) 02 for (int j = 0; j <= i; j++) 03 a[i][j] += max(a[i + 1][j], a[i + 1][j + 1]);
递归版本:
01int dfs(int i, int j) { 02 if (i == 4) return a[4][j]; 03 if (memo[i][j] != -1) return memo[i][j]; 04 memo[i][j] = a[i][j] + max(dfs(i + 1, j), dfs(i + 1, j + 1)); 05 return memo[i][j]; 06}
规则:DP 的「是什么」由状态定义和转移方程决定,和「怎么算」(循环还是递归)无关;记忆化只是给递归套了缓存。
考点:自底向上与自顶向下的等价性。
解析:两个版本的状态 dp[i][j] 都表示「从 到末行的最大和」,转移都是 + max(下方两格)。自底向上循环先算下层再算上层;记忆化从顶层递归下去问,碰到没算的就算,靠 memo 保证每个格子只算一次。计算图相同、方程相同,结果必然相同。
排除法:递归不会退化成循环;编译器一般不做这种递归→循环优化(尾递归才行);「用同一个数组」不是等价的原因。
关于记忆化搜索和循环递推,下面哪种说法正确?
规则:同阶复杂度、常数差异;记忆化在「状态大量用不到」的场景更省。
考点:两种实现方式的取舍。
解析:数字三角形所有格子都会用到,两者都是 ,递推省去函数调用常数略快。但有的问题状态空间很大而实际会访问到的很少(比如搜索树的稀疏访问),记忆化只算访问到的,递推却要填满整张表——那时记忆化更快。
排除法:差距只是常数;「快一个数量级」都说过头;记忆化当然能处理最优化问题。
记忆化搜索能显著提速的前提是什么?
规则:记忆化只对「重叠子问题」有收益——不重叠时存了也没人再用,白存。
考点:记忆化提速的前提。
解析:记忆化把每个状态的答案存下来供复用。若状态从不重复(每个子问题只被要一次),缓存全是浪费。数字三角形、斐波那契都大量重叠,所以记忆化收益巨大。
排除法:递归深度只受栈限制,与提速前提无关;正负、全局变量都无关。
题目:给出一个数字三角形,从顶端走到底部,每一步可走左下方或右下方,求路径上的数字和的最大值。
本题数据(5 行):
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
01#include <iostream> 02using namespace std; 03int a[5][5] = { 04 {7}, {3, 8}, {8, 1, 0}, {2, 7, 4, 4}, {4, 5, 2, 6, 5} 05}; 06int main() { 07 for (int i = 3; i >= 0; i--) 08 for (int j = 0; j <= i; j++) 09 a[i][j] += max(a[i + 1][j], a[i + 1][j + 1]); 10 cout << a[0][0] << endl; 11 return 0; 12}
本题第一问:程序输出多少?(最大路径和)
(1 分)本题第二问:a[1][1] 在程序运行后变成多少?
本题第三问:若把 i 的循环从 i = 3 改成 i = 0(从顶层往下做自底向上),会怎样?
本题第四问:若把 max 改成 min,程序输出多少?
本题第五问:如果改用记忆化搜索(自顶向下 + memo 判空),得到的结果和循环版一样吗?
(1 分)0/1 背包问题: 件物品第 件重 、价值 (每件选或不选),装进容量 的背包求最大总价值,如 、。状态 dp[i][j] 表示什么?
规则:二维背包的状态 =「考虑前几件 × 容量还剩多少」二元组,存该状态下最大价值。
考点:背包 DP 的状态定义。
解析:dp[i][j] 表示只从前 件里选、总重量不超过 时能拿到的最大价值。两维分别代表「物品维」和「容量维」,这是背包 DP 的状态空间。
排除法:「剩余空间」「能放几件」是别的口径;「第 件的重量」是输入数据不是状态。
两件物品:,容量 。用二维 DP 算出 dp[2][6] 等于多少?
01int w[2] = {2, 4}, v[2] = {3, 6}; 02int dp[3][7] = {0}; 03for (int i = 1; i <= 2; i++) 04 for (int j = 0; j <= 6; j++) { 05 if (j >= w[i - 1]) 06 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i - 1]] + v[i - 1]); 07 else dp[i][j] = dp[i - 1][j]; 08 }
规则:,两件都选:重量 恰好装满,价值 。
考点:二维背包 dp 表的数值追踪。
解析:(只考虑第 1 件,重 2 值 3): 时全为 3。第 2 行:;;。两件全选恰好用满容量 6。
排除法: 是容量 4 或 5 的答案(只装第 2 件); 是把两件价值翻倍; 是只装第 1 件。
三件物品:,容量 。用二维 DP 算出 dp[3][5] 等于多少?
01int w[3] = {2, 3, 4}, v[3] = {3, 4, 5}; 02int dp[4][6] = {0}; 03for (int i = 1; i <= 3; i++) 04 for (int j = 0; j <= 5; j++) { 05 if (j >= w[i - 1]) 06 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i - 1]] + v[i - 1]); 07 else dp[i][j] = dp[i - 1][j]; 08 }
规则:容量 5 的最优是选物品 1 和 2(重量 ,价值 )。
考点:二维背包 dp 表的数值追踪。
解析:;;。所以 。物品 3 重 4,容量 5 装它只剩 1 放不了别的,不如前两件。
排除法: 是把 3 件全选(重量 )的错误; 只选了物品 3; 是 但物品 1 和 3 重量 装不下。
题目: 件物品,第 件重 、价值 ,背包容量 。每件物品最多选一次,求能装下的最大总价值。
本题数据:w = {2, 3, 4, 5},v = {3, 4, 5, 6},容量 V = 8。
01#include <iostream> 02using namespace std; 03int main() { 04 int w[4] = {2, 3, 4, 5}; 05 int v[4] = {3, 4, 5, 6}; 06 int V = 8, n = 4; 07 int dp[5][9] = {0}; 08 for (int i = 1; i <= n; i++) 09 for (int j = 0; j <= V; j++) { 10 if (j >= w[i - 1]) 11 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i - 1]] + v[i - 1]); 12 else dp[i][j] = dp[i - 1][j]; 13 } 14 cout << dp[n][V] << endl; 15 return 0; 16}
本题第一问:程序最终输出多少?
(1 分)本题第二问:dp[4][6] 等于多少?
本题第三问:为什么转移要先判断 j >= w[i-1]?
本题第四问:若把容量改成 V = 9,程序输出多少?
本题第五问:把转移误写成 dp[i][j] = max(dp[i][j-1], dp[i-1][j-w[i-1]] + v[i-1])(「不选」分支改用同一行的 dp[i][j-1]),对一组数据 w = {3, 2}、v = {2, 1}、容量 4 运行,输出会变成多少?
01int w[2] = {3, 2}, v[2] = {2, 1}; 02int dp[3][5] = {0}; 03for (int i = 1; i <= 2; i++) 04 for (int j = 0; j <= 4; j++) { 05 if (j >= w[i - 1]) 06 dp[i][j] = max(dp[i][j - 1], dp[i - 1][j - w[i - 1]] + v[i - 1]); 07 else dp[i][j] = dp[i - 1][j]; 08 }(1 分)
0/1 背包(每件物品选或不选,求容量 内最大价值)优化成一维数组后,容量循环应该怎么写?
规则:一维 0/1 背包必须倒序遍历容量,保证「选第 件」用的是上一轮(还没被本轮污染)的 dp 值。
考点:滚动数组的容量方向。
解析:dp[j] = max(dp[j], dp[j-w] + v) 里,dp[j-w] 必须来自「只考虑前 件」的旧状态。若正序,j-w < j 会先被本轮更新(已经考虑过第 件),于是第 件可能被反复选——变成完全背包。倒序时 j-w 总是还没被本轮更新,语义正确。
排除法:正序会重复选物品;顺序有本质区别;「中间向两边」不存在这种写法。
为什么一维 0/1 背包的容量循环必须倒序?用一个物品(重 2 值 3、容量 4)说明正序会怎样。
倒序写法:
01for (int j = 4; j >= 2; j--) dp[j] = max(dp[j], dp[j - 2] + 3);
正序写法:
01for (int j = 2; j <= 4; j++) dp[j] = max(dp[j], dp[j - 2] + 3);
规则:正序会让小容量的新值污染大容量的旧值,等于「同一件物品反复拿」;倒序保证每个 dp[j-w] 都是上一轮的旧值。
考点:0/1 vs 完全背包的循环方向。
解析:单物品(w=2, v=3, V=4):正序 时 ; 时 ——拿了两件相同的(0/1 背包只能拿一件,应为 3)。倒序 时 ; 时 。结果正确。
排除法:正序不越界();倒序不丢方案;不是常数差异,是正确性差异。
0/1 背包(每件至多选一件)的一维版把容量循环改成正序,得到的算法等价于什么?
规则:0/1 与完全背包一维代码的唯一区别就是容量循环方向:倒序 = 每件最多一次,正序 = 每件无限次。
考点:完全背包 = 正序循环。
解析:正序时 dp[j-w] 可能是本轮已经更新过、包含第 件多次的值,于是第 件可以被「反复叠加」——这正是完全背包「无限取用」的语义。所以同样是两行循环,方向一变模型就变。
排除法:多重/分组背包需要额外维度或分组结构;正序有明确含义(完全背包),不是无意义。
一件物品重 2 值 3,容量 4,用「正序循环」的背包跑一遍,dp[4] 是多少?
01int dp[5] = {0}; 02for (int j = 2; j <= 4; j++) 03 dp[j] = max(dp[j], dp[j - 2] + 3);
规则:正序循环 = 完全背包,同一件物品能反复拿,容量 4 能放两件(),价值 。
考点:完全背包的数值验证。
解析::;:;:。注意 已经是本轮算好的 3,所以放了两件。
排除法: 是 0/1 背包(倒序)的答案; 无依据; 是放四件的错误。
背包的「恰好装满」变体:要求选出的物品总重恰好等于容量(凑不出即无解)。它的一维 DP,dp 数组的初值应该怎么设?(与「最多能装」的区别)
规则:恰好装满要求「装到正好 V」才有效,用 -∞ 标记「该容量无法达到」,只有 dp[0]=0 是天然合法的起点;最多装则所有容量都可以「什么都不装」,全 0。
考点:恰好装满 vs 最多装的初值差异。
解析:最多装时容量 至少可以装 0(不选任何物品),所以 初值全 0,答案就是 。恰好装满时,容量 必须被某组物品精确填满才有效,填不满的用 标记,转移时 max 不会选到它;最后检查 是否仍是 判断是否有解。
排除法:全 0 会让「没恰好装满」的容量也参与比较;「恰好装满全 -∞」丢掉了 的合法起点;「全 -∞」没有起点。
物品:,容量 。用「最多装」和「恰好装满」两种模型分别计算,答案各是多少?
01int w[2] = {4, 5}, v[2] = {10, 100};
最多装模型初值全 0;恰好装满模型初值 dp[0] = 0、其余 -inf。
规则:最多装:容量 8 放第 2 件(重 5 值 100),剩 3 放不下任何东西,共 100;恰好装满:8 无法由 (每件至多一次)恰好凑出,答案是「无解」。
考点:恰好装满与最多装的答案差异。
解析:最多装时第 1 件(重 4)和第 2 件(重 5)合起来 装不下,只能单放第 2 件得 100(放第 1 件只得 10)。恰好装满时要求重量恰好等于 8: 需要两件同物品(0/1 不允许), 没有重 3 的物品,所以 8 不可达,dp[8] 保持 。
排除法: 是两件都装(超容量 9)的错误; 是只放第 1 件; 配 混淆了两个模型的答案。
继续上题数据(物品 ,容量 ):恰好装满版程序怎么判断「8 无法恰好装满」?
规则:恰好装满版用 -∞ 当「不可达」哨兵,最后 dp[V] 若仍为哨兵值说明没有组合能恰好装满。
考点:恰好装满的合法性判定。
解析:转移时 max(dp[j], dp[j-w]+v) 不会选中 (它远小于任何合法值),所以无解容量的值始终保持初值。程序最后判断 dp[V] 是否 > -inf/2 之类阈值即可区分有解无解。
排除法:dp[8] 与 dp[0] 没有可比关系;dp[4] 是否有值不决定 8;无判断会输出哨兵值当答案,明显错误。
「0/1 背包」名字里的 0/1 表示什么?
规则:0/1 = 每件物品「取(1)或不取(0)」二选一。
考点:术语含义。
解析:0/1 背包的约束是「每件物品最多取一次」,决策空间是 。与之相对,完全背包每件可取任意次。名字直接来自决策的二值性。
排除法:「容量 0/1」「重量 0/1」是望文生义;「只选一件」是另一类问题(最多选一件)。
背包容量 ,物品:(价值恰好等于重量)。按「单价相同、重量大价值大」贪心先拿 ,剩容量 4 装不下 和 。为什么这不是最优?下面的 0/1 背包 DP 用同一组数据:
01#include <iostream> 02using namespace std; 03int main() { 04 int w[3] = {10, 9, 5}; 05 int v[3] = {10, 9, 5}; 06 int V = 14; 07 int dp[15] = {0}; 08 for (int i = 0; i < 3; i++) 09 for (int j = V; j >= w[i]; j--) 10 dp[j] = max(dp[j], dp[j - w[i]] + v[i]); 11 cout << dp[V] << endl; 12 return 0; 13}
规则:贪心按单价拿在「可分物品」上正确,但 0/1 背包物品不可分,贪心会留下浪费的空间;DP 枚举「容量组合」才能拿到最优。
考点:0/1 背包为什么必须 DP 而非贪心。
解析:跟踪 DP:只处理 (10,10) 后 dp[10..14] = 10;加上 (9,9) 后 dp[14] 仍是 10(装不下两件);加上 (5,5) 后 dp[14] = max(10, dp[9] + 5) = max(10, 9+5) = 14——(9,9) 和 (5,5) 组合恰占满 14。而贪心先拿 (10,10),剩 4 装不下任何东西,只拿 10。局部最优(单件最大)不保证全局最优,必须用 DP 枚举容量组合。
排除法:贪心在此例明显次优;排序不能解决「组合决策」问题;背包不需装满。
一维写法 dp[j] = max(dp[j], dp[j-w]+v) 只用了 dp[j] 和 dp[j-w] 两个格子,为什么就能覆盖「前 件」的全部信息?
01for (int i = 1; i <= n; i++) 02 for (int j = V; j >= w[i - 1]; j--) 03 dp[j] = max(dp[j], dp[j - w[i - 1]] + v[i - 1]);
规则:滚动数组的精髓 = 用「更新顺序」保证旧状态在被覆盖前用完。
考点:一维滚动数组的正确性论证。
解析:第 轮开始时, 全来自前 件(上一轮的结果)。倒序遍历时更新 用到的 一定还没被本轮更新(,本轮只更新了更大的下标),它仍是「前 件」的旧值——这正是「选第 件」需要的输入。所以一轮下来每个容量都正确完成了「选/不选」二选一。
排除法:dp 数组不是存全部物品信息,是存「当前轮之前的最优」;max 不去重;快慢与正确性无关。
物品:,容量 ,用一维倒序写法跑完,dp[8] 和 dp[7] 分别是多少?
01int w[4] = {2, 3, 4, 5}, v[4] = {3, 4, 5, 6}; 02int dp[9] = {0}; 03for (int i = 0; i < 4; i++) 04 for (int j = 8; j >= w[i]; j--) 05 dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
规则:(物品 2+4, 容量, 价值);(物品 2+3, 容量, 价值)。
考点:一维滚动数组的终值追踪。
解析:容量 8:物品 2(w3,v4)+ 物品 4(w5,v6)= 容量,价值 10;物品 1+3 = 值 8 剩 2 可加物品 1?物品 1 已在组合里。物品 1+2+3 = 。所以 10。容量 7:物品 2+3 = 7,值 9;物品 1+4 = 值 9;并列 9。
排除法: 需要容量 7 装出 10 的组合——物品 2+4 是 不行; 需要三件且不超 8—— 不行。
二维 dp[i][j] 优化成一维 dp[j] 时,为什么可以丢掉「物品维」?
01int dp[9] = {0}; 02for (int i = 0; i < 4; i++) 03 for (int j = 8; j >= w[i]; j--) 04 dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
规则:滚动数组丢维度靠的是「旧值用完后才覆盖」的执行顺序,不是数学恒等。
考点:滚动数组省维度的原理。
解析:二维版里 dp[i][j] 只需要 dp[i-1][...] 上一行,更早的行用不到了。一维版让 dp[j] 在每轮开始时扮演「上一行」,倒序更新保证更新 dp[j] 时它用的 dp[j-w] 还是上一行的值;覆盖发生在它被用完之后。于是物品维被「时间」替代。
排除法:不是「物品顺序无关」——物品顺序影响每轮处理的对象,但总结果不变;编译器不做这种优化;「更快」是副作用不是原理。
补全「恰好装满」0/1 背包的一维转移(NEG 表示不可达)。
01int dp[105]; 02for (int j = 1; j <= V; j++) dp[j] = NEG; 03dp[0] = 0; 04for (int i = 0; i < n; i++) 05 for (int j = V; j >= w[i]; j--) 06 dp[j] = ____;
规则:恰好装满的转移和最多装一模一样(max(dp[j], dp[j-w]+v)),区别只在初值。
考点:恰好装满的转移方程。
解析:转移逻辑不变——容量 恰好装满,要么用前 件恰好装满 (dp[j]),要么用第 件配合前 件恰好装满 (dp[j-w]+v)。因为初值用 NEG 标记不可达,max 不会选到不可达的路径,最后 dp[V] 是否为 NEG 就是有没有解。
排除法:min 是求最小花费的问题;相加是计数问题;直接 dp[j-w] 丢了价值。
「金币无限枚,面值 1、3、4,问能否凑出 6」和「只有一枚 1 元、一枚 3 元、一枚 4 元硬币」这两类问题,分别用什么背包模型?
规则:物品能否重复取用决定背包模型:可无限取 = 完全背包,每种至多一个 = 0/1 背包。
考点:背包模型的识别。
解析:无限枚硬币意味着每种面值可取任意多个,是「完全背包」(正序循环);「只有一枚」是每种至多一个,是「0/1 背包」(倒序循环)。题面里的「有几种/每个数量」直接决定用哪个模型。
排除法:两者模型不同;都能用背包(这是典型的硬币找零/凑数背包);「都不能」错误。
物品:,容量 ,用完全背包的正序一维写法跑完,dp[5] 是多少?
01int w[2] = {1, 3}, v[2] = {1, 8}; 02int dp[6] = {0}; 03for (int i = 0; i < 2; i++) 04 for (int j = w[i]; j <= 5; j++) 05 dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
规则:正序循环 = 完全背包,每件可反复拿:容量 5 里拿 一件加 两件,,价值 。
考点:完全背包的数值追踪(与 0/1 背包对照)。
解析:处理第 1 件 后 dp[j] = j;处理第 2 件 (正序):,,。注意 还是本轮的旧值,拼上 8 恰好是「一件 8 加两件 1」。g++ 实跑验证 。
排除法: 是 0/1 背包(每件至多一次、倒序)的答案; 是只拿一件 ; 是只拿五件 。
补全完全背包二维版的「选第 件」分支(数据:,容量 5)。
01int w[2] = {1, 3}, v[2] = {1, 8}; 02int dp[3][6] = {0}; 03for (int i = 1; i <= 2; i++) 04 for (int j = 0; j <= 5; j++) { 05 if (j >= w[i - 1]) 06 dp[i][j] = ____; 07 else dp[i][j] = dp[i - 1][j]; 08 }
规则:完全背包二维的「选」分支用本行的 dp[i][j-w]——它在这一轮里已经允许拿第 件,链条层层叠加,正好表达「无限取」。
考点:完全背包与 0/1 背包二维转移的唯一差别。
解析:0/1 背包「选」用上一行 dp[i-1][j-w](第 件还没参与过);完全背包用本行 dp[i][j-w](可能已经拿过若干件第 件)。一维正序循环正是这个「本行引用」的滚动等价。本组数据正确版输出 ;误用上一行就退化成 0/1,输出 (均经 g++ 实跑验证)。
排除法:用上一行是 0/1 背包的转移;直接相加丢掉「不选」分支,容量小时会漏掉更优的组合;右端读本行还没算出的 dp[i][j],语义错乱。
同一份二维完全背包代码:「选第 件」的分支为什么用本行的 dp[i][j - w[i-1]],而不能像 0/1 背包那样用上一行的 dp[i-1][j - w[i-1]]?
01for (int i = 1; i <= n; i++) 02 for (int j = 0; j <= V; j++) { 03 if (j >= w[i - 1]) 04 dp[i][j] = max(dp[i - 1][j], dp[i][j - w[i - 1]] + v[i - 1]); 05 else dp[i][j] = dp[i - 1][j]; 06 }
规则:二维完全背包的「选」分支引用本行 = 一维正序循环的滚动等价,两处机制同源:引用「已含第 件」的状态,才可能反复叠加。
考点:完全背包「无限取」的机制(0/1 背包倒序的镜像)。
解析:正序填第 行时,dp[i][j-w] 先于 dp[i][j] 被算出,它里面可能已经是「拿了一或几件第 件」的值;+v[i-1] 再叠一件,就实现了「同一件反复拿」。上一行 dp[i-1][...] 整行都不含第 件,一次转移最多拿一件——那正是 0/1 背包。一维写法里「正序」让 dp[j-w] 先被本轮更新,与本行引用完全同构(上一节 0/1 倒序题是同一机制的另一面)。
排除法:j-w 有 if 挡着不会越界;快慢与正确性无关;用上一行结果真会变(本组数据 ),不是等价写法。
题目:面值 1、2、5 的硬币各无限枚,凑出总金额 5,有多少种凑法?(1+2+2 和 2+1+2 算同一种)
01#include <iostream> 02using namespace std; 03int main() { 04 int c[3] = {1, 2, 5}; 05 int dp[6] = {0}; 06 dp[0] = 1; 07 for (int i = 0; i < 3; i++) 08 for (int j = c[i]; j <= 5; j++) 09 dp[j] += dp[j - c[i]]; 10 cout << dp[5] << endl; 11 return 0; 12}
本题第一问:程序输出多少?
(1 分)本题第二问:为什么 dp[0] 要初始化为 1 而不是 0?
本题第三问:只保留面值 1 和 2(删掉面值 5),凑 5 有几种凑法?
(1 分)本题第四问:把两重循环换位——容量 j 放外层、面值放内层(其余不变),凑 5 的答案变成多少、为什么?
本题第五问:只跑到面值 1、2 处理完(面值 5 还没进循环)时,dp[4] 是多少?
运行下面的程序,输出多少?
01#include <iostream> 02using namespace std; 03int main() { 04 int c[3] = {1, 5, 7}; 05 int T = 10; 06 int dp[11]; 07 for (int j = 0; j <= T; j++) dp[j] = 99; 08 dp[0] = 0; 09 for (int i = 0; i < 3; i++) 10 for (int j = c[i]; j <= T; j++) 11 dp[j] = min(dp[j], dp[j - c[i]] + 1); 12 cout << dp[T] << endl; 13 return 0; 14}
规则:最少枚数 = 取 min 的完全背包:凑 10 最少 枚()。
考点:硬币最少枚数的完全背包实现(g++ 实跑验证)。
解析:面值 1:;面值 5(正序,可重复用):、;面值 7:, 尝试 不更优,保持 2。贪心「先拿最大的 7」得 共 4 枚,DP 找到 更少。
排除法: 是贪心的枚数;、 没有对应的组合。
硬币面值只有 5 和 7,要凑 3 元——根本凑不出。下面的程序输出 99 而不是 0。初值为什么要这样设?
01#include <iostream> 02using namespace std; 03int main() { 04 int c[2] = {5, 7}; 05 int T = 3; 06 int dp[4]; 07 for (int j = 0; j <= T; j++) dp[j] = 99; 08 dp[0] = 0; 09 for (int i = 0; i < 2; i++) 10 for (int j = c[i]; j <= T; j++) 11 dp[j] = min(dp[j], dp[j - c[i]] + 1); 12 cout << dp[T] << endl; 13 return 0; 14}
规则:min 类 DP 用「极大值 = 不可达」当哨兵;dp[0]=0 是唯一合法种子;末值仍是哨兵即无解。
考点:最少枚数模型的三件套(极大值初值、dp[0]=0、无解判定)。
解析:若初值全 0,dp[j]=min(0, dp[j-c]+1) 永远停在 0——凑不出也显示「0 枚」,错得很安静。设 99 后,有解的格子必然被某个可行方案压到 99 以下,无解的保持 99;程序最后判 dp[T] >= 99(或大于某个阈值)即可区分有无解。g++ 实跑:本程序输出 99。
排除法:int 上限约 ,99 只是个「足够大」的哨兵;dp[0]=0 是合法起点不是非法金额;全 0 初值会让 min 永远取 0,全表作废。
同一套「面值 1、2、5 凑 5」的完全背包框架:甲程序内层转移写 dp[j] += dp[j - c](初值 dp[0]=1),乙程序写 dp[j] = min(dp[j], dp[j - c] + 1)(初值 dp[0]=0、其余 99)。甲、乙分别求什么?
01dp[0] = 1; 02for (int i = 0; i < 3; i++) 03 for (int j = c[i]; j <= 5; j++) 04 dp[j] += dp[j - c[i]];
规则:+= 是计数(方案累加),min + 1 是步数(每用一枚硬币记 1)——两种转移对应完全背包的两个经典变种。
考点:凑方案数与最少枚数的转移辨识。
解析:甲把「凑出 的每种方案再接一枚 」全部累加,得凑法总数 4;乙每次转移花 1 枚,min 挑最少,凑 5 用一枚面值 5 即 1 枚(g++ 实跑验证两个程序的输出)。两者初值也不同:计数靠 dp[0]=1 当种子,步数靠 dp[0]=0(0 元 0 枚)。
排除法:甲乙正好说反;转移不同含义就不同;本题没有「价值」概念,与 max/min 价值无关。
0/1 背包每件至多拿 1 件,完全背包每件拿无限件。「多重背包」的「多重」指的是什么?
规则:多重背包 = 每件物品的件数上限是给定的 :0 件到 件任取,介于 0/1(上限 1)与完全(上限 )之间。
考点:三类背包的约束辨析。
解析:多重背包只多了一个参数 。 时退化为 0/1 背包, 时再拿也装不下、等价于完全背包——它是中间形态。
排除法:「装下好几组」是分组背包的领地且描述不对;「拆成小份」是可分背包(贪心场景);「多个背包」是另一类问题。
一件物品重 2 值 3、最多拿 3 件,容量 。把它拆成 3 件独立的 0/1 物品后跑 0/1 背包,dp[8] 是多少?
01#include <iostream> 02using namespace std; 03int main() { 04 int V = 8, s = 3, w = 2, v = 3; 05 int dp[9] = {0}; 06 for (int k = 0; k < s; k++) 07 for (int j = V; j >= w; j--) 08 dp[j] = max(dp[j], dp[j - w] + v); 09 cout << dp[V] << endl; 10 return 0; 11}
规则:拆分后是 3 件相同的 0/1 物品,最多同时选中 3 件:,价值 。
考点:多重背包朴素拆分的数值追踪(g++ 实跑验证 9)。
解析:3 件拆分件各至多选一次,倒序循环保证这一点。第 1 件后 ;第 2 件后 ;第 3 件后 。若去掉「最多 3 件」的限制(完全背包),容量 8 能装 4 件,得 12——上限 的意义就是把它压回 9。
排除法: 是完全背包(无限拿)的答案; 只拿了两件; 只拿了一件。
多重背包的朴素解法是把第 件复制 份、当普通 0/1 物品跑。为什么这样拆是等价的?
01for (int k = 0; k < s; k++) 02 for (int j = V; j >= w; j--) 03 dp[j] = max(dp[j], dp[j - w] + v);
规则:同质拆分: 个相同「名额」,0/1 每件至多一次 = 名额至多用一次。
考点:多重背包朴素拆分的正确性。
解析:拆分件之间没有任何差别,最优方案「拿 件」总可以对应到「恰好选中 个拆分件」;反过来,0/1 方案选中几件就等于拿几件,永远不会超过 。两边一一对应,答案相等。代价:物品总数从 变成 ,复杂度 。
排除法:容量没变;转移方程不会「自动去重」,是拆分结构保证了件数上限;多重背包本身就能 DP(拆分只是最朴素的实现)。
把「最多 件」按 (最后不够补余数)打包成几件 0/1 物品。为什么这样拆又对又省?
01int cnt = 0; 02for (int k = 1; s > 0; k *= 2) { 03 int take = min(k, s); 04 w2[cnt] = w * take; 05 v2[cnt] = v * take; 06 cnt++; 07 s -= take; 08}
规则: 连续覆盖,再加余数补到 :任何「拿 件」()都能由这些打包件的某个子集凑出。
考点:二进制拆分的正确性与效率。
解析: 的子集和覆盖 的每个整数(二进制表示),余数把上界延到 。于是「拿 件」总能被表达,且不会表达出 的件数(子集和最大就是 )。件数从 降到 : 拆 共 4 件; 只拆 10 件。
排除法:对齐与溢出都不是正确性理由;拆完各件重量是 的不同倍数,并不相等。
用上面的二进制拆分代码处理,拆出了几件、每件对应几件原物品?
01int cnt = 0; 02for (int k = 1; s > 0; k *= 2) { 03 int take = min(k, s); 04 cnt++; 05 s -= take; 06}
规则: 依次取 (累计 7),余 一次补完:拆出 共 4 件。
考点:二进制拆分的执行追踪(g++ 实跑验证 parts = 1 2 4 3)。
解析:第 1 轮 ,take=min(1,10)=1,剩 9;第 2 轮 ,take=2,剩 7;第 3 轮 ,take=4,剩 3;第 4 轮 但 min(8,3)=3,剩 0,结束。子集和覆盖 :如拿 5 件取 ,拿 9 件取 ,拿 10 件全取。
排除法: 是从大往小贪心拆; 不是 2 的幂序列;「十个 1」正是朴素拆分,二进制拆分要避免的就是它。
「分组背包」问题里,物品被分成了若干组。它的选取规则是?
规则:分组背包 = 组内互斥(至多一件),组间独立(每组都可不选或选一件)。
考点:分组背包的约束识别。
解析:典型情境如「每类课程最多选一门」「每个部件从一类品牌里挑一个」。 组、每组若干件,容量共享,求最大价值。注意「至多」——允许某组一件都不选。
排除法:「恰好一件」少了「不选」的自由;「全选」是约束反了;「只能选一组」过度限制,组与组之间没有互斥。
题目:分组背包:物品分成 2 组,每组至多选 1 件,背包容量 。组 1:;组 2:。求最大总价值。
01#include <iostream> 02using namespace std; 03int main() { 04 int V = 7; 05 int gw[2][2] = {{3, 4}, {3, 2}}; 06 int gv[2][2] = {{5, 6}, {4, 2}}; 07 int gn[2] = {2, 2}; 08 int dp[8] = {0}; 09 for (int i = 0; i < 2; i++) 10 for (int j = V; j >= 0; j--) 11 for (int t = 0; t < gn[i]; t++) 12 if (j >= gw[i][t]) 13 dp[j] = max(dp[j], dp[j - gw[i][t]] + gv[i][t]); 14 cout << dp[V] << endl; 15 return 0; 16}
本题第一问:程序输出多少?
(1 分)本题第二问:程序跑完后 dp[5] 是多少?
本题第三问:为什么容量循环 j 放在组内物品循环 t 的外面?
本题第四问:若把循环改成「组内每件物品各配一轮完整的容量倒序循环」(当成两件独立 0/1 物品),输出变成多少?
(1 分)本题第五问:容量改成 ,输出多少?
(1 分)某物品「最多拿 1000 件」。朴素拆分与二进制拆分分别把它拆成多少件 0/1 物品?
规则:朴素拆 件;二进制拆 加余数,件数 。
考点:两种拆分的件数量级。
解析:,余 一次补上,共 10 件(g++ 实跑验证 cnt=10)。背包总复杂度从 降到 —— 时两者差 100 倍。
排除法:二进制拆分不可能比朴素还多;朴素拆分没有「拆一半」的说法;两个数对调是混淆了两种方法。
最长上升子序列 LIS:在数列 中跳着选出一个严格递增的子序列,求最长长度——如 的答案是 、长 。它的 做法双层循环里 dp[i] 的含义是?
规则:这套 模板的状态口径是「以 结尾」:转移 要求 且 ,拼接接口就是结尾。
考点:LIS 状态定义辨析。
解析:「以 结尾」让每个 有确定的最后元素,新数能不能接在它后面一眼可判。「前 个里最长」是初学者最常见的混淆口径——它存的是全表冠军,但不知道冠军结尾是什么数,没法转移(见本节「为什么」题)。「以 开头」是反向状态的口径,对应从右往左的另一套写法,不是本模板。
排除法:「删掉第 个」与题意无关;「前 个里最长」是混淆口径;「开头」是反向定义。
LIS 的 代码里,dp[i] 为什么必须定义为「以 结尾」的最长上升子序列,而不能定义成「前 个数里最长」?
01for (int i = 0; i < n; i++) { 02 dp[i] = 1; 03 for (int j = 0; j < i; j++) 04 if (a[j] < a[i]) dp[i] = max(dp[i], dp[j] + 1); 05}
规则:状态定义要给转移留接口——「结尾固定」才能判断「能不能接」。
考点:LIS 状态设计的核心思考。
解析:转移 的前提是「 能接在以 结尾的那条子序列后面」,判断依据正是 。若 是「前 个里最长」,它可能是以任何数结尾的, 是否能延长它完全未知——方程写不出来。「以 结尾」牺牲了「答案就是 」的便利(要全表取 max),换来可转移性。
排除法:LIS 不要求每个数都被选;数组大小与定义无关;「前 个里最长」是确定的值、完全能存,只是没法转移。
运行下面的程序,输出多少?
01#include <iostream> 02using namespace std; 03int main() { 04 int a[3] = {2, 3, 1}; 05 int dp[3]; 06 for (int i = 0; i < 3; i++) { 07 dp[i] = 1; 08 for (int j = 0; j < i; j++) 09 if (a[j] < a[i]) dp[i] = max(dp[i], dp[j] + 1); 10 } 11 cout << dp[2] << endl; 12 return 0; 13}
规则: 是「以 结尾」的最长上升子序列长度 :1 前面没有比它小的数。
考点:结尾口径与「前 个里最长」的区分(g++ 实跑验证输出 1)。
解析:(以 2 结尾);(以 3 结尾,2 接在前面);: 都不小于 1,没有转移来源,保持初值 1。注意整条数列的 LIS 是 2(2,3)——但那不是 :dp[i] 只对「以 结尾」负责。
排除法: 是「前 3 个里最长」口径的答案,也是全表 max; 无依据; 错,至少含 自己这一个数。
题目:求数列 的最长严格上升子序列长度。
01#include <iostream> 02using namespace std; 03int main() { 04 int a[7] = {1, 7, 3, 5, 9, 4, 8}; 05 int n = 7, dp[7], ans = 0; 06 for (int i = 0; i < n; i++) { 07 dp[i] = 1; 08 for (int j = 0; j < i; j++) 09 if (a[j] < a[i]) dp[i] = max(dp[i], dp[j] + 1); 10 if (dp[i] > ans) ans = dp[i]; 11 } 12 cout << ans << endl; 13 return 0; 14}
本题第一问:程序输出多少?
(1 分)本题第二问:dp[3](以 5 结尾)是多少?
本题第三问:答案为什么要用 ans = max(ans, dp[i]) 扫全表,而不是直接输出 dp[n-1]?
本题第四问:把内层条件改成 a[j] <= a[i],换数据 跑,输出变成多少、含义是什么?
本题第五问:把最后一个 8 删掉(数组变 6 个数),输出多少?
(1 分)补全 LIS 内层转移(dp[i] 已初始化为 1)。
01for (int i = 0; i < n; i++) { 02 dp[i] = 1; 03 for (int j = 0; j < i; j++) 04 if (a[j] < a[i]) 05 dp[i] = ____; 06}
规则:能接上()就把「以 结尾的最长」延长一格,与当前值取 max。
考点:LIS 转移方程的代码实现。
解析:dp[j] + 1 = 以 结尾的子序列加上 ;dp[i] 保留「不接这条、接别的」的选择,所以外面包 max。初值 1 兜底「前面没有更小的数」的情形。
排除法:求长度用 min 方向反了;dp[j-1] 是「前一个下标」不是「前驱元素」;长度里混进 a[i] 的值语义错乱。
数列 ,用同一套 LIS 代码(答案 = 全表 max),输出多少?
01for (int i = 0; i < n; i++) { 02 dp[i] = 1; 03 for (int j = 0; j < i; j++) 04 if (a[j] < a[i]) dp[i] = max(dp[i], dp[j] + 1); 05 if (dp[i] > ans) ans = dp[i]; 06}
规则: 表为 1 2 3 1 2(g++ 实跑验证),全表 max :。
考点:答案位置在前缀峰值的高频陷阱。
解析:前三个数一路上升, 是全表最大;末尾的 1、2 只能各自开头再来,、。若偷懒输出 dp[n-1] 得 2,正好掉坑。
排除法: 是 dp[n-1] 陷阱值; 是把「5 个数全选」当上升(1 接在 7 后不升); 只算了单个数。
最长公共子序列 LCS:对两个字符串(如 abcde 与 ace,公共子序列 ace),找出按顺序出现在两者中的最长子序列,求其长度。二维 DP:dp[i][j] 表示什么?
规则:这套模板的状态是「前缀 × 前缀」: = 与 的 LCS 长度,边界 ,答案 。
考点:LCS 状态定义辨析。
解析:前缀口径的转移只需要「末字符比一次 + 三个邻格」,完整覆盖所有子问题。「以 、 结尾」是另一套可行状态(相等时才增长),与本题代码的 dp[i][j] 含义不同——不相等时本代码的 dp[i][j] 仍可能很大,因为它不要求结尾配对。
排除法:「结尾」口径与本代码不符;「是否相同」只是一个布尔判断不是累计长度;「后缀」口径下标方向反了。
运行下面的程序,输出多少?
01#include <iostream> 02#include <cstring> 03using namespace std; 04int main() { 05 char a[] = "abcde", b[] = "ace"; 06 int n = strlen(a), m = strlen(b); 07 int dp[7][5] = {0}; 08 for (int i = 1; i <= n; i++) 09 for (int j = 1; j <= m; j++) { 10 if (a[i - 1] == b[j - 1]) 11 dp[i][j] = dp[i - 1][j - 1] + 1; 12 else 13 dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]); 14 } 15 cout << dp[n][m] << endl; 16 return 0; 17}
规则:abcde 与 ace 的 LCS 是 ace,长度 。
考点:LCS 转移的数值追踪(g++ 实跑验证输出 3)。
解析:逐行填表:(a):与 a 相等得 1,后两列继承 1;(b):全 1;(c):与 c 相等 ;(d):继承 2;(e):与 e 相等 。公共子序列 ace 三个字符都配上了对。
排除法: 漏了 e 的配对; 无从谈起(b、d 根本不在 b 串里); 只数了 a。
题目:字符串 = abcbdab, = bdcaba,求最长公共子序列的长度。
01#include <iostream> 02#include <cstring> 03using namespace std; 04int main() { 05 char a[] = "abcbdab"; 06 char b[] = "bdcaba"; 07 int n = strlen(a), m = strlen(b); 08 int dp[9][8] = {0}; 09 for (int i = 1; i <= n; i++) 10 for (int j = 1; j <= m; j++) { 11 if (a[i - 1] == b[j - 1]) 12 dp[i][j] = dp[i - 1][j - 1] + 1; 13 else 14 dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]); 15 } 16 cout << dp[n][m] << endl; 17 return 0; 18}
本题第一问:程序输出多少?
(1 分)本题第二问:dp[2][2](a 的前 2 个 ab 与 b 的前 2 个 bd)是多少?
本题第三问:不相等时,转移为什么只取 max(dp[i-1][j], dp[i][j-1])(两个「只删一边末字符」的方向),而不需要 dp[i-1][j-1] + 1?
本题第四问:把相等分支误写成 dp[i][j] = dp[i - 1][j - 1](漏了 +1),输出变成多少?
本题第五问:相等时直接写 dp[i][j] = dp[i-1][j-1] + 1,为什么不用再和 max(dp[i-1][j], dp[i][j-1]) 比较一次?
补全 LCS 的不相等分支。
01for (int i = 1; i <= n; i++) 02 for (int j = 1; j <= m; j++) { 03 if (a[i - 1] == b[j - 1]) 04 dp[i][j] = dp[i - 1][j - 1] + 1; 05 else 06 dp[i][j] = ____; 07 }
规则:不相等时在「删 a 末字符」「删 b 末字符」两个子问题里取大者。
考点:LCS 转移的代码实现。
解析:dp[i-1][j]、dp[i][j-1] 分别是「a 退一格」「b 退一格」的最优,两者覆盖了所有可能(见本节第三问的为什么)。+1 只属于相等分支,加在这里会把不相等的字符也凭空配对。
排除法:右下角引用了还没算的 dp[i][j] 自己;只取 dp[i-1][j-1] 同时删两个末字符会丢解(某一边的末字符本可单独配对);不相等不许 +1。
把两个串对调: = ace, = abcde(前文小例的镜像),用同一套代码算,输出多少?
01char a[] = "ace", b[] = "abcde"; 02int dp[5][8] = {0}; 03for (int i = 1; i <= n; i++) 04 for (int j = 1; j <= m; j++) { 05 if (a[i - 1] == b[j - 1]) 06 dp[i][j] = dp[i - 1][j - 1] + 1; 07 else 08 dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]); 09 }
规则:LCS 对两个串对称:,仍是 (ace)。
考点:换数据再追踪 + 对称性验证(g++ 实跑验证输出 3)。
解析:对调后表变成 (行是 ace,列是 abcde),逐行填:a 配上得 1;c 与 abc 的 c 相对得 2;e 与 abcde 的 e 相对得 3。转移方程对 、 完全对称,答案不变。
排除法:、 是没填完表的半截值; 是把整条 串当公共子序列(ace 只有 3 个字符)。
「最长公共子序列」和「最长公共子串」的区别是什么?
规则:子序列保序可不连续;子串必须连续。两者是不同的 DP 模型。
考点:术语辨析(高频混淆点)。
解析:ace 是 abcde 的子序列(跳过了 b、d),但不是子串。LCS 的转移允许「两边各自跳到下一个配对字符」;最长公共子串要求配对字符一个紧挨一个,转移是 (相等)/ (不相等,断了就归零)。真题里两者的题面措辞一字之差,读题先圈出「子序列」还是「子串」。
排除法:第一项正好说反;两者模型不同不是同义词;子序列对两串长度没有相等要求。
有面值 1、3、4 的硬币各无限枚,要凑出 6 元、硬币数最少。贪心「每次尽量用最大面值」会选 共 3 枚。为什么这不是最优?下面的完全背包 DP(硬币可无限取,正序循环)算最少枚数:
01#include <iostream> 02using namespace std; 03int main() { 04 int c[3] = {1, 3, 4}; 05 int T = 6; 06 int dp[7]; 07 for (int j = 0; j <= T; j++) dp[j] = 99; 08 dp[0] = 0; 09 for (int i = 0; i < 3; i++) 10 for (int j = c[i]; j <= T; j++) 11 dp[j] = min(dp[j], dp[j - c[i]] + 1); 12 cout << dp[T] << endl; 13 return 0; 14}
规则:贪心在「币值不成倍数」时不保证最优——本例 用 2 枚优于贪心的 3 枚。
考点:贪心与 DP 的边界(组合决策必须枚举)。
解析:跟踪 DP:面值 1 使 dp[1..6] = 1..6;面值 3 时 dp[3]=1、dp[6] = min(6, dp[3]+1) = 2(正序循环让 3 可以复用,正是「硬币无限枚」);面值 4 后 dp[6] 保持 2。所以凑 6 最少 2 枚()。贪心拿 4 后余量 2 只能用 1 补,共 3 枚。这类「选哪些面值」的组合决策没有贪心捷径,得用 DP 枚举。
排除法:幂与贪心正确性无关(面值 1、5、10 之类倍数关系才支持贪心);「4 不该存在」无稽之谈;完全背包也能处理无限枚,说「贪心处理不了无限枚」混淆了模型。
下面的爬楼梯代码(每次走 1 或 2 级,求走法数)有个 bug,n = 6 时输出多少?(正确的 1/2 阶爬楼梯 应为 )
01#include <iostream> 02using namespace std; 03int main() { 04 int n = 6; 05 int dp[10] = {0}; 06 dp[0] = 1; dp[1] = 1; 07 for (int i = 2; i <= n; i++) 08 dp[i] = dp[i - 1] * 2; 09 cout << dp[n] << endl; 10 return 0; 11}
规则:dp[i] = dp[i-1] * 2 错在「每级都把上一步的每种走法再走 2 级」——它把「从 走 1 级」和「从 走 2 级」混成一种,重复计数。
考点:转移方程写错导致计数失真。
解析:dp[i-1]*2 的语义是「到 的每种走法都有两种延伸」——但延伸到 的合法方式只有「跨 1 级」一种(跨 2 级会越过 )。正确的「跨 1 级 + 跨 2 级」两类分类被偷换成了「一步变两步」,数值变成 :、、、、。
排除法: 是正确版本; 是 再减一的手算错; 是 。
青蛙每次可以跳 1 级或 3 级台阶,求跳到第 级的跳法数。运行下面的程序输出多少?
01#include <iostream> 02using namespace std; 03int main() { 04 int n = 9; 05 int dp[12] = {0}; 06 dp[0] = 1; dp[1] = 1; dp[2] = 1; 07 for (int i = 3; i <= n; i++) 08 dp[i] = dp[i - 1] + dp[i - 3]; 09 cout << dp[n] << endl; 10 return 0; 11}
规则:最后一步跨 1 级或 3 级,所以 ,。
考点:转移方程随「跳法集合」变化。
解析:;;;;;;。
排除法: 是 ; 是 1/2/3 阶 附近的量级; 是手算错。
下面的打家劫舍代码(每家有现金 ,相邻两家不能同时偷,求最大金额)把 dp[1] 错写成 dp[1] = a[1](漏掉了和 a[0] 取 max),对 a = {4, 1, 1, 5} 输出多少?(正确代码输出 9)
01#include <iostream> 02using namespace std; 03int main() { 04 int a[4] = {4, 1, 1, 5}; 05 int dp[4]; 06 dp[0] = a[0]; 07 dp[1] = a[1]; 08 for (int i = 2; i < 4; i++) 09 dp[i] = max(dp[i - 1], dp[i - 2] + a[i]); 10 cout << dp[3] << endl; 11 return 0; 12}
规则: 漏了和 a[0] 取 max,等于丢弃「只偷第 1 家」的方案,错误会沿递推一路传递。
考点:边界初值错导致全盘错(P16 型错误)。
解析:正确版本 、()、、。错误版本 ,于是 、。正确方案「偷 4 和 5」得 9,被初值错误连累成了 6。
排除法: 是正确版答案; 是 相邻非法; 是中间值。
一维 0/1 背包的容量循环被误写成 j > w[i](漏了 =),物品重 2 值 3、容量 时,dp[2] 是多少?
01int w = 2, v = 3, V = 3; 02int dp[4] = {0}; 03for (int j = V; j > w; j--) 04 dp[j] = max(dp[j], dp[j - w] + v);
规则:j > w 让 j == w 这一格永远进不了循环——恰好装下这件物品的容量从未被转移。
考点:循环边界漏等号的典型 bug。
解析:正确写法 j >= w 会更新 dp[2]( 时 )。误写 j > w 后,V=3 时只有 进循环:; 因为 2 > 2 为假被跳过,dp[2] 保持 0。凡是「容量正好等于某物品重量」的组合全部漏掉。
排除法: 是 dp[3] 的值;、 是物品重量不是 dp 值。
打家劫舍升级成环形: 家围成一圈,首尾两家也相邻,不能同时偷。做法是「分别做 rob(去掉第 1 家) 和 rob(去掉最后一家) 两个直线问题,取较大值」。为什么这样拆?看下面的实现(rob 是直线打家劫舍):
01#include <iostream> 02using namespace std; 03int rob(int a[], int n) { 04 if (n == 1) return a[0]; 05 int dp[105]; 06 dp[0] = a[0]; 07 dp[1] = max(a[0], a[1]); 08 for (int i = 2; i < n; i++) 09 dp[i] = max(dp[i - 1], dp[i - 2] + a[i]); 10 return dp[n - 1]; 11} 12int main() { 13 int a[3] = {2, 3, 2}; 14 int b[2] = {a[1], a[2]}; 15 int c[2] = {a[0], a[1]}; 16 cout << max(rob(b, 2), rob(c, 2)) << endl; 17 return 0; 18}
规则:环形问题通过「排除一个端点」拆成两个直线子问题取 max。
考点:环形结构的线性 DP 处理(拆成两个直线子问题取 max)。
解析:直线打家劫舍不会同时约束首尾;环多了一条「1 与 n 相邻」。分类:若最优方案不偷第 1 家,那第 2..n 家之间只剩「相邻约束」,正是直线问题(b 去掉第 1 家);若最优方案不偷第 家,同理是第 1..n-1 的直线问题(c 去掉最后一家)。因为方案要么不偷 1 要么不偷 n(不可能两个都偷),两类合起来覆盖所有合法方案,取大即环的最优。本例 rob({3,2})=3、rob({2,3})=3,答案是 3。
排除法:「多一堆石头」是凭空捏造的约束,不是本题;「切两条链」表述不准确——是两个直线子问题取最值;「必须都偷」违反题意。
环形打家劫舍(相邻两家不能同时偷,且首尾相邻围成一圈):a = {2, 3, 2} 围成一圈。用「去掉第 1 家 / 去掉最后一家取 max」的方法,答案是多少?
01int rob(vector<int> v); 02int main() { 03 int a[3] = {2, 3, 2}; 04 cout << max(rob({3, 2}), rob({2, 3})) << endl; 05 return 0; 06}
规则:环形答案 3,比直线答案 4 小——因为首尾相邻后「偷 2 和 2」不合法了。
考点:环形约束下答案的变化。
解析:直线版 (偷 3 和两端之一?直线可偷两个 2 得 4 或偷 3 得 3,最优 4)。环形下两端 2 与 2 相邻,只能取其一:;。取 max 得 3。合法方案只有「偷中间的 3」或「偷某个 2」,最优 3。
排除法: 是直线答案(偷两端两个 2),环形不合法; 是三家和; 是只偷一个 2。
从 网格左上角走到右下角,每次只能向右或向下走。用 DP 求路径数,状态 dp[i][j] 应该表示什么?
规则:dp[i][j] = 从起点到 的路径条数,转移是 。
考点:二维状态的定义。
解析:只能右/下走,到达 前一步要么来自上方 要么来自左方 ,两类路径数相加。边界:第一行、第一列都只有 1 条路径(一路直走)。
排除法:「还能走几步」是另一类状态;「网格数字」是输入;「最少步数」是 常数,与路径数无关。
题目:一个 网格(3 行 3 列),从左上角 出发,每次只能向右或向下走,求到达右下角 的不同路径数。
01#include <iostream> 02using namespace std; 03int main() { 04 int dp[3][3] = {0}; 05 for (int j = 0; j < 3; j++) dp[0][j] = 1; 06 for (int i = 0; i < 3; i++) dp[i][0] = 1; 07 for (int i = 1; i < 3; i++) 08 for (int j = 1; j < 3; j++) 09 dp[i][j] = dp[i - 1][j] + dp[i][j - 1]; 10 cout << dp[2][2] << endl; 11 return 0; 12}
本题第一问:程序输出多少?
(1 分)本题第二问:为什么 dp[0][j] 和 dp[i][0] 要单独赋成 1?
本题第三问:为什么 dp[i][j] = dp[i-1][j] + dp[i][j-1] 不重不漏?
本题第四问:若把网格改成 (3 行 4 列),输出多少?
(1 分)本题第五问:时间复杂度是多少?
(1 分)C++ 里 int dp[10]; 声明在函数内部且不初始化,下面的说法哪个正确?
规则:局部变量/数组不初始化 = 未定义行为(垃圾值);全局数组才自动清零。
考点:DP 数组初值必须显式给。
解析:DP 的所有递推都依赖正确的初值(dp[0]=...、dp[1]=...),垃圾值会让整张表错乱且不报错。安全写法:int dp[105] = {0}(全 0)或显式 memset;全局数组 int dp[105]; 自动 0。
排除法:局部数组不自动清零;不初始化是合法语法(能编译);OS 不会为栈内存清零。
数字三角形记忆化版里漏写了 if (memo[i][j] != -1) return memo[i][j]; 这一行判空。程序还能算对吗?复杂度怎样?
01int memo[5][5]; 02int dfs(int i, int j) { 03 if (i == 4) return a[4][j]; 04 memo[i][j] = a[i][j] + max(dfs(i + 1, j), dfs(i + 1, j + 1)); 05 return memo[i][j]; 06}
规则:判空是记忆化的灵魂——没有它,memo 只是「算完顺手存一下」的装饰,调用照旧重复。
考点:记忆化 = 判空 + 落盘,缺一不可。
解析:漏掉判空后,每次 dfs(i,j) 都不检查缓存,直接重新递归计算并覆盖写回 memo——行为与朴素递归完全相同(结果对,但重复计算),复杂度 。判空那行才是「查缓存」的关键:命中即返回,避免重算。
排除法:结果不会错(只是慢);不会死循环(有边界);不会越界。
爬楼梯(每次 1/2 级)用下面的递推,、、 分别是多少?
01int dp[12] = {0}; 02dp[0] = 1; dp[1] = 1; 03for (int i = 2; i <= 10; i++) 04 dp[i] = dp[i - 1] + dp[i - 2];
规则: 就是斐波那契:,,,。
考点:斐波那契递推的手算。
解析:…… 每个是前两个之和。第 10 级共 89 种走法。
排除法:、、 都是加错前一项的手算错误。
数字三角形有 行时,用「自顶向下记忆化递归」和「自底向上循环」各有什么隐患?
规则:递归占用调用栈,深度太大栈溢出;循环递推只消耗堆内存数组。
考点:记忆化递归的栈深度限制。
解析:dfs 从 一路递归到第 行,每层一个栈帧,系统栈通常只有几 MB, 层栈溢出。自底向上循环不递归,需要 的 dp 表( 行时约 格放不下,需要滚动行优化)——但至少不会崩栈。
排除法:循环不压栈;「循环栈溢出」错;「无隐患」忽略了递归栈与数组内存。
为什么背包问题(每件物品选或不选)需要二维状态(前几件 容量),而爬楼梯(每次 1 或 2 级,求走法数)一维就够?
爬楼梯:
01int dp[105]; 02dp[i] = dp[i - 1] + dp[i - 2];
背包:
01int dp[105][1005]; 02dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w] + v);
规则:状态维度 = 决策所需的独立信息个数;两个独立参数就要二维。
考点:状态维度的确定。
解析:爬楼梯计算 只需知道 ,历史(走法细节)不影响未来,一维够。背包在第 件做决策时,必须同时知道「考虑过几件」(保证每件只选一次)和「还剩多少容量」(判断装不装得下),丢掉任一维都做不出正确决策。LCS 同理要记两个串各用到第几个字符。
排除法:「数组大小」由状态决定而不是反过来;「最大值要二维」错误,打家劫舍求最大值照样一维;编译器优化与维度无关。
拿到一个新 DP 题,正确的基本顺序是什么?
规则:DP 解题四步:状态 → 转移 → 初值 → 顺序。
考点:DP 的标准化流程。
解析:状态定义决定一切:它确定了数组维度、转移方程引用哪些格子、初值给什么。顺序错了就出现「方程引用未定义状态」「初值漏设」这类 bug。
排除法:转移建立在状态之上,不能倒序;数组大小由状态推出;「猜答案倒推」不是工程做法。
为什么 DP 的边界初值错一处,会导致整个表几乎全错,而不是只影响开头几格?
01int dp[6] = {0}; 02dp[1] = cost[0];
若误写成 dp[1] = 0。
规则:递推链上每个状态都依赖前面的状态,源头错 → 全链错(级联污染)。
考点:边界初值的关键性。
解析:dp[i] 的计算只用更小的下标,任何一个错误初值都会进入它「下游」的所有计算。以最小花费爬楼梯为例,dp[1] 错成 0,dp[2]=min(0,dp[0])+cost[1] 就偏小,dp[3]、dp[4] 一路偏下去,最后答案也错,而且程序不报错——这是 DP 最容易「安静地错」的点。
排除法:编译器不修正逻辑错误;初值影响的远不止一格;DP 不会自愈。
最小花费爬楼梯(第 级要付 cost[i-1],每次从 或 级跳上来):cost = {5, 6, 1, 2, 7},正确版本返回 min(dp[5], dp[4])。先算 dp[1] 到 dp[5],问正确答案是多少?
01int dp[6] = {0}; 02dp[1] = cost[0]; 03for (int i = 2; i <= 5; i++) 04 dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i - 1]; 05int ans = min(dp[5], dp[4]);
规则: 表:、、、、。答案 。
考点:换数据再追踪最小花费爬楼梯。
解析:(路线 0→2→4,付 ),(路线 0→2→4→5 付 或 0→1→3→5 付 )。站第 4 级跨 2 级到顶花 8,站第 5 级跨 1 级到顶花 13,取 8。
排除法: 是 的误加;、 无对应组合。
1 #include <algorithm> 2 #include <iostream> 3 #include <limits> 4 5 using namespace std; 6 7 const int MAXN = 105; 8 const int MAXK = 105; 9 10 int h[MAXN][MAXK]; 11 12 int f(int n, int m) 13 { 14 if (m == 1) return n; 15 if (n == 0) return 0; 16 17 int ret = numeric_limits<int>::max(); 18 for (int i = 1; i <= n; i++) 19 ret = min(ret, max(f(n - i, m), f(i - 1, m - 1)) + 1); 20 return ret; 21 } 22 23 int g(int n, int m) 24 { 25 for (int i = 1; i <= n; i++) 26 h[i][1] = i; 27 for (int j = 1; j <= m; j++) 28 h[0][j] = 0; 29 30 for (int i = 1; i <= n; i++) { 31 for (int j = 2; j <= m; j++) { 32 h[i][j] = numeric_limits<int>::max(); 33 for (int k = 1; k <= i; k++) 34 h[i][j] = min( 35 h[i][j], 36 max(h[i - k][j], h[k - 1][j - 1]) + 1); 37 } 38 } 39 40 return h[n][m]; 41 } 42 43 int main() 44 { 45 int n, m; 46 cin >> n >> m; 47 cout << f(n, m) << endl << g(n, m) << endl; 48 return 0; 49 }
当输入为 7 3 时,第 行用来取最小值的 min 函数执行了 次。( )
输出的两行整数总是相同的。( )
(1 分)当 m 为 时,输出的第一行总为 n。( )
算法 g(n,m) 最为准确的时间复杂度分析结果为( )。
当输入为 20 2 时,输出的第一行为( )。
当输入为 100 100 时,输出的第一行为( )。
试补全程序。
1 #include <iostream> 2 #include <string> 3 #include <vector> 4 using namespace std; 5 6 int min(int x, int y, int z) { 7 return min(min(x, y), z); 8 } 9 10 int edit_dist_dp(string str1, string str2) { 11 int m = str1.length(); 12 int n = str2.length(); 13 vector<vector<int>> dp(m + 1, vector<int>(n + 1)); 14 15 for (int i = 0; i <= m; i++) { 16 for (int j = 0; j <= n; j++) { 17 if (i == 0) 18 dp[i][j] = ①; 19 else if (j == 0) 20 dp[i][j] = ②; 21 else if (③) 22 dp[i][j] =④; 23 else 24 dp[i][j] = 1 + min(dp[i][j - 1], dp[i - 1][j], ⑤); 25 } 26 } 27 return dp[m][n]; 28 } 29 30 int main() { 31 string str1, str2; 32 cin >> str1 >> str2; 33 cout << "Minimum number of operations: " 34 << edit_dist_dp(str1, str2) << endl; 35 return 0; 36 }
①处应填( )
(1 分)②处应填( )
(1 分)③处应填( )
(1 分)④处应填( )
(1 分)⑤处应填( )
(1 分)1 #include <iostream> 2 #include <vector> 3 using namespace std; 4 5 int compute(vector<int>& cost) { 6 int n = cost.size(); 7 vector<int> dp(n+1, 0); 8 dp[1] = cost[0]; 9 for (int i = 2; i <= n; i++) { 10 dp[i] = min(dp[i-1], dp[i-2]) + cost[i-1]; 11 } 12 return min(dp[n], dp[n-1]); 13 } 14 15 int main() { 16 int n; 17 cin >> n; 18 vector<int> cost(n); 19 for (int i = 0; i < n; i++) { 20 cin >> cost[i]; 21 } 22 cout << compute(cost) << endl; 23 return 0; 24 }
当输入的 cost 数组为 {10, 15, 20} 时,程序的输出为 15。( )
如果将 dp[i-1] 改为 dp[i-3],程序可能会产生编译错误。( )
程序总是输出 cost 数组中最小的元素。( )
当输入的 cost 数组为 {1, 100, 1, 1, 1, 100, 1, 1, 100, 1} 时,程序的输出为( )。
如果输入的 cost 数组为 {10, 15, 30, 5, 5, 10, 20},程序的输出为( )。
若将代码中的 min(dp[i-1], dp[i-2]) + cost[i-1] 修改为 dp[i-1] + cost[i-2],输入 cost 数组为 {5, 10, 15} 时,程序的输出为( )。