# LeetCode 691. 贴纸拼词
参考视频:https://www.acwing.com/video/2516/
我们有 n 种不同的贴纸。每个贴纸上都有一个小写的英文单词。
您想要拼写出给定的字符串 target ,方法是从收集的贴纸中切割单个字母并重新排列它们。如果你愿意,你可以多次使用每个贴纸,每个贴纸的数量是无限的。
返回你需要拼出 target 的最小贴纸数量。如果任务不可能,则返回 -1 。
注意:在所有的测试用例中,所有的单词都是从 1000 个最常见的美国英语单词中随机选择的,并且 target 被选择为两个随机单词的连接。
示例 1:
输入: stickers = ["with","example","science"], target = "thehat"
输出:3
解释:
我们可以使用 2 个 "with" 贴纸,和 1 个 "example" 贴纸。
把贴纸上的字母剪下来并重新排列后,就可以形成目标 “thehat“ 了。
此外,这是形成目标字符串所需的最小贴纸数量。示例 2:
输入:stickers = ["notice","possible"], target = "basicbasic"
输出:-1
解释:我们不能通过剪切给定贴纸的字母来形成目标 “basicbasic”。提示:
n == stickers.length
1 <= n <= 50
1 <= stickers[i].length <= 10
1 <= target <= 15
stickers [i] 和 target 由小写英文单词组成
# 题意及分析
# 题意
本题的题意是比较好理解的。
给出一些单词,以及一个目标单词,用最少的给出单词,拼出这个目标单词,规则是可以任意使用每个给出单词中的字母,并且每个给出单词也是可以无限使用的。(参考示例 1)
# 思路
大体上的思路肯定非常好想到,但是本题的难点在于实现。
看数据范围,target 最大才 15 个字母,stickers 最大才 50 个单词,因此在数据范围的提示下很容易想到状态压缩暴力搜索。
大体思路:枚举 stickers 中每个单词,枚举单词中的每个字母,把字母填充到 target,每次都更新 得到当前状态 使用了多少单词,这样当我们 枚举 target 到最后状态的时候,我们也就知道 得到最后状态 使用了多少单词。
具体地:
将 target 的二进制位中的每一位是否为 1, 表示为是否填充,也就是说当 target 中的二进制位全是 1 时候,我们认为已经成功匹配了 target。比如:当前 target 的长度是 4,那么,当 target 长度的二进制位都是 1,也就是 1111 的时候,我们认为 target 已经找到,target 的二进制位初始化为 0。
通过填充每个单词中的每个字母来更新状态。比如:示例 1 中的单词 "with" 中的字母 "t" 匹配到了 target 中的字母 "t",我们就认为 target 中的 “t” 的位置已经被填充了(即,那个位置的的二进制位变成 1)。
状态定义,使用 f (state) 表示当前状态 所需要的最小单词数量。
累了,提莫的,随便写写不应该写这么详细,也不知道该咋写了,直接看代码注释吧。
# Code
const int N = 1 << 15; | |
int f[N], g[N][26]; | |
class Solution { | |
public: | |
int n; | |
string target; | |
vector<string> strs; | |
//target 最大 15. 也就是最多使用 15 个单词,每个单词出一个字母。 | |
// 所以极限值定为 20。 | |
const int INF = 20; | |
// 将 字符 c 填充到状态 state | |
int fill(int state, char c) { | |
//v 表示状态 state 中添加了一个字母 c 之后的状态 | |
// 为什么需要 g? 因为可能 不同的单词中有相同的字母 | |
// 遇到这种情况,可以直接返回,也就是下一步的剪枝。 | |
// 比如:当前状态 1101 遇到了字母 a, | |
// 而在枚举下一个单词时候 1101 又遇到了 a, | |
// 如果此时已经更新 (g [1101][a] 不等于初始值 - 1) 则直接返回即可。 | |
auto& v = g[state][c - 'a']; | |
if (v != -1) return v; // 剪枝。 | |
v = state; | |
// 枚举 target 的每一位。 | |
// (注意 target 的最大值也才 15,所以虽然 fill 操作频繁 但这一步并不慢) | |
for (int i = 0; i < n; i ++ ) { | |
//state >> i & 1 表示把 state 的二进制位的第 i 位拿出来 | |
// 因此,这个 if 表示如果状态 state 的第 i 位不是 1(或者说是 0) | |
// 那么就判断,当前字符 c 是不是和它一样,如果一样就填进去。 | |
if (!(state >> i & 1) && target[i] == c) { | |
// 把 v 的第 i 位变成 1,v 变成新的状态 | |
v += 1 << i; | |
break; // 已经把 c 填充到 state 了 所以直接 break 就行 | |
} | |
} | |
return v; | |
} | |
// 爆搜 | |
int dfs(int state) { | |
//v 表示当前状态下需要添加的单词数量 | |
auto& v = f[state]; | |
// 剪枝。如果 v 已经被计算过则直接返回 | |
if (v != -1) return v; | |
// 如果状态为全 1, 说明搜索完毕,不需要添加任何单词了 | |
if (state == (1 << n) - 1) return v = 0; | |
// 把 v 置为较大的数,如果最后 v 仍然是 INF,说明 v 从没真正有效更新过 | |
v = INF; | |
// 枚举每个贴纸,也就是每个单词 | |
for (auto& str: strs) { | |
int cur = state; //cur 临时保存当前状态。 | |
// 用单词的每个字母去填充当前状态。 | |
for (auto c: str) cur = fill(cur, c); | |
// 填充完 如果和 填充前 不一样,说明状态有更新 | |
// 并且因为 目标是 最少使用的贴纸数量,所以这里用 min | |
// cout << state << ' '; | |
if (cur != state) { | |
v = min(v, dfs(cur) + 1); | |
// cout << v << ' '; | |
} | |
} | |
return v; | |
} | |
int minStickers(vector<string>& stickers, string _target) { | |
memset(f, -1, sizeof f); | |
memset(g, -1, sizeof g); | |
target = _target; | |
strs = stickers; | |
n = target.size(); | |
int res = dfs(0); | |
// 如果所有的贴纸都不能满足目标,则返回 - 1; | |
if (res == INF) res = -1; | |
return res; | |
} | |
}; |
# 总结
状态压缩问题与位运算强相关,所以位运算方面的东西至少要了解常用操作,本题才好理解,比如:抠出第 i 位数等。
关于本题还有两个问题想谈一下,是我卡了一会才想明白的:
是如何确定 target 被完全搜索出来的呢?(或者说是什么时候返回 - 1 的呢?或者说是什么时候 res 能等于 INF 呢?)
答:注意注释中有一段是 “状态全为 1,表示搜索完毕” 那块,在它的下面我们每次 dfs 都把 v 变成了 INF,也就是说,只有最后进行 “不明显回溯” 的时候,v 的值才会被真的更新。而什么时候触发 “不明显回溯” 呢?就是当状态全为 1 的时候,我们才把 v 变成 0 开始回溯。
这也保证了,只有 target 全部被搜索出来,min 的比较才有意义,不然全部都是 min (INF, INF)。
只有当状态全为 1,开始回溯了,才会变成
min(INF, 1),min(INF, 2)....
这种比较。关于 “回溯”、“不明显回溯” 如果不太明白,或者上这段解释不懂,我建议先看这篇文章,否则,即便我个人觉得上面这段解释已经比较清楚了,您还是可能看不懂。
为什么在填充字符 c 到 state 的时候,也就是 fill 函数,当找到第一个符合条件的空位就填进去呢?不用考虑后面其他符合条件的吗?
答:不用的,我们的爆搜 dfs 函数,每次都搜索了所有的单词,也就是当前单词,下一次搜索还会重复搜到,所以如果后面仍有符合条件的,还会被填充进去的,不会漏掉。
还想写啥来着,我忘记了,不磨叽了,就这样吧~~
END