青蛙與緩存:簡化實用版動態規劃

1. 從一隻青蛙提及

青蛙

話說有一隻青蛙,想要跳下n級臺階下水塘,它每次能夠跳1個臺階或者2個臺階,那麼請問它一共有多少種跳法下水塘(好比,n=30時)?python

用數學的語言來看,咱們須要求一個青蛙跳的函數f(n),對這種自變量取值爲非負整數的函數,咱們能夠從比較小的狀況開始考慮,不可貴到f(1)=1, f(2)=2,問題是之後的窮舉愈來愈麻煩。面試

想象你就是那隻青蛙,面對n級臺階,第一次你能夠先跳1級,那麼剩下n-1級,有f(n-1)種跳法,第一次也能夠跳兩級,那麼剩下n-2級,有f(n-2)種跳法,因此這個問題的答案並不陌生,是神奇的斐波拉契數列:算法

\begin{aligned} F_{0} &=0 \\ F_{1} &=1 \\ F_{n} &=F_{n-1}+F_{n-2} \quad(\mathrm{n} \geq 2) \end{aligned}

解決這類求函數值問題的第一步,是找到一個遞推式。咱們把遞推式翻譯成python代碼:數組

def fib(n):
    if n==0:
        return 1
    if n==1:
        return 1
    return fib(n-1)+fib(n-2)
複製代碼
%%time
fib(30)

Wall time: 269 ms
832040
複製代碼

運行時間284ms,有夠慢的,爲何慢?由於重複計算實在太多,以計算f(5)爲例,調用關係以下:緩存

f(5)==>f(4), f(3)
f(4)==>f(3), f(2), f(3)==>f(2), f(1)
f(3)==>f(2), f(1), f(2)==>f(1), f(0), f(2)==>f(1), f(0), f(1)
f(2)==>f(1), f(0), f(1), f(1), f(0), f(1), f(0), f(1)
f(1), f(0), f(1), f(1), f(0), f(1), f(0), f(1)
複製代碼

那麼一個很天然的想法是咱們把中間計算結果都緩存下來,幸運的是,python中自帶了這個「電池」。bash

from functools import lru_cache
@lru_cache()
def fib(n):
    if n==0:
        return 1
    if n==1:
        return 1
    return fib(n-1)+fib(n-2)
複製代碼
%%time
fib(30)
Wall time: 0 ns
832040
複製代碼

快到沒計量出時間來。python中lru_cache的基本原理是構建一個字典,字典的key爲調用參數,value就是該參數的計算結果。大體等價於以下代碼:函數

def fib(n):
    if n in fib.cache:
        return fib.cache[n]
    if n==0:
        ans = 1
    elif n==1:
        ans = 1
    elif:
        ans = fib(n-1)+fib(n-2)
    fib.cache[n] = ans
    return ans
fib.cache = {}
複製代碼

固然,針對這個問題,咱們可使用更加細緻的緩存方法, 乃至去掉遞歸改用循環(至關於只保留兩個緩存,大大減小了空間佔用,可是若是咱們要反覆計算各個n值,那麼或許前一個方法才更合適):spa

def fib(n):
    a, b = 0, 1
    for i in range(n):
        a, b = b, a+b
    return a
複製代碼

本題等同於 leetcode 70, 在leetcode上的python3解答以下:翻譯

from functools import lru_cache
class Solution:
 @lru_cache()
    def climbStairs(self, n: int) -> int:
        if n==0:
            return 1
        if n==1:
            return 1
        return self.climbStairs(n-1)+self.climbStairs(n-2)
複製代碼

執行用時52 ms,內存消耗13.2MB。設計

2. 簡化實用版動態規劃

咱們從這隻青蛙中取得比較通用的啓示,解決相似的可構造遞推函數的問題:

  1. 尋找一個遞推關係,創建遞歸函數,問題變成多個子問題的求解;
  2. 爲了防止反覆計算一樣的子問題,使用緩存,用空間換時間。

在通常的算法教材或面試題解中,會花很多時間來設計這個緩存結構,在實際的工程問題中,咱們可能對多使用一些緩存空間沒有那麼敏感,所以只須要開發遞歸函數,再加上通用的緩存方案就基本解決問題了。只有在緩存空間成爲問題時,咱們才須要進一步去考慮適應問題的更小的緩存。

爲了檢驗這套方案,咱們再看幾道題,直接在leetcode上再找幾個來刷。

最大子序和

給定一個整數數組 nums ,找到一個具備最大和的連續子數組(子數組最少包含一個元素),返回其最大和。

示例:

輸入: [-2,1,-3,4,-1,2,1,-5,4], 輸出: 6 解釋: 連續子數組 [4,-1,2,1] 的和最大,爲 6。

咱們考慮數組中每個位置結尾能獲得的最大和的遞推關係。

\begin{aligned}  f(0)&=nums(0) \\ f(k)&=max(f(k-1), 0)+nums(k) \quad(k>0) \end{aligned}

基於此不可貴到最終結果爲

ans = max_{i=0}^n(f(i))

在leetcode中翻譯成python3代碼以下:

from functools import lru_cache
class Solution:
    def maxSubArray(self, nums: List[int]) -> int:
        self.nums = nums
        return max(self.f(i) for i in range(len(nums)))
    
 @lru_cache()
    def f(self, k):
        if k == 0:
            return self.nums[0]
        else:
            return max(self.f(k-1), 0) + self.nums[k]
複製代碼

執行耗時76 ms,內存消耗13.7 MB。

最小路徑和

給定一個包含非負整數的 m x n 網格,請找出一條從左上角到右下角的路徑,使得路徑上的數字總和爲最小。

示例:

輸入: [ [1,3,1], [1,5,1], [4,2,1] ] 輸出: 7 解釋: 由於路徑 1→3→1→1→1 的總和最小。

將矩陣中每一個位置做爲右下角,求最小路徑和,不可貴到以下遞推公式:

\begin{aligned} 
f(0, 0)&=grid(0, 0) \\
f(x, 0)&=f(x-1, 0)++grid(x, 0) \quad(x>0) \\
f(0, y)&=f(0, y-1)++grid(0, y) \quad(y>0) \\
f(x, y)&=min(f(x-1, y), f(x, y-1))+grid(x, y) \quad(x>0, y>0) 
\end{aligned}

在leetcode中翻譯成python3代碼以下:

from functools import lru_cache
class Solution:
    def minPathSum(self, grid: List[List[int]]) -> int:
        self.grid = grid
        return self.f(len(grid)-1, len(grid[0])-1)
 @lru_cache()
    def f(self, x, y):
        if x == 0 and y == 0:
            return self.grid[0][0]
        elif y == 0:
            return self.f(x-1, 0) + self.grid[x][0]
        elif x == 0:
            return self.f(0, y-1) + self.grid[0][y]
        else:
            return min(self.f(x-1,y), self.f(x,y-1)) + self.grid[x][y]
複製代碼

執行耗時1052ms,內存消耗13.9M。

相關文章
相關標籤/搜索