終拿位元組Offer...動態規劃覆盤...

迷失技術de小豬發表於2021-08-06

大家好!我是 Johngo 呀!

和大家一起刷題不快不慢,沒想到已經進行到了第二階段,「動態規劃」這部分題目很難,而且很不容易理解,目前我的題目做了一半,憑著之前對於「動態規劃」的理解和最近做的題目做一個階段性的總結!這篇文章其實是我之前寫過的一篇,然後現在拿來再做一個潤色。

「動態規劃」看這篇我...保證可以!

目標:給小白以及沒有明確思路的同學一個指引!

拍胸脯保證:讀完這篇文章,對於大多數的動態規劃的思維邏輯能有一個質的提升。

本文較長,建議先收藏,或者直接到 GitHub 中下載文件(https://github.com/xiaozhutec/share_leetcode.git)

那麼,我們們開始吧...

零、初印象

動態規劃,一直以來聽著就是一種很高深莫測的演算法思想。

尤其是上學時候演算法的第一堂課,老師巴拉巴拉列了一大堆的演算法核心思想,貪心、回溯、動態規劃... ...,開始感覺要在演算法世界裡遊刃有餘的進行解決各種各樣牛B問題了,沒想到的還是稀裡糊塗學過了之後還就真的是學過了(大學的課程還真是一個樣子)。

再後來才明白,大學的課程一般來說就是入門級講解,用來開拓眼界,真正想要有一番自己的見解,必須要在背後下一番辛苦,形成自己的思考邏輯tips:這個思考邏輯一定是要有記錄的,是真的有時候會忘記。

再後來返回頭來看,動態規劃理解起來還是比較困難,重疊子問題、動態轉移方程,優化點等等等等,稀裡糊塗,最後痛定思痛,好好看著其他人的分享理解了一部分,在之後瘋狂刷題幾十道。現在回過頭來再看,算是基本可以佛擋殺佛了。

在我的這些學習積累過程中,把一部分「動態規劃」的問題覆盤出來。希望可以給到大家一點小小的幫助,相信在讀完這篇文章的時候,你會感覺到動態規劃給你帶來的奇妙之處。也一定對動態規劃形成自己的思考方式

相信我!這不是一篇難以讀懂的文章!

一、本文要點

1.相較於暴力解法,動態規劃帶給我們的是什麼?為什麼會有重疊子問題以及怎麼去避免的?

2.用不同難度的動態規劃問題舉例說明, 最後會使用《打家劫舍》系列三個題再重溫一次!

「動態規劃」思維邏輯

看完本篇文章後,相信大家會對DP問題會有一個初步的思考,一定會入門。後面大家可以繼續練習相關問題,熟能生巧,思考的多了就會形成自己的思維邏輯。

好了,話不多說,開搞...

二、動態規劃帶來的優勢

看完定有收穫,加油!???

平時在我們演算法設計的過程中,一般講求的是演算法的執行效率和空間效率的利用情況。

也就是我們熟知的時間複雜度(執行時耗費時間的長度)和空間複雜度(執行時佔用儲存單元的長度)

那下面用時間複雜度和空間複雜度來評估下傳統演算法設計和用動態規劃思想解決下的效率情況。

引出:傳統遞迴 vs. 動態規劃

先用一個被大佬們舉例舉到爛的?,這個栗子很爛,但是真的很香:必須著重強調

**斐波那契(Fibonacci)數列的第n項 **

舉薦理由:在我自己看來 Fibonacci 是動態規劃設計中的入門級案例,就好比說程式設計中的“hello world”,大資料中的“word count”。

Fibonacci 幾乎完美的詮釋了動態規劃帶來的思想和技巧然而沒有任何其他的要考慮的細枝末節,這種很清晰的方法看起來很適合整個的動態規劃的思維方式,很適合入門來進行的思考方式。

接下來我們們先來看題目:

寫一個函式,輸入n,求斐波那契(Fibonacci)數列的第 n 項。斐波那契數列的定義如下:

F(0) = 0, F(1) = 1
F(N) = F(N - 1) + F(N - 2), 其中 N > 1.
斐波那契數列由 0 和 1 開始,之後的斐波那契數就是由之前的兩數相加而得出。

比較一下傳統遞迴解法和動態規劃思想下的解決對比

1. 遞迴解決

這個例子恐怕是我們大學中第一堂遞迴的經典案例了。

那麼首先嚐試用遞迴來解決。做起來比較簡單,就是不斷的去遞迴呼叫。

看下面程式碼:

def fib(self, n):
    print('計算 F(%d)' % n)
    if n < 2:
        return n
    return self.fib(n-1) + self.fib(n-2)

輸出的結果:

計算 F(4)
計算 F(3)
計算 F(2)
計算 F(1)
計算 F(0)
計算 F(1)
計算 F(2)
計算 F(1)
計算 F(0)

可以明顯看到一個現象:重複計算

總計 9 次的計算過程中,相同的計算結果有三對進行了重複計算(下圖中同色項,不包含灰色),也就是說在遞迴的過程中,把曾經計算過的項進行了又一次的重複計算,這樣對於時間效率是比較低的,唯一的好處可能就是程式碼看起來比較好懂,但是終歸不是一個好的演算法設計方法。

程式碼中,在計算N的時候就去遞迴計算 fib(N-1) + fib(N-2),那麼,這種情況下的計算過程中。會是下面圖中的一個計算過程。

可以發現,會有相當一部分的重複計算,這樣對於時間和空間都是重複的資源消耗。

參考圖中相同顏色的項,比如說粉色的重複計算、黃色的重複計算等

為了更好的說明這種重複計算帶來時間效率的低下。再比如說,相比上述圖中的計算節點,再增加一個節點的計算,增加計算F(5),那麼由於遞迴的計算方式,會有更多的項(下圖中線框中部分)進行了重複的計算。在計算F(5)的時候,會遞迴呼叫F(4)F(3),而在下圖中,計算F(4)的時候,又會完整的去計算F(3)。這樣,如果N很大的話,會產生更大的時間消耗。

這樣,這棵樹的規模進行進行成倍增加,時間複雜度很明顯的進行了成倍的擴張。對於時間上來說是很恐怖的.

時間複雜度帶來的低效率嚴重超過了程式碼的可讀性,所以我們可以想辦法將過去計算過的節點進行儲存。這樣,我們就會用到下面要說的「動態規劃」思想帶來的時間上的高效。

時間複雜度:O(2^N)​ ---> 指數級

空間複雜度:O(N)​

2. 動態規劃解決

到重點了:大概解釋一下字面意思:

動態規劃:我們不直接去解決問題,而是在每一步解決問題的時候,達到每一步的最優情況。換句話說,就是在每一步解決問題過程中,利用過去的狀態以及當前狀態的情況而達到一個當前的最優狀態.

規劃:在一般解決該類問題的時候,會有一個“填表格”的過程,無論是簡單情況下的一維表格還是複雜一點的二維表格,都是以開闢空間換時間的思想,以爭取最佳的時間效率. (儲存過程中間值,方便後續直接使用).

動態:用上面的案例來說,遞迴解決過程中的每一步都會從基本問題不斷的“自頂向下”去求解,在每一步驟中,會有相同的計算邏輯進行了重複的計算。相比於遞迴思想,動態規劃思想增加了對歷史上計算結果的儲存,逐步記錄下中間的計算結果,在每一步求得最優值.

因此,動態規劃可以避免重複計算,達到了時間上的最優,從$O(2^N)$指數級變為$O(N)$常數級別,相較於開闢的一段記憶體空間存放中間過程值的開銷,是非常值得的.

那麼,「動態規劃」思維方式對 Fibonacci 進行問題的解決有什麼實質性的幫助

依據題中的規則:

F(0) = 0, F(1) = 1

F(N) = F(N - 1) + F(N - 2), when N > 1

那麼,??F(N) 的值只與他的前兩個狀態有關係??

a. 初始化值 : F(0) = 0, F(1) = 1

b. 想要計算得到F(2)

那麼F(2) = F(0) + F(1) --> F(0)、F(1)直接拿取,儲存 F(2)

c. 想要計算得到F(3)

那麼F(3) = F(2) + F(1) --> F(1)、F(2)直接拿取,儲存 F(3)

d. 想要計算得到F(4)

那麼F(4) = F(3) + F(2) --> F(2)、F(3)直接拿取,儲存 F(4)

利用動態規劃思想,以一維陣列輔助實現的Fibonacci,看下圖

結合之前的遞迴呼叫,這樣子解決是不是很簡單的思路,僅僅靠儲存過程中的一些值就能很簡單的利用迴圈就可以實現了,沒必要用遞迴反覆計算進行實現。

想要計算得到第 n 個值的多少?那麼,以下幾點是我們必須要做到的

第一、定義一個一維陣列 ---> 一般用dp來命名

第二、動態方程的設定 ---> 題中的F(N) = F(N - 1) + F(N - 2)

第三、初始化數值 ---> F(0) = 0和F(1) = 1

上述的 3 點就是動態規劃思想的幾個核心要素或者說是解決問題的步驟!

下面來看下要實現的程式碼(程式碼中,用dp來代替上面的F()

class Solution(object):
    def fib(self, N):
        if N == 0:
            return 0
     
        dp = [0 for _ in range(N+1)]		# 1定義dp[i]儲存第i個計算得到的數值
        dp[0] = 0   	# 2初始化
        dp[1] = 1			# 2初始化
        for i in range(2, N+1):	# 3動態方程實現,由於0和1都實現了賦值,現在需要從第2個位置開始賦值
            dp[i] = dp[i - 1] + dp[i - 2]
       
        print dp		 # 記錄計算過程中的次數,與上述遞迴形成對比
        return dp[N]

輸出:

[0, 1, 1, 2, 3]
3

以上,最重要的就是1 2 3 點,而執行過程參照輸出對比遞迴演算法,計算少了很多,同樣的計算只計算了一次。

時間複雜度:$O(N)$

空間複雜度:$O(N)$

介紹了上面的內容了,此處來條分割線吧,針對上述的 遞迴 vs. DP


既然動態規劃的方案也介紹了,下面我們們再仔細看看,是否有優化的空間,畢竟對於一個演算法方案的設計,都有找到其優化點,無論是時間還是空間的效率都想要達到一個理想的值。

3. 動態規劃 + 優化

我們們看下這張圖解,發現每個計算節點都只與前兩個項有關係。換句話說,我們們只要儲存兩個值就好了,計算新的節點值的時候,把新的值賦值給前兩個值的第一個就好。

話說只要兩個值,現在定義兩個變數 dp1 和 dp2。那麼,現在我們們一步一步模擬一下:

a. 初始化值 : F(0) = 0, F(1) = 1

b. 想要計算得到F(2), 那麼F(2) = F(0) + F(1) --> 儲存 F(2)

順帶將F(1)賦值給dp1, f(2)賦值給dp2

c. 想要計算得到F(3), 那麼F(3) = F(2) + F(1) --> 儲存 F(3)

順帶將F(2)賦值給dp1, F(3)賦值給dp2

d. 想要計算得到F(3), 那麼F(4) = F(3) + F(2) --> 儲存 F(4)

順帶將F(3)賦值給dp1, F(4)賦值給dp2

至此為止,整個過程僅僅用到了兩個變數來儲存過程中產生的值,也就之前沒有優化的空間效率得到了優化。

我們們把程式碼也貼一下吧,供參考

class Solution(object):
    def fib_dp1(self, N):
        if N == 0: return 0

        dp1, dp2 = 0, 1

        for i in range(2, N+1):
            dp1 = dp1 + dp2
            dp1, dp2 = dp2, dp1

        return dp2

看起來是不是更加簡潔了。

再回想一次遞迴解決,這簡直令人興奮!!

洋洋灑灑不知不覺寫了這麼多了。

如果有讀者說這太簡單了,我這篇文章內容面對的是小白級別的,如果讀者是中等往上的水平,可直接跳到後面的案例三開始參考。

另外,如果有任何的意見可隨時對我的文章進行評論,歡迎&感謝大家一起討論!

為了方便檢視,GitHub已經存好:https://github.com/xiaozhutec/share_leetcode.git

大家感覺這個例子怎麼樣,三點說明:1.定義dp陣列 2.動態方程 3.初始化數值 4.優化項

這也說明了為什麼用斐波那契數列來引入動態規劃的,因為斐波那契數列本身就明確的告訴你動態方程是什麼,初始化的值是什麼,所以好好的體會這種思想,尤其是從傳統遞迴 -> 動態規劃的思想解決,再到優化的方面,很值得深思。

那接下來,我們們就找幾個有代表性的栗子來嚐嚐鮮,下面是案例的一個說明圖:

三、利用動態規劃四大解題步驟處理問題

上面用斐波那契數列問題,引出了下面的幾點,在這裡再詳細贅述一下。

在後面的案例中將會盡量嚴格按照這幾個步驟進行解決問題:

步驟一:定義dp陣列的含義

步驟二:定義狀態轉移方程

步驟三:初始化過程轉移的初始值

步驟四:可優化點(可選)

步驟一:定義dp陣列的含義

絕大部分情況下,我們需要定義一維陣列或者二維陣列進行儲存在計算過程中產生的最優值,這裡為什麼是最優值呢?是因為在解決問題過程中,一般情況dp陣列用來儲存從開始到當前情況的最優值,故而儲存的是截止到目前的最優值,避免重複計算(這裡看起來思維有混亂的同學們,想想上面 Fibonacci 遞迴解法和動態規劃的對比)

所以,dp無論是一維的還是二維的,要想清楚代表什麼,一般來說代表的是截止到目前情況下的最優值

步驟二:定義狀態轉移方程

什麼是動態轉移方程? 如果有一個問題擺在我們面前,然後這個問題在解決的過程中,會發現有很多的重疊子問題,重疊子結構,而通過這些子問題的解決,最終將會把該問題進行解決。

通俗來說,在解決問題過程中,能夠發現一個不斷解決子問題的動態規律,比如說 Fibonacci 中的F(N) = F(N - 1) + F(N - 2),而在其他的可以用動態規劃解決的問題中,需要我們自己去發現這樣的內在規律。這個是最難的也是最終於要的,只要這一步解決了,接下來我們解決這個問題基本就沒問題了。

步驟三:初始化過程轉移的初始值

順著步驟二的思路來,既然動態方程定義好了,是不是需要一個支點來撬動它進行不斷的計算下去。

那麼,這個支點就需要我們來初始定義,將動態方程啟用,進行計算。舉例來說Fibonacci中的F(0) = 0F(1) = 1,有了這兩個值,它的動態方程F(N) = F(N - 1) + F(N - 2)就可以進行下去了

這個就是我們要想好的初始值,實際問題可能還需要我們想想清楚.

步驟四:可優化點(可選)

可優化的這裡,最重要的會是dp陣列這塊,也會有不同問題不同的優化點。

在例子中,我們會進行不同的優化,其中,最主要的優化點還是在空間的優化方面。

總之一點,建議大家動筆多畫畫圖,很多細節慢慢就會出現了。

下面按照之前設定的案例順序,都來看看

案例一:打家劫舍I 「來自leetcode198」

你是一個專業的小偷,計劃偷竊沿街的房屋。每間房內都藏有一定的現金,影響你偷竊的唯一制約因素就是相鄰的房屋裝有相互連通的防盜系統,如果兩間相鄰的房屋在同一晚上被小偷闖入,系統會自動報警。

給定一個代表每個房屋存放金額的非負整數陣列,計算你在不觸動警報裝置的情況下,能夠偷竊到的最高金額。

示例 1:

輸入: [1,2,3,1]
輸出: 4
解釋: 偷竊 1 號房屋 (金額 = 1) ,然後偷竊 3 號房屋 (金額 = 3)。
     偷竊到的最高金額 = 1 + 3 = 4 。

示例2:

輸入: [2,7,9,3,1]
輸出: 12
解釋: 偷竊 1 號房屋 (金額 = 2), 偷竊 3 號房屋 (金額 = 9),接著偷竊 5 號房屋 (金額 = 1)。
     偷竊到的最高金額 = 2 + 9 + 1 = 12 。

把經典案例系列拆分開討論下吧,我們們首先將「打家劫舍I」來看看

該題可以用動態規劃的思想來解決的原因是,在小偷不斷偷取的過程中,始終想要偷得的物品價值最大,最優,每一步驟都與之前的偷取情況有關係,而且每一步都要考慮是否能偷,是否會帶來最大利益,這就使得我們可以用動態規劃的思想來解決問題。 然後嚴格按照四步驟進行解題。

步驟一: 定義dp陣列的含義

之前提到的,dp陣列儲存的值一般代表截止目前的最優值,在該題目中,我們定義:

dp[i] 代表到達第 i 個房屋偷得的最高金額,也就是當前最大子序和

無論房屋有幾間,最後我們取到dp陣列的最後一個值就求得小偷偷得的最高金額

步驟二:找出關係元素間的動態方程

動態規劃解決的問題,一般來說就是解決最優子問題,“自頂向下” 的去不斷的計算每一步驟的最優值;

也就是想要得到dp[i]的值,我們必須要知道dp[i-1]dp[i-2]dp[i-3] ... 的每一步的最優值,在這個狀態轉移的過程中,我們必須要想清楚怎麼去定義關係式。然而在每一步的計算中,都與前幾項有關係,這個固定的關係就是我們要尋找的重疊子問題,也同樣是接下來要詳細定義的動態方程;

該題目中,當小偷到達第 i個屋子的時候,他的選擇有兩種:一種是偷,另外一種是不偷, 然後選擇價值較大者

a. 偷的情況計算:以下圖為例,必然是dp[3] = nums[2] + dp[1],如果是偷取該屋子的話,相鄰屋子是不能偷取的,因此,通項式子是:dp[i] = nums[i-1] + dp[i-2]

b. 不偷的情況計算:必然是dp[3] = dp[2],如果是不偷取該屋子的話,相鄰屋子就是其最優值,因此,通項式子是:dp[i] = dp[i-1]

最後,要想偷得最高金額,那麼,必須選取在偷與不偷之間的最大值作為我們是否選取的關鍵點。即:

動態方程: dp[i] = max(dp[i-1], nums[i-1]+dp[i-2])

步驟三:初始化數值設定

初始化: 給沒有房子時,dp一個位置,即:dp[0]

1 當size=0時,沒有房子,dp[0]=0

2 當size=1時,有一間房子,偷即可:dp[1]=nums[0]

那麼,按照這個思路來整理一下程式碼:

class Solution(object):

    def rob(self, nums):
      # 1.dp[i] 代表當前最大子序和
      # 2.動態方程: dp[i] = max(dp[i-1], nums[i-1]+dp[i-2])
      # 3.初始化: 給沒有房子時,dp一個位置,即:dp[0]
      #   3.1 當size=0時,沒有房子,dp[0]=0;
      #   3.2 當size=1時,有一間房子,偷即可:dp[1]=nums[0]
      size = len(nums)
      if size == 0:
        return 0

      dp = [0 for _ in range(size+1)]

      dp[0] = 0
      dp[1] = nums[0]
      for i in range(2, size+1):
        dp[i] = max(dp[i-1], nums[i-1]+dp[i-2])
        return dp[size]

時間複雜度:O(N)

空間複雜度:O(N)

那下面想想看有沒有可優化的地方,儘量的釋放一部分計算機資源。

步驟四:優化

dp[i] = max(dp[i-1], nums[i-1]+dp[i-2]) 關係來看,每一次動態變化,都與前兩次狀態有關係(dp[i-1], dp[i-2]),而前面的一些值是沒有必要留存的。

所以,dp只需要定義兩個變數就好,將空間複雜度降為O(1)

class Solution(object):

    def rob_o(self, nums):
        # 依照上面的思路,其實我們用到的資料永遠都是dp的dp[i-1]和dp[i-2]兩個變數
        # 因此,我們可以使用兩個變數來存放前兩個狀態值
        # 空間使用由O(N) -> O(1)

        size = len(nums)
        if size == 0:
            return 0

        dp1 = 0
        dp2 = nums[0]
        for i in range(2, size+1):
            dp1 = max(dp2, nums[i-1]+dp1)
            dp1, dp2 = dp2, dp1
        return dp2

時間複雜度:O(N)

空間複雜度:O(1)

說完《打家劫舍I 》,中間穿插另外一道題目,利用二維dp來解決的一個問題。

最後再說說《打家劫舍II 》和《打家劫舍III》,把這一系列的打家劫舍問題搞明白了,相信你對動態規劃有一個較為深刻的入門體驗

如果有讀者說這太簡單了,我這篇文章內容面對的是小白級別的,如果讀者是中等往上的水平,可直接跳到後面的案例三開始參考。

另外,如果有任何的意見可隨時對我的文章進行評論,歡迎大家一起討論!

案例二:不同路徑「來自leetcode62」

一個機器人位於一個 m x n 網格的左上角 (起始點在下圖中標記為“Start” )。

機器人每次只能向下或者向右移動一步。機器人試圖達到網格的右下角(在下圖中標記為“Finish”)。

問總共有多少條不同的路徑?

示例 1:

輸入: m = 3, n = 2
輸出: 3
解釋:
從左上角開始,總共有 3 條路徑可以到達右下角。

1. 向右 -> 向右 -> 向下
2. 向右 -> 向下 -> 向右
3. 向下 -> 向右 -> 向右

示例 2:

輸入: m = 7, n = 3
輸出: 28

提示:

1 <= m, n <= 100
題目資料保證答案小於等於 2 * 10 ^ 9

下面依然按照四個步驟來進行討論:

步驟一:定義dp陣列的含義

當前這道題是從左上角到右下角的,題目中規定只能向右或者向下走,所以我們必須要定義一個二維陣列來儲存計算過程中的值。

所以,這塊定義:dp[i][j]: 代表到達位置 (i, j) 的所有路徑的總數

即:機器人從左上角到右下角所有路徑的總和,dp中每個位置的值代表行走到達 (i, j) 每個位置的總共的路徑數

步驟二:找出關係元素間的動態方程

由於題目中規定只能向右或者向下走,所以在機器人行進的時候,只能是向右或向下.

那麼,分別討論下兩種情況,想要到達位置(i, j),可以從位置(i-1, j)或者(i, j-1)出發到達。因此,到達位置(i, j) 的總的路徑數一定是 到達位置(i-1, j)路徑數 + 到達位置(i, j-1)路徑數。那麼,現在可以定義動態方程:

動態方程:dp[i][j] = dp[i-1][j] + dp[i][j-1]

步驟三:初始化數值設定

很明顯,在機器人走第 0 行,第 0 列的時候,無論怎麼走,都只有 1 種走法。

因此,初始化值的設定,一定是 dp[0..m]\[1] 或者 dp[1]\[0..n] 都等於1

因此初始值如下:

dp[0] [0….n-1] = 1; // 機器人一直向右走,第 0 列統統為 1

dp[0…m-1] [0] = 1; // 機器人一直向下走,第 0 列統統為 1

現在,按照這個思路來整理一下程式碼

class Solution(object):

    def uniquePaths1(self, m, n):

        # 初始化表格,由於初始化0行 0列都為1。那麼,先全部置為1
        dp = [[1 for _ in range(m)] for _ in range(n)]

        for i in range(1, n):
            for j in range(1, m):
                dp[i][j] = dp[i-1][j] + dp[i][j-1]

        return dp[n-1][m-1]

上述程式碼中由於dp[0..m]\[1] 或者 dp[1]\[0..n] 都等於1,所以在定義二維陣列dp時候,統統賦初始值為 1

然後從位置(1, 1)開始計算每個位置的總路徑數

時間複雜度:O(M*N)

空間複雜度:O(M*N)

既然到這裡了,下面再想想看有沒有可優化的地方

步驟四:優化

可以依照前面的解決的思路,應該也可以從空間上進行一定的優化

參照前面的案例,之前定義的是一維陣列dp,優化點是每一步驟都只與前面的兩個計算好的數值有關係,然後優化點就是將dp[N] -> dp1dp2,空間複雜度由 O(N) -> O(1),如果是很大規模的資料計算的話,空間效率提升了不少.

現在這個例子中的動態方程是dp[i][j] = dp[i-1][j] + dp[i][j-1],很明顯,每一步驟中的狀態值只與左邊相鄰的值和上面的值相關。舉例(為了方便,用 3*4 來舉例)

這個完整的圖片描述中,機器人從左上角的位置(1, 1)開始移動,逐漸每一步都根據動態方程進行前進,明顯的可以看出機器人每移動一格,所得到的路徑總和只與它的上方和左方數值有關係。也就是我們會發現,機器人移動到第2行的時候,第0行資料完全是沒有用的狀態

因此,這個優化點就出來了,在演算法設計的時候,dp僅僅定義2行N列的陣列就ok了,省去了m-2行的空間開銷。這個程式碼如果大家想明白了請自行設計出來,自己寫出來一定會有更加深刻的理解,再強調:多思考,形成潛移默化的思維方式!

看完這個步驟之後,是不是很明顯的優化點,為什麼上面沒有給出大家程式碼呢?是因為我看到貌似可以繼續優化的點(粘住空間優化項了哈哈哈),那就繼續在空間開銷上做文章。

引導:根據上述我們們的優化方案,說道 "機器人移動到第2行的時候,第0行資料完全是沒有用的狀態",其實當前聰明的讀者你想想,再看看,下面的圖中(從上圖擷取過來)。 其實,不僅僅是第 0 行完全沒用了,而且在第2 行做移動的時候,移動到位置(i, j)的時候,計算好位置(i, j),那麼接下來,位置(i-1, j)的資料也就沒用了。

換句話說,一邊走著,第 1 行開始的某些資料也就沒用了,還在佔著空間!

這塊大家一定多想想,多理解,多畫圖

下面按照這種思路,看下圖的步驟,也畫好了用一維陣列進行解決問題,也畫出來每一步驟與上圖的類比過程:

在這裡,有犯困的同學可以自己動手畫一畫,理解一下,個人感覺是一個很好的思維擴充套件!挺有意思!

接下來,就按照這樣的思路進行程式碼實現,會發現碼起來很簡單

class Solution(object):

    def uniquePaths2(self, m, n):
        if m > n:
            m, n = n, m

        dp = [1 for _ in range(m)]

        for i in range(1, n):
            for j in range(1, m):
                dp[j] = dp[j] + dp[j-1]

        return dp[m-1]

時間複雜度:O(m*n)

空間複雜度:O(min(m ,n))

是不是從思維方面簡單幹淨了許多?

搞清楚上面的栗子之後呢,我們將上面的例題進行一個簡單的難度增加,說白了,就是在路上打幾個阻礙點!

來看:

案例三:不同路徑II 「來自leetcode63」

一個機器人位於一個 m x n 網格的左上角 (起始點在下圖中標記為“Start” )。

機器人每次只能向下或者向右移動一步。機器人試圖達到網格的右下角(在下圖中標記為“Finish”)。

現在考慮網格中有障礙物。那麼從左上角到右下角將會有多少條不同的路徑?

說明:m 和 n 的值均不超過 100。

示例 1:

輸入:
[
  [0,0,0],
  [0,1,0],
  [0,0,0]
]
輸出: 2
解釋:
3x3 網格的正中間有一個障礙物。
從左上角到右下角一共有 2 條不同的路徑:

1. 向右 -> 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右 -> 向右

我們們先看一下題中的兩個關鍵點:

關鍵點1:只能向右或者向下

關鍵點2:有障礙物為1, 無障礙物為0

根據 關鍵點1 和 關鍵點2 依然按照四個步驟來進行討論:

步驟一:定義dp陣列的含義

這個題目中定義的dp陣列是和上一個例題中定義的dp陣列的含義是相同的,但由於該題中已經定義有陣列 obstacleGrid,可以直接利用,無需額外開闢空間。

那麼,就利用obstacleGrid作為動態規劃中儲存計算過程中的最優值。

步驟二:找出關係元素間的動態方程

參照上一題目,規定動態方程: obstacleGrid[i]\[j] = obstacleGrid[i-1]\[j] + obstacleGrid[i]\[j-1]

由於機器人在移動過程中有障礙物,那麼,對上面動態方程加一些限制條件

a.若當前 obstacleGrid[i][j] 為0。那麼,直接計算動態方程下的計算過程

b.若當前 obstacleGrid[i][j] 不為0。那麼,直接置該位置的值為0

所以,在進行動態方程遍歷的時候,先進行 obstacleGrid[i][j]的判斷,再進行動態方程的計算執行。

步驟三:初始化數值設定

相比於上一題目,相似的是,在機器人走第 0 行,第 0 列的時候,無論怎麼走,都只有 1 種走法

但由於有障礙物,那走到障礙物的時候,後面都是走不下去的(下圖用第一行來舉例)。

所以,初始化第 0 行,第 0 列的時候,障礙物 1 後面的都是不可達的。所以,初始化行和列的邏輯表達:

該位置是否可達=前一個位置的狀態and該位置能否可達 得到能否到達這個位置

只有前一個位置為1(可達,只有1種方式) ,當前位置為0(無障礙物)這種情況才能到達該位置,然後為該位置設 1 (可達,只有1種方式)

# 0 行初始化表示式: 
obstacleGrid[0][row] = int(obstacleGrid[0][row] == 0 and obstacleGrid[0][row-1] == 1)
# 0 列初始化表示式: 
obstacleGrid[clo][0] = int(obstacleGrid[clo][0] == 0 and obstacleGrid[clo-1][0] == 1)

這些都準備就緒之後,按照相關思路進行編碼

class Solution(object):

    def uniquePathsWithObstacles1(self, obstacleGrid):
      	# 行列長度
        m = len(obstacleGrid)
        n = len(obstacleGrid[0])

        # 如果在位置(0, 0),哪裡都去不了,直接返回0
        if obstacleGrid[0][0] == 1:
            return 0

        # 否則,位置(0, 0)可以到達
        obstacleGrid[0][0] = 1

        # 初始化 0 列
        for clo in range(1, m):
            obstacleGrid[clo][0] = int(obstacleGrid[clo][0] == 0 and obstacleGrid[clo-1][0] == 1)

        # 初始化 0 行
        for row in range(1, n):
            obstacleGrid[0][row] = int(obstacleGrid[0][row] == 0 and obstacleGrid[0][row-1] == 1)

        # 從位置(1, 1)根據動態方程開始計算
        for i in range(1, m):
            for j in range(1, n):
                if obstacleGrid[i][j] == 0:
                    obstacleGrid[i][j] = obstacleGrid[i-1][j] + obstacleGrid[i][j-1]
                else:
                    obstacleGrid[i][j] = 0

        return obstacleGrid[m-1][n-1]

時間複雜度: O(mxn)

空間複雜度: O(1)

步驟四:優化

這塊的優化先不談了,這裡基本沒有什麼優化點,之前都是由於自己要開闢記憶體空間,通過空間的優化來進行,而本題是在給定的陣列中進行操作的。

有了這幾個案例的基礎之後,我們們後面把經典的《打家劫舍》系列剩下的兩個題目討論完,就先告一段落,後面也希望以不同的方式與大家多多交流,互相學習

如果有讀者看著累了,可以先儲存,收藏下來,待消化了前面的內容,方便再回來看看。

再次備註GitHub地址,裡面有該文件的地址:https://github.com/xiaozhutec/share_leetcode.git

案例四:打家劫舍II 「來自leetcode213」

你是一個專業的小偷,計劃偷竊沿街的房屋,每間房內都藏有一定的現金。這個地方所有的房屋都圍成一圈,這意味著第一個房屋和最後一個房屋是緊挨著的。同時,相鄰的房屋裝有相互連通的防盜系統,如果兩間相鄰的房屋在同一晚上被小偷闖入,系統會自動報警。

給定一個代表每個房屋存放金額的非負整數陣列,計算你在不觸動警報裝置的情況下,能夠偷竊到的最高金額。

示例 1:

輸入: [2,3,2]
輸出: 3
解釋: 你不能先偷竊 1 號房屋(金額 = 2),然後偷竊 3 號房屋(金額 = 2), 因為他們是相鄰的。

示例 2:

輸入: [1,2,3,1]
輸出: 4
解釋: 你可以先偷竊 1 號房屋(金額 = 1),然後偷竊 3 號房屋(金額 = 3)。
     偷竊到的最高金額 = 1 + 3 = 4 。

與《打家劫舍I》不同的是,《打家劫舍I》的屋子是線性的,而《打家劫舍II》是環狀的,所以要考慮的點會增加一些,因為首位相連線的情況,我們們分為下面三種情況進行設定:

a. 不偷首偷尾

b. 偷首不偷尾

c. 首位都不偷

顯然,c 種方式損失太大,不會獲得最高的金額,故選取 a 和 b。

那麼,下面分為兩種情況,分別計算不包含首和不包含尾這兩種情況來判斷小偷哪種方式偷取的金額最高。

下面依然按照之前的四個步驟來進行分析:

步驟一: 定義dp陣列的含義

dp[i] 代表的含義和之前一致,dp陣列儲存的值一般代表截止目前的最優值

所以,dp[i] 代表到達第 i 個房屋偷得的最高金額,也就是當前最大子序和

但是最後會討論不包含首不包含尾這兩種情況下得到的dp陣列的最後一位,然後獲取其中較大者,就是我們要取得的最高金額

步驟二:找出關係元素間的動態方程

動態方程可參照《打家劫舍I》,有很詳細的圖解過程,該例子動態方程的變化和之前是完全一致的:

dp[i] = max(dp[i-1], nums[i-1]+dp[i-2])

步驟三:初始化設定

初始化: 給沒有房子時,dp一個位置,即:dp[0]
a. 當 size=0 時,沒有房子,小偷沒辦法偷:dp[0]=0
b. 當 size=1 時,有一間房子,只要偷即可:dp[1]=nums[0]

由於屋子首位相連線,所以在計算時候,直接分為兩種情況。

第一種略過第一個屋子,第二種略過第二個屋子,這樣得到的兩個陣列結果。最後只要比較最後一位數值的大小就ok了。解決!

該例子步驟三之後,感興趣的同學可以自己寫一下程式碼,和《打家劫舍I》的程式碼很類似,後面我寫了優化後的程式碼,可能會更加的明白怎麼寫。我們們直接到步驟四,有了上面的案例,直接來看看優化後的方案:

步驟四:優化

同樣從 dp[i] = max(dp[i-1], nums[i-1]+dp[i-2]) 關係來看

每一次動態變化,都與前兩次狀態有關係(dp[i-1], dp[i-2]),而前面的一些值是沒有必要留存的,只要儲存兩個變數來儲存過程最優值就好。

程式碼中有詳細的註釋:

class Solution(object):

    def rob(self, nums):
        # 點睛:與打家劫舍I的區別是屋子圍成了一個環
        #   那麼,很明顯可以分為三種情況:
        #   1. 首位都不偷
        #   2. 偷首不偷尾
        #   3. 不偷首偷尾
        # 顯然,第1種方式損失太大,選取2、3。
        # 那麼,分為兩種情況,分別計算不包含首和不包含尾這兩種情況來判斷哪個大哪個小

        # 1.dp[i] 代表當前最大子序和
        # 2.動態方程: dp[i] = max(dp[i-1] and , nums[i-1]+dp[i-2])
        # 3.初始化: 給沒有房子時,dp一個位置,即:dp[0]
        #   3.1 當size=0時,沒有房子,dp[0]=0;
        #   3.2 當size=1時,有一間房子,偷即可:dp[1]=nums[0]

        # 依照《打家劫舍I》的優化方案進行計算

        # nums處理,分別切割出去首和去尾的子串
        nums1 = nums[1:]
        nums2 = nums[:-1]

        size = len(nums)
        if size == 0:
            return 0
        if size == 1:
            return nums[0]

        def handle(size, nums):
            dp1 = 0
            dp2 = nums[0]
            for i in range(2, size+1):
                dp1 = max(dp2, nums[i-1]+dp1)
                dp1, dp2 = dp2, dp1
            return dp2

        res1 = handle(size-1, nums1)
        res2 = handle(size-1, nums2)

        return max(res1, res2)

時間複雜度:O(N)

空間複雜度:O(1)

再看看下面小偷遇到的情況,小偷很難...

案例五:打家劫舍III 「來自leetcode337」

在上次打劫完一條街道之後和一圈房屋後,小偷又發現了一個新的可行竊的地區。這個地區只有一個入口,我們稱之為“根”。 除了“根”之外,每棟房子有且只有一個“父“房子與之相連。一番偵察之後,聰明的小偷意識到“這個地方的所有房屋的排列類似於一棵二叉樹”。 如果兩個直接相連的房子在同一天晚上被打劫,房屋將自動報警。

計算在不觸動警報的情況下,小偷一晚能夠盜取的最高金額。

示例 1:

輸入: [3,2,3,null,3,null,1]

 		 3
		/ \
   2   3
    \   \ 
     3   1

輸出: 7 
解釋: 小偷一晚能夠盜取的最高金額 = 3 + 3 + 1 = 7.

示例 2:

輸入: [3,4,5,1,3,null,1]

 		 3
		/ \
   4   5
  / \   \ 
 1   3   1
輸出: 9
解釋: 小偷一晚能夠盜取的最高金額 = 4 + 5 = 9.

題目出的很好,但是立馬會給人一種小偷也不是好當的的趕腳...

言歸正傳,我們們先來說說題目本身!

《打家劫舍》的小偷從一維線性到環形,再到二維矩形的屋子?是我想簡單了,直接就幹到樹形了,是不是看著很香,而且很想,看下去,研究研究...

來整理幾點思路,再來按照四步走:

1.由於房屋是樹狀的,因此,我們可以使用遍歷樹的傳統方法進行遍歷(前序、中序、後續)

2.簡單的思路是,從樹低進行往上遍歷,拿到最優的打劫值。可以選用後續遍歷

3.得到每一節點的最優值,最後選取最優的結果

依然按照三個步驟來進行分析(無優化點)

步驟一: 定義dp陣列的含義

dp[i]代表該節點及以下打最多的劫(拿到最多的錢)

步驟二:找出關係元素間的動態方程

根據我們每走到一個節點,都會有兩種情況,那就是 偷(1)不偷(0)。我們分開來討論:

a. 用 dp[0] 代表不偷取該節點到目前為止拿到最多的錢,那麼兒子節點偷不偷都ok。

所以: dp[0] = max(left[0], left[1]) + max(right[0], right[1])

b. 用 dp[1] 代表偷了該節點到目前為止拿到最多的錢,則兒子節點都不能被偷。

所以:dp[1] = value + left[0] + right[0] (value代表該結點的價值)

有看不懂的地方嗎?再緊接著解釋一下:

left[0]代表不偷取左孩子拿到最高的金額

left[1]代表偷取左孩子拿到最高的金額

right[0]代表不偷取右孩子拿到最高的金額

right[1]代表偷取右孩子拿到最高的金額

步驟三:初始化設定

該例子的初始化比較簡單,就是當前樹的形狀為空的時候,直接返回dp[0, 0]

下面貼出完整程式碼,其中包含樹的初始化程式碼 && 一大堆的註釋:

# Definition for a binary tree node.
class TreeNode(object):
    def __init__(self, x):
        self.val = x
        self.left = None
        self.right = None

class Solution():

    def rob(self, root):

        # 說明:
        # 1.由於房屋是樹狀的,因此,我們可以使用遍歷樹的傳統方法進行遍歷(前序、中序、後續)
        # 2.簡單的思路是,從樹低進行往上遍歷,拿到最優的打劫值。可以選用後續遍歷
        # 3.得到每一節點的最優值,最後選取最優的結果

        # 1.dp[i]代表該節點及以下拿到的最多的錢
        # 2.動態方程:
        #   2.1 dp[0]代表不偷該節點拿到最多的錢,則兒子節點偷不偷都ok。dp[0] = max(left[0], left[1]) + max(right[0], right[1])
        #   2.2 dp[1]代表偷了該節點拿到最多的錢,則兒子節點都不能被偷。dp[1] = var + left[0] + right[0]
        # 3.初始化:當前樹的形狀為空的時候,直接返回dp[0, 0]
        def postTrasval(root):
            dp = [0, 0]
            if not root:
                return dp
            left = postTrasval(root.left)
            right = postTrasval(root.right)

            dp[0] = max(left[0], left[1]) + max(right[0], right[1])
            dp[1] = root.val + left[0] + right[0]

            return dp

        dp = postTrasval(root)
        return max(dp[0], dp[1])


if __name__ == '__main__':
    # initial tree structure
    T = TreeNode(3)
    T.left = TreeNode(2)
    T.right = TreeNode(3)
    T.left.right = TreeNode(3)
    T.right.right = TreeNode(1)

    # The solution to the Question
    s = Solution()
    print(s.rob(T))

至此為止,想要講解的全部完畢了!

洋洋灑灑過萬字,自己都沒想到寫了這麼多!

在強調一點吧,這些題目全部理解加自己另外練習,理解了文中的題目,再加以練習,一定能夠cover關於動態規劃80%以上的題目,基本上都是dp為一維陣列,二維陣列的題目,很少有很奇怪的題型出現。所以,本文將《打家劫舍》經典案例詳細講解了一次,還有不同路徑的問題,也是很經典的題目,而經典題目一定很具有代表性。優化方向很多,本文也只介紹了關於空間方面的優化,因為這個是最最常見的。

最後,大家一定多畫圖多畫圖多畫圖,多思考,題解百邊其義自見!!

還有,多理解四步驟, 加油!

相關文章