动态规划(Dynamic Programming)思路和Python解题示例
动态规划是一种主要用来优化朴素递归的方法,每当输入不同值调用递归函数出现大量重复的(子)输入和调用(返回结果)时,就可以考虑使用动态递归的方式来优化复杂度。
动态规划的主要思想是存储子问题的结果,以便于在接下来可能出现的的重复子问题中直接使用已有的结果,这样子便可以将时间复杂度从指数级别降低到多项式(nlogn…)或线性级别,是一种以空间换时间的算法。
需要提及一点的是,动态规划(Dynamic Programming)中的programming并不是编程的意思,规划也不是指要望向很长远的未来去计划,programming可以理解为递推,也就是问题的解决方法存在F(n)=F(n-1)+F(n-2)这样类似的关系,每一步的结果都与他前面/相邻的一步或几步的结果有关,由这相邻的一步或几步推出而得到当前步的结果
动态规划解法的关键点在于:
- 递归+状态记忆,也就是递推
- 状态转移(递推/DP)方程的推导与定义
- 最优子结构,最优子结构是dp问题被分解为子问题,子问题递归求解时的最优解
以一个斐波那契数列的为示例:
朴素递归写法:
def fib(n):if n <= 1:return nreturn fib(n-1) + fib(n-2)
复杂度为O(2^n)
添加了记忆化缓存后的斐波那契解法:
cached = {0: 0,
}def fib_with_cache(n):print("执行次数+1")if n <= 1:cached[n] = nreturn nif cached.get(n) is None:cached[n] = fib_with_cache(n - 1) + fib_with_cache(n - 2)return cached[n]
复杂度为O(n)
这种解法通过储存子结构(例如计算fib(5)时fib(3)就是fib(5)的子结构)的最优解(这里只有唯一解也是最优解),使得在计算较大的问题(例如fib(7))时,能够利用之前较小问题(fib(3))计算过的步骤,从而避免了重复计算带来的复杂度
单独执行一次fib_with_cache(3)
需要执行3次该函数
>>> print(fib_with_cache(3)) # 5
执行次数+1
执行次数+1
执行次数+1
执行次数+1
执行次数+1
2
先执行一次n=2再执行一次n=3,fib_with_cache(3)
只需要执行3次该函数
>>> print(fib_with_cache(2)) # 3
>>> print(fib_with_cache(3)) # 3
执行次数+1
执行次数+1
执行次数+1
1
执行次数+1
执行次数+1
执行次数+1
2
这里的递推方程的方式可以写成f(n) = f(n-1) + f(n-2)
F[0] = 0;
F[1] = 1;
for (i=2; i<= n; i++){F[i] = F[i-1] + F[i-2]
}
return F[n];
实例和解题思路
不同路径问题
给定一个m×n的格子,最左上格子为起点,最右下格子为终点,只能向右或者向下走,求解从起点到终点的总路径数
以1个3×3的格子为例
给各个格子命名
初始站在起点A,那么路径肯定就只有1条,那么从A→B的路径有1条,从A→D的路径有1条
从A→E的路径有2条:A→B→E和A→D→E
A→E路径数就等于A→B路径数与A→D路径数之和,也就是1+1=2
实际上可以推断出,从起点A到任何一个格子的路径数都等于这个格子的左边的格子的路径数+上方的路径数
因此可以总结出这个问题的递推方程:
dp[h][v] = dp[h - 1][v] + dp[h][v - 1]
其中dp是一个m×n的二维数组,h和v分别为水平和垂直方向的索引
沿着上面的分析思路,最后结果就存放在数组的最右下角dp[m-1][n-1]
class Solution:def uniquePaths(self, m: int, n: int) -> int:# 初始化格子数组,以1填补值,稍后会做计算并覆盖dp = [[1 for _ in range(n)] for _ in range(m)]# 从dp[1][1]开始遍历(最左边的一列和最上边的一行的值一定全为1)# 每一个格子的路径数都等于它左一和上一个格子的路径数之和for h in range(1, m):for v in range(1, n):dp[h][v] = dp[h-1][v] + dp[h][v-1]return dp[m-1][n-1]S = Solution()
print(S.uniquePaths(3, 3))
print(S.uniquePaths(3, 7))
输出
6
28
不同路径2
在一个m×n的格子中,最左上的一个格子为起点,最右下的一个格子为终点,其中值为X的格子代表障碍,不能经过,从起点出发只能往右或者往下前进,求从起点到终点的路径总数
相比于上一个问题,这里定义dp方程时需要考虑两种情况
if dp[i][j] == "空地":opt[i][j] = opt[i-1][j] + opt[i][j-1]
else: // 障碍opt[i][j] = 0
其中opt[i][j]
表示在(i, j)位置时的(最少要走的)路径数,也就是最优解/最优子结构
这里以反向递推的方式,从终点到起点来递推路径的条数(实际上使用递归的解法时从终点往起点回推的方式更符合我们的逻辑)
import numpy as nparr = np.ones((5, 4)).astype(int) # 5行4列的数组# 设置网格中的的障碍
zeros = [(1, 1),(3, 0),(2, 2),
]
for i, j in zeros:arr[i][j] = 0def count_the_paths(grid):width = len(grid[0])depth = len(grid)# 最下和最右边的格子不会变化for d in range(depth-2, -1, -1):for w in range(width-2, -1, -1):if grid[d][w] != 0:grid[d][w] = grid[d+1][w] + grid[d][w+1]return grid[0][0]if __name__ == "__main__":print(count_the_paths(arr))
解法和上一题基本一样,就是多了一个判断障碍的情况,还有这里用的是反向递推的思路,会更容易理解
输出
5
再列举出DP、回溯和贪心算法的特性和区别,加深对DP的理解:
回溯(递归):穷举式的重复计算
贪心:每次都选择当前遇到部分的问题中的局部最优解
DP:找出并记录(部分)子结构最优解,然后用动态转移方程推导出下一个状态(位置)的最优解
回溯+记录局部最优解避免重复计算就是DP,也就是说DP是回溯+贪心的组合
习题部分
一、零钱兑换问题(leetcode #322)
给定不同面额的硬币coins
,例如[1, 2, 5],和一个总金额amount
,例如3,计算满足总金额amount所需的最少的硬币数(给的例子要返回2,因为最少为1+2,2个硬币),如果无法满足就返回-1
,
注:硬币的数量没有限制,硬币面额最小为1
经过分析,解决这个问题需要注意以下几点:
- 优先选择最大的面额不一定是最优解,如coins = [1, 3, 8, 9],amount = 11,11 = 8+3→2个和11 = 9+1+1→3个,所以优先选择最大面额不一定是最优解(贪心解法,不一定会得到最终最优解)
解题的关键,找出状态关系和列出状态转移方程
组成amount
的最少硬币数(最终最优解)等于amount减去面额列表conis中某个面额得到的前一个总金额(状态)amount - coins[x]的最优解+1,这个1就是减去的那个面额对应的1个硬币,x可能是conins中的任意一个面额
也就得到了如下的递归思路
然后继续思考,假设面额amount为x,例如amount = 11,那么不论何种情况下它最多需要x个硬币(那么这个例子里为11),因为硬币面额最小为1,从反向递推的思路出发,从面额最小,即amount = min(coins)递推到总金额amount,求解所需的最小硬币数
from typing import Listclass Solution:def coinChange(self, coins: List[int], amount: int) -> int:ceiling = amount + 1mem_arr = [0] + [ceiling] * amountfor coin in coins:for i in range(coin, ceiling):mem_arr[i] = min(mem_arr[i], mem_arr[i-coin] + 1) # key:return mem_arr[amount] if mem_arr[amount] < ceiling else -1S = Solution()
print(S.coinChange([0], 0))
print(S.coinChange([0], 1))
print(S.coinChange([1, 2], 1))
print(S.coinChange([1, 2], 2))
print(S.coinChange([1, 2], 3))
print(S.coinChange([3, 2, 1, 0, 5], 6))
输出
0
-1
1
1
2
2
二、求最大乘积子序列(的乘积)
题目要求:
在一个整数序列中寻找最大乘积的子序列的乘积,例如
arr = [5, 0, -4, 2, -3]
result = 24
因为-4×2×(-3) = 24
arr = [2, -1, 1, 1]
result = 2
arr = [3]
result = 3
根据前面递推的思路
遍历nums所有元素,到当前元素nums[j]为止的最大连乘积可能等于
- 当前元素nums[j]为正数时,前一步为止的最大正数乘积*nums[j]
- 当前元素nums[j]为负数时,前一步为止的最小负数乘积*nums[j]
- 当前元素为0,最大连乘积结果可能为0或者前一步为止的最大正数乘积*nums[j]
总之,到当前元素nums[j]为止的最大连乘积为1,2,3的情况的最大值
遍历时需要找到到达当前步数为止的最大(正数)乘积和最小(负数)乘积
设以nums[i]结尾的最大连续子串的乘积为max_curr,以nums[i]结尾的最小连续子串的乘积为min_curr
那么到当前步数j时的
最大连续子串乘积max_curr = max(max_curr*nums[j], nums[j], min_curr*nums[j])
最小连续子串乘积min_curr = min(max_curr*nums[j], nums[j], min_curr*nums[j])
也就是我们的状态转移方程
初始状态:
max_curr = min_curr = nums[0], max_li = [nums[0]]
将每一步的max_curr放入到max_li中,最终的结果为max(max_li)
def max_product(nums):max_li = [nums[0]]max_curr = min_curr = nums[0]for j in range(1, len(nums)):max_curr, min_curr = max(max_curr * nums[j], nums[j], min_curr * nums[j]), min(max_curr * nums[j], nums[j],min_curr * nums[j])max_li.append(max_curr)res = max(max_li) if max_li else max_currreturn resif __name__ == '__main__':li = [5, 0, -4, 2, -3] # -4*2*(-3) = 24print(max_product(li))
输出
24