蓝桥杯国赛动态规划精讲:从不同路径II到滚动数组优化
2026/8/28 18:53:16 网站建设 项目流程

1. 项目概述:从一道经典DP题切入蓝桥杯国赛备战

最近在带几个学生冲刺蓝桥杯国赛,发现很多同学在动态规划(DP)这个坎上总是绕不过去,尤其是遇到带障碍的变种题目,思路就容易乱。今天我们就拿LeetCode上那道经典的“不同路径 II”来开刀,这题在蓝桥杯的历年真题和模拟题里出现过各种“变装”,核心就是考察带障碍的网格DP,以及如何用滚动数组进行空间优化。如果你对“遇到障碍物怎么处理”、“dp数组怎么初始化”、“为什么可以用一维数组”这些问题还心存疑惑,那这篇笔记就是为你准备的。我会从最朴素的二维DP思路开始,一步步推导到最优的空间优化方案,并分享一些在国赛高压环境下快速识别此类题目、避免踩坑的实战心得。

这道题本身描述很简单:一个机器人位于一个 m x n 网格的左上角,每次只能向下或者向右移动一步,试图到达右下角。但网格中有些格子设置了障碍物(用1表示),机器人不能进入。问总共有多少条不同的路径?这直接对应了蓝桥杯中常见的“方案计数”类问题,是理解DP思想一个非常好的载体。我们不仅要算出答案,更要理解状态定义、转移方程背后的逻辑,以及优化技巧的适用场景,这才是冲刺国赛应有的深度。

2. 核心思路拆解:状态定义与转移方程的构建逻辑

2.1 为什么是动态规划?—— 问题性质的判断

拿到任何一道算法题,尤其是蓝桥杯这种时间紧迫的比赛,第一步必须是快速判断题型。“不同路径 II”几乎把DP的特性写在了脸上:第一,求的是“总共有多少种路径”,这是一个计数问题,通常涉及累加,符合DP的“计数型”应用场景。第二,机器人的移动有非常强的“方向性”限制(只能向右或向下),这意味着到达某个格子(i, j)的路径,只可能从它的上方(i-1, j)或者左方(i, j-1)过来。这种“当前状态仅由有限个前驱状态决定”的性质,是DP的“最优子结构”特征。第三,在计算(i, j)时,我们会反复用到(i-1, j)(i, j-1)的值,存在“重叠子问题”。这三条合在一起,动态规划就是最自然且高效的解法。

这里有一个关键的思维定式需要打破:很多新手一看到网格、路径,就想用深度优先搜索(DFS)去暴力枚举。对于小规模网格(比如20x20以内),DFS或许能跑出结果。但蓝桥杯国赛的题目,m和n轻松上百,路径数是指数级增长的,DFS必然超时。DP将指数复杂度降到了多项式级别(O(m*n)),这是质变。所以,在赛场上,看到网格路径计数,首先就该在脑海里敲响DP的警钟。

2.2 二维DP数组的定义与初始化陷阱

最直观的思路是定义一个二维数组dp[i][j],表示从起点(0, 0)走到格子(i, j)的不同路径数量。我们的目标是求dp[m-1][n-1]

状态转移方程几乎可以脱口而出:如果当前格子(i, j)不是障碍物,那么到达这里的路径数等于从上面来的路径数加上从左边来的路径数。即:dp[i][j] = dp[i-1][j] + dp[i][j-1]如果(i, j)是障碍物,那么显然一条路都没有,dp[i][j] = 0

难点和坑点往往集中在初始化上。初始化是为状态转移提供正确的“起点”或“边界条件”。

  1. 第一行(i=0)和第一列(j=0)的初始化:这是最容易出错的地方。因为机器人只能向右或向下走,所以对于第一行的任何格子(0, j),它只能从它的左边(0, j-1)过来,不可能从上方来(因为没有上方)。同理,对于第一列的任何格子(i, 0),只能从它的上方(i-1, 0)过来。因此:
    • 初始化dp[0][0]:如果起点就是障碍物,那直接返回0。否则,dp[0][0] = 1,表示在起点有1种方式(不动)。
    • 初始化第一行:for j in range(1, n):如果(0, j)不是障碍物,那么dp[0][j] = dp[0][j-1]注意,这里不是直接等于1!因为如果第一行中某个格子(0, k)是障碍物,那么它右边的所有格子(0, j) (j>k)都不可能到达,路径数应该是0。这个“阻断效应”必须通过递推来体现,即dp[0][j]的值依赖于dp[0][j-1]
    • 初始化第一列:逻辑同上,for i in range(1, m):如果(i, 0)不是障碍物,则dp[i][0] = dp[i-1][0]

避坑提示:绝对不要想当然地把第一行和第一列全部初始化为1。这是无障碍版本“不同路径 I”的做法。在“II”中,障碍物会像一堵墙一样,挡住整行或整列后续的格子。你必须用递推的方式初始化,让障碍物的“阻断”效果传递下去。

  1. 遍历顺序:由于计算dp[i][j]需要dp[i-1][j](上方)和dp[i][j-1](左方),这两个值必须在dp[i][j]之前被计算出来。最自然的遍历顺序就是两层循环,外层i从0到m-1,内层j从0到n-1。这样,当计算到(i, j)时,(i-1, j)(上一行同列)已经在外层i-1的循环中算过了,(i, j-1)(本行前一列)已经在本层i循环的内层j-1步算过了。这个顺序保证了状态转移的依赖性得到满足。

3. 从二维到一维:滚动数组的空间优化艺术

二维DP的思路清晰,代码也容易写。但它的空间复杂度是O(m*n)。在蓝桥杯比赛中,虽然通常不会卡空间,但掌握空间优化技巧是体现算法功力的重要方面,有时也能为其他计算腾出内存。对于这类“每一行的状态只依赖于上一行和本行左侧状态”的DP,经典的优化手段就是使用滚动数组,将空间复杂度降至O(n)。

3.1 滚动数组的工作原理

我们仔细观察状态转移方程:dp[i][j] = dp[i-1][j] + dp[i][j-1]

  • dp[i-1][j]:这是“上一行”的j列的值。
  • dp[i][j-1]:这是“本行”已经计算出来的j-1列的值。

如果我们只用一个一维数组dp_1d来表示“当前行”正在计算的状态,那么:

  • 在计算新的dp_1d[j](即二维中的dp[i][j])时,dp_1d[j]本身在上一轮循环(计算i-1行时)存储的值,恰好就是dp[i-1][j]
  • dp_1d[j-1]在本次循环中刚刚被更新过,它存储的就是dp[i][j-1]

因此,状态转移可以在一维数组上原地进行:dp_1d[j] = dp_1d[j] + dp_1d[j-1]等号右边的dp_1d[j]是“旧值”,代表从上方来的路径数;等号右边的dp_1d[j-1]是“新值”,代表从左方来的路径数。等号左边的dp_1d[j]是更新后的当前格子的路径数。

3.2 一维DP的初始化与遍历细节

使用一维数组后,初始化和遍历需要一些调整:

  1. 初始化dp_1d数组现在代表“当前行”。我们初始化它相当于初始化二维DP的第一行。

    • 首先,dp_1d[0]代表起点(0,0)。如果起点无障碍,则dp_1d[0] = 1,否则为0。
    • 对于j从1到n-1:如果(0, j)无障碍,则dp_1d[j] = dp_1d[j-1](因为只能从左来);如果有障碍,则dp_1d[j] = 0。这和二维初始化第一行的逻辑完全一致。
  2. 遍历:外层循环i从1到m-1(因为第0行已经初始化好了),代表处理第1行到最后一行。

    • 在每一行i开始计算前,dp_1d数组中存储的实际上是“上一行”(i-1行)的结果。
    • 内层循环j从0到n-1
      • j=0时(第一列):计算dp_1d[0](即dp[i][0])。它只能从上方来,也就是上一行的dp_1d[0](即dp[i-1][0])。所以如果当前格子(i,0)无障碍,新的dp_1d[0]就等于它自身(上一行的值);如果有障碍,则置0。注意,此时dp_1d[0]被更新为当前行的值。
      • j>0时:执行我们推导出的转移方程dp_1d[j] = dp_1d[j] + dp_1d[j-1]。但这里有一个极其重要的前提:必须保证等号右边的dp_1d[j]dp_1d[j-1]是正确的值。dp_1d[j-1]在本轮循环中刚刚更新过,是对的。dp_1d[j]还是上一行的值,也是对的。所以这个计算是安全的。
    • 关键点:内层循环j必须从0到n-1顺序遍历。因为计算dp_1d[j]依赖于dp_1d[j-1](本行左侧),如果从后往前遍历,dp_1d[j-1]还是上一行的值,逻辑就错了。

实操心得:在写一维DP代码时,我习惯在每一行i的开头,先判断当前行的第一个格子(第一列)。单独处理j=0的情况可以让逻辑更清晰,避免在j的循环内部做if j==0的判断,影响代码简洁性和轻微的性能。对于障碍物的判断,只需要在更新dp_1d[j]之前检查obstacleGrid[i][j]是否为1即可。

4. 代码实现与逐行解析

下面给出Python语言的一维滚动数组实现,并附上详细注释。这个版本清晰且高效,是比赛中的推荐写法。

def uniquePathsWithObstacles(obstacleGrid): """ :type obstacleGrid: List[List[int]] :rtype: int """ m, n = len(obstacleGrid), len(obstacleGrid[0]) # 边界情况1:起点就是障碍物 if obstacleGrid[0][0] == 1: return 0 # 初始化一维dp数组,长度为n(列数) dp = [0] * n # 初始化起点 dp[0] = 1 # 初始化第一行 (i=0) for j in range(1, n): # 如果第一行的第j列是障碍物,则dp[j]为0(且会阻断后续,但由于是递推,后续自然为0) # 如果不是障碍物,则路径数等于左边格子的路径数 dp[j] = dp[j-1] if obstacleGrid[0][j] == 0 else 0 # 遍历剩余行 (i从1到m-1) for i in range(1, m): # 处理当前行第一列 (j=0) # 如果当前格子是障碍物,则到此的路径数为0 if obstacleGrid[i][0] == 1: dp[0] = 0 # 如果不是障碍物,dp[0]保持不变(因为只能从上方来,而dp[0]当前存储的就是上方的值) # 注意:这里不需要 dp[0] = dp[0],因为值没变。 # 处理当前行剩余列 (j从1到n-1) for j in range(1, n): if obstacleGrid[i][j] == 1: # 当前格子是障碍物,路径数清零 dp[j] = 0 else: # 状态转移:dp[j](新)= dp[j](旧,上方来) + dp[j-1](左方来) dp[j] = dp[j] + dp[j-1] # 最终结果存储在dp数组的最后一个位置 return dp[-1]

代码关键点解析:

  1. dp数组的含义:在整个过程中,dp[j]表示“在当前正在处理的行i上,到达第j列格子的路径总数”。在进入第i行时,它存储的是第i-1行的结果;在处理完第i行后,它存储的是第i行的结果。
  2. 第一行初始化的循环for j in range(1, n): dp[j] = dp[j-1] if obstacleGrid[0][j] == 0 else 0。这行代码精妙地处理了第一行的“阻断效应”。如果(0,1)是障碍,dp[1]被设为0,那么当j=2时,dp[2] = dp[1],自然也是0,障碍物右侧全部被正确置零。
  3. 每行开头对第一列的处理if obstacleGrid[i][0] == 1: dp[0] = 0。这是必须的,因为第一列只能从上方来。如果当前行的第一列是障碍,那么到此的路径数就是0,并且会“阻断”下方所有行的第一列(因为下方格子依赖的上方路径数变成了0)。如果不是障碍,dp[0]就保持原样,因为它本身就代表从上方来的路径数。
  4. 内层核心转移dp[j] = dp[j] + dp[j-1]。这是滚动数组优化的精髓。在计算这一刻,等号右边的dp[j]是“上一行i-1的第j列的值”,等号右边的dp[j-1]是“本行i的第j-1列的值(刚刚计算完)”。两者相加,完美对应了二维的dp[i-1][j] + dp[i][j-1]

5. 蓝桥杯国赛实战技巧与常见坑点

在国赛的紧张环境中,仅仅会解这道题是不够的,还要快、要准。下面结合我的备赛和带队经验,分享几个针对性的技巧和常见错误。

5.1 快速识别与题型变种

“不同路径 II”是一个母题,蓝桥杯会围绕它做很多变化。看到以下特征,要能立刻联想到此类DP:

  • 网格地图:题目给一个m x n的矩阵,有可走区域和不可走区域(障碍)。
  • 移动限制:通常只能向右、向下,有时会增加向左、向上(变成搜索或BFS/DFS),但DP常见的是单向移动。
  • 求解目标:求从左上到右下的“路径数”、“最大/最小权重和”、“是否存在路径”等。
    • 路径数:就是本题,状态值表示方案数。
    • 最大/最小和:每个格子有分数或代价,求一条路径使得总分最大或总代价最小。状态转移方程变为dp[i][j] = max/min(dp[i-1][j], dp[i][j-1]) + grid[i][j]。初始化也要相应调整(第一行/列是累加)。
    • 存在性判断:问是否能到达,可以用布尔型DP数组,或者用本DP方法,最后看dp[m-1][n-1]是否大于0。

实战技巧:在阅读题目时,迅速在草稿纸上画出2x3或3x2的小网格,手动模拟一下规则。这个小习惯能极大帮助你理解状态转移,避免想当然。

5.2 初始化与边界处理的致命细节

这是错误的重灾区,除了前面提到的“第一行第一列递推初始化”外,还有几个高频坑点:

  1. 障碍物在起点或终点:这是许多同学忘记判定的特例。代码中必须在一开始就检查obstacleGrid[0][0] == 1obstacleGrid[m-1][n-1] == 1。如果终点是障碍物,直接返回0。虽然从逻辑上讲,路径数肯定是0,但如果不判断,你的DP过程可能会因为某些初始化方式而得到一个非0的错误结果。
  2. 输入网格为1x1:当m=1n=1时,你的循环可能不会执行。必须单独处理:如果这个唯一格子无障碍,返回1;有障碍,返回0。
  3. 使用一维DP时,每行开始对dp[0]的处理:务必根据当前行第一列是否有障碍物来重置dp[0]。不能因为它之前有值就不管了。如果当前行(i,0)是障碍,dp[0]必须被设为0,以阻断后续所有行对第一列的依赖。

5.3 调试与验证方法

在比赛中,写完后快速验证比追求一次写对更重要。

  1. 设计微型测试用例:不要只用题目给的例子。自己设计几个有代表性的小案例:
    • 案例1:[[0]],预期1。
    • 案例2:[[1]],预期0。
    • 案例3:[[0,0,0],[0,1,0],[0,0,0]](标准示例),预期2。
    • 案例4:[[0,1],[0,0]],预期1。
    • 案例5:障碍物完全堵住第一行中间:[[0,0,1,0,0]],预期0(因为无法绕过障碍到达终点)。 用这些案例快速跑一遍你的代码,基本能覆盖大部分边界错误。
  2. 打印DP表:如果时间允许,或者遇到复杂变种,在本地调试时,可以打印出二维DP表(即使你写的是一维,也可以临时用二维来打印中间状态)。肉眼对比每个格子的值是否正确,是定位初始化或转移方程错误最直接的方法。

5.4 空间优化选择的考量

在蓝桥杯比赛中,对于mn在100-200量级的题目,使用O(m*n)的二维DP空间(约40KB-160KB)是完全可接受的,代码也更易读、易调试。不必为了优化而优化。一维滚动数组的代码相对容易出错,尤其是初始化部分。

我的建议是:在时间紧迫的赛场,如果你对二维DP非常有把握,可以先写出二维的版本,确保正确拿到基础分。如果后面有时间复查,并且题目有明确的空间限制提示,再考虑优化为一维。清晰的逻辑和正确的答案永远比炫技更重要。在平时练习时,则要两种方法都熟练掌握,理解其本质。

6. 举一反三:相关真题与扩展思考

掌握了“不同路径 II”,你就拥有了解决一大类二维网格DP问题的钥匙。我们可以看看它如何延伸到其他真题。

扩展1:最小路径和(LeetCode 64,蓝桥杯常见变种)题目:给定一个包含非负整数的m x n网格,找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。解法迁移:状态dp[i][j]表示到达(i,j)的最小路径和。转移方程:dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]。初始化:dp[0][0] = grid[0][0];第一行dp[0][j] = dp[0][j-1] + grid[0][j];第一列dp[i][0] = dp[i-1][0] + grid[i][0]。同样可以使用滚动数组优化。这和“不同路径 II”的框架完全一致,只是把“加法”换成了“取min后加法”。

扩展2:带权值的不同路径(蓝桥杯模拟题)题目:网格中有障碍,每个可通行格子有一个权值(正数),求所有可达路径的权值之和(每条路径的权值是经过格子权值的乘积/和)。解法迁移:如果是求和,那么状态dp[i][j]表示到达(i,j)的所有路径的权值总和。转移方程:dp[i][j] = dp[i-1][j] + dp[i][j-1] + count*weight?不,这里容易搞混。实际上,如果路径权值是格子权值之和,那么这变成了“路径数”和“最小路径和”的结合体,需要更复杂的状态设计。这提示我们,DP的状态定义必须与问题要求的结果严格对应。当问题变得复杂时,可能需要增加DP的维度。

思维进阶:如何思考更复杂的网格DP?当移动方向增加(如可以上下左右),或者问题要求更多(如路径不能重复、有次数限制),单纯的二维坐标DP可能不够用。这时常见的思路是:

  1. 增加状态维度:例如,用dp[i][j][k]表示在(i,j)且已经使用了k次某种能力的方案数。
  2. 结合其他算法:例如,将网格转化为图,使用BFS求最短路径(无权)或Dijkstra算法(有权)。
  3. 记忆化搜索:当移动规则复杂,难以确定递推顺序时,用DFS+记忆化(Memoization)可能更直观。这本质上是递归形式的DP,思维负担更小,但可能有栈溢出风险。

回到我们的主题,对于冲刺蓝桥杯国赛,把“不同路径 II”及其一维优化吃透,足以应对大部分基础到中等的二维线性DP题目。核心是训练出看到问题就能抽象出状态定义和转移方程的条件反射,同时把初始化、边界处理的细节变成肌肉记忆。在最后的备考阶段,多找此类题目进行限时训练,总结错题本,比盲目刷题更有效。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询