LeetCode 1079中等回溯 · 枚举
活字印刷 图解题解
这道题到底在问什么
有一套字模 tiles,每个上面刻一个大写字母,每个字模只能用一次。返回能印出的非空字母序列的数目(长度从 1 到 tiles 长度都算,顺序不同算不同序列)。
- 输入
- tiles = "AAB"
- 输出
- 8 (A, B, AA, AB, BA, AAB, ABA, BAA)
- 输入
- tiles = "V"
- 输出
- 1 (只有 "V" 一种)
最优解:一步一步想明白
- 3记住这套「挑一个就算一种、走到头收回换下一个、相同字母只挑一次」,下面每一帧都在套它。
- 4上面一排是三个字模 A、A、B(已经排好序),下面 path 是正在拼的序列,右边收集每一种印出来的序列。规矩:每层挑一个没用过的字模接到 path 末尾,接出一个就算印出一种。
- 5挑第 0 个字模 A,接到末尾,拼出序列 "A"。每拼出一个非空序列就算印出一种,答案加到 1。
- 6挑第 1 个字模 A,接到末尾,拼出序列 "AA"。每拼出一个非空序列就算印出一种,答案加到 2。
- 7挑第 2 个字模 B,接到末尾,拼出序列 "AAB"。每拼出一个非空序列就算印出一种,答案加到 3。
- 8"AAB" 这条已经拼满、把末尾的 B 收回来,path 退回 "AA",回上一层换下一个字模。
- 9"AA" 这条能接的都试过了,把末尾的 A 收回来,path 退回 "A",回上一层换下一个字模。
- 10挑第 2 个字模 B,接到末尾,拼出序列 "AB"。每拼出一个非空序列就算印出一种,答案加到 4。
- 11挑第 1 个字模 A,接到末尾,拼出序列 "ABA"。每拼出一个非空序列就算印出一种,答案加到 5。
- 12"ABA" 这条已经拼满、把末尾的 A 收回来,path 退回 "AB",回上一层换下一个字模。
- 13"AB" 这条能接的都试过了,把末尾的 B 收回来,path 退回 "A",回上一层换下一个字模。
- 14"A" 这条能接的都试过了,把末尾的 A 收回来,path 退回 空,回上一层换下一个字模。
- 15这一层又遇到字母 A,它和前一个 A 一样、而且前一个这层还没挑,选它拼出来的序列会和刚才那条重复,跳过去重。
- 16挑第 2 个字模 B,接到末尾,拼出序列 "B"。每拼出一个非空序列就算印出一种,答案加到 6。
- 17挑第 0 个字模 A,接到末尾,拼出序列 "BA"。每拼出一个非空序列就算印出一种,答案加到 7。
- 18挑第 1 个字模 A,接到末尾,拼出序列 "BAA"。每拼出一个非空序列就算印出一种,答案加到 8。
- 19"BAA" 这条已经拼满、把末尾的 A 收回来,path 退回 "BA",回上一层换下一个字模。
- 20"BA" 这条能接的都试过了,把末尾的 A 收回来,path 退回 "B",回上一层换下一个字模。
- 21这一层又遇到字母 A,它和前一个 A 一样、而且前一个这层还没挑,选它拼出来的序列会和刚才那条重复,跳过去重。
- 22"B" 这条能接的都试过了,把末尾的 B 收回来,path 退回 空,回上一层换下一个字模。
- 23三个字模的所有挑法都搜遍了,右边正好攒了 8 种不同的非空序列:A、AA、AAB、AB、ABA、B、BA、BAA。所以 "AAB" 的答案就是 8。
⚠️ 容易写错的地方
✗ 错:只数「用满全部字模」的排列
✓ 对:长度 1 到 n 的每个非空序列都算
题目要的是所有非空序列,每挑一个字模就已经印出一种,不是只数放满的
✗ 错:把两个相同字母当不同字模各算一遍
✓ 对:相同字母在同一层只挑一次去重
两个 A 互换拼出的是同一个序列,重复计数会把答案算大
✗ 错:递归返回后忘了还原计数 / used
✓ 对:回来立刻把计数加回、used 置回 false
不还原,这个字母在兄弟分支里就少了,会漏掉一大批序列
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
class Solution:
def numTilePossibilities(self, tiles: str) -> int:
def dfs(cnt: Counter) -> int:
ans = 0
for i, x in cnt.items():
if x > 0:
ans += 1
cnt[i] -= 1
ans += dfs(cnt)
cnt[i] += 1
return ans
cnt = Counter(tiles)
return dfs(cnt)C++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
class Solution {
public:
int numTilePossibilities(string tiles) {
int cnt[26]{};
for (char c : tiles) {
++cnt[c - 'A'];
}
function<int(int* cnt)> dfs = [&](int* cnt) -> int {
int res = 0;
for (int i = 0; i < 26; ++i) {
if (cnt[i] > 0) {
++res;
--cnt[i];
res += dfs(cnt);
++cnt[i];
}
}
return res;
};
return dfs(cnt);
}
};Java
import java.util.*;
class Solution {
public int numTilePossibilities(String tiles) {
int[] cnt = new int[26];
for (char c : tiles.toCharArray()) {
++cnt[c - 'A'];
}
return dfs(cnt);
}
private int dfs(int[] cnt) {
int res = 0;
for (int i = 0; i < cnt.length; ++i) {
if (cnt[i] > 0) {
++res;
--cnt[i];
res += dfs(cnt);
++cnt[i];
}
}
return res;
}
}复杂度
时间
O(n · n!)
搜索树节点数即非空序列数,上界约 n·n!;本题 n ≤ 7 实际很小
空间
O(n)
递归栈深最多 n 层;计数表固定 26 个槽位视作常数
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 活字印刷 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
这题和全排列(lc46/lc47)有什么区别?+
全排列只数「用满所有元素」的排列,本题长度 1 到 n 的每个非空序列都要数,所以每个搜索树节点都算一种,而不是只数叶子。去重的处理和 lc47 一样:要么按字母计数、要么排序后同层跳过相同且未用的字母。
能不能不搜索,直接用组合数学公式算?+
可以但更绕。要按每个字母的出现次数做「多重集排列」求和:枚举每种长度、每个字母用几个,套多重集排列公式累加,本质是指数生成函数的思路。n 只有 7,回溯既直观又够快,面试里写回溯更稳。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄声线把 活字印刷 一步步讲透(全站 0 节语音精讲,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。