去做题

第十四单元:动态规划 · 最优子结构与路径决策

Unit 14: Dynamic Programming — Optimal Substructure & Path Decisions
计算机科学 6 · 封神冰川 Deified Glacier · 黄金选择

14.1 动态规划概述

Overview of Dynamic Programming

封神冰川的最后一道试炼里,门卫要求你收集足够多的黄金才能通关。硬币一行行铺在唯一的出口通道上,你每往前走一行只能拾取一枚,而且只能选下一行里紧邻的左右两枚之一。一步走错,致命的光束就会将你烧成灰烬。要在"每步只有局部两个选择"的约束下,收集到全局最多的黄金——这正是计算机科学中一门威力强大的算法思想:动态规划(Dynamic Programming, DP)。这是本课程数据结构与算法之旅的压轴单元,也是你从"会写代码"迈向"会设计算法"的关键一跃。

本单元主线:用二维数组存储每行每列的硬币价值;从底部向上逐行计算每个格子出发能收集到的最大累计值;写出状态转移方程(把当前格加上"左下、右下"中较大者);从顶行选出最优起点;回溯记录每一步的左右方向;最终根据这条路径移动英雄、精准踩过每一枚硬币。

14.1.1 什么是动态规划(What Is Dynamic Programming)

14.1.1.1 用子问题的解拼出大问题的解 Build the big answer from sub-answers

动态规划是一种把复杂问题拆成一系列重叠子问题、并复用子问题结果来求最优解的方法。它的精髓在于:不重复计算已经算过的子问题,而是把每个子问题的答案存起来(通常放在一张表里),后续直接查用。相比"暴力枚举所有路径"(路径数会指数级爆炸),DP 把工作量降到了可控的多项式级别。

🧊 就像规划下山的最佳雪道
你站在冰川顶端要滑到山脚,每个岔路口只能往左下或右下。与其把所有可能的路线全走一遍(那太多了),不如反过来想:先算出"从山脚每个点出发能得多少分",再一层层往上推"从每个岔路口出发能得的最高分"。等推回山顶,哪条起步方向分最高,一眼就能看出——每个岔路口的答案都只算一次,却被上层反复复用。这就是动态规划的智慧。

14.1.2 最优子结构(Optimal Substructure)

14.1.2.1 大问题的最优解含子问题的最优解 The best overall solution contains best sub-solutions

动态规划能奏效,靠的是问题具备最优子结构:一条从某格出发的最优路径,必然由"这一格 + 它下面某个邻居出发的最优路径"构成。所以只要知道了"左下格"和"右下格"各自能拿到的最大值,当前格的最大值就等于自身价值 + 两者中的较大者。这个"局部最优拼出全局最优"的性质,正是我们敢于逐行递推的底气。

DP 的两大前提:一是最优子结构(大问题的最优解可由子问题最优解组合而来);二是重叠子问题(同一个子问题会被多次用到,值得存起来复用)。本关的硬币路径两条件都满足——每个格子的"最大累计值"会被它上方的多个格子引用,因此用一张表存下来最划算。

14.2 二维数组建模

Modeling with a 2D Array

14.2.1 硬币地图(The Gold Map)

14.2.1.1 把散落的硬币装进网格 Place scattered coins into a grid

第一步是把游戏世界里散落的硬币,整理成一个整齐的二维数组(gold map):行代表第几排、列代表这一排的第几个位置,每个元素存该位置硬币的价值,没硬币的地方填 0。关卡用列表推导式先造出一张全 0 的模板,再把每枚硬币按坐标"归位"填进去。这种"先建表、再填值"的建模,是动态规划的起点。

# 先造一张全 0 的二维模板,再填入硬币价值
def makeGoldMap(coins):
    template = [[0 for j in range(2 * coinLines - 1)] for i in range(coinLines)]
    for coin in coins:
        row = int((coin.pos.y - zeroPoint.y) / distanceY)
        col = int((coin.pos.x - zeroPoint.x) / distanceX)
        template[row][col] = coin.value
    return template
10904 01090 80209
硬币地图示例:■ 硬币价值 · 0 表示空位。行是排、列是位置

14.2.2 坐标换算索引(Converting Coordinates to Indices)

14.2.2.1 从游戏坐标算出行列 Compute row/col from game coordinates

游戏里硬币有 pos.x、pos.y 的物理坐标,要把它们换算成数组的行、列索引。做法是:减去起点 zeroPoint、再除以行/列间距、取整。row = int((coin.pos.y - zeroPoint.y) / distanceY) 得到第几排,col 同理得到第几列。这套"物理坐标 → 逻辑索引"的换算,让算法能在干净的网格上思考,最后再换算回去移动英雄。

锯齿状网格:本关每行的硬币是交错排列的(相邻两行的列位错开),所以列数用了 2 * coinLines - 1 来容纳。这正是为什么每步只能选"左下"或"右下"——下一行的硬币恰好落在当前列的左下方和右下方。理解这个几何布局,状态转移就顺理成章。

14.3 自底向上递推

Bottom-Up Recurrence

14.3.1 从底部逐行累计(Accumulate Row by Row from the Bottom)

14.3.1.1 为什么从最后一行往上算 Why compute from the last row upward

动态规划的核心步骤是递推。这里我们采用自底向上(bottom-up):最后一行本身就是"从该格出发能拿到的最大值"(因为下面没有格子了),把它当作基础;然后倒数第二行的每个格子,加上它左下/右下中较大的那个已算好的值;如此一行行往上,直到第一行。每个格子最终存的,就是"从此格出发,一路向下能收集到的黄金最大总和"。

自底向上:下一行算好了,才算上一行 第 i 行(待算) 当前 第 i+1 行(已算) 左下 右下 当前格 += max(左下, 右下)

14.3.2 倒序遍历(Iterating in Reverse)

14.3.2.1 用 range 从倒数第二行走到第 0 行 Use range to loop from the second-to-last row to row 0

"自底向上"在代码里就是倒序遍历行。最后一行是基础不用动,从倒数第二行(索引 len - 2)开始,一直递推到第 0 行(索引 0)。Python 用 range(len(goldMap) - 2, -1, -1) 实现这个"从大到小、步长 -1"的倒序循环。方向对了,才能保证每次用到的"下一行"都是已经算好的。

# 倒序遍历:从倒数第二行递推到第 0 行
for i in range(len(goldMap) - 2, -1, -1):
    line = goldMap[i]
    for j in range(len(line)):
        if line[j] == 0:
            continue          # 空位跳过,不是硬币
        # … 在这里做状态转移(见 14.4)…
方向反了就全错:动态规划的递推方向至关重要。若从顶行往下算,用到的"下一行"还没算好,结果就是错的。必须先算下面、再算上面——所以用倒序 range(len-2, -1, -1)。这是 DP 里最需要想清楚的一点。

14.4 状态转移方程

The State Transition Equation

14.4.1 取左下右下较大者(Take the Larger of Down-Left and Down-Right)

14.4.1.1 动态规划的灵魂公式 The soul of dynamic programming

状态转移方程是动态规划的灵魂——它描述"当前状态的值,如何由更小的子状态推出"。本关的方程很简洁:当前格的累计值 = 自身价值 + max(左下格累计值, 右下格累计值)。因为从当前格只能走向左下或右下,自然要选那个"后续收益更大"的方向。用 max() 一步选出较大者,累加上去即可。

# 状态转移:当前格 += 左下、右下中较大者
goldMap[i][j] += max(goldMap[i + 1][j - 1], goldMap[i + 1][j + 1])
#            自身已含 ↑            左下(i+1,j-1)          右下(i+1,j+1)
·当前
3
·
10·7
当前格价值 3,左下已算得 10、右下已算得 7 → 3 + max(10, 7) = 13,并记下"选左下"

14.4.2 边界处理(Handling the Edges)

14.4.2.1 最左列没有左下、最右列没有右下 The leftmost column has no down-left; the rightmost has no down-right

方程在网格边缘要特殊处理:最左列(j == 0)没有左下邻居,只能选右下;最右列(j == len - 1)没有右下邻居,只能选左下;中间的格子才用 max() 二选一。这三种情况用 if / elif / else 分开处理,避免访问到不存在的邻居而取错值或越界。

# 边界分情况:最左只能右下,最右只能左下,中间取较大
if j == 0:
    goldMap[i][j] += goldMap[i + 1][j + 1]   # 只有右下
elif j == len(line) - 1:
    goldMap[i][j] += goldMap[i + 1][j - 1]   # 只有左下
else:
    goldMap[i][j] += max(goldMap[i + 1][j - 1], goldMap[i + 1][j + 1])
边界是 DP 的常见陷阱:状态转移方程本身往往优雅简洁,但边界情况(第一行/最后一行、最左列/最右列)容易被忽略。养成"先想清楚边界怎么办、再写主逻辑"的习惯,能避免绝大多数 DP 的隐蔽 bug。

14.5 选起点与回溯路径

Choosing the Start and Recovering the Path

14.5.1 选最优起点(Choose the Best Starting Column)

14.5.1.1 第一行里累计值最大的那一列 The column in row 0 with the largest accumulated value

递推完成后,第一行(第 0 行)每个格子的值,就是"从该格起步能收集到的黄金总和"。要拿最多黄金,只需在第一行里找出值最大的那一列作为起点。遍历第 0 行,用一个"擂台"变量记录当前最大值和对应列号,一遍扫下来就得到最优起点 bestCol。

# 在第 0 行找累计值最大的列 = 最优起点
def chooseStartCol(goldMap):
    bestCol = 0
    bestValue = 0
    for i in range(len(goldMap[0])):
        if goldMap[0][i] > bestValue:
            bestValue = goldMap[0][i]
            bestCol = i
    return bestCol

14.5.2 记录左右方向(Recording Left/Right Choices)

14.5.2.1 从起点再走一遍,逐行决定方向 Walk down from the start, deciding direction each row

知道起点和每格的最大值后,还要回溯出具体的行走路径。从起点列开始,逐行向下:比较当前格的左下和右下哪个累计值更大,就往哪边走,并把这一步记为 "L"(左)或 "R"(右),同时更新列号。边界列则只能朝唯一可行的方向走。这样得到一个方向序列 path,就是英雄该走的完整路线。

# 从起点逐行回溯,记录每步是往左(L)还是往右(R)
def findPath(goldMap, col):
    path = []
    for i in range(len(goldMap) - 1):
        if col == 0:                 # 最左列只能向右
            path.append("R")
            col += 1
        elif col == len(goldMap[i]) - 1:   # 最右列只能向左
            path.append("L")
            col -= 1
        elif goldMap[i + 1][col - 1] < goldMap[i + 1][col + 1]:
            path.append("R")       # 右下更大,向右
            col += 1
        else:
            path.append("L")       # 否则向左
            col -= 1
    return path
··起23··
·R→20·13·
L→11·6·4
从起点(23)回溯:每步选累计值更大的下一格,记下方向 R、L……得到路径 ["R", "L", …]
"算最大值"与"记路径"是两步:动态规划常常分成两个阶段——先自底向上算出每格的最优值(processMap),再从最优起点顺着这些值回溯出具体路径(findPath)。前者回答"最多能拿多少",后者回答"具体怎么走"。很多 DP 问题都遵循这个"先求值、再回溯"的经典范式。

14.6 沿路径移动英雄

Moving the Hero Along the Path

14.6.1 按方向前进(Advance by Direction)

14.6.1.1 把 L/R 序列翻译成移动指令 Translate the L/R sequence into moves

最后,把起点列换算回游戏 X 坐标先走到起点,再遍历 path 序列:遇到 "L" 就向左下方移动一格(x - distanceX, y + distanceY),遇到 "R" 就向右下方移动一格(x + distanceX, y + distanceY)。每一步都精准落在下一枚硬币上,既收集了黄金,又避开了致命光束。这一步把"逻辑上的路径"变成了"世界里的行动"。

# 沿路径移动:L 向左下, R 向右下
for p in route:
    if p == "L":
        hero.moveXY(hero.pos.x - distanceX, hero.pos.y + distanceY)
    else:
        hero.moveXY(hero.pos.x + distanceX, hero.pos.y + distanceY)
建二维地图 自底向上递推 选最优起点 回溯路径 移动

14.7 实战关卡精讲

Level Strategy & Analysis

这一节聚焦关卡的策略、思路与算法设计,只展示用于说明的关键片段,完整通关程序请你自己动手完成。

第 31 关 · 黄金选择

🪙 黄金选择 —— 动态规划求最大黄金路径

扣哒世界 黄金选择关卡 - koudashijie.com
图2:黄金选择——硬币交错铺在通道上,每行只能取一枚且只能选左下/右下;用动态规划算出黄金最多的路径并沿之前进

策略:建表 → 递推 → 选起点 → 回溯 → 移动

门卫要求收集足够黄金,你沿唯一出口方向前进,每行取一枚硬币,只能选下一行紧邻的左下或右下之一,走错就被光束烧毁。这是典型的网格最大路径和问题,标准动态规划五步走:① 把硬币建成二维地图;② 自底向上递推每格最大累计值;③ 在顶行选最优起点;④ 回溯出左右方向序列;⑤ 按序列移动英雄。

思路拆解一:递推每格的最大累计值

processMap 倒序遍历行(从倒数第二行到第 0 行),对每个硬币格套用状态转移:边界列只能朝唯一方向累加,中间列用 max() 取左下右下较大者。空位(值为 0)直接 continue 跳过。递推完,顶行每格就存着"从此起步的黄金总和"。

# 关键片段:自底向上递推(核心)
for i in range(len(goldMap) - 2, -1, -1):
    line = goldMap[i]
    for j in range(len(line)):
        if line[j] == 0:
            continue
        if j == 0:
            goldMap[i][j] += goldMap[i + 1][j + 1]
        elif j == len(line) - 1:
            goldMap[i][j] += goldMap[i + 1][j - 1]
        else:
            goldMap[i][j] += max(goldMap[i + 1][j - 1], goldMap[i + 1][j + 1])

思路拆解二:选起点并回溯路径

用 chooseStartCol 在顶行找出累计值最大的列作为 startColumn;再用 findPath 从这一列逐行向下,比较左下右下、记录 "L"/"R"。注意回溯时用的比较逻辑要与递推时的选择保持一致,才能沿着当初算出的最优路径行走。

# 关键片段:选起点 + 回溯 + 走到起点
processedMap = processMap(myMap)
startColumn = chooseStartCol(processedMap)
hero.moveXY(startColumn * distanceX + zeroPoint["x"], hero.pos.y)
route = findPath(processedMap, startColumn)   # 得到 L/R 方向序列
为什么不暴力枚举:若硬币有 10 行、每步 2 选 1,路径总数高达 2⁹ = 512 条,行数再多会指数爆炸。动态规划把每个格子的最优值只算一次并存起来复用,总工作量只与格子数成正比。这就是 DP 相比暴力搜索的核心威力——用空间(那张表)换时间。
精准踩点,一步都不能错:关卡强调"准确地移动到下一枚硬币上,一个错误的步骤会被光束烧毁"。所以坐标换算(col * distanceX + zeroPoint["x"])和每步 distanceX / distanceY 的位移必须精确;逻辑索引与物理坐标要严格对齐,算法算得再对,走偏一格也会失败。

14.8 常见错误

Common Mistakes

14.8.1 新手最容易踩的坑(Pitfalls for Beginners)

14.8.1.1 动态规划的典型问题 Typical issues with dynamic programming

扣哒世界 动态规划常见错误速查 - koudashijie.com
图3:递推方向反了、漏了边界、回溯与递推逻辑不一致、坐标换算出错,是本单元最常见的失误
易错点清单:
  • 递推方向搞反——必须自底向上(倒序 range(len-2, -1, -1)),从顶往下算会用到还没算好的值。
  • 漏了边界处理——最左列没左下、最右列没右下,不分情况直接取会取错值或越界。
  • 回溯与递推不一致——findPath 里选方向的比较逻辑要和 processMap 里的选择对应,否则走的不是最优路径。
  • 忘记跳过空位——值为 0 的空位不是硬币,不 continue 跳过会把 0 也当路径节点处理。
  • 坐标换算出错——row/col 与 pos.x/pos.y 的换算、以及移动时的 distanceX/distanceY 必须精确对齐。
  • 起点擂台初值不当——chooseStartCol 里 bestValue 初值要足够小(如 0),否则可能选不出真正的最大列。
  • 把"求值"和"回溯"混为一谈——先递推算最大值、再单独回溯路径是两个阶段,别试图一次循环里既算值又定路径而搞乱。
  • 混淆行列索引——goldMap[i][j] 中 i 是行、j 是列,写反会全盘错乱。

本单元小结 ✨

恭喜你征服了算法世界里最令人着迷的思想之一——动态规划!在封神冰川的收官之作"黄金选择"里,你学会了用二维数组为问题建模、存储每行每列的硬币价值;掌握了自底向上逐行递推(倒序遍历、先算下面再算上面);写出了本关的状态转移方程——当前格加上"左下、右下"中的较大者,并妥善处理了边界;学会了在顶行选出最优起点、再回溯记录每一步的左右方向;最终把逻辑路径翻译成移动指令,精准地驱动英雄收集最多黄金。这套"建表—递推—求最优—回溯"的范式,是解决无数最优化问题的通用利器。来回顾一下核心概念:

核心概念关键点对应写法
二维数组建模行列存硬币价值goldMap[row][col]
自底向上递推先算下面再算上面range(len-2, -1, -1)
状态转移方程加左下右下较大者+= max(左下, 右下)
边界处理最左/最右只有一个方向if j==0 / elif j==len-1
选最优起点顶行最大累计值chooseStartCol
回溯路径逐行记 L/RfindPath → ["R","L",…]
沿路径移动L 左下 / R 右下moveXY(x±dX, y+dY)
DP 优势子问题只算一次空间换时间

懂得把大问题拆成子问题、用一张表记住每个子问题的最优答案、再拼出全局最优——动态规划这份"以退为进、步步为营"的智慧,是你算法修行中一座重要的里程碑。
封神冰川的动态规划试炼圆满完成,计算机科学 6 的征途至此功德圆满,恭喜你!🧊❄️🪙

扣哒世界 编程知识 · 计算机科学 6(封神冰川):第十四单元

扣哒世界教研团队