十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从石子合并问题入门区间动态规划:原理、实现与优化详解

从石子合并问题入门区间动态规划:原理、实现与优化详解 1. 项目概述从一道经典DP题看信奥刷题的“道”与“术”最近在带学生刷信奥题发现很多同学卡在了动态规划DP的入门阶段尤其是像“石子合并”这类经典的区间DP问题。题目本身不难但背后的思考方式和代码实现细节恰恰是区分“会做题”和“懂算法”的关键。今天我就以洛谷P1775这道“石子合并弱化版”为例不光是给出一份C题解更想拆解一下面对这类问题时我们该如何思考、如何设计、如何避坑。这道题可以看作是更著名的P1880环形石子合并的简化版去掉了环形条件是理解区间DP思想绝佳的“第一块敲门砖”。无论你是正在备战CSP-J/S的信奥新手还是想巩固DP基础的C学习者相信这篇结合了原理、代码与实战心得的拆解都能让你对“如何用程序解决最优合并问题”有一个透彻的理解。2. 问题核心化繁为简的区间DP思想2.1 题意理解与问题建模我们先抛开代码把问题还原到最原始的场景。题目描述很简单有一排N堆石子排成一列每堆石子有一定的质量。现在要将它们合并成一堆。合并的规则是每次只能合并相邻的两堆石子合并的代价是这两堆石子的质量之和。目标是找到一种合并顺序使得总合并代价最小。举个例子假设有4堆石子质量依次为1, 2, 3, 4。一种合并顺序是先合并前两堆代价123得到石子堆[3, 3, 4]再合并前两堆代价336得到[6, 4]最后合并这两堆代价6410。总代价361019。但这是最优的吗显然不是。如果我们先合并中间的两堆2和3总代价可能会更小。我们的任务就是找出这个最小的总代价。这里的关键在于“合并相邻两堆”这个限制。它意味着整个合并过程可以看作是对原始序列的一个个区间进行“收缩”的过程。最终那唯一的一堆石子是由原始序列的某个区间合并而来的。而合并的总代价可以分解为最后一步合并的代价加上形成最后那两堆石子各自所需的代价。这天然地符合动态规划中“最优子结构”的性质——整个问题的最优解包含了其子问题的最优解。因此我们很自然地想到用dp[i][j]来表示一个状态将第i堆到第j堆石子合并成一堆所需的最小代价。最终我们要求的就是dp[1][N]。这就是区间DP最经典的建模方式状态表示一个区间决策是枚举这个区间最后一次合并是在哪里“切一刀”。2.2 状态转移方程的推导与理解定义了状态接下来就是思考状态之间如何转移。考虑区间[i, j]我们要把它合并成一堆。在最后一次合并操作发生时这个区间一定是由两个连续的子区间[i, k]和[k1, j]合并而来的其中k是介于i和j-1之间的一个分界点。那么合并区间[i, j]的总代价 合并左子区间[i, k]的最小代价 (dp[i][k]) 合并右子区间[k1, j]的最小代价 (dp[k1][j]) 将最后这两堆合并的代价。这最后的代价是多少就是区间[i, j]内所有石子的总质量。因为无论之前怎么合并最后一步就是把代表左区间的“一堆”和代表右区间的“一堆”合二为一这两堆的质量分别是左区间总质量和右区间总质量相加即为整个区间的总质量。于是我们得到了核心的状态转移方程dp[i][j] min(dp[i][k] dp[k1][j] sum(i, j))其中i k j。 这里的sum(i, j)表示从第i堆到第j堆的石子质量之和。为什么必须加上sum(i, j)这是新手最容易迷糊的地方。dp[i][k]已经包含了将[i, k]合并成一堆的所有代价dp[k1][j]同理。但这两个值只计算了“形成最后那两堆”的过程代价。最后一步合并操作本身的代价即把这两堆合起来还没有被计入。而这一步的代价就是这两堆石子的质量之和也就是整个区间[i, j]的总质量。想通这一点区间DP的转移逻辑就清晰了。为了快速计算任意区间和sum(i, j)我们通常会使用前缀和技巧。定义prefix[i]为前i堆石子的质量和prefix[0] 0。那么sum(i, j) prefix[j] - prefix[i-1]。这样就能在O(1)时间内得到区间和否则在转移过程中反复计算区间和会导致复杂度激增。3. 算法实现细节与C编码解析3.1 基础版本代码实现与逐行解读理解了原理我们来看C代码如何实现。首先是最直观但并非最优的递归记忆化搜索写法这有助于理解问题的本质结构。#include iostream #include vector #include climits using namespace std; vectorint stones; // 存储石子质量下标从1开始 vectorint prefix; // 前缀和数组 vectorvectorint memo; // 记忆化数组初始化为-1表示未计算 // 计算合并区间[l, r]的最小代价 int solve(int l, int r) { if (l r) return 0; // 只有一堆石子无需合并代价为0 if (memo[l][r] ! -1) return memo[l][r]; // 已经计算过直接返回 int current_sum prefix[r] - prefix[l-1]; // 区间[l, r]的总质量 int min_cost INT_MAX; // 初始化为最大值 // 枚举所有可能的分割点k for (int k l; k r; k) { int cost solve(l, k) solve(k1, r) current_sum; if (cost min_cost) { min_cost cost; } } memo[l][r] min_cost; // 记录结果 return min_cost; } int main() { int n; cin n; stones.resize(n 1); prefix.resize(n 1, 0); memo.assign(n 1, vectorint(n 1, -1)); for (int i 1; i n; i) { cin stones[i]; prefix[i] prefix[i-1] stones[i]; // 计算前缀和 } cout solve(1, n) endl; return 0; }这段代码非常直接地反映了我们的思路。solve(l, r)函数就是计算dp[l][r]。递归的边界条件是l r此时代价为0。我们用一个memo二维数组来存储已经计算过的子问题结果避免重复计算这就是记忆化搜索。时间复杂度为 O(N^3)因为总共有 O(N^2) 个状态每个状态需要 O(N) 的时间枚举分割点k。对于弱化版的N300这个复杂度是勉强可以接受的300^327,000,000但绝非最优。注意在信奥竞赛中直接使用递归深度可能过大对于N300递归树深度可达300虽然本题可能不会栈溢出但这是一个不好的习惯。更稳健、更标准的做法是使用递推迭代法。3.2 标准递推解法与循环顺序的奥秘竞赛中更常见的写法是递推自底向上的动态规划。这里有一个非常重要的细节递推的顺序。我们不能简单地用i和j的双重循环因为计算dp[i][j]时需要用到dp[i][k]和dp[k1][j]这要求比[i, j]更短的区间已经被计算出来了。正确的做法是按区间长度从小到大进行递推。#include iostream #include vector #include climits using namespace std; int main() { int n; cin n; vectorint stones(n 1); vectorint prefix(n 1, 0); // 1. 读入数据并计算前缀和 for (int i 1; i n; i) { cin stones[i]; prefix[i] prefix[i-1] stones[i]; } // 2. DP数组初始化 // dp[i][j] 表示合并区间[i, j]的最小代价 vectorvectorint dp(n 1, vectorint(n 1, 0)); // 对于ij的情况dp[i][i] 0数组默认值已是0符合定义 // 3. 核心递推过程 // len: 当前考虑的区间长度从2开始长度为1的区间无需合并 for (int len 2; len n; len) { // i: 区间起点 for (int i 1; i len - 1 n; i) { int j i len - 1; // 区间终点 dp[i][j] INT_MAX; // 初始化为无穷大 int sum_ij prefix[j] - prefix[i-1]; // 区间[i,j]的总质量 // k: 枚举分割点将区间分为[i, k]和[k1, j] for (int k i; k j; k) { int cost dp[i][k] dp[k1][j] sum_ij; if (cost dp[i][j]) { dp[i][j] cost; } } } } // 4. 输出结果 cout dp[1][n] endl; return 0; }这是区间DP最标准的模板之一。三层循环最外层循环len控制区间长度。我们必须先处理所有小区间才能处理包含它们的大区间。这是动态规划“无后效性”和“最优子结构”的体现。中层循环i在给定长度下枚举所有可能的区间起点。内层循环k对于确定的区间[i, j]枚举所有可能的分割点寻找最小代价。时间复杂度清晰地为 O(N^3)。空间复杂度为 O(N^2)。3.3 算法优化四边形不等式优化初探对于洛谷P1775N300O(N^3)的算法完全够用。但如果我们遇到数据范围更大的题目比如N3000O(N^3)就无法承受了。这里就引出了一个重要的优化技巧——四边形不等式优化。它能将内层枚举k的复杂度从O(N)降低到O(1)从而使总复杂度降至O(N^2)。其核心思想是利用决策单调性。对于区间DP问题dp[i][j] min(dp[i][k] dp[k1][j]) w(i, j)如果代价函数w(i, j)满足四边形不等式并且具有区间包含单调性那么最优决策点s[i][j]即使得dp[i][j]取最小值的k满足s[i][j-1] s[i][j] s[i1][j]。在石子合并问题中w(i, j)就是石子质量区间和它满足上述性质。因此我们可以记录s[i][j]作为区间[i, j]的最优分割点。在计算dp[i][j]时我们不需要从i到j-1枚举所有k只需要在s[i][j-1]到s[i1][j]这个更小的范围内枚举即可。在平均情况下这能将内层循环降至常数级别。// 四边形不等式优化版本的核心循环框架 vectorvectorint dp(n1, vectorint(n1, 0)); vectorvectorint s(n1, vectorint(n1, 0)); // 记录最优决策点 // 初始化长度为1的区间决策点就是自己 for (int i 1; i n; i) { s[i][i] i; } for (int len 2; len n; len) { for (int i 1; i len - 1 n; i) { int j i len - 1; dp[i][j] INT_MAX; int sum_ij prefix[j] - prefix[i-1]; // 关键优化枚举范围缩小 for (int k s[i][j-1]; k s[i1][j]; k) { int cost dp[i][k] dp[k1][j] sum_ij; if (cost dp[i][j]) { dp[i][j] cost; s[i][j] k; // 更新最优决策点 } } } }实操心得在竞赛中除非题目数据范围明确要求N1000或者你对该优化非常熟练否则先写出正确的O(N^3)算法确保拿到基础分。四边形不等式优化是一个“锦上添花”的技巧理解其原理比死记模板更重要。对于P1775我们使用标准DP即可。4. 从“弱化版”到“强化版”的思维延伸4.1 对比P1880环形结构的处理技巧P1775是线性排列而经典的P1880石子合并问题中石子是排成一个环形的。这意味着合并的相邻关系首尾相连情况更复杂。如何解决一个巧妙的方法是破环成链。既然环形不好处理我们就把环形拉直成两倍长度的链。具体来说对于一个长度为N的环形序列a1, a2, ..., aN我们构造一个长度为2N的线性序列a1, a2, ..., aN, a1, a2, ..., aN。然后我们在这个长度为2N的链上做完全相同的线性区间DP。最终我们要求的答案不再是简单的dp[1][N]而是所有长度为N的区间[i, iN-1](其中1 i N) 的dp值中的最小值或最大值根据题目求最小代价还是最大代价。因为环形合并的任意一种方案都可以对应这条链上某个长度为N的连续区间的合并方案。// 环形石子合并求最小值和最大值的核心思路 int n; cin n; vectorint a(2*n 1); vectorint prefix(2*n 1, 0); for (int i 1; i n; i) { cin a[i]; a[in] a[i]; // 复制一遍形成链 } for (int i 1; i 2*n; i) { prefix[i] prefix[i-1] a[i]; } // DP计算链上所有区间 vectorvectorint dp_min(2*n1, vectorint(2*n1, 0)); vectorvectorint dp_max(2*n1, vectorint(2*n1, 0)); // ... 初始化与递推过程区间长度从2到n ... int ans_min INT_MAX, ans_max 0; for (int i 1; i n; i) { // 枚举起点 int j i n - 1; // 长度为n的区间终点 ans_min min(ans_min, dp_min[i][j]); ans_max max(ans_max, dp_max[i][j]); } cout ans_min endl ans_max endl;通过解决P1775再理解这个“破环成链”的技巧你就能顺利攻克更复杂的P1880。这是信奥学习中非常重要的能力掌握基础模型然后学习如何将其适配和扩展到更复杂的情景。4.2 动态规划 debug 与验证技巧写DP代码尤其是区间DP很容易因为下标、边界或循环顺序出错。分享几个我常用的调试和验证方法小数据手工模拟永远不要相信未经小数据验证的DP程序。对于N3或4的情况手工计算出所有可能的合并顺序和代价与程序输出对比。这是最快发现状态定义或转移方程错误的方法。打印DP表在程序中间将计算好的dp数组打印出来。对于小N观察表格是否按预期填充。检查对角线元素dp[i][i]是否为0检查dp[i][i1]相邻两堆的值是否等于这两堆的质量和这是验证初始化是否正确的好办法。// 调试代码片段 if (n 5) { cout DP Table: endl; for (int i 1; i n; i) { for (int j 1; j n; j) { cout dp[i][j] \t; } cout endl; } }关注边界和初始化区间DP的边界通常是len1或len2。确保你的循环从正确的长度开始通常是2。确保dp[i][i] 0。在递推版本中dp数组的初始值要设置正确通常非对角线初始化为一个很大的数如INT_MAX。前缀和验证单独测试你的前缀和数组prefix是否正确。写一个简单的测试输入几个数手动计算和与程序输出的prefix数组对比。5. 常见错误与避坑指南在实现石子合并这类区间DP时以下几个坑几乎每个初学者都会踩一遍循环顺序错误这是最经典的错误。错误示例// 错误计算dp[i][j]时dp[i][k]或dp[k1][j]可能还没算出来 for (int i 1; i n; i) { for (int j i; j n; j) { for (int k i; k j; k) { // ... } } }必须按区间长度递增的顺序枚举。区间和计算错误忘记使用前缀和或者在计算sum(i, j)时下标写错。正确的公式是prefix[j] - prefix[i-1]。如果石子数组从0开始存储那么公式要相应调整为prefix[j1] - prefix[i]。务必保持下标统一。dp数组初始化不当递推法dp[i][i]必须初始化为0。对于i ! j的情况如果求最小值应初始化为一个很大的数如INT_MAX否则默认值0会导致结果错误。记忆化搜索法记忆化数组memo必须初始化为-1或其他不可能出现的值以区分“未计算”和“计算结果为0”的状态。整数溢出石子质量之和可能很大。对于N300每堆质量假设最大为1000总代价可能超过int范围。在比赛中如果题目没有明确说明保险起见使用long long类型定义dp数组和前缀和数组。vectorvectorlong long dp(n1, vectorlong long(n1, LLONG_MAX)); vectorlong long prefix(n1, 0); // 在状态转移时也要注意用long long计算 long long cost dp[i][k] dp[k1][j] sum_ij;忽略“弱化版”与“原版”的区别P1775是线性P1880是环形。如果用线性DP的代码去提交环形题目必然错误。一定要仔细读题明确数据结构。递归深度过大对于N较大的情况如300递归深度可能达到300虽然通常不会栈溢出但在某些评测环境下可能存在风险。优先使用递推写法更安全、更高效。6. 举一反三区间DP的通用解题框架与思维训练石子合并是区间DP的“母题”。掌握它就掌握了一类问题的通用解法。许多问题都可以抽象成类似的模型能量项链环形区间DP状态转移时乘积关系不同。凸多边形的划分在多边形上划分三角形求最优权值。括号匹配计算最长合法括号子序列dp[i][j]表示区间[i, j]的最大匹配数。字符串回文划分判断或分割回文串。它们的共性在于问题对象是序列或环上的一个区间决策涉及对区间的划分并且满足最优子结构。面对一道新的区间DP问题我的思考路径通常是识别模型问题是否涉及序列/环上的区间操作最终答案是否可以通过合并小区间答案得到定义状态尝试用dp[i][j]表示区间[i, j]的某种最优解。思考状态需要包含哪些信息有时需要增加维度如dp[i][j][0/1]表示合并后是最大值还是最小值。推导转移考虑区间[i, j]的最后一步操作。它通常是如何把区间分成两部分或多部分写出转移方程。确定顺序状态依赖关系决定了递推顺序通常是区间长度从小到大。处理边界确定最小子问题通常是长度为1或2的区间的解。优化在写出正确解后再考虑是否能用四边形不等式、单调队列等优化复杂度。刷题的目的不是记住每一道题的代码而是通过有限的经典题目提炼出可以解决无限新问题的思维模式和解题框架。P1775这道“弱化版”石子合并正是打磨你区间DP思维的一块绝佳磨刀石。把它的原理吃透把代码写熟再遇到它的“兄弟姐妹”时你就能从容应对了。
返回列表