持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第10天,点击查看活动详情
题目链接:691. 贴纸拼词
题目描述
我们有 n
种不同的贴纸。每个贴纸上都有一个小写的英文单词。
您想要拼写出给定的字符串 target
,方法是从收集的贴纸中切割单个字母并重新排列它们。如果你愿意,你可以多次使用每个贴纸,每个贴纸的数量是无限的。
返回你需要拼出 target
的最小贴纸数量。如果任务不可能,则返回 -1
。
注意: 在所有的测试用例中,所有的单词都是从 1000
个最常见的美国英语单词中随机选择的,并且 target
被选择为两个随机单词的连接。
提示:
stickers[i]
和target
由小写英文单词组成
示例 1:
输入: stickers = ["with","example","science"], target = "thehat"
输出:3
解释:
我们可以使用 2 个 "with" 贴纸,和 1 个 "example" 贴纸。
把贴纸上的字母剪下来并重新排列后,就可以形成目标 “thehat“ 了。
此外,这是形成目标字符串所需的最小贴纸数量。
复制代码
示例 2:
输入:stickers = ["notice","possible"], target = "basicbasic"
输出:-1
解释:我们不能通过剪切给定贴纸的字母来形成目标“basicbasic”。
复制代码
整理题意
题目给定了一组单词 stickers
,定义为每个单词在一张贴纸上,每个单词贴纸有无数张,又给定了目标字符串 target
,现在要求利用这些单词贴纸,来拼凑这个字符串,要求返回使用贴纸最少的数量,如果不能拼凑成目标字符串 target
返回 -1
。
解题思路分析
首先观察题目数据范围,目标字符串 target
长度不超过 15
,看到这个数据范围,通常要联想到使用 状态压缩 ,所谓状态压缩也就是将状态采用整数的形式进行表示,比如这里我们需要表示目标字符串 target
的构造状态,对于每个字符有两种状态,已构造和未构造两种状态,所以可以采用二进制进行状态表示,该二进制整数有 15
位(target
长度),每一位对应目标字符串 target
中的字符的构造状态,0
表示未构造,1
表示已构造。因为
小于 int
型表示范围,所以可以直接采用 int
整数来表示状态,初始状态全为 0
(0000……0000
)表示都还未构造,目标状态全为 1
(1111……1111
)表示构造完成。

利用整数来表示状态就是状态压缩,同样我们还可以扩展到三进制、四进制等等
方法一:状态压缩 + 记忆化搜索
对于每个状态可以采用爆搜的方法进行判断所需的最少贴纸数量,但是考虑到爆搜在时间复杂度上面会超时 TLE
,通常会对搜索进行 记忆化 处理,这也就是所谓的 记忆化搜索 了。我们记录每个状态到构造完成所需的最少贴纸数,当再次到达相同状态时(也就是已经搜索过的状态),我们可以直接返回答案,而不需要再次搜索答案。
方法二:状态压缩 + 动态规划
对于每个状态可以在 n
个单词贴纸中选取一个贴纸转移至下一个状态,同样可以从状态 0
开始,遍历每一种状态所需的最小贴纸数量,直至目标状态。
优化
在记忆化搜索和动态规划中仍然存在重复操作,比如对于 贴纸1
和 贴纸2
来说,先选取 贴纸1
再选取 贴纸2
和先选取 贴纸2
再选取 贴纸1
最后到达的状态是一样的,也就是殊途同归的。我们不考虑选取的顺序,而是考虑选取包含当前状态缺少的字符贴纸。
对于每个状态中缺少的字符,我们希望能够快速从所有贴纸中找到包含当前缺少字符的贴纸,对所有单词贴纸进行预处理,得到一个二维不定长数组 vis
,vis[i]
中包含当前所需字母 i
的单词贴纸编号。
那么对于每个状态来说,我们只需找到当前状态第一个所缺少的是一个字母,通过字母找到对应包含该字母的所有贴纸进行遍历和转移状态,这样就避免了 1 -> 2
和 2 -> 1
殊途同归的问题了。
具体实现
方法一:状态压缩 + 记忆化搜索
- 首先初始化记忆数组,将每个状态记录为不可达状态(
-1
),将状态0
记录为0
,表示无需贴纸即可到达该状态。 - 从状态
0
出发进行搜索; - 对于每个状态进行判断是否为目标状态(停止搜索),是否已经搜索过(返回之前记录的答案)。
- 对于每个状态尝试使用
n
个贴纸进行状态转移,对下一个状态继续进行搜索,选取到达目标状态所需贴纸数最少的作为当前状态的答案。 - 返回当前状态所需最少贴纸数量并记录。
方法二:状态压缩 + 动态规划(带优化)
- 预处理从
a - z
每个字母被哪些贴纸所包含,记录贴纸编号。 - 创建并初始化
dp[i]
一维数组所有状态所需贴纸数为m + 1
(m
为target
长度),表示所有状态不可达,这是因为长度为m
的字符串最多需要m
张贴纸。并将dp[0]
初始化为0
表示无需贴纸。 - 遍历
[0, mask - 1)
左闭右开区间(mask == 1 << m
),因为mask - 1
为最终状态,无缺少字符,所以无需遍历。 - 查找当前状态下第一个缺少的字符,包含当前缺少字符的贴纸编号在预处理的时候已经处理过了,直接遍历即可。
- 通过字母数组哈希表记录每个贴纸上的字母个数,并对当前状态下所缺少的字符进行更新。
- 更新能够通过当前状态到达的下一个状态的最少贴纸数即可。
复杂度分析
方法一:状态压缩 + 记忆化搜索
- 时间复杂度:
,其中
m
为target
的长度,c
为每个sticker
的平均字符数。一共有 个状态。计算每个状态时,需要遍历n
个sticker
。遍历每个sticker
时,需要遍历它所有字符和target
所有字符。 - 空间复杂度: ,记忆化时需要保存每个状态的贴纸数量。
方法二:状态压缩 + 动态规划(带优化)
- 时间复杂度: ,由于预处理了贴纸编号,所以时间复杂度是远远小于理论值的。
- 空间复杂度: ,记忆化时需要保存每个状态的贴纸数量。
代码实现
方法一:状态压缩 + 记忆化搜索
class Solution {
private:
int n, m;
vector<int> vis;
vector<string> stickers;
string target;
int dfs(int state){
if(state == (1 << m) - 1) return 0;
if(vis[state] != -1) return vis[state];
//最多m张贴纸,因为target长度为m,所以设置m+1为最大值
int ans = m + 1;
for(int i = 0; i < n; i++){
int nxt = state;
int len = stickers[i].length();
for(int j = 0; j < len; j++){
char c = stickers[i][j];
for(int k = 0; k < m; k++){
//如果当前字符与target中字符相同且当前状态没有这个字符
if(c == target[k] && ((1 << k) & nxt) == 0){
nxt |= (1 << k);
break;
}
}
}
if(nxt != state) ans = min(ans, dfs(nxt) + 1);
}
//记录最小贴纸数同时返回最小贴纸数
return vis[state] = ans;
}
public:
int minStickers(vector<string>& sti, string tar) {
stickers = sti;
target = tar;
n = stickers.size();
m = target.length();
//vis数组记录当前状态state到达target所需最小贴纸数量,初始化为-1表示不可达
vis.resize(1 << m, -1);
//二进制表示target状态state,0表示没有,1表示有
int res = dfs(0);
return res == m + 1 ? -1 : res;
}
};
复制代码
方法二:状态压缩 + 动态规划(带优化)
class Solution {
public:
int minStickers(vector<string>& stickers, string target) {
int n = stickers.size();
int m = target.length();
//记录包含所需字母的贴纸序号
vector<vector<int>> vis(26);
for(int i = 0; i < 26; i++) vis[i].clear();
for(int i = 0; i < n; i++){
int len = stickers[i].length();
for(int j = 0; j < len; j++){
int c = stickers[i][j] - 'a';
//将当前贴纸序号压入对应字母列表中,保证不重不漏
if(vis[c].empty() || vis[c].back() != i){
vis[c].push_back(i);
}
}
}
//状态总数mask
int mask = 1 << m;
//初始化为m+1,因为最多m张贴纸即可完成
vector<int> dp(mask, m + 1);
//初始化状态 0 无需贴纸
dp[0] = 0;
//遍历每一种状态
for(int pre = 0; pre < mask - 1; pre++){
//无法通过状态0到达该状态
if(dp[pre] == m + 1) continue;
//寻找当前状态下第一个缺少的字符
int c = -1;
for(int i = 0; i < m; i++){
if((pre & (1 << i)) == 0){
c = i;
break;
}
}
//因为小于 mask - 1 所以 c 一定是可以找到缺少的字符
c = target[c] - 'a';
//遍历包含当前字母的贴纸序号
int sz = vis[c].size();
for(int i = 0; i < sz; i++){
int k = vis[c][i];
int len = stickers[k].length();
//字母数组哈希表记录贴纸上的字母个数
int num[26];
memset(num, 0, sizeof(num));
for(int j = 0; j < len; j++) num[stickers[k][j] - 'a']++;
//now 表示从状态 pre 出发通过该贴纸可以到达的状态
int now = pre;
for(int j = 0; j < m; j++){
//对比当前状态是否缺少该字符,以及当前贴纸是否有
if(((1 << j) & now) == 0 && num[target[j] - 'a'] > 0){
num[target[j] - 'a']--;
now |= (1 << j);
}
}
//更新答案
dp[now] = min(dp[now], dp[pre] + 1);
}
}
//如果不能达到状态mask - 1,dp[mask - 1]的值为-1
return dp[mask - 1] == m + 1 ? -1 : dp[mask - 1];
}
};
复制代码
总结
- 该题核心思想为 状态压缩 和 搜索 (
DFS
是 逆向搜索 的过程,动态规划是 正向搜索 的过程)。当我们看见数据范围较小(小于20
时),同时这道题目中有涉及到是否选取、是否使用这样的二元状态,那么这道题目很可能就是一道状态压缩的题目。 - 其次需要注意优化,考虑到选取贴纸顺序与最终状态无关紧要,只是殊途同归的问题,考虑如何优化顺序导致的重复状态成为难点。首先确定每次需要找的字符为第一个缺少的字符,其次考虑如何快速寻找包含缺少字符的贴纸编号,这里就自然能够想到预处理出每个字母被哪些贴纸包含。
- 优化前后的时间复杂度差异还是很明显的:
结束语
人生难免会面对各种烦恼,若一直耿耿于怀,只会让自己难受。何不把往事清零,学会轻装前行,放下烦恼,才能腾出手来拥抱当下的幸福。愿我们都能拥有好状态,去笑对生活中的风风雨雨。