本文参考“文献”:https://leetcode-cn.com/problems/fibonacci-number/solution/dong-tai-gui-hua-tao-lu-xiang-jie-by-labuladong/
此博客对上面内容有所修整。
- 从算法题目层面来讲,本篇讲了斐波那契数列(通过用不同的算法(Python语言实现)求斐波那契数列来对【递归 → 动态规划 】进行了讲解)与凑零钱问题。
- 从动态规划这一知识点来讲,本篇通过两个算法题目讲了动态规划的两个特性:重叠子问题、最优子结构。
- 之后又增加了爬楼梯问题进行练习。
斐波那契数列
递归的暴力解法
def fib(N):
if (N == 1 or N == 2):
return 1
return fib(N-1) + fib(N-2)
print(fib(8)) # 打印21
递归的时间复杂度是很大的,是n的指数级别,随着n的增大,运算时间不可想象。时间复杂度之所以这么大,是因为计算过程中存在着重复计算。
- 递归算法的时间复杂度怎么计算?子问题个数乘以解决一个子问题需要的时间。而子问题的个数我们可以通过如下递归树发现,即为递归树中节点的总数。我们知道二叉树节点的总数为指数级别,所以递归算法的时间复杂度为O( 2 n 2^{n} 2n)
PS. 但凡遇到需要递归的问题,最好都画出递归树,这对你分析算法的复杂度,寻找算法低效的原因都有巨大帮助。
计算斐波那契数列是从后往前计算的,就是计算f(n)=f(n-1)+f(n-2),然后再递归计算f(n-1),又是从后往前计算,就是因为这样的从后往前计算,所以才会有很多的重复计算。参考如下递归树,易发现出现了许多许多许多的重复计算…
动态规划算法解决了重叠子问题!
带备忘录的递归解法
在我的《算法》学习中的动态规划一节中,知道了“通常的做法是,为了节约重复求相同子问题的时间,引入一个数组,不管它们是否对最终解有用,把所有子问题的解存于该数组中,这就是动态规划法所采用的基本方法”,所以,下面我们就用这种思路来进一步完善上面的递归算法。
def fib(N):
if(N < 1):
return 1
result = [0] * (N+1) # 注意长度为N+1,因为f(n)就代表索引为n处存放值,所以长度应该设为n+1
return helper(result, N)
def helper(result, n):
if(n == 1 or n == 2):
return 1
if(result[n] != 0):
return result[n]
else:
result[n] = helper(result, n-1) + helper(result, n-2)
return result[n]
print(fib(8)) # 打印21
画出递归树:
可以看出:带「备忘录」的递归算法,把一棵存在巨量冗余的递归树通过「剪枝」,改造成了一幅不存在冗余的递归图,极大减少了子问题(即递归图中节点)的个数。 这种情况下的时间复杂度 = 输入规模,即O(n)。比起暴力算法,这一变化是降维打击。
这种解法和动态规划的思想已经差不多了,只不过这种方法叫做「自顶向下」,动态规划叫做「自底向上」。
自顶向下:以上面的递归树来说,我们的做法总是从一个规模较大的原问题向下逐渐分解成较小规模的子问题,然后逐层返回答案,这就是所谓的「自顶向下」。
自底向上:与「自顶向上」相反,从最底下、最简单、问题规模最小的问题开始向上推,直至推出我们想要的问题的答案,这也就是动态规划的思路。也因此,动态规划一般都脱离了递归,由循环迭代完成整个计算。
动态规划(DP)
有了上一步「备忘录」的启发,我们可以把这个「备忘录」独立出来成为一张表(dp_ table
),在这张表上完成「自底向上」的推算以及结果的保存。
def fib(N):
dp_table = [0] * (N+1)
dp_table[1] = 1
dp_table[2] = 1
for i in range(3, N+1):
dp_table[i] = dp_table[i-1] + dp_table[i-2]
return dp_table
print(fib(8)) # 打印[0, 1, 1, 2, 3, 5, 8, 13, 21]
有上面算法形成的结果可用下图表示:
实际上,带备忘录的递归解法中的「备忘录」,最终完成后就是这个dp_table
,所以说这两种解法其实是差不多的,大部分情况下,效率也基本相同。
这里,引出动态转移方程这个名词,它实际上就是描述问题结构的数学形式。对于斐波那契数列:
如何理解动态转移方程?把 f(n)想做一个状态 n,这个状态 n 是由状态 n−1 和状态 n - 2相加后转移而来。
列出状态转移方程是很有必要的【状态转移方程直接代表着暴力解法】,它有助于我们发现规律,从而根据这一规律写出算法并对算法进行优化(优化方法无非是用备忘录
或者dp_table
,再无奥妙可言)。
对于上面那个算法,因为仅仅求一个数列,所以没必要返回一个列表。你当然可以直接使用print(fib(8)[8])
语句输出对应的结果,但显然,我们还有时间复杂度更低的算法来实现:
def fib(N):
if (N < 2):
return '输入有误,请重新输入'
previous = 0
current = 1
for i in range(N-1): # 注意循环次数为N-1,举例验证易知
sum = previous + current
previous = current
current = sum
return current
print(fib(8)) # 打印21
思路:根据斐波那契数列的状态转移方程,当前状态只和其前两个状态有关,所以并不需要那么长的一个dp_table
来存储所有的状态,只要想办法存储之前的两个状态就行了。
总结:
- 斐波那契数列的例子严格来说不算动态规划,以上旨在演示算法设计螺旋上升的过程。
- 当问题中要求求一个最优解或在代码中看到循环或
max
、min
等函数时,十有八九,需要动态规划大显身手。- 以上,我们知道了动态规划特性 之 重叠子问题;另一个特性最优子结构我们也顺便讲解下(通过下面的“凑零钱问题”)。
凑零钱问题
碎碎念:即使对上面的内容都理解透彻,我还是没能独立想出此问题的解决方案呀…
写出动态转移方程
这不正验证了上面“总结”中所提到的:遇到min
函数时,十有八九需要动态规划 嘛。
而这也正体现了所谓最优子结构:原问题的最优解由各个子问题的最优解构成。 。即 f(11)的最优解由 f(10),f(9),f(6)的最优解转移而来。注意,要符合「最优子结构」,子问题间必须互相独立、互不影响。
碎碎念:虽然没想出来动态转移方程是怎么写的,但是通过研究这个动态转移方程,我认为是可以通过一个个举例试验,然后总结出动态转移方程的(所以说,以后遇到这种问题时,可以采用一个个试验的方式)。
- 试验过程如下(肯定是要从最简单的、能知道结果的试验开始下手!):
如果我要凑0元,那没有零钱能凑出来;
如果我要凑1元,那就刚好取一张1元的零钱;
如果我要凑2元,那就取两张1元的零钱;(我这里假设零钱的面额分别是1元、5元、10元)
如果我要凑3元,那我不用干了诶(不需再继续动脑子了),我直接采用“凑2元”的方法+“凑1元”的方法来找到结果就行了;
如果我要凑4元,那我就用“凑3元”的方法+“凑1元”的方法来找到结果;
如果我要凑5元,按理说我应该能够直接拿出一张5元的零钱,而不是去采用“凑4元”的方法+“凑1元”的方法来找到结果;这么来看的话,此时产生了矛盾(两种方法)哦,所以我们要比较这两种方式,然后选最优方案。而这一比较就是通过下图式子实现的:
如果我要凑6元,我就可以用“凑5元”已找到的最优方法+“凑1元”的方法来找到结果了…
…
暴力解决(递归)
直接对状态转移方程进行应用:
def coinCharge(amount, coins=[1, 5, 10]):
'''
amount: 总额
coins: 零钱面值
'''
if (amount == 0):
return 0
result = 1001 # 我们初始化result(兑换出的零钱张数)是一个很大很大不可达的数(这里用1001代替);这是为了第14行的比较语句(因为第一轮比较时我们必须使result变成(pieces + 1),所以设置首先将其为一个不可达的数,目的是无论如何,应该将result置为piece+1了)
for i in range(len(coins)):
if (amount - coins[i] >= 0):
pieces = coinCharge(amount-coins[i]) # pieces表示要凑的零钱张数
if(pieces == -1):
continue
result = min(result, pieces + 1) # min()的第一个参数是上一轮的结果,第二个参数是本轮结果,两者进行比较,从而从多种方案中选出最佳方案
if (result == 1001): # 说明没有执行过min(result, pieces + 1)语句 → 说明总是不符合amount - coins[i] >= 0 → 说明传入的数值是负数了
return -1
else:
return result
print(coinCharge(25)) # 打印结果:3
- 碎碎念:我认为上面程序不容易想到的一点是使用min()函数以及初始化result为一个不可达的数…这两点是连在一起的…必须要同时能想到
- 递归树如下:
- 时间复杂度分析:子问题总数 × 每个子问题的时间。子问题总数为递归树节点个数,是 O ( 3 n ) O(3^n) O(3n)。每个子问题中含有一个 for 循环,复杂度为 O(3)。所以总时间复杂度为 O ( 3 ∗ 3 n ) O(3*3^n) O(3∗3n),指数级别。
带备忘录的递归解决
首先,你得想想你设计的“备忘录”是啥格式的(还能像上一道题那样弄个数组后一个接一个的直接存储数据吗?如果可以的话,我们这里存储的数据要表示的含义是什么样的呢?)…虽然仍然是需要一个数组来保存相关的数据,但对于这道题来说,我们是直接保存要找的零钱的面值呢,还是保存每个面值需要的张数呢…看了答案后,我知道了应该选后者…
关于对这个“备忘录”的理解,两次理解错误后,才真正正确的弄懂了。。。
def coinCharge(amount, coins=[1, 5, 10]):
'''
note: "备忘录"
PS. 其长度之所以是(amount+1) 是因为我们假设每个索引对应的是面值;
然后该索引对应的值表示:当要找的总额为该索引时,所需要的零钱的张数。
从而,构成了一个用来保存【每个金额下(索引)找零钱时需要的零钱张数(索引对应的值)】的数组。
因为对于amount来说的总额,不可能需要(amount+1)面额的零钱,
所以长度设为(amount+1)。
初始化所有值为-2,方便以后取出数据时用它来进行判断。
'''
note = [-2]*(amount+1)
return helper(amount, note, coins)
def helper(amount, note, coins):
if (amount == 0):
return 0
if (note[amount] != -2): # note[amount] != -2说明该索引对应的amount所需要找的零钱的张数已经计算出来了
return note[amount]
result = 1001
for i in range(len(coins)):
if (amount - coins[i] >= 0):
subPromResult = helper(amount - coins[i], note, coins) # subPromResult表示子问题的结果
if (subPromResult == -1): # 如果子问题“误解”,继续循环
continue
result = min(result, subPromResult+1)
if (result == 1001):
note[amount] = -1
else:
note[amount] = result
return note[amount]
print(coinCharge(25))
动态规划
def coinCharge(amount, coins=[1, 5, 10]):
'''
动态规划用到的“备忘录” 与 带备忘录的递归算法中的“备忘录” 是不同的!
这里的思路是这样的:
“备忘录”的长度和值都设置为amount+1;长度设置为amount+1已经说过;
值设置为amount+1是因为:“自底向上写备忘录”时,
如果想选出最佳结果,即找的零钱张数最少的,那么就应该让其初始化为最大...
'''
dp_table = [amount+1] * (amount+1)
dp_table[0] = 0
for i in range(1, len(dp_table)): # 自底向上补全备忘录
for j in range(len(coins)):
if(i - coins[j] >= 0): # 只要i - coins[j] >= 0
dp_table[i] = min(dp_table[i], dp_table[i-coins[j]] + 1)
# 当i=5时,就是dp_table[5]与dp_table[5-5]比较了,很明显,后者小
# 应该自己走一下这个循环体
if dp_table[amount] > amount: # 即,dp_table[amount]仍为amount+1,说明传入的参数是个负数(没法找给负数钱...)
return -1
else:
return dp_table[amount] # 由于我们是“自底向上”的,且不需要递归了,所以传入amount,就返回dp_table[amount]
print(coinCharge(55))
for循环执行:
总结:
- 凑零钱问题与斐波那契数列相比,更复杂了些,需要进行一些判断…
- 计算机解决问题其实没有任何技巧,它唯一的解决办法就是穷举,穷举所有可能性。算法设计无非就是先思考 “如何穷举”,然后再追求 “如何聪明地穷举”。
- 列出动态转移方程,就是在解决 “如何穷举” 的问题。之所以说它难,一是因为很多穷举需要递归实现,二是因为有的问题本身的解空间复杂,不那么容易穷举完整。
- 备忘录、dp_table 就是在追求 “如何聪明地穷举”。用空间换时间的思路,是降低时间复杂度的不二法门,除此之外,试问,还能玩出啥花样呢?
爬楼梯问题
递归
def climbStairs(n):
if n == 1:
return 1 # 阶梯数n为1时,只有一种方案(上一阶台阶)
elif n == 2:
return 2 # 阶梯数n为2时,有两种方案(一次上一阶,上两次;一次上两阶)
elif n >= 3:
n = climbStairs(n-1) + climbStairs(n-2)
return n
print(climbStairs(5))
- 画出函数调用以及回调图,对于我来说还是最好的,这也就相当于递归树了:
带备忘录的递归
def climbStairs(n):
note = [-1] * (n+2) # 经过调试,发现长度应该是(n+2), 因为后面自己设置了note[1]、note[2],当n为1时,如果在不加2的情况下去设置它们,就会出现索引超出范围的错误
note[1] = 1
note[2] = 2
# 下面三行代码用来测试“备忘录”确实被记上了东西
#print(note)
#helper(n, note)
#print(note)
return helper(n, note)
def helper(n, note):
if note[n] != -1:
return note[n]
if n >= 3:
note[n] = helper(n-1, note) + helper(n-2, note)
return note[n]
print(climbStairs(5))
动态规划
def climbStairs(n):
dp_table = [-1] * (n+2)
dp_table[1] = 1
dp_table[2] = 2
for i in range(3, len(dp_table)):
dp_table[i] = dp_table[i-1] + dp_table[i-2]
#print(dp_table) # 查看我们建好的“备忘录”,会发现其实多计算了一个。原因不再赘述
return dp_table[n]
print(climbStairs(5))
碎碎念:这道题与裴波那契数列很像,所以自己倒也能想出来的。