【动态规划】压缩状态
状态压缩动态规划
状态压缩动态规划(BitMask DP),指的是一类使用二进制(也有使用三进制等的情形)数来表示动态规划中的状态的动态规划方法。其时间复杂度一般包含
力扣上涉及的题目如下:
序号 | 题目 |
---|---|
1 | 465. 最优账单平衡 |
2 | 464. 我能赢吗 |
3 | 691. 贴纸拼词 |
4 | 864. 获取所有钥匙的最短路径 |
应用
应用1:Leetcode.465
题目
给你一个表示交易的数组 transactions ,其中
示例 1:
输入:transactions = [[0,1,10],[2,0,5]]
输出:2
示例 2:
输入:transactions = [[0,1,10],[1,0,1],[1,2,5],[2,0,5]]
输出:1
解题思路
枚举子集
这道题,需要用到枚举子集的思路,具体的做法,就是用一个整数
for (int i = 1; i < (1 << n); ++i) { for (int j = i; j; j = (j - 1) & i) { // ... } }
其中,j = (j - 1) & i
表示
分析
首先将所有的交易数据做一次预处理,记录每一个用户的最终的账单
如果最终的账单金额为正,则表示收益,如果最终的账单金额为负,则表示负债。
在交易过程中,有些用户可能最终的账单为
对于剩下的账单金额不为零的用户,我们将其账单金额
例如,
我们可以将这些用户分为两组:
注意,这里
结论:也就是说,假如一个账单集合
这里,我们用二进制数
对于子集
其中,
表示异或运算, 表示 的补集。 表示集合 的二进制数的LSB。
因此,我们可以从小到大枚举
动态规划
我们假设
边界条件
容易得出,当集合中的元素个数为零时,交易次数为零,因此,边界条件:
状态转移
通过观察,可以总结出以下结论:
-
如果集合
中所有元素之和不为零,那么集合
所有用户无法通过交易平账,所以,我们将其置为正无穷,即 ; -
如果集合
中所有元素之和为零,那么集合
中所有元素,最多可以通过 次操作,完成平账。因此,我们可以通过枚举集合 所有的子集 及其对应的补集 ,找到它们的交易次数最少的一个组合,就是最优的交易次数,即
综上,状态转移方程:
其中,
因此,当集合中的元素个数为
代码
class Solution: def minTransfers(self, transactions: List[List[int]]) -> int: acounts = defaultdict(int) for transaction in transactions: acounts[transaction[0]] += transaction[2] acounts[transaction[1]] -= transaction[2] bill = list() for _, account in acounts.items(): if account: bill.append(account) n = len(bill) m = 1 << n # 总的状态数 _sum = [0] * m # 枚举所有的状态,计算当前分组的交易金额的总和 for i in range(1, m): # 枚举状态i的子集j for j in range(n): if i & (1 << j): _sum[i] = _sum[i ^ (1 << j)] + bill[j] # i ^ (1 << j) 表示补集 break dp = [0] * m # 枚举所有的状态 for i in range(1, m): if _sum[i]: dp[i] = float("INF") else: dp[i] = bin(i).count("1") - 1 # 从最低有效位开始枚举所有的子集,找到最少的交易次数 j = (i - 1) & i while j > 0: dp[i] = min(dp[i], dp[j] + dp[i ^ j]) j = (j - 1) & i return dp[m - 1]
应用2:Leetcode.464
题目
在 "100 game" 这个游戏中,两名玩家轮流选择从 1 到 10 的任意整数,累计整数和,先使得累计整数和 达到或超过 100 的玩家,即为胜者。如果我们将游戏规则改为 “玩家 不能 重复使用整数” 呢?
例如,两个玩家可以轮流从公共整数池中抽取从 1 到 15 的整数(不放回),直到累计整数和 >= 100。
给定两个整数 maxChoosableInteger (整数池中可选择的最大数)和 desiredTotal(累计和),若先出手的玩家能稳赢则返回 true ,否则返回 false 。假设两位玩家游戏时都表现 最佳 。
示例 1:
输入:maxChoosableInteger = 10, desiredTotal = 11
输出:false
解释:
无论第一个玩家选择哪个整数,他都会失败。
第一个玩家可以选择从 1 到 10 的整数。
如果第一个玩家选择 1,那么第二个玩家只能选择从 2 到 10 的整数。
第二个玩家可以通过选择整数 10(那么累积和为 11 >= desiredTotal),从而取得胜利.
同样地,第一个玩家选择任意其他整数,第二个玩家都会赢。
示例 2:
输入:maxChoosableInteger = 10, desiredTotal = 0
输出:true
解题思路
注意,题目中的累计整数和表示两位选手选择的数字之和,所以,我们只需要枚举所有的选择顺序。
状态压缩
假设最大可选择的数字为
由于
我们假设
我们采用自顶向下的方式枚举所有的状态,这里,我们定义一个函数:
boolean dfs(int n, int state, int desiredTotal, int currentTotal)
用于表示从未选择的数字中,选择一个数字,如果能获胜,则返回
算法的思路:
- 枚举所有先手玩家可能选择的数字,并用
记录每一个已经选择过的数字; - 对于每一个没有选择过的数字,判断对于当前玩家选择最优解是否能获胜,若选择的数字与当前的累计整数和相加大于
,则当前玩家一定能获胜; - 否则,继续判断对手从剩余数字中选择,选择一个数字,判断对手是否有最优解,如果对手不能赢,则先手玩家一定获胜;
由于遍历过程中,会存在很多重复状态,这里通过一个备忘录
代码
- Java实现
class Solution { private Map<Integer, Boolean> memory = new HashMap<>(); public boolean canIWin(int maxChoosableInteger, int desiredTotal) { if ((1 + maxChoosableInteger) * maxChoosableInteger / 2 < desiredTotal) { return false; } return dfs(maxChoosableInteger, 0, desiredTotal, 0); } private boolean dfs(int n, int state, int desiredTotal, int currentTotal) { if (!memory.containsKey(state)) { boolean result = false; // 枚举先手所有可能选择的数字 for (int i = 0; i < n; i++) { // 选择一个没有使用过的数字 if (((state >> i) & 1 ) == 0 ) { // 对于当前选手他会选择的最优解,如果累计和已经大于目标值,则当前选手能获胜 if (i + 1 + currentTotal >= desiredTotal) { result = true; break; } // 对手从选择剩余的数字中选择,是否会赢,如果对手不能赢,则返回先手获胜 if (!dfs(n, state | (1 << i), desiredTotal, currentTotal + i + 1)) { result = true; break; } } } memory.put(state, result); } return memory.get(state); } }
- Python实现
from functools import cache class Solution: def canIWin(self, maxChoosableInteger: int, desiredTotal: int) -> bool: @cache def dfs(usedNumbers: int, currentTotal: int) -> bool: for i in range(maxChoosableInteger): if (usedNumbers >> i) & 1 == 0: if currentTotal + i + 1 >= desiredTotal or not dfs(usedNumbers | (1 << i), currentTotal + i + 1): return True return False return (1 + maxChoosableInteger) * maxChoosableInteger // 2 >= desiredTotal and dfs(0, 0)
应用3:Leetcode.691
题目
解题思路
假设目标字符串
我们可以将目标字符串将其分解为子问题,即,拼凑出某个子序列
这里,我们定义一个函数:
int dp(String [] stickers, String target, int[] memory, int state)
用于表示不同状态所需要的最小贴纸数量,其中,
我们用
在计算过程中,我们需要选择最优的单词
我们假设初始状态为
对于贴纸上的每一个单词:
- 我们首先计算该单词
中每个字符出现的次数; - 遍历目标子序列中未使用过的字符,如果该字符在单词
中出现次数大于零,则将出现次数减一,并将当前状态 求补集; - 如果该单词
对目标子序列有贡献,则继续求解子问题;
代码实现
class Solution { public int minStickers(String[] stickers, String target) { int m = target.length(); int [] memory = new int[1 << m]; Arrays.fill(memory, -1); // 空字符串所需贴纸的数量为零 memory[0] = 0; int result = dp(stickers, target, memory, (1 << m) - 1); return result <= m ? result : -1; } private int dp(String [] stickers, String target, int[] memory, int state) { int m = target.length(); // 没有计算过的状态 if (memory[state] < 0){ int result = m + 1; for (String sticker : stickers) { int left = state; int[] count = new int[26]; for (int i = 0; i < sticker.length(); i++) { count[sticker.charAt(i) - 'a']++; } for (int i = 0; i < target.length(); i++) { char currentChar = target.charAt(i); if (((state >> i) & 1) == 1 && count[currentChar - 'a'] > 0) { count[currentChar - 'a']--; // 计算left的补集 left = left ^ (1 << i); } } // 如果left对目标子序列有贡献,则继续求解剩余的子序列 if (left < state) { result = Math.min(result, dp(stickers, target, memory, left) + 1); } } memory[state] = result; } return memory[state]; } }
应用3:Leetcode.864
题目
解题思路
我们使用一个整数
- 如果整数
的第 位为 ,则表示获取到第 把钥匙; - 如果整数
的第 位为 ,则表示未获取到第 把钥匙;
对于所有的钥匙序号,我们可以对矩阵中的数据做一个预处理,用一个
代码实现
from collections import deque from typing import List START = "@" EMPTY_ROOM = "." WALL = "#" DIRECTIONS = [(-1, 0), (1, 0), (0, -1), (0, 1)] class Solution: def shortestPathAllKeys(self, grid: List[str]) -> int: m, n = len(grid), len(grid[0]) start_x = start_y = 0 # 记录每一把钥匙的状态位 status = dict() count = 0 for i in range(m): for j in range(n): # 记录起点的位置 if grid[i][j] == START: start_x, start_y = i, j # 如果这个位置是钥匙,并且没有被访问过 elif grid[i][j].islower() and grid[i][j] not in status: status[grid[i][j]] = count # 记录该钥匙的状态位 count += 1 # 通过广度优先遍历,查找所有钥匙的最短路径,队列中保存每个位置及其状态,起始状态没有钥匙用0表示 q = deque([(start_x, start_y, 0)]) visit = dict() # 记录每个点的步数 visit[(start_x, start_y, 0)] = 0 while q: _x, _y, mask = q.popleft() for direction in DIRECTIONS: # 从起点开始遍历邻近的节点 x, y = _x + direction[0], _y + direction[1] # 只要下一个点在网格内,并且不是墙,就可以移动 if 0 <= x < m and 0 <= y < n and grid[x][y] != WALL: # 如果当前位置是空房间或者起点 if grid[x][y] == EMPTY_ROOM or grid[x][y] == START: # 并且未被访问过,就将这个位置的步数加1,并将其加入队列中 if (x, y, mask) not in visit: visit[(x, y, mask)] = visit[(_x, _y, mask)] + 1 q.append((x, y, mask)) # 如果当前位置是钥匙 elif grid[x][y].islower(): # 钥匙对应的状态位就是index index = status[grid[x][y]] if (x, y, mask | (1 << index)) not in visit: visit[(x, y, mask | (1 << index))] = visit[(_x, _y, mask)] + 1 # 当所有的二进制位都为1时,表示钥匙收集齐了 if (mask | (1 << index)) == (1 << len(status)) - 1: return visit[(x, y, mask | (1 << index))] q.append((x, y, mask | (1 << index))) # 如果当前位置是锁,对应的锁就是grid[x][y].lower() else: # 不可能出现的场景:两次经过了某个房间,并且这两次我们拥有钥匙的情况是完全一致的 if (x, y, mask) in visit: continue # 钥匙对应的状态位就是index index = status[grid[x][y].lower()] # 该状态位必须是1,即遍历的路径上已经拾取了对应的钥匙,才能通过该位置 if mask & (1 << index): # 将该位置新的状态对应的步数加1,并将其放入队列 visit[(x, y, mask)] = visit[(_x, _y, mask)] + 1 q.append((x, y, mask)) return -1
应用4:Leetcode.1494
题目
给你一个整数 n 表示某所大学里课程的数目,编号为 1 到 n ,数组 relations 中, relations[i] = [xi, yi] 表示一个先修课的关系,也就是课程 xi 必须在课程 yi 之前上。同时你还有一个整数 k 。
在一个学期中,你 最多 可以同时上 k 门课,前提是这些课的先修课在之前的学期里已经上过了。请你返回上完所有课最少需要多少个学期。题目保证一定存在一种上完所有课的方式。
示例 1:
输入:n = 4, relations = [[2,1],[3,1],[1,4]], k = 2
输出:3
解释:上图展示了题目输入的图。在第一个学期中,我们可以上课程 2 和课程 3 。然后第二个学期上课程 1 ,第三个学期上课程 4 。
解题思路
注意,题目中的条件,在上过某些课程的前提下,选出满足约束条件的课程,在本学期可以上的课程需要满足:
-
课程之前没上过;
-
课程的先修课已经全部都上完了。
以题目中的用例为例,假设有 5 门课程,编号为:
我们用一个整数
设全集
同时,假设
这里,为了方便状态表示,我们重新对课程进行编号,从
边界条件
需要上的课程数为零时,不需要任何学期就可以完成,所以,边界条件为:
状态转移
对于每一个课程及其先修课程,我们使用一个二进制的比特位来记录其状态,因此,其状态转移方程为:
其中,
对于状态
为了方便计算,我们可以从
此时,状态转移方程 1 等价于:
状态转移过程:
-
我们考虑从小到大枚举集合
的非空子集 ,作为一个学期内要学完的课程;注意,集合
中的课程数不能超过每学期最多可以上的课程数 ,即这里 中的所有元素的先修课程必须都在 的补集 中,表示前面学期已经学完了 中的所有课程的先修课,即 。 -
当
满足如下条件时,我们就可以学习课程 :-
的大小不能超过每学期最多可以上的课程数 ; -
剩下课程集合
为可以在有限的学期内完成学习;即
。 -
剩下课程集合
中不存在 的先修课程。即
。
-
-
否则,
仍然为 。
因此,我们可以从小到大枚举每一个状态
代码实现
from typing import List class Solution: def minNumberOfSemesters(self, n: int, relations: List[List[int]], k: int) -> int: max_state = (1 << n) dp = [float("INF")] * max_state # prerequisite[1] = 0110 表示1的先修课为2和3 prerequisite = [0] * max_state for relation in relations: prerequisite[(1 << (relation[1] - 1))] |= 1 << (relation[0] - 1) dp[0] = 0 # taken表示已经上过的课,假设taken = 0111,表示课程1 2 3已经上过了 for taken in range(1, max_state): prerequisite[taken] = prerequisite[taken & (taken - 1)] | prerequisite[taken & (-taken)] # taken 中有任意一门课程的前置课程没有完成学习 if (prerequisite[taken] | taken) != taken: continue # 当前学期可以进行学习的课程集合 current = taken ^ prerequisite[taken] # 如果个数小于 k,则可以全部学习,不再枚举子集 if current.bit_count() <= k: dp[taken] = min(dp[taken], dp[taken ^ current] + 1) else: # 枚举子集 sub_mask = current while sub_mask: if sub_mask.bit_count() <= k: dp[taken] = min(dp[taken], dp[taken ^ sub_mask] + 1) sub_mask = (sub_mask - 1) & current return int(dp[-1])
参考:
本文作者:LARRY1024
本文链接:https://www.cnblogs.com/larry1024/p/17161618.html
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步