動態規劃覆盤...(上)

這是我參與8月更文挑戰的第2天,活動詳情查看:8月更文挑戰html


你們好!我是 Johngo 呀!node

和你們一塊兒刷題不快不慢,沒想到已經進行到了第二階段,「動態規劃」這部分題目很難,並且很不容易理解,目前個人題目作了一半,憑着以前對於「動態規劃」的理解和最近作的題目作一個階段性的總結!這篇文章實際上是我以前寫過的一篇,而後如今拿來再作一個潤色。python

「動態規劃」看這篇我...保證能夠!git

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

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

本文較長,建議先收藏,或者直接到 GitHub 中下載文檔(github.com/xiaozhutec/…編程

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

20210806.jpg

零、初印象

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

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

再後來才明白,大學的課程通常來講就是入門級講解,用來開拓眼界,真正想要有一番本身的看法,必需要在背後下一番辛苦,造成本身的思考邏輯tips:這個思考邏輯必定是要有記錄的,是真的有時候會忘記。

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

在個人這些學習積累過程當中,把一部分「動態規劃」的問題覆盤出來。但願能夠給到你們一點小小的幫助,相信在讀完這篇文章的時候,你會感受到動態規劃給你帶來的奇妙之處。也必定對動態規劃造成本身的思考方式

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

1、本文要點

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

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

「動態規劃」思惟邏輯

看完本篇文章後,相信你們會對DP問題會有一個初步的思考,必定會入門。後面你們能夠繼續練習相關問題,熟能生巧,思考的多了就會造成本身的思惟邏輯。

好了,話很少說,開搞...

2、動態規劃帶來的優點

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

平時在咱們算法設計的過程當中,通常講求的是算法的執行效率和空間效率的利用狀況。

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

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

引出:傳統遞歸 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(2^N) 指數級變爲 O ( 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 個值的多少?那麼,如下幾點是咱們必需要作到的

第1、定義一個一維數組 ---> 通常用dp來命名

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

第3、初始化數值 ---> 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)

空間複雜度: 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已經存好:github.com/xiaozhutec/…

你們感受這個例子怎麼樣,三點說明:1.定義dp數組 2.動態方程 3.初始化數值 4.優化項

這也說明了爲何用斐波那契數列來引入動態規劃的,由於斐波那契數列自己就明確的告訴你動態方程是什麼,初始化的值是什麼,因此好好的體會這種思想,尤爲是從傳統遞歸 -> 動態規劃的思想解決,再到優化的方面,很值得深思。

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

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

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

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

步驟一:定義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地址,裏面有該文檔的地址:github.com/xiaozhutec/…

案例四:打家劫舍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種方式損失太大,選取二、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爲一維數組,二維數組的題目,不多有很奇怪的題型出現。因此,本文將《打家劫舍》經典案例詳細講解了一次,還有不一樣路徑的問題,也是很經典的題目,而經典題目必定很具備表明性。優化方向不少,本文也只介紹了關於空間方面的優化,由於這個是最最多見的。

最後,你們必定多畫圖多畫圖多畫圖,多思考,題解百邊其義自見!!

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

相關文章
相關標籤/搜索
本站公眾號
   歡迎關注本站公眾號,獲取更多信息