https://leetcode-cn.com/probl...java
在 "100 game" 這個遊戲中,兩名玩家輪流選擇從 1 到 10 的任意整數,累計整數和,先使得累計整數和達到或超過 100 的玩家,即爲勝者。 若是咱們將遊戲規則改成 「玩家不能重複使用整數」 呢? 例如,兩個玩家能夠輪流從公共整數池中抽取從 1 到 15 的整數(不放回),直到累計整數和 >= 100。 給定一個整數 maxChoosableInteger (整數池中可選擇的最大數)和另外一個整數 desiredTotal(累計和),判斷先出手的玩家是否能穩贏(假設兩位玩家遊戲時都表現最佳)? 你能夠假設 maxChoosableInteger 不會大於 20, desiredTotal 不會大於 300。 示例: 輸入: maxChoosableInteger = 10 desiredTotal = 11 輸出: false 解釋: 不管第一個玩家選擇哪一個整數,他都會失敗。 第一個玩家能夠選擇從 1 到 10 的整數。 若是第一個玩家選擇 1,那麼第二個玩家只能選擇從 2 到 10 的整數。 第二個玩家能夠經過選擇整數 10(那麼累積和爲 11 >= desiredTotal),從而取得勝利. 一樣地,第一個玩家選擇任意其餘整數,第二個玩家都會贏。
題目的函數簽名以下:python
def canIWin(self, maxChoosableInteger: int, desiredTotal: int) -> bool:
即給你兩個整數 maxChoosableInteger 和 desiredTotal,讓你返回一個布爾值。git
首先考慮兩種特殊狀況,後面全部的解法這兩種特殊狀況都適用,所以再也不贅述。github
考慮完了特殊狀況,咱們繼續思考通常狀況。面試
首先咱們來簡化一下問題, 若是數字能夠隨便選呢?這個問題就簡單多了,和爬樓梯沒啥區別。這裏考慮暴力求解,使用 DFS + 模擬的方式來解決。算法
注意到每次可選的數字都不變,都是 [1, maxChoosableInteger] ,所以無需經過參數傳遞。或者你想傳遞的話,把引用往下傳也是能夠的。api
這裏的 [1, maxChoosableInteger] 指的是一個左右閉合的區間。
爲了方便你們理解,我畫了一個邏輯樹:數組
接下來,咱們寫代碼遍歷這棵樹便可。緩存
可重複選的暴力核心代碼以下:數據結構
class Solution: def canIWin(self, maxChoosableInteger: int, desiredTotal: int) -> bool: # acc 表示當前累計的數字和 def dfs(acc): if acc >= desiredTotal: return False for n in range(1, maxChoosableInteger + 1): # 對方有一種狀況贏不了,我就選這個數字就能贏了,返回 true,表明能夠贏。 if not backtrack(acc + n): return True return False # 初始化集合,用於保存當前已經選擇過的數。 return dfs(0)
上面代碼已經很清晰了,而且加了註釋,我就很少解釋了。咱們繼續來看下若是數字不容許重複選 會怎麼樣?
一個直觀的思路是使用 set 記錄已經被取的數字。當選數字的時候,若是是在 set 中則不取便可。因爲可選數字在動態變化。也就是說上面的邏輯樹部分,每一個樹節點的可選數字都是不一樣的。
那怎麼辦呢?很簡單,經過參數傳遞唄。並且:
若是使用值傳遞,對應是這樣的:
若是在每次遞歸返回的是時候主動回溯狀態,對應是這樣的:
注意圖上的藍色的新增的線,他們表示遞歸返回的過程。咱們須要在返回的過程撤銷選擇。好比我選了數組 2, 遞歸返回的時候再把數字 2 從 set 中移除。
簡單對比下兩種方法。
這裏我用了第二種方式 - 狀態回溯。和上面代碼沒有太大的區別,只是加了一個 set 而已,惟一須要注意的是須要在回溯過程恢復狀態(picked.remove(n))。
代碼支持:Python3
Python3 Code:
class Solution: def canIWin(self, maxChoosableInteger: int, desiredTotal: int) -> bool: if desiredTotal <= maxChoosableInteger: return True if sum(range(maxChoosableInteger + 1)) < desiredTotal: return False # picked 用於保存當前已經選擇過的數。 # acc 表示當前累計的數字和 def backtrack(picked, acc): if acc >= desiredTotal: return False if len(picked) == maxChoosableInteger: # 說明所有都被選了,沒得選了,返回 False, 表明輸了。 return False for n in range(1, maxChoosableInteger + 1): if n not in picked: picked.add(n) # 對方有一種狀況贏不了,我就選這個數字就能贏了,返回 true,表明能夠贏。 if not backtrack(picked, acc + n): picked.remove(n) return True picked.remove(n) return False # 初始化集合,用於保存當前已經選擇過的數。 return backtrack(set(), 0)
有的同窗可能會問, 爲何不使用記憶化遞歸?這樣能夠有效減小邏輯樹的節點數,從指數級降低到多項式級。這裏的緣由在於 set 是不可直接序列化的,所以不可直接存儲到諸如哈希表這樣的數據結構。
而若是你本身寫序列化,好比最粗糙的將 set 轉換爲字符串或者元祖存。看起來可行,set 是 ordered 的,所以若是想正確序列化還須要排序。固然你可用一個 orderedhashset,不過效率依然很差,感興趣的能夠研究一下。
以下圖,兩個 set 應該同樣,可是遍歷的結果順序可能不一樣,若是不排序就可能有錯誤。
至此,問題的關鍵基本上鎖定爲找到一個能夠序列化且容量大大減小的數據結構來存是否是就可行了?
注意到 maxChoosableInteger 不會大於 20 這是一個強有力的提示。因爲 20 是一個不大於 32 的數字, 所以這道題頗有可能和狀態壓縮有關,好比用 4 個字節存儲狀態。力扣相關的題目還有很多, 具體你們可參考文末的相關題目。
咱們能夠將狀態進行壓縮,使用位來模擬。實際上使用狀態壓縮和上面思路如出一轍,只是 API 不同罷了。
假如咱們使用的這個用來代替 set 的數字名稱爲 picked。
好比咱們剛纔用了集合,用到的集合 api 有:
那咱們其實就用位來模擬實現這三個 api 就罷了。詳細可參考個人這篇題解 - 面試題 01.01. 斷定字符是否惟一
這個不難。 好比我要模擬 picked.add(n),只要將 picked 第 n 爲置爲 1 就行。也就是說 1 表示在集合中,0 表示不在。
使用或運算和位移運算能夠很好的完成這個需求。
位移運算
1 << a
指的是 1 的二進制表示全體左移 a 位, 右移也是同理
| 操做
a | b
指的是 a 和 b 每一位都進行或運算的結構。 常見的用法是 a 和 b 其中一個當成是 seen。 這樣就能夠當二值數組和哈希表用了。 好比:
seen = 0b0000000 a = 0b0000001 b = ob0000010 seen |= a 後, seen 爲 0b0000001 seen |= b 後, seen 爲 0b0000011
這樣我就能夠知道 a 和 b 出現過了。 固然 a , b 以及其餘你須要統計的數字只能用一位。 典型的是題目只須要存 26 個字母,那麼一個 int( 32 bit) 足夠了。 若是是包括大寫,那就是 52, 就須要至少 52 bit。
有了上面的鋪墊就簡單了。好比要模擬 n in picked。那隻要判斷 picked 的第 n 位是 0 仍是 1 就好了。若是是 0 表示不在 picked 中,若是是 1 表示在 picked 中。
用或運算和位移運算能夠很好的完成這個需求。
& 操做
a & b
指的是 a 和 b 每一位都進行與運算的結構。 常見的用法是 a 和 b 其中一個是 mask。 這樣就能夠得指定位是 0 仍是 1 了。 好比:
mask = 0b0000010 a & mask == 1 說明 a 在第二位(從低到高)是 1 a & mask == 0 說明 a 在第二位(從低到高)是 0
其實只要逐個 bit 比對,若是當前 bit 是 1 則計數器 + 1,最後返回計數器的值便可。
這沒有問題。而實際上,咱們只關心集合大小是否等於 maxChoosableInteger。也就是我只關心第 maxChoosableInteger 位以及低於 maxChoosableInteger 的位是否所有是 1。
這就簡單了,咱們只須要將 1 左移 maxChoosableInteger + 1 位再減去 1 便可。一行代碼搞定:
picked == (1 << (maxChoosableInteger + 1)) - 1
上面代碼返回 true 表示滿了, 不然沒滿。
至此你們應該感覺到了,使用位來代替 set 思路上沒有任何區別。不一樣的僅僅是 API 而已。若是你只會使用 set 不會使用位運算進行狀態壓縮,只能說明你對位 的 api 不熟而已。多練習幾道就好了,文末我列舉了幾道相似的題目,你們不要錯過哦~
代碼支持:Java,CPP,Python3,JS
Java Code:
public class Solution { public boolean canIWin(int maxChoosableInteger, int desiredTotal) { if (maxChoosableInteger >= desiredTotal) return true; if ((1 + maxChoosableInteger) * maxChoosableInteger / 2 < desiredTotal) return false; Boolean[] dp = new Boolean[(1 << maxChoosableInteger) - 1]; return dfs(maxChoosableInteger, desiredTotal, 0, dp); } private boolean dfs(int maxChoosableInteger, int desiredTotal, int state, Boolean[] dp) { if (dp[state] != null) return dp[state]; for (int i = 1; i <= maxChoosableInteger; i++){ int tmp = (1 << (i - 1)); if ((tmp & state) == 0){ if (desiredTotal - i <= 0 || !dfs(maxChoosableInteger, desiredTotal - i, tmp|state, dp)) { dp[state] = true; return true; } } } dp[state] = false; return false; } }
C++ Code:
class Solution { public: bool canIWin(int maxChoosableInteger, int desiredTotal) { int sum = (1+maxChoosableInteger)*maxChoosableInteger/2; if(sum < desiredTotal){ return false; } unordered_map<int,int> d; return dfs(maxChoosableInteger,0,desiredTotal,0,d); } bool dfs(int n,int s,int t,int S,unordered_map<int,int>& d){ if(d[S]) return d[S]; int& ans = d[S]; if(s >= t){ return ans = true; } if(S == (((1 << n)-1) << 1)){ return ans = false; } for(int m = 1;m <=n;++m){ if(S & (1 << m)){ continue; } int nextS = S|(1 << m); if(s+m >= t){ return ans = true; } bool r1 = dfs(n,s+m,t,nextS,d); if(!r1){ return ans = true; } } return ans = false; } };
Python3 Code:
class Solution: def canIWin(self, maxChoosableInteger: int, desiredTotal: int) -> bool: if desiredTotal <= maxChoosableInteger: return True if sum(range(maxChoosableInteger + 1)) < desiredTotal: return False @lru_cache(None) def dp(picked, acc): if acc >= desiredTotal: return False if picked == (1 << (maxChoosableInteger + 1)) - 1: return False for n in range(1, maxChoosableInteger + 1): if picked & 1 << n == 0: if not dp(picked | 1 << n, acc + n): return True return False return dp(0, 0)
JS Code:
var canIWin = function (maxChoosableInteger, desiredTotal) { // 直接獲勝 if (maxChoosableInteger >= desiredTotal) return true; // 所有拿完也沒法到達 var sum = (maxChoosableInteger * (maxChoosableInteger + 1)) / 2; if (desiredTotal > sum) return false; // 記憶化 var dp = {}; /** * @param {number} total 剩餘的數量 * @param {number} state 使用二進制位表示抽過的狀態 */ function f(total, state) { // 有緩存 if (dp[state] !== undefined) return dp[state]; for (var i = 1; i <= maxChoosableInteger; i++) { var curr = 1 << i; // 已經抽過這個數 if (curr & state) continue; // 直接獲勝 if (i >= total) return (dp[state] = true); // 可讓對方輸 if (!f(total - i, state | curr)) return (dp[state] = true); } // 沒有任何讓對方輸的方法 return (dp[state] = false); } return f(desiredTotal, 0); };
你們對此有何見解,歡迎給我留言,我有時間都會一一查看回答。更多算法套路能夠訪問個人 LeetCode 題解倉庫:https://github.com/azl3979858... 。 目前已經 37K star 啦。你們也能夠關注個人公衆號《力扣加加》帶你啃下算法這塊硬骨頭。