封神冰川的最后一道试炼里,门卫要求你收集足够多的黄金才能通关。硬币一行行铺在唯一的出口通道上,你每往前走一行只能拾取一枚,而且只能选下一行里紧邻的左右两枚之一。一步走错,致命的光束就会将你烧成灰烬。要在"每步只有局部两个选择"的约束下,收集到全局最多的黄金——这正是计算机科学中一门威力强大的算法思想:动态规划(Dynamic Programming, DP)。这是本课程数据结构与算法之旅的压轴单元,也是你从"会写代码"迈向"会设计算法"的关键一跃。
动态规划是一种把复杂问题拆成一系列重叠子问题、并复用子问题结果来求最优解的方法。它的精髓在于:不重复计算已经算过的子问题,而是把每个子问题的答案存起来(通常放在一张表里),后续直接查用。相比"暴力枚举所有路径"(路径数会指数级爆炸),DP 把工作量降到了可控的多项式级别。
动态规划能奏效,靠的是问题具备最优子结构:一条从某格出发的最优路径,必然由"这一格 + 它下面某个邻居出发的最优路径"构成。所以只要知道了"左下格"和"右下格"各自能拿到的最大值,当前格的最大值就等于自身价值 + 两者中的较大者。这个"局部最优拼出全局最优"的性质,正是我们敢于逐行递推的底气。
第一步是把游戏世界里散落的硬币,整理成一个整齐的二维数组(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
游戏里硬币有 pos.x、pos.y 的物理坐标,要把它们换算成数组的行、列索引。做法是:减去起点 zeroPoint、再除以行/列间距、取整。row = int((coin.pos.y - zeroPoint.y) / distanceY) 得到第几排,col 同理得到第几列。这套"物理坐标 → 逻辑索引"的换算,让算法能在干净的网格上思考,最后再换算回去移动英雄。
2 * coinLines - 1 来容纳。这正是为什么每步只能选"左下"或"右下"——下一行的硬币恰好落在当前列的左下方和右下方。理解这个几何布局,状态转移就顺理成章。动态规划的核心步骤是递推。这里我们采用自底向上(bottom-up):最后一行本身就是"从该格出发能拿到的最大值"(因为下面没有格子了),把它当作基础;然后倒数第二行的每个格子,加上它左下/右下中较大的那个已算好的值;如此一行行往上,直到第一行。每个格子最终存的,就是"从此格出发,一路向下能收集到的黄金最大总和"。
"自底向上"在代码里就是倒序遍历行。最后一行是基础不用动,从倒数第二行(索引 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 里最需要想清楚的一点。状态转移方程是动态规划的灵魂——它描述"当前状态的值,如何由更小的子状态推出"。本关的方程很简洁:当前格的累计值 = 自身价值 + max(左下格累计值, 右下格累计值)。因为从当前格只能走向左下或右下,自然要选那个"后续收益更大"的方向。用 max() 一步选出较大者,累加上去即可。
# 状态转移:当前格 += 左下、右下中较大者 goldMap[i][j] += max(goldMap[i + 1][j - 1], goldMap[i + 1][j + 1]) # 自身已含 ↑ 左下(i+1,j-1) 右下(i+1,j+1)
方程在网格边缘要特殊处理:最左列(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])
递推完成后,第一行(第 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
知道起点和每格的最大值后,还要回溯出具体的行走路径。从起点列开始,逐行向下:比较当前格的左下和右下哪个累计值更大,就往哪边走,并把这一步记为 "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
["R", "L", …]processMap),再从最优起点顺着这些值回溯出具体路径(findPath)。前者回答"最多能拿多少",后者回答"具体怎么走"。很多 DP 问题都遵循这个"先求值、再回溯"的经典范式。最后,把起点列换算回游戏 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)
这一节聚焦关卡的策略、思路与算法设计,只展示用于说明的关键片段,完整通关程序请你自己动手完成。
门卫要求收集足够黄金,你沿唯一出口方向前进,每行取一枚硬币,只能选下一行紧邻的左下或右下之一,走错就被光束烧毁。这是典型的网格最大路径和问题,标准动态规划五步走:① 把硬币建成二维地图;② 自底向上递推每格最大累计值;③ 在顶行选最优起点;④ 回溯出左右方向序列;⑤ 按序列移动英雄。
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 方向序列
col * distanceX + zeroPoint["x"])和每步 distanceX / distanceY 的位移必须精确;逻辑索引与物理坐标要严格对齐,算法算得再对,走偏一格也会失败。
range(len-2, -1, -1)),从顶往下算会用到还没算好的值。findPath 里选方向的比较逻辑要和 processMap 里的选择对应,否则走的不是最优路径。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/R | findPath → ["R","L",…] |
| 沿路径移动 | L 左下 / R 右下 | moveXY(x±dX, y+dY) |
| DP 优势 | 子问题只算一次 | 空间换时间 |
懂得把大问题拆成子问题、用一张表记住每个子问题的最优答案、再拼出全局最优——动态规划这份"以退为进、步步为营"的智慧,是你算法修行中一座重要的里程碑。
封神冰川的动态规划试炼圆满完成,计算机科学 6 的征途至此功德圆满,恭喜你!🧊❄️🪙
扣哒世界 编程知识 · 计算机科学 6(封神冰川):第十四单元
扣哒世界教研团队