动态规划入门:从棋盘路径问题掌握状态定义与转移方程

发布时间:2026/7/28 10:18:43

动态规划入门:从棋盘路径问题掌握状态定义与转移方程 1. 项目概述从棋盘问题二看算法竞赛中的路径计数最近在带学生准备一些算法竞赛正好翻到了上海计算机学会2024年5月月赛的题目其中丙组的T5“棋盘问题二”引起了我的注意。这类棋盘路径问题可以说是动态规划DP入门的经典试金石它不涉及特别复杂的数据结构但对思维逻辑的严谨性和状态定义的准确性要求极高。很多初学者在接触DP时总觉得状态转移方程“只可意会”而棋盘问题恰恰提供了一个将抽象思维可视化的绝佳场景——你可以实实在在地看到一个“棋盘”想象一个“棋子”在上面移动这比单纯处理一维数组要直观得多。这道题的核心简单来说就是给定一个N x M的棋盘棋子在左上角(1,1)起点要走到右下角(N, M)终点。棋子只能向右或向下移动。这听起来就是最基础的“不同路径”问题。但题目真正的挑战在于棋盘上存在一些“障碍格”棋子不能落在这些格子上。同时题目还可能对路径的“代价”或“属性”有额外要求比如路径上经过的数字之和、是否需要满足特定奇偶性等这需要我们在基础模型上增加状态维度。解决这类问题不仅是为了AC一道题更是为了掌握一种将复杂约束条件转化为清晰状态定义的思维能力这种能力在解决更复杂的优化问题时至关重要。2. 核心思路拆解状态定义与转移方程的构建逻辑面对棋盘问题我们的第一反应往往是搜索DFS/BFS。对于小规模棋盘比如N, M 10搜索是可行的。但题目数据范围往往会设得较大比如N, M 100甚至1000搜索的指数级时间复杂度将无法承受。这时动态规划的优势就体现出来了它可以将时间复杂度优化到O(N*M)甚至更低。2.1 基础模型无障碍棋盘的不同路径我们先从最简单的模型开始一个N行M列的无障碍棋盘求从(1,1)到(N,M)的总路径数。这里的“状态”非常自然设dp[i][j]表示从起点(1,1)走到格子(i,j)的不同路径总数。那么如何走到(i,j)呢根据“只能向右或向下”的规则棋子只可能从它的上方(i-1, j)或者左方(i, j-1)走过来。因此到达(i,j)的路径数就等于到达(i-1,j)的路径数与到达(i,j-1)的路径数之和。这就引出了我们的状态转移方程dp[i][j] dp[i-1][j] dp[i][j-1]当然我们需要边界条件或称初始状态起点dp[1][1] 1因为从起点到起点只有一种方式不动。对于第一行i1的格子它们只能从左方来因为上方没有格子。所以当j1时dp[1][j] dp[1][j-1]。同理对于第一列j1的格子它们只能从上方来。所以当i1时dp[i][1] dp[i-1][1]。这个模型是所有棋盘路径问题的基石。2.2 引入障碍状态转移的“断路”机制现在引入障碍。假设我们有一个二维数组grid[N1][M1]为了方便我们从1开始索引grid[i][j] 1表示该格子是障碍grid[i][j] 0表示可通过。我们的状态定义dp[i][j]依然表示走到(i,j)的路径数但需要增加一个关键判断如果(i,j)本身是障碍那么不可能有任何路径到达这里所以dp[i][j]应该直接为0。相应地状态转移方程也需要修改。只有当(i,j)不是障碍时我们才计算从上方和左方转移过来的路径。同时在计算转移来源时也必须确保来源格子不是障碍。因为如果来源格子是障碍从那里过来的路径数为0。所以更严谨的写法是 如果grid[i][j] 1则dp[i][j] 0。 否则dp[i][j] (grid[i-1][j] 0 ? dp[i-1][j] : 0) (grid[i][j-1] 0 ? dp[i][j-1] : 0)。这里有一个编程细节为了处理边界i1或j1时访问dp[i-1][j]或dp[i][j-1]会导致数组越界我们通常会将dp数组定义为(N2) x (M2)大小并将下标0的行和列初始化为0作为虚拟边界。这样状态转移可以统一写成dp[i][j] (grid[i][j] 1) ? 0 : (dp[i-1][j] dp[i][j-1])因为对于边界格子其虚拟上方或左方的dp值为0符合“没有路径从界外来”的逻辑。2.3 进阶思考路径代价与多维状态“棋盘问题二”之所以是“二”通常意味着它比基础的无障碍路径计数更复杂。常见的进阶方向有带权路径每个格子有一个数值代价或收益要求计算所有路径的代价之和或者求一条总代价最小/最大的路径。这时dp[i][j]的含义就需要变为“到达(i,j)时的最小总代价”转移方程变为取min或max操作并加上当前格子的代价grid[i][j]。路径属性约束例如要求路径上经过的数字之和为偶数或者路径必须经过某个特定格子。这需要在状态中增加一个维度来记录这个属性。比如定义dp[i][j][k]其中k0表示路径和为偶数到达(i,j)的路径数k1表示路径和为奇数。转移时需要根据当前格子的数字奇偶性来更新k的状态。理解如何根据问题约束来增加状态维度是解决复杂DP问题的关键。这需要仔细分析哪些信息是决定未来决策所必需的必须把它们纳入状态定义中。3. 代码实现与细节剖析理论清晰后我们来看代码实现。这里我以“带障碍的路径计数”为基础模型给出一个完整的C实现并穿插讲解关键细节和易错点。#include iostream #include vector using namespace std; int main() { int n, m; cin n m; // 为了方便从1开始索引我们定义大小为 (n2) x (m2) 的网格和dp数组 // 第0行和第0列作为虚拟边界全部初始化为障碍或0值 vectorvectorint grid(n 2, vectorint(m 2, 1)); // 1表示障碍0表示通路 vectorvectorlong long dp(n 2, vectorlong long(m 2, 0)); // 读取棋盘1-based索引 for (int i 1; i n; i) { for (int j 1; j m; j) { cin grid[i][j]; // 假设输入中0表示通路1表示障碍 } } // 初始化起点。注意如果起点就是障碍那么路径数为0。 if (grid[1][1] 0) { dp[1][1] 1; } // 动态规划填表 for (int i 1; i n; i) { for (int j 1; j m; j) { // 跳过起点因为已经初始化了 if (i 1 j 1) continue; // 如果当前格子是障碍dp值保持为0初始化值 if (grid[i][j] 1) { dp[i][j] 0; continue; } // 状态转移只能从上方或左方来 // 因为dp[0][*]和dp[*][0]都是0所以边界情况也适用 dp[i][j] dp[i - 1][j] dp[i][j - 1]; // 注意如果路径数可能非常大题目可能要求取模 // dp[i][j] % MOD; } } // 输出终点(n, m)的路径数 cout dp[n][m] endl; return 0; }3.1 关键实现细节与避坑指南数组索引与边界处理这是最容易出错的地方。坚持使用1-based索引即下标从1开始表示第一行第一列并预留第0行和第0列作为“哨兵”可以极大地简化边界条件的代码。如上所示dp[0][j]和dp[i][0]自然为0使得状态转移方程dp[i][j] dp[i-1][j] dp[i][j-1]对第一行和第一列的格子也成立无需特殊判断。数据类型与溢出路径数可能增长得非常快。对于100x100的棋盘无障碍路径数是一个巨大的组合数。int类型几乎肯定会溢出。务必使用long long64位整数。如果题目明确要求对一个大数取模如1e97则在每次加法后立即取模。障碍格子的处理顺序在双重循环中我们首先判断grid[i][j]是否为障碍。如果是则显式地将dp[i][j]设为0虽然它初始化就是0但显式设置更清晰然后continue跳过转移。这确保了障碍格子的值不会被错误地计算。起点的初始化这是一个逻辑点。dp[1][1]应该初始化为1吗前提是(1,1)不是障碍。如果起点就是障碍那么整个问题无解所有dp值都应为0。代码中必须包含这个判断。输入格式务必看清题目描述障碍物的表示方式可能不同。有的题目用‘#’表示障碍用‘.’表示通路有的用1表示通路0表示障碍。读取和判断时要对应正确。注意上面的代码假设输入中0表示通路1表示障碍。如果题目规定相反需要在读取后或判断时进行取反逻辑。4. 从路径计数到最小代价路径“棋盘问题二”很可能不是简单的计数而是引入了“代价”概念。我们来看看如何修改模型。假设每个格子(i,j)有一个非负代价cost[i][j]要求从起点到终点的所有路径中总代价最小的那条路径的代价是多少。这时dp[i][j]的定义就需要改变它表示从起点(1,1)走到(i,j)的最小总代价。状态转移方程也相应变为到达(i,j)的最小代价等于从上方来的最小代价和从左方来的最小代价中较小的那个再加上踏上(i,j)格子本身的代价。dp[i][j] min(dp[i-1][j], dp[i][j-1]) cost[i][j]边界条件dp[1][1] cost[1][1]。对于第一行i1, j1只能从左方来dp[1][j] dp[1][j-1] cost[1][j]。对于第一列j1, i1只能从上方来dp[i][1] dp[i-1][1] cost[i][1]。如果还有障碍物那么障碍物格子的dp值可以设为无穷大INT_MAX或LLONG_MAX表示不可达并在状态转移时忽略来自障碍物格子的路径。// 最小代价路径核心转移代码片段 const long long INF 1e18; vectorvectorlong long dp(n 2, vectorlong long(m 2, INF)); if (grid[1][1] ! 障碍) dp[1][1] cost[1][1]; for (int i 1; i n; i) { for (int j 1; j m; j) { if (i 1 j 1) continue; if (grid[i][j] 障碍) continue; // dp[i][j] 保持 INF long long from_top (grid[i-1][j] ! 障碍) ? dp[i-1][j] : INF; long long from_left (grid[i][j-1] ! 障碍) ? dp[i][j-1] : INF; if (from_top INF from_left INF) { // 两个方向都不可达当前格子也不可达 dp[i][j] INF; } else { dp[i][j] min(from_top, from_left) cost[i][j]; } } } // 最终答案 if (dp[n][m] INF) { cout 无路径 endl; } else { cout dp[n][m] endl; }5. 常见问题与调试技巧实录在实际编码和调试这类问题时我遇到和学生们犯过的错误五花八门。这里总结几个高频问题5.1 初始化错误问题忘记初始化dp[1][1]或者错误地将其初始化为0在最小代价问题中。排查总是先单独检查起点状态。对于计数问题起点若非障碍则为1对于代价问题起点代价就是cost[1][1]。5.2 数组越界问题在循环中访问了dp[i-1][j]当i1时访问了dp[0][j]如果数组没有多开一行就会越界。解决强烈推荐“多开一圈”的数组定义法。如vectorvectorlong long dp(n 2, vectorlong long(m 2, 0))并从下标1开始使用。虚拟的0行0列自动提供了安全的边界值。5.3 整数溢出问题路径数巨大使用int导致结果出现负数或完全错误。解决在竞赛中只要涉及计数或累加除非题目明确说明范围很小否则无脑使用long long。这是一个成本极低的好习惯。5.4 状态转移逻辑遗漏问题在带障碍的问题中只判断了当前格子(i,j)是否为障碍但忘记了在计算dp[i-1][j] dp[i][j-1]时dp[i-1][j]或dp[i][j-1]本身可能因为对应格子是障碍而为0。如果代码逻辑是if(grid[i][j]!障碍) dp[i][j]dp[i-1][j]dp[i][j-1]这本身没问题因为来源格子的dp值如果为0加法自然体现。但更清晰的写法是显式判断来源格子是否可达尤其是在求最小值等问题中。5.5 输入读取与题意理解偏差问题这是最致命的错误。题目说“1表示障碍”你代码里判断if(grid[i][j]1)但实际输入样例中可能用‘#’表示障碍。解决编码前花一分钟仔细阅读输入输出格式。写代码时将“通路”和“障碍”的判断条件用有意义的常量或布尔变量表示例如const int OBSTACLE 1; if (grid[i][j] OBSTACLE) { ... }这样如果理解错了只需修改一个常量。5.6 调试技巧打印DP表当程序结果不对时最有效的调试方法之一就是打印出整个dp表对于小规模数据。cout DP Table: endl; for (int i 1; i n; i) { for (int j 1; j m; j) { cout dp[i][j] \t; } cout endl; }对照着手算或逻辑推导的几行几列很容易发现哪里开始出错的。例如如果发现第一行的某个值不对那肯定是第一行的初始化或转移逻辑有问题。6. 性能优化与空间复杂度思考我们当前的算法时间复杂度是O(NM)这对于N, M在1000以内的题目通常足够了。空间复杂度也是O(NM)即dp数组的大小。在某些极端情况下如果N, M非常大比如10^4O(N*M)的空间约10^8个long long占用接近800MB可能会超出内存限制。这时我们可以进行空间优化。观察状态转移方程dp[i][j]只依赖于dp[i-1][j]上一行和dp[i][j-1]当前行左边。因此我们并不需要保存整个二维表只需要保存“上一行”和“当前行”即可。vectorlong long prev_row(m 2, 0), curr_row(m 2, 0); // 初始化第一行 curr_row[1] (grid[1][1] 0) ? 1 : 0; for (int j 2; j m; j) { curr_row[j] (grid[1][j] 0) ? curr_row[j-1] : 0; } if (n 1) { // 只有一行的情况 cout curr_row[m] endl; return 0; } for (int i 2; i n; i) { // 交换上一行变成旧的当前行新的当前行待计算 swap(prev_row, curr_row); // 计算新当前行的第一个元素 curr_row[1] (grid[i][1] 0) ? prev_row[1] : 0; // 计算新当前行的其余元素 for (int j 2; j m; j) { if (grid[i][j] 1) { curr_row[j] 0; } else { curr_row[j] prev_row[j] curr_row[j-1]; } } } cout curr_row[m] endl;这样空间复杂度从O(N*M)降到了O(M)。这种优化在笔试或竞赛中遇到大数据时非常有用。不过在初学阶段先写出清晰正确的二维DP版本更为重要优化可以在理解透彻后进行。7. 举一反三相关变种问题掌握了基础模型你可以尝试解决一系列变种问题这些都是对状态定义和转移方程设计能力的很好锻炼最大收益路径每个格子有收益值求最大总收益路径。将状态转移中的min改为max即可。路径方案数带模数路径数巨大要求输出对1e97取模的结果。在每次加法后立即取模dp[i][j] (dp[i-1][j] dp[i][j-1]) % MOD。有“传送门”的棋盘某些格子是传送门到达后会立刻传送到另一个指定格子。这需要在状态转移时特殊处理当(i,j)是传送门时dp[i][j]的值直接加到目标格子的dp值上而dp[i][j]本身可能置0或保持取决于题目规则是“经过”还是“到达并传送”。必须经过某些点的路径计数可以将棋盘按必须经过的点分割成若干段分别计算每段之间的路径数然后相乘。路径回文问题要求从左上到右下的路径构成的序列如经过格子的字符是回文串。这通常需要结合DP和区间DP的思想状态可能定义为dp[x1][y1][x2][y2]表示从起点到(x1,y1)和从终点到(x2,y2)的两条对称路径的匹配情况复杂度较高。解决这些问题的心法是仔细分析问题的新约束条件思考这个条件如何影响“状态”。是需要增加一个维度来记录信息如奇偶性、余数、特定计数还是需要改变状态的含义如从计数变为最值多练习这种建模能力就会逐渐内化。棋盘问题就像动态规划的一个微观世界它规则清晰场景具体。通过反复练习这类问题你能深刻理解“状态”、“状态转移方程”、“最优子结构”和“无后效性”这些DP核心概念。下次再遇到更复杂的DP问题不妨先在脑子里画一个“棋盘”想想“状态”是什么“棋子”怎么走或许就能找到突破口。

相关新闻