打家劫舍問題合集(普通、有環、二叉樹)

Life_Goes_On發表於2020-08-05

一、打家劫舍1

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

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

示例 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 。

這是一個經典的動態規劃問題,直接想到用動態規劃來解決。
對於每一號房屋來說,可以選擇偷竊,也可以選擇不偷竊,如果偷竊,則前一個房屋一定不能偷竊。

1.1 冗餘dp

狀態定義:

dp [ i ] [ 0 ] 表示不偷第 i 號房屋能偷到的最多的錢;
dp [ i ] [ 0 ] 表示偷第 i 號房屋能偷到的最多的錢。

狀態轉移方程:

dp[ i ][ 0 ] = max( dp[ i-1 ][ 1 ], dp[ i-1 ][ 0 ] );

因為這家不偷,上家可以不偷、或者偷。

dp[ i ][ 1 ] = max( dp[ i-1 ][ 0 ] + nums[ i ] )。

這家要偷,肯定是上一家不偷。

class Solution {
    public int rob(int[] nums) {
        if( nums.length<1 )return 0;
        if( nums.length<2 )return nums[0];
        int[][] dp = new int[nums.length][2];
        dp[0][0] = 0;
        dp[0][1] = nums[0];

        for( int i=1; i<nums.length; i++ ){
            dp[i][0] = Math.max( dp[i-1][1], dp[i-1][0] );
            dp[i][1] = dp[i-1][0]+nums[i];
        }
        return Math.max( dp[nums.length-1][0], dp[nums.length-1][1] );
    }
}

1.2 壓縮版本1

前面對於 dp 的定義太細化,分了拿和不拿第 i 家的情況下能夠獲取的最大值,事實上這是一個重複的計算,如果dp本來就是最大值,何必再分拿還是不拿呢?

狀態定義:

dp[ i ] 表示到了第 i 號房的時候能夠獲得的最大金額。

轉移方程:

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

這時候不用考慮上一家的屋子要不要偷,因為 dp[ i-1 ] 就是更新的最大值,包含了偷或不偷的情況。

class Solution {
    public int rob(int[] nums) {
        if( nums.length<1 )return 0;
        if( nums.length<2 )return nums[0];
        int[] dp = new int[nums.length];
        dp[0] = nums[0];
        dp[1] = Math.max( nums[0],nums[1] );
        for( int i=2; i<nums.length; i++ ){
            dp[i] = Math.max( dp[i-2]+nums[i], dp[i-1] );
        }
        return dp[nums.length-1];
    }
}

1.3 壓縮版本2

考慮到這個狀態轉移方程最多隻依賴於前兩天,所以三個變數就足夠了,我們還可以把空間優化到 O(1)

class Solution {
    public int rob(int[] nums) {
        if( nums.length<1 )return 0;
        if( nums.length<2 )return nums[0];
        int a = nums[0];
        int b = Math.max( nums[0],nums[1] );
        for( int i=2; i<nums.length; i++ ){
            int c = Math.max( a + nums[i], b );
            a = b;
            b = c;
        }
        return b;
    }
}

二、打家劫舍 2

在上一道題的基礎之上,這個地方所有的房屋都圍成一圈,這意味著第一個房屋和最後一個房屋是緊挨著的。

說白了,就是從 0 到 n-2 家進行dp;或者從 1 到 n-1 家進行 dp ,取兩者最大值。

所以藉助打家劫舍 1 的程式碼,我們還是比較容易寫出這道題目的。

class Solution {
    public int rob(int[] nums) {
        int size = nums.length;
        if( size==0 )return 0;
        if( size==1 )return nums[0];
        if( size==2 )return Math.max(nums[0],nums[1]);
        int dp1 = helper(nums, 0, size-2);
        int dp2 = helper(nums, 1, size-1);
        return Math.max( dp1, dp2 );
    }
    //普通動態規劃
    public int helper(int[] nums, int left, int right){
        int a = nums[left];
        int b = Math.max(a, nums[left+1]);
        for( int i=left+2; i<=right; i++ ){
            int c = Math.max(a + nums[i], b);
            a = b;
            b = c;
        }
        return b;
    }
}

三、打家劫舍 3

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

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

示例 1:
輸入: [3,2,3,null,3,null,1]
打家劫舍問題合集(普通、有環、二叉樹)

輸出: 7
解釋: 小偷一晚能夠盜取的最高金額 = 3 + 3 + 1 = 7.
示例 2:
輸入: [3,4,5,1,3,null,1]
打家劫舍問題合集(普通、有環、二叉樹)

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

分析:
樹的問題一般都要用遞迴來解決,這個題的動態規劃的初始可以這樣思考:

  1. 選擇root,那麼答案就是root.val + rob(左子樹的左右)+rob(右子樹的左右);
  2. 不選擇root,那麼答案就是rob(左子樹)+rob(右子樹)

3.1 遞迴

按照上面說的思路,遞迴就能直接解決這個問題:

class Solution {
    public int rob(TreeNode root) {
        if( root==null )return 0;
        int a = root.val;
        //左右子樹
        int left = rob(root.left);
        int right = rob(root.right);
        
        int ll = root.left == null ? 0 : rob(root.left.left);
        int lr = root.left == null ? 0 : rob(root.left.right);
        int rl = root.right == null ? 0 : rob(root.right.left);
        int rr = root.right == null ? 0 : rob(root.right.right);

        return Math.max( left + right , root.val + ll + lr + rl + rr );
    }
}

但是程式碼效率非常非常低。

3.2 動態規劃

從最上面的分析我們能看出來,遞迴求解子樹的過程,本來就有很多重複,子樹從上到下,只是根節點的一個個選擇與否的問題,而直接遞迴忽略了記憶這個事,沒有用到動態規劃。

想要動態規劃,就得自底向上,利用快取將他們存起來,因為樹的節點的特殊性,陣列不太方便,所以可以藉助hashmap

一個 map 來 儲存對於每一個 key(Treenode),選擇他的情況下的value(最大值);
另一個map來儲存對於每一個 key(Treenode),不選擇他的情況下的value(最大值)。

class Solution {
    private HashMap<TreeNode, Integer> map1 = new HashMap();//選
    private HashMap<TreeNode, Integer> map2 = new HashMap();//不選

    public int rob(TreeNode root) {
        if(root==null)return 0;
        dfs(root);
        return Math.max( map1.getOrDefault(root,0), map2.getOrDefault(root,0));
    }

    public void dfs(TreeNode node){
        if( node == null )return;
        dfs(node.left);
        dfs(node.right);
        //選當前節點,那麼不選左右
        map1.put(node, node.val + map2.getOrDefault(node.left, 0) + map2.getOrDefault(node.right, 0));
        //不選當前節點,左右可選可不選,選大的
        map2.put(node, Math.max(map1.getOrDefault(node.left, 0),map2.getOrDefault(node.left, 0))
         + Math.max(map1.getOrDefault(node.right, 0), map2.getOrDefault(node.right, 0)));
    }
}

3.3 動態規劃空間優化

上面的過程我們能看出來,其實做的是後序遍歷,先求左右再求根,如此向上。

那麼傳遞到上一層的時候,不需要所有的前置值,用到的總共只有 4 個值,所以其實用臨時變數傳遞就可以,沒必要用整個hashmap,也沒必要對映。

這和把線性動態規劃的陣列優化為幾個值是一樣的做法。

class Solution {
    public int rob(TreeNode root) {
        if(root==null)return 0;
        int[] ans = dfs(root);
        return Math.max( ans[0],ans[1] );
    }

    //返回當前子樹選和不選能夠獲取的最大值,只用一個包含兩個元素的陣列
    public int[] dfs(TreeNode node){
        if( node == null )return new int[]{0,0};
        int[] l = dfs(node.left);
        int[] r = dfs(node.right);
        int[] temp = new int[2];
        //選當前節點,那麼不選左右
        temp[0] = node.val + l[1] + r[1];
        //不選當前節點,那麼左右求最大(可選可不選)
        temp[1] = Math.max(l[0],l[1])+Math.max(r[0],r[1]);
        return temp;
    }
}

打家劫舍問題,over。

相關文章