1. 问题背景与题目解析
LeetCode 691题"贴纸拼词"是一个经典的动态规划与状态压缩结合的算法题目。题目要求我们使用给定的贴纸集合(每个贴纸是一个字符串数组)拼出目标单词,且每个贴纸可以无限次使用。我们需要找出拼出目标单词所需的最少贴纸数量。
这个问题的实际应用场景很广泛,比如在拼图游戏、文字处理系统或者教育资源开发中都会遇到类似的字符串组合问题。题目给出的函数签名是:
java复制public int minStickers(String[] stickers, String target)
其中stickers数组代表可用的贴纸集合,target是我们需要拼出的目标单词。函数需要返回拼出目标单词所需的最少贴纸数量,如果无法拼出则返回-1。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 问题分析与解题思路
2.1 问题难点剖析
这道题看似简单,但实际上有几个关键难点:
- 贴纸使用顺序不影响结果:使用贴纸A再贴纸B,与先B后A效果相同,这会导致大量重复计算
- 贴纸可以无限次使用:同一个贴纸可以被多次使用,增加了搜索空间
- 目标字符串的字符顺序不重要:只需要字符数量匹配,不考虑顺序
- 部分匹配情况:贴纸可能只匹配目标字符串的一部分字符
2.2 核心解题思路
基于上述难点,我们可以采用以下策略:
- 状态表示:使用字符计数数组来表示目标字符串的剩余部分
- 广度优先搜索(BFS):将问题转化为状态空间搜索问题
- 记忆化:避免重复计算相同状态
- 贪心剪枝:优先尝试可能带来更大进展的贴纸
3. 详细解法与代码实现
3.1 预处理阶段
首先我们需要对输入数据进行预处理:
java复制// 统计目标字符串的字符频率
int[] targetCount = new int[26];
for (char c : target.toCharArray()) {
targetCount[c - 'a']++;
}
// 预处理每个贴纸的字符频率
int[][] stickerCounts = new int[stickers.length][26];
for (int i = 0; i < stickers.length; i++) {
for (char c : stickers[i].toCharArray()) {
stickerCounts[i][c - 'a']++;
}
}
3.2 BFS与状态压缩实现
使用BFS来探索从初始状态到目标状态的最短路径:
java复制Queue<int[]> queue = new LinkedList<>();
Set<String> visited = new HashSet<>();
// 初始状态是完整的targetCount
queue.offer(targetCount);
visited.add(Arrays.toString(targetCount));
int level = 0;
while (!queue.isEmpty()) {
int size = queue.size();
for (int i = 0; i < size; i++) {
int[] current = queue.poll();
if (isEmpty(current)) {
return level;
}
// 尝试每一个贴纸
for (int[] sticker : stickerCounts) {
int[] next = current.clone();
// 应用贴纸
for (int j = 0; j < 26; j++) {
if (sticker[j] > 0 && next[j] > 0) {
next[j] = Math.max(0, next[j] - sticker[j]);
}
}
String nextStr = Arrays.toString(next);
if (!visited.contains(nextStr)) {
visited.add(nextStr);
queue.offer(next);
}
}
}
level++;
}
return -1;
3.3 辅助函数实现
判断当前状态是否已经完成(所有字符计数为0):
java复制private boolean isEmpty(int[] count) {
for (int num : count) {
if (num > 0) return false;
}
return true;
}
4. 算法优化与性能分析
4.1 剪枝优化策略
原始BFS方法在最坏情况下性能不佳,我们可以添加以下优化:
- 贴纸筛选:只保留包含目标字符串中至少一个字符的贴纸
- 贪心选择:优先尝试能覆盖最多剩余字符的贴纸
- 状态压缩:使用更高效的状态表示方法减少内存使用
优化后的贴纸预处理:
java复制List<int[]> usefulStickers = new ArrayList<>();
for (int[] sticker : stickerCounts) {
boolean useful = false;
for (int i = 0; i < 26; i++) {
if (sticker[i] > 0 && targetCount[i] > 0) {
useful = true;
break;
}
}
if (useful) {
usefulStickers.add(sticker);
}
}
4.2 时间复杂度分析
设目标字符串长度为n,贴纸数量为m:
- 最坏情况下时间复杂度:O(m^n)
- 优化后平均时间复杂度:O(m^(n/2))
- 空间复杂度:O(m^n)(由状态数量决定)
5. 实际应用与变种问题
5.1 实际应用场景
- 教育资源开发:设计拼字游戏或识字软件
- 广告文案生成:从给定的关键词集合中组合出目标文案
- 密码破解:从字符片段中尝试组合出可能的密码
5.2 常见变种问题
- 限制贴纸使用次数:每个贴纸只能使用有限次
- 考虑贴纸成本:不同贴纸有不同的使用成本,求最小总成本
- 部分匹配得分:即使不能完全匹配,也能根据匹配程度获得分数
6. 常见错误与调试技巧
6.1 常见实现错误
- 忽略贴纸重复使用:错误地认为每个贴纸只能使用一次
- 状态表示不当:使用字符串直接比较而非字符计数
- BFS层级计数错误:在错误的位置增加level计数器
6.2 调试技巧
- 打印中间状态:在每次状态转换时打印当前状态
- 小规模测试:先用简单例子验证算法正确性
- 边界测试:测试空目标、空贴纸集等特殊情况
提示:在实现时,可以先用小规模数据手动模拟算法过程,确保对状态转换的理解正确。
7. 完整代码示例
以下是经过优化的完整Java实现:
java复制import java.util.*;
public class Solution {
public int minStickers(String[] stickers, String target) {
int[] targetCount = new int[26];
for (char c : target.toCharArray()) {
targetCount[c - 'a']++;
}
// 预处理有用的贴纸
List<int[]> usefulStickers = new ArrayList<>();
for (String sticker : stickers) {
int[] stickerCount = new int[26];
for (char c : sticker.toCharArray()) {
stickerCount[c - 'a']++;
}
usefulStickers.add(stickerCount);
}
// 进一步优化:移除完全被其他贴纸覆盖的贴纸
usefulStickers = optimizeStickers(usefulStickers, targetCount);
Queue<int[]> queue = new LinkedList<>();
Set<String> visited = new HashSet<>();
queue.offer(targetCount);
visited.add(serialize(targetCount));
int level = 0;
while (!queue.isEmpty()) {
int size = queue.size();
for (int i = 0; i < size; i++) {
int[] current = queue.poll();
if (isEmpty(current)) {
return level;
}
// 尝试每个有用的贴纸
for (int[] sticker : usefulStickers) {
int[] next = applySticker(current, sticker);
String nextStr = serialize(next);
if (!visited.contains(nextStr)) {
visited.add(nextStr);
queue.offer(next);
}
}
}
level++;
}
return -1;
}
private List<int[]> optimizeStickers(List<int[]> stickers, int[] target) {
List<int[]> optimized = new ArrayList<>();
for (int[] sticker : stickers) {
boolean isUseful = false;
for (int i = 0; i < 26; i++) {
if (sticker[i] > 0 && target[i] > 0) {
isUseful = true;
break;
}
}
if (isUseful) {
optimized.add(sticker);
}
}
return optimized;
}
private int[] applySticker(int[] current, int[] sticker) {
int[] next = Arrays.copyOf(current, current.length);
for (int i = 0; i < 26; i++) {
if (sticker[i] > 0 && next[i] > 0) {
next[i] = Math.max(0, next[i] - sticker[i]);
}
}
return next;
}
private boolean isEmpty(int[] count) {
for (int num : count) {
if (num > 0) return false;
}
return true;
}
private String serialize(int[] count) {
StringBuilder sb = new StringBuilder();
for (int num : count) {
sb.append(num).append(",");
}
return sb.toString();
}
}
8. 性能对比与进一步优化
8.1 不同实现方式的性能对比
- 纯DFS回溯:最直观但性能最差,容易超时
- BFS基础实现:比DFS好,但仍有优化空间
- BFS+剪枝:性能显著提升
- 双向BFS:进一步减少搜索空间
8.2 双向BFS实现思路
双向BFS从初始状态和目标状态同时开始搜索:
java复制// 初始化两个队列和访问集合
Queue<int[]> queueStart = new LinkedList<>();
Queue<int[]> queueEnd = new LinkedList<>();
Map<String, Integer> visitedStart = new HashMap<>();
Map<String, Integer> visitedEnd = new HashMap<>();
// 开始状态是完整的targetCount
queueStart.offer(targetCount);
visitedStart.put(serialize(targetCount), 0);
// 结束状态是全零数组
int[] endState = new int[26];
queueEnd.offer(endState);
visitedEnd.put(serialize(endState), 0);
while (!queueStart.isEmpty() && !queueEnd.isEmpty()) {
// 交替从两个方向扩展
int result = expand(queueStart, visitedStart, visitedEnd);
if (result != -1) return result;
result = expand(queueEnd, visitedEnd, visitedStart);
if (result != -1) return result;
}
return -1;
8.3 其他优化方向
- 启发式搜索:使用优先队列,优先扩展更有希望的状态
- 并行搜索:利用多线程同时探索多个路径
- 预处理目标字符:提前排除不可能的情况
9. 语言特性与实现差异
9.1 Java实现特点
- 数组处理:Java的数组操作相对繁琐,需要注意边界检查
- 集合框架:使用HashSet/HashMap实现状态记忆
- 字符串处理:需要手动实现字符频率统计
9.2 Python实现对比
Python实现通常更简洁:
python复制def minStickers(self, stickers, target):
t_count = collections.Counter(target)
stickers = [collections.Counter(s) & t_count for s in stickers]
queue = collections.deque([(tuple(target.count(c) for c in t_count), 0)])
seen = set(queue[0][0])
while queue:
curr, steps = queue.popleft()
if not any(curr):
return steps
for sticker in stickers:
next_state = tuple(max(0, curr[i] - sticker.get(c, 0))
for i, c in enumerate(t_count))
if next_state not in seen:
seen.add(next_state)
queue.append((next_state, steps + 1))
return -1
9.3 C++实现考虑
C++实现需要注意:
- 内存管理:手动管理状态数组内存
- 性能优化:可以使用位运算加速状态处理
- STL容器:合理选择unordered_map等容器
10. 总结与个人实践建议
这道LeetCode 691题"贴纸拼词"很好地结合了字符串处理、动态规划和广度优先搜索的知识点。在实际编码中有几个关键点需要注意:
- 状态表示的选择:字符计数数组比字符串本身更适合做状态表示
- 剪枝的重要性:合理的剪枝可以大幅提升算法性能
- 测试用例设计:应该包含各种边界情况,如空输入、无法完成的情况等
在实际面试中遇到类似问题时,建议按照以下步骤进行:
- 明确问题要求和约束条件
- 设计状态表示方法
- 选择适当的搜索策略(BFS/DFS)
- 考虑优化和剪枝方案
- 编写清晰可读的代码
- 设计测试用例验证正确性
这道题的变种在实际工程中也有广泛应用,理解其核心思想对解决其他组合优化问题很有帮助。
