LeetCode 例題精講 | 14 打家劫舍問題:動態規劃的解題四步驟

2021-03-02 面向大象編程

本期例題:LeetCode 198. House Robber 打家劫舍(Easy)

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

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

示例:

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

House Robber 問題(翻譯為小偷問題、打家劫舍問題)是一道非常經典的動態規劃入門題目。如果你對於動態規劃還不是很了解,或者沒怎麼做過動態規劃的題目的話,那麼我非常建議你用 House Robber 這道題來入門。

動態規劃可能被很多同學奉為最難理解的算法題類型,因為它的解法很靈活,變化很多。但是動態規劃又經常作為筆試題的壓軸,我們不得不去面對它。

其實,動態規劃是一類很講究「觸類旁通」的題型。很多動態規劃的解法需要你做過某一類型的例題,再做類似的題目的時候就可以想起來相應的思路。如果你做過不少題目,但是拿到新的題目依然沒有思路,那說明你做過的題目不夠典型。例如很多文章拿來大講特講的換硬幣問題,實際上並不太適合用來理解動態規劃的思想。

這正是我寫《LeetCode 例題精講》系列文章的意義所在。我的文章會做到兩點,一是選擇最有代表性的例題,二是通過該例題講解一類問題的解題套路。從本期開始的幾篇文章我會講解動態規劃問題,每篇文章用一道最典型的例題,帶你學習動態規劃的解題套路。

本文選擇一個非常典型的動態規劃問題:打家劫舍,在一步步求解這道題的過程中,講解動態規劃題目的四個基本步驟。

動態規劃的解題四步驟

動態規劃的的四個解題步驟是:

不瞞你說,在 LeetCode 上我做過的幾十道動態規劃題目,都是用這個解題四步驟做出來的。這個解題步驟適用於任何一道動態規劃題目,能夠讓你很快理清解題的各個要點。

步驟一:定義子問題

稍微接觸過一點動態規劃的朋友都知道動態規劃有一個「子問題」的定義。什麼是子問題?子問題是和原問題相似,但規模較小的問題。例如這道打家劫舍問題,原問題是「從全部房子中能偷到的最大金額」,將問題的規模縮小,子問題就是「從

打家劫舍問題的子問題定義

可以看到,子問題是參數化的,我們定義的子問題中有參數

原問題要能由子問題表示。例如這道題中,一個子問題的解要能通過其他子問題的解求出。例如這道題中,

打家劫舍問題由於比較簡單,定義子問題實際上是很直觀的。一些比較難的動態規劃題目可能需要一些定義子問題的技巧。

步驟二:寫出子問題的遞推關係

這一步是求解動態規劃問題最關鍵的一步。然而,這一步也是最無法在代碼中體現出來的一步。在做題的時候,最好把這一步的思路用注釋的形式寫下來。做動態規劃題目不要求快,而要確保無誤。否則,寫代碼五分鐘,找 bug 半小時,豈不美哉?

我們來分析一下這道題的遞推關係:

假設一共有

子問題的遞推關係

另外,我們在寫遞推關係的時候,還要注意遞推關係的 base case。這樣才能構成完整的遞推關係,後面寫代碼也不容易在邊界條件上出錯。在這道題中,是

步驟三:確定 DP 數組的計算順序

在確定了子問題的遞推關係之後,下一步就是依次計算出這些子問題了。在很多教程中都會寫,動態規劃有兩種計算順序,一種是自頂向下的、使用備忘錄的遞歸方法,一種是自底向上的、使用 DP 數組的循環方法。不過在普通的動態規劃題目中,99% 的情況我們用循環方法就很好解決,所以我們最好在一開始就堅持自底向上方法,使用 DP 數組,這樣才有助於領悟動態規劃的真正精髓。

DP 數組也可以叫「子問題數組」,因為 DP 數組中的每一個元素都對應一個子問題。如下圖所示,dp[k] 對應子問題

DP 數組與子問題的對應關係

那麼,只要搞清楚了子問題的計算順序,就可以確定 DP 數組的計算順序。對於打家劫舍問題,我們分析子問題的依賴關係,發現每個

DP 數組的依賴順序

那麼,既然 DP 數組中的依賴關係都是向右指的,DP 數組的計算順序就是從左向右。這樣我們可以保證,計算一個子問題的時候,它所依賴的那些子問題已經計算出來了。

確定了 DP 數組的計算順序之後,我們就可以寫出題解代碼了:

public int rob(int[] nums) {
    if (nums.length == 0) {
        return 0;
    }
    // 子問題:
    // f(k) = 偷 [0..k) 房間中的最大金額

    // f(0) = 0
    // f(1) = nums[0]
    // f(k) = max{ rob(k-1), nums[k-1] + rob(k-2) }

    int N = nums.length;
    int[] dp = new int[N+1];
    dp[0] = 0;
    dp[1] = nums[0];
    for (int k = 2; k <= N; k++) {
        // 套用子問題的遞推關係
        dp[k] = Math.max(dp[k-1], nums[k-1] + dp[k-2]);
    }
    return dp[N];
}

注意在上面的代碼中,有一大段關於子問題定義、遞推關係的公式。在面試的時候,我們一般需要先把子問題的定義、遞推關係給面試官解釋清楚,同時順手把這些內容用注釋的形式寫下來。這樣一來讓自己寫代碼不容易出錯,二來也能讓面試官更好地理解自己的思路。

步驟四:空間優化

空間優化是動態規劃問題的進階內容了。對於初學者來說,可以不掌握這部分內容,寫出來的代碼無非是空間複雜度高一些。不過,打家劫舍問題同樣是空間優化的一個很好的例子,所以你不妨了解一下。

讓我們回顧一下在編程入門階段學習的斐波那契數列的求解方法。斐波那契數列的遞推關係是

// 計算斐波那契數列
// f(1) = f(2) = 1, f(n) = f(n-1) + f(n-2)
int fib(int n) {
    if (n <= 2) {
        return 1;
    }
    int a = 1;
    int b = 1;
    for (int i = 2; i < n; i++) {
        int c = a + b;
        a = b;
        b = c;
    }
    return b;
}

請記住這段代碼,這段我們很早就會的代碼裡,其實蘊含了打家劫舍類動態規劃問題的空間優化技巧。

空間優化的基本原理是,很多時候我們並不需要始終持有全部的 DP 數組。對於打家劫舍問題,我們發現,最後一步計算

那麼,我們可以只用兩個變量保存兩個子問題的結果,就可以依次計算出所有的子問題。這樣一來,空間複雜度也從

空間優化前後對比(動圖)

優化後的代碼如下所示:

public int rob(int[] nums) {
    int prev = 0;
    int curr = 0;

    // 每次循環,計算「偷到當前房子為止的最大金額」
    for (int i : nums) {
        // 循環開始時,curr 表示 dp[k-1],prev 表示 dp[k-2]
        // dp[k] = max{ dp[k-1], dp[k-2] + i }
        int temp = Math.max(curr, prev + i);
        prev = curr;
        curr = temp;
        // 循環結束時,curr 表示 dp[k],prev 表示 dp[k-1]
    }

    return curr;
}

可以看到,我們只用到了三個變量:prev、curr 和 temp。在每一輪循環中,prev 都表示 dp[k-2],curr 都表示 dp[k-1],而 temp 負責計算出 dp[k] 的值,再往前循環複製。這三個變量的操作方式,和斐波那契數列中的三個變量一模一樣。

如果你是 Python 選手的話,還可以利用多重賦值的語法,去掉 temp 變量,在循環裡只用一行代碼:

def rob(self, nums: List[int]) -> int:
    prev = 0
    curr = 0
    
    # 每次循環,計算「偷到當前房子為止的最大金額」
    for i in nums:
        # 循環開始時,curr 表示 dp[k-1],prev 表示 dp[k-2]
        # dp[k] = max{ dp[k-1], dp[k-2] + i }
        prev, curr = curr, max(curr, prev + i)
        # 循環結束時,curr 表示 dp[k],prev 表示 dp[k-1]

    return curr

總結

本文用打家劫舍問題展示了動態規劃問題的基本解題四步驟。動態規劃問題種類繁多,有些題目非常有難度,但這些問題千變萬化也離不開這四個基本解題步驟,只不過是把四步驟的某些步驟變得更難了。例如:

在「定義子問題」的步驟,有的題目需要定義二維、三維的子問題。在「子問題遞推關係」的步驟,有的題目需要分情況討論,有複雜的 max、min、sum 表達式。在「DP 數組計算順序」的步驟,有的題目需要反向計算,甚至斜向計算。在「空間優化」的步驟,有的題目需要使用臨時變量,使用特殊的計算順序。

那麼你可能要問,題目千變萬化,怎麼才能全部學會呢?答案是由易到難、循序漸進,吃透例題,觸類旁通。

看到這裡,恭喜你走好了動態規劃入門的第一步。接下來,我會循序漸進地講解其他動態規劃題目,讓你能逐步掌握動態規劃問題的技巧,敬請期待。

往期文章

我是 nettee,致力於分享面試算法的解題套路,讓你真正掌握解題技巧,做到舉一反三。我的《LeetCode 例題精講》系列文章正在寫作中,關注我的公眾號,獲取最新文章。

相關焦點

  • LeetCode 例題精講 | 15 最長公共子序列:二維動態規劃的解法
    如果說打家劫舍問題是動態規劃的最佳入門題,那麼 LCS 問題就是二維動態規劃的最佳入門題,問題經典,方法典型。本文會在一步步求解 LCS 問題的過程中,講解二維動態規劃問題的解題要領。本文假設你已經了解了打家劫舍問題的解法以及動態規劃問題的基本解題步驟。
  • 打家劫舍問題:動態規劃的解題步驟
    而這些歷史記錄,我們得需要一些變量來保存,一般是用一維數組或者二維數組來保存,綜上所述的動態規劃問題其實可以簡化成為三個步驟,用於解決大部分的動態規劃問題。本期例題:LeetCode 198. House Robber 打家劫舍(Easy)你是一個專業的小偷,計劃偷竊沿街的房屋。
  • LeetCode 例題精講 | 16 最大子數組和:子數組類問題的動態規劃技巧
    Maximum Length of Repeated Subarray 最長公共子數組(Medium)在前面的文章中,我們分別講解了一維和二維動態規劃問題的解題步驟與基本思路。不過,僅僅掌握基本步驟是不夠的。要想熟練做出動態規劃題目,還要掌握足夠的解題技巧。
  • LeetCode 例題精講 | 05 雙指針*鍊表問題:快慢指針
    只要在解題時用到了兩個指針(鍊表指針、數組下標皆可),都可以叫做雙指針方法。根據兩個指針運動方式的不同,雙指針方法可以分成同向指針、對向指針、快慢指針等。當雙指針方法遇到鍊表問題,我們主要使用快慢指針方法。很多時候鍊表操作遇到疑難雜症時,可以使用快慢指針,減少算法所需的時間或者空間。
  • LeetCode 例題精講 | 07 變位詞問題:基本數據結構的威力
    本期例題:LeetCode 242 - Valid Anagram[1](Easy)給定兩個字符串 s 和 t ,判斷 t 是否是 s 的變位詞,即是否可以將 s 中的字母重新排列來得到 t。在上一篇文章中,我們講述了「基本操作」對於解題思路的重要作用。
  • 經典動態規劃:打家劫舍系列問題
    有好幾位讀者私下問我 LeetCode 「打家劫舍」系列問題(英文版叫 House Robber)怎麼做,我發現這一系列題目的點讚非常之高,是比較有代表性和技巧性的動態規劃題目,今天就來聊聊這道題目。打家劫舍系列總共有三道,難度設計非常合理,層層遞進。
  • Leetcode刷題-動態規劃
    本文對leetcode上部分動態規劃問題進行分析並用python實現5. 最長回文子串 題目描述給定一個字符串 s,找到 s 中最長的回文子串。你可以假設 s 的最大長度為 1000。思路2:動態規劃,參考了leetcode上面官方答案的思路。
  • LeetCode-4-動態規劃
    categories: - 數據結構與算法 - LeetCode四、動態規划動態規劃三大步驟:動態規劃,無非就是利用歷史記錄,來避免我們的重複計算。題解一|動態規劃:    此題類似於青蛙跳臺階問題,不同的只是多了很多限制條件。
  • 物理競賽典型例題精講——堵洞球洞上浮
    高中物理競賽典型題與解題步驟如圖1所示,一個裝滿水的容器底部有一半徑為r的圓洞,洞由一個質量為m,半徑為R(R>r)的球堵住,容器中的水慢慢減少,當達到一個確定指h0時,球從圓洞處升起,求h0的具體值。
  • 動態規劃初級試煉場
    動態規劃初級試煉場友情提示:本文中列舉的部分例題,並不一定只能用動態規劃求解,但文中只會給出問題的動態規劃解法。這篇文章首先會介紹一下下動態規劃的一般解題思路,然後會按照這個思路講解四個 我們通過動態規劃的方式,計算出以每個元素作為結尾的最大子序和,然後在這些結果中選取最大的一個,作為最終的結果。
  • 關於動態規劃,你該了解這些!
    2021年的第一個工作日,我們正式開啟動態規劃系列,老錄友們都知道「代碼隨想錄」的傳統,新系列開始,一定是先講理論基礎! 剛來的錄友可能會著急想刷題,別急哈,耐心把基礎篇看完,你一定會有所收穫!什麼是動態規划動態規劃,英文:Dynamic Programming,簡稱DP,如果某一問題有很多重疊子問題,使用動態規劃是最有效的。
  • 小學奧數知識點梳理-類牛吃草問題例題精講
    類牛吃草問題:是牛吃草問題的變形,經常會碰到的題型如:抽(淘)水問題,檢票口檢票問題,水庫洩洪問題等等,只要理解了這類問題關鍵的兩個不變量:消長速度和原有量,就能夠掌握問題的本質和解題思路,以不變應萬變。
  • 最全leetcode解題攻略:思路知識點代碼都有,搞定AI大廠筆試
    要弄清一個問題可能過於複雜,但第二個問題很好get:不少過來人建議,最好的方式就是刷題。Google、微軟、Facebook等公司,就會通過做題的方式面試應聘者。GitHub上有個叫lucifer的中國小哥哥,將Leetcode題庫中數百道題目的解題過程全盤分享,解題思路和代碼都有。民間曾一度流傳,leetcode上,基本就是網際網路大廠拿來應聘面試者的考題了。來看看。
  • 最全中文leetcode解題攻略:思路知識點代碼都有,搞定AI大廠筆試
    要弄清一個問題可能過於複雜,但第二個問題很好get:  不少過來人建議,最好的方式就是刷題。Google、微軟、Facebook等公司,就會通過做題的方式面試應聘者。  GitHub上有個叫lucifer的中國小哥哥,將Leetcode題庫中數百道題目的解題過程全盤分享,解題思路和代碼都有。  民間曾一度流傳,leetcode上,基本就是網際網路大廠拿來應聘面試者的考題了。  來看看。
  • ​LeetCode刷題實戰198:打家劫舍
    今天和大家聊的問題叫做 打家劫舍,我們先來看題面:https://leetcode-cn.com/problems/house-robber/You are a professional robber planning to rob houses along a street.
  • 多人行程問題圖文精講,比視頻講解更易懂,小學數學提分助手
    本節為多人行程問題,一文搞定,將帶給你以下知識:多人行程問題核心知識,多人行程問題解答步驟,例題分步驟詳細解析。【分步驟精講1】【例題1總結】此題是追及與相遇問題,反覆使用相遇及追及問題公式,先畫圖,再一步步分析每個過程。同時還要注意,題幹中給的單位是否統一,以及答案要求的什麼單位,是否涉及單位換算。
  • 「算法與數據結構」一張腦圖帶你看動態划算法之美
    聯繫👉TianTianUp,遇到問題的話,可以聯繫作者噢,願意陪你一起學習一起探討問題。腦圖👇什麼是動態規划動態規劃(Dynamic Programming),因此常用 DP 指代動態規劃。動態規劃,首先我們得清楚,它的概念是啥,它能解決什麼問題,維基百科對它的解釋👇❝動態規劃在尋找有很多重疊子問題的情況的最佳解時有效。它將問題重新組合成子問題,為了避免多次解決這些子問題,它們的結果都逐漸被計算並被儲存,從簡單的問題直到整個問題都被解決。因此,動態規劃儲存遞歸時的結果,因而不會在解決同樣的問題時花費時間。動態規劃只能應用於有最佳子結構的問題。
  • LeetCode 例題精講 | 04 用雙指針解 Two Sum:縮減搜索空間
    而更優的算法,則可以在一次操作內排除掉多個不合格的單元格,從而快速削減搜索空間,定位問題的解。那麼我們來看看,本題的雙指針解法是如何削減搜索空間的。那麼讓我們來看看這個思想在另一個例題中的應用。更大的問題是,刪除掉左上部分或者右下部分之後,剩餘的形狀不再是一個規則的矩形,就無法繼續進行二分搜索了。
  • 或許是比力扣 leetcode 更好的選擇?推薦兩個編程算法寶藏網站
    Dinic 屬於圖論部分,我本來想用其解決二分圖問題上圖是我第一次在這個網站見到的網頁。數據結構頁面,左邊是索引節選這個網站有三個優勢:•知識點 極其全面 ,分類明確:動態規劃、圖論、數據結構...每個知識點中的知識點都很 細碎、詳細•
  • LeetCode 例題精講 | 18 前綴和:空間換時間的技巧
    本文將教會你「前綴和」的算法套路,做出以下 LeetCode 例題:LeetCode 724. Find Pivot Index(Easy)LeetCode 560.動態規劃就是一類空間換時間的算法。動態規劃通過保存所有子問題的計算結果,可以避免子問題的重複計算。這種方法的代價是 DP 數組 佔用了較多的空間。