在了解动态规划前,不妨先看看题目 http://acm.hdu.edu.cn/showproblem.php?pid=2046。截图如下.
问你当n为任意数(n>0)时, 骨牌的铺放方案有几种?
咱可以先从归纳法试试,列举出可能出现的解。
当n=1时,只有一种铺放方案,即 |
当n=2时,有两种方案,即 || 或 =
当n=3时,有||| ,|= ,=|
当n=4时,有||||, |=|, =||, ||=, ==
当n=5时,有。。。8种,我不写了。。
看到这里,你发现这数字有什么规律没有?他们的解序列为1,2,3,5,8 。。。下一个数字是?
智商达到正常人水平的已经看出来下个数字是13了,如果没想出来,面壁思过去(▼ヘ▼#)
设解数列表示为X[n], 则对于任意n>2有 X[n] = X[n-1]+X[n-2], X[1] = 1, X[2] = 2
但这只是归纳抖机灵,有什么逻辑推理依据使等式成立吗?
当然有!
仔细想想,X[n]是不是就相当于从X[n-1]的最右摆放上一个|,也相当于从X[n-2]的最右摆放上一个=?
那么这个公式就很明显成立, X[n] = X[n-1] + X[n-2] !
啥,你问我为什么不在X[n-2]的最右摆上一个|| ? 嗯,,你可以画一下,在X[n-2]的最右摆上一个||出现的情况都被 在X[n-1]加上一个 | 的情况包含了。
再来看一道题,小明腿短,他一次只能上一步台阶,或者上两步台阶,问他从第0台阶上到第n台阶有多少种走法?
当n=1时,只有一种,小明往上迈一步。
当n=2时,有两种,一种是直接迈两步,一种是迈完一步在迈两步。
当n=3时,有3种,从n=1的情况迈2步,加上n=2的两种情况。
当n=4时,有5种,从_____________, 加上____________ →_→ (填空题)
如果你没看懂。那就重新看一遍吧(笑哭)
以上两道题都是非常经典的斐波那契数列,即下一个数等于前两项的和。
说了这么多还没说代码。接下来我们来实现一下斐波那契数列吧。
我让老铁用递推的方式实现斐波那契,这时候老铁很自信的写出了如下代码:
def Fibonacci(n):
if n==1:
return 1
if n==2:
return 2
return Fibonacci(n-1)+Fibonacci(n-2)
我问老铁你这时间复杂度,空间复杂度是多少。老铁还蜜汁自信的回答说O(n), O(n).
我气的差点当场去世。。
可以画一下调用树分析分析。只画了n=5的情况如下:
上图可见n=3的情况下还被多次计算,当n更大时重复多次计算会更明显,时间复杂度O(2^n),当n很大时,计算机就算不动了。(实测n=40就要算几十秒了)
我说老铁,你这写的是递归啊,我要的是递推!
何为递推,就像上面我们在分析问题的时候,我们可以从现有的方案中递推到下一个,即为自底向上的思考方式,而不是自顶向下。
这时候老铁有想法了,很快写出了以下代码。
def Fibonacci(n):
if n==1:
return 1
if n==2:
return 2
a, b = 1, 2
for i in range(2, n):
b, a = b+a, b
return b
这下可以了,老铁成功掌握了递推的思想,时间复杂为O(n)
这时候杠精要说了,斐波那契数列有通项公式,你为啥不用通项公式方法去计算呢?这。。我还不是要为了引入下一个关键点嘛。
好,先看例题 https://leetcode-cn.com/problems/longest-increasing-subsequence/
老铁编码能力强悍,直接枚举出所有可能的解,很自信的写下以下代码:
def solve(num: list):
def dfs(depth: int, count: int, last: int):
"""
递归遍历所有可能出现的上升序列
:param depth: 递归深度
:param count: 当前序列的解,即上升子序列长度
:param last: 当前序列的最后一个数字,后面的数字必须比这个数字大
:return: 从数组第depth个元素开头的子序列的解
"""
if depth >= len(num):
return count
t1 = 0
if num[depth] > last:
# 当前数字比上一个数字大,可入队
t1 = dfs(depth + 1, count + 1, num[depth])
t2 = dfs(depth + 1, count, last)
return max(t1, t2)
ans = 0
for idx, item in enumerate(num):
ans = max(ans, dfs(idx, 1, item))
return ans
这个代码基本上就是暴力遍历法的解,但是有个最坏的情况是假如整个数组都是上升序列,那么每种可能出现的排列都会被遍历过,那么整体的时间复杂度就是O(2^n)。有没有什么办法优化呢?
Ok,那么接下来就引入我们本篇的主角了,当当当,动态规划来了。我们想想能否设计一种状态转移方程,就像斐波那契数列那样,问题的解从方程X[n] = X[n-1] + X[n-2]推导得出呢?
我们先从数组长度为1的情况考虑,很明显解为1,我们记下来X[1] = 1.
数组长度为2时呢,当a[2] > a[1] 时,解为2,否则为1.我们记下来X[2]=2 或 X[2]=1
数组长度为3时呢,看看a[3] 是否大于 a[1] 或者a[2],如果都是,则从X[1]中X[2]找到最大值,使X[3] = max(X[1], X[2])。如果a[3] 都比a[1], a[2]小,记下X[3] = 1。 如果a[3]只比a[1]或者a[2]一个大,记下X[3]=2
当数组长度为4时,看看b∈{1, 2, 3}中满足a[b] <a[4]的集合,表示为m,则X[4] = max(X[m])+1
数组长度为n时,看看对于所有的m(m<n且m>0),满足a[n]>a[m], 找到所有的X[m], 则X[n] = max(X[m]+1)
很明显了,我们就可以得到状态转移方程:
X[n] = max(X[m]+1) (a[m] < a[n])
如果你看到这里还不明白的话,可以直接看看代码:
def solve(nums: list):
x = []
for idx, num in enumerate(nums):
tmp = []
# 找到所有比a[n]小的数组下标
for i in range(idx):
if num > nums[i]:
tmp.append(i)
if not tmp:
x.append(1)
else:
# 找出符合条件的下标集的最大值
tmp1 = [x[_] for _ in tmp]
x.append(max(tmp1)+1)
return max(x)
这份代码就是按照我上述方法直接写出来的,但是空间复杂度不太美丽,多次申请新数组。
如果直接对着状态转移方程编程,我们可以得出如下代码。
def solve(nums: list):
dp = [] # dp[i]表示从以第i个元素结尾的最长上升序列长度
for i, num in enumerate(nums):
dp.append(1) # 初始化dp[i] = 1
for j in range(i):
if num > nums[j]:
dp[i] = max(dp[i], dp[j]+1)
return dp[len(nums)-1]
怎么样,是不是很简短优美?
题目中说到可以优化到O(nlogn)时间复杂度的做法,大致想法就是我们可以改为这样的策略:设dp[i]为上升子序列长度为i,且该上升子序列的末尾最小值为dp[i]。具体做法这里不做展开
再说说经典的背包问题吧。给一个承重量为w的背包,你作为一个职业小偷,面对m个物品,每个物品都有对应的重量u和价值v,问你要怎么选择这n个物品使得你偷到的东西物品价值最多?(w,m,u,v都大于0)
面对这个问题的时候,你可能首先想到贪心策略,即优先拿单位价值(v/u)最高的物品。但这个是不行的,毕竟背包重量有限,举个反例,背包重量为11,有三个物品,对应的(重量,价值)为(8, 16), (4, 7), (7, 10),如果拿了(8,16)的话,只能使最终价值达到16,低于选后两者的总价值17.
咱又想到老方法,当背包重量为1时,背包最大价值为...这里省略了推算过程,我直接上公式 了:
dp[i] = max( dp[i-u]+v, dp[i]) dp[i]代表背包重量为i时能得到的最大价值
写出代码就是
def solve(n, m, u: list, v: list):
dp = [0 for _ in range(n + 1)]
for i in range(m):
ui, vi = u[i], v[i]
j = n
while j:
if j >= ui:
dp[j] = max(dp[j], dp[j - ui] + vi)
else:
break
j -= 1
return dp[n]
print(solve(11, 3, [8, 4, 7], [16, 7, 10]))