本期任务:介绍算法中关于动态规划思想的几个经典问题
一、问题描述
"""
有一个由非负整数组成的三角形,第一行只有一个数,除了最下行之外每个数的左下方和右下方各有一个数.
1
3 2
4 10 1
4 3 2 20
从第一行的数开始,每次可以往左下或右下走一格,直到走到最下行,把沿途经过的数全部加起来,如何走才能使得这个和尽量大?
输入:三角形的行数n,数字三角形的各个数(从上到下,从左到右)
n=4
[1, 3, 2, 4, 10, 1, 1, 3, 2, 20]
输出:最大的和
24
"""
二、算法思路
本题的解法与数字矩阵问题大同小异,可以阅读【算法】【动态规划篇】第2节:数字矩阵问题,加深对此类问题的理解。
1. 策略选择
一个模型:
- 数字三角形问题是典型的“多阶段决策最优解”问题,每一层决策一次,共决策n次(n为数字三角形行数);最优解是最长路径值。
三个特征:
-
重复子问题:
- 不同的决策序列,到达某个相同的阶段时,可能会产生重复的状态。
- 本题中,不同的路径可能到达相同的位置,如示例中的数字10对应位置的状态由 ( 1 , 3 , 10 ) (1,3,10) (1,3,10)和 ( 1 , 2 , 10 ) (1,2,10) (1,2,10)
-
无后效性:
-
最优子结构:
- 后面阶段的状态可以通过前面阶段的状态推导出。
- 本题中,每一个状态都可以通过上一轮的状态推倒而来,如示例中的数字10对应位置的状态由数字10与上一行的数字3和2对应状态中较大者的值共同决定。
综上所述,本问题满足一个模型、三个特征,所以可以使用动态规划来求解。
当然,凡是能用动态规划解决的问题,都可以用回溯思想来暴力求解,具体实现代码文末已给出,更多关于回溯思想的应用,可以参照:【算法】【回溯篇】第7节:0-1背包问题
2. 动态规划算法思路
动态规划使用的流程:自顶向下分析问题,自底向上解决问题!
- 由于本题给定的是用一维数组表示的二维矩阵,所以需要对数组元素进行重新编号,并用字典(代码中的new2old)来保存新旧编号的关系,以方便后续访问。(大白话:明明是二维矩阵,非得给我一个一维数组,嘿嘿,不熟,换回来。)
- 使用与原矩阵规模一致的二维维数组来保存从第0行到任意位置的最短路径长度。
- 更新过程(状态转移思路):
- (0, 0)到(i, j)的最长路径为:当前位置的值+(0, 0)到上一轮两个父节点的最长路径的较大者
d p _ a r r [ i ] [ j ] = a r r [ n e w 2 o l d . g e t ( ( i , j ) ) ] + m a x ( d p _ a r r [ i − 1 ] [ j ] , d p _ a r r [ i − 1 ] [ j − 1 ] ) dp\_arr[i][j] = arr[new2old.get((i, j))] + max(dp\_arr[i - 1][j], dp\_arr[i - 1][j - 1]) dp_arr[i][j]=arr[new2old.get((i,j))]+max(dp_arr[i−1][j],dp_arr[i−1][j−1])
- (0, 0)到(i, j)的最长路径为:当前位置的值+(0, 0)到上一轮两个父节点的最长路径的较大者
三、Python代码实现
1. 动态规划解法
class Solution():
def reindex(self, arr):
"""
由于输入的是一个一维数组,而且数字三角形不满足完全二叉树的特性,需要重新编号
"""
d = list()
index_i = 0
index_j = 0
for i in range(len(arr)):
d.append((index_i, index_j))
index_j += 1
if index_i < index_j:
index_i += 1
index_j = 0
d = {item[1]: item[0] for item in enumerate(d)} # 由新标号反推就编号
return d
def dp(self, n, arr):
"""
使用动态规划法求解数字三角形问题
:param n: 三角形高度
:param arr: 保存三角形数据的一维数组
:return: 最大路径值
"""
new2old = self.reindex(arr) # 对数组进行重新标号,方便后续访问左右孩子(三角形不同于完全二叉树)
dp_arr = [[0 for j in range(i + 1)] for i in range(n)] # 维护一个三角形的数组用来存储max_dist(i,j)
dp_arr[0][0] = arr[0] # 初始化首个元素
for i in range(1, n):
dp_arr[i][0] += arr[new2old.get((i, 0))] + dp_arr[i - 1][0] # 初始化第一列元素
dp_arr[i][i] += arr[new2old.get((i, i))] + dp_arr[i - 1][i - 1] # 初始化对角线元素
# 填写各阶段重复子问题的最优解
for i in range(2, n):
for j in range(1, i):
# (0, 0)到(i, j)的最长路径为:当前位置的值+(0, 0)到上一轮两个父节点的最长路径的较大者
dp_arr[i][j] = arr[new2old.get((i, j))] + max(dp_arr[i - 1][j], dp_arr[i - 1][j - 1])
return max(dp_arr[-1]) # 返回最后一行的最大值
def main():
arr = [1, 3, 2, 4, 10, 1, 1, 3, 2, 20]
n = 4
client = Solution()
print(client.dp(n, arr))
if __name__ == '__main__':
main()
运行结果:
24
2. 回溯解法
class Solution():
def reindex(self, arr):
"""
由于输入的是一个一维数组,而且数字三角形不满足完全二叉树的特性,需要重新编号
"""
d = list()
index_i = 0
index_j = 0
for i in range(len(arr)):
d.append((index_i, index_j))
index_j += 1
if index_i < index_j:
index_i += 1
index_j = 0
d = {item[1]: item[0] for item in enumerate(d)} # 由新标号反推就编号
return d
def trackback(self, n, arr):
self.size = n
self.arr = arr
self.max_v = 0
self.new2old = self.reindex(arr) # 对数组进行重新标号,方便后续访问左右孩子(三角形不同于完全二叉树)
self.helper(0, 0, 1)
return self.max_v
def helper(self, index_i, index_j, value):
if index_i == self.size - 1: # 当遍历到最后一行时进行结算
if self.max_v < value:
self.max_v = value
return
self.helper(index_i + 1, index_j, value + self.arr[self.new2old[(index_i + 1, index_j)]])
self.helper(index_i + 1, index_j + 1, value + self.arr[self.new2old[(index_i + 1, index_j + 1)]])
def main():
arr = [1, 3, 2, 4, 10, 1, 1, 3, 2, 20]
n = 4
client = Solution()
print(client.trackback(n, arr))
if __name__ == '__main__':
main()
运行结果:
24