缺字問題 題解|APCS 2024 年 6 月中高級 第 3 題
前置知識
這題的核心(候選字串照字典序一個一個生出來、第一個沒出現的就是答案)下面會從頭講;要先會的是這幾件事,同一個主題兩邊都有的,對照著讀會更快上手:
- 用遞迴枚舉所有組合:NTUCPC Guide〈枚舉〉與〈遞迴〉——要帶走的是「一位一位決定,每一位都從小的選項試起,填滿了就檢查」;吳邦一《AP325》第 1 章「以遞迴來進行窮舉」的例題 P-1-7 子集合乘積、P-1-9 N-Queen 是同一套寫法(教材 PDF 第 38~43 頁;Python 版第 1 章)。
set:NTUCPC Guide〈Set 與 Map〉——要帶走的是「放進去、查有沒有,各 \(O(\log n)\)」;《AP325》第 2 章「C++ 可供搜尋的容器(set/map)」一節(教材 PDF 第 57~61 頁;Python 版第 2 章)。string的substr、find,以及字典序。- \(K\) 進位(只有最後的「另一種做法」會用到)。
簡潔題意
給字母表 \(A\)(已排序、不重複,最多 \(10\) 個小寫字母)、長度 \(L \le 8\)、字串 \(S\)(長度 \(\le 5 \times 10^5\),可能含有 \(A\) 以外的字母)。從 \(A\) 挑字母(可以重複)組成長度恰為 \(L\) 的字串,找出「沒有在 \(S\) 中連續出現」的字典序最小者;題目保證存在,而且 \(|A|^L \le 600000\)。
依正確通過的測試資料筆數給分,其中:
- 子題組 1(\(20\) 分):\(|S| = 1000\)。
- 子題組 2(\(80\) 分):無額外限制。
候選字串照字典序生出來,第一個沒出現的就是答案
「字典序最小的缺字」最直接的找法:把所有候選字串照字典序一個一個生出來,每生一個就檢查它有沒有在 \(S\) 裡出現,碰到第一個沒出現的就印出來、停。
照字典序生的方法是遞迴:先決定第 1 個字母、再決定第 2 個……每一位都從 \(A\) 裡最小的字母試起(\(A\) 已經排好序,照順序跑 for (char ch : A) 就是由小到大),填滿 \(L\) 位就檢查。這樣生出來的順序正是字典序:第 1 個字母小的一定先出現,第 1 個字母相同時第 2 個字母小的先出現,以此類推(圖 1)。
會不會把 \(|A|^L\) 個候選全看完?不會:\(S\) 最多只有 \(|S| - L + 1\) 個長度 \(L\) 的視窗,所以最晚檢查到第 \(|S| - L + 2\) 個候選一定會碰到沒出現的。要花多少時間,取決於「檢查一個候選有沒有出現」怎麼做。
先拿下 20 分:直接在 \(S\) 裡找
檢查最直接的寫法:S.find(current),在 \(S\) 裡從頭找這個子字串。
#include <bits/stdc++.h>
using namespace std;
string A, S;
int L;
string current; // 正在組的候選字串
bool search() { // 依字典序逐一試候選字串,找到第一個沒出現的就印出來
if ((int)current.size() == L) {
if (S.find(current) == string::npos) { // S 裡找不到:就是它
cout << current << endl;
return true;
}
return false;
}
for (char ch : A) { // A 已經排好序:先試小的字母
current.push_back(ch);
if (search()) return true;
current.pop_back();
}
return false;
}
int main() {
cin >> A >> L >> S;
search();
return 0;
}
每次 find 最多要掃過整個 \(S\),最多檢查 \(|S|\) 個候選,合起來 \(O(|S|^2 L)\):\(|S| = 1000\) 時不到 \(10^7\),子題組 1 的 \(20\) 分穩穩到手。\(|S| = 5 \times 10^5\) 就不行了——候選幾乎全部出現的測資本站實測 \(3.2\) 秒,超過時限。
從 20 分到 100 分:先把 \(S\) 的每個視窗收進 set
慢在每個候選都重新掃一次 \(S\)。反過來做:先把 \(S\) 裡每一個長度 \(L\) 的子字串(視窗)放進一個 set<string>,之後檢查候選只要問 set 有沒有這個字串,\(O(\log |S|)\)。
含有 \(A\) 以外字母的視窗不用特別剔除——候選字串只用 \(A\) 的字母,永遠不會跟那種視窗相等,放進去只是多占一點空間。
#include <bits/stdc++.h>
using namespace std;
string A, S;
int L;
set<string> appeared; // S 裡出現過的每一個長度 L 的子字串
string current; // 正在組的候選字串
bool search() { // 依字典序逐一試候選字串,找到第一個沒出現的就印出來
if ((int)current.size() == L) {
if (appeared.count(current) == 0) { // 沒出現過:就是它
cout << current << endl;
return true;
}
return false;
}
for (char ch : A) { // A 已經排好序:先試小的字母
current.push_back(ch);
if (search()) return true;
current.pop_back();
}
return false;
}
int main() {
cin >> A >> L >> S;
for (int i = 0; i + L <= (int)S.size(); i++) appeared.insert(S.substr(i, L));
search();
return 0;
}
放視窗 \(O(|S| \log |S| \cdot L)\)、檢查候選最多 \(|S|\) 次,\(|S| = 5 \times 10^5\) 本站實測 \(0.18\) 秒。和 \(20\) 分版相比只換掉一件事:「S.find」變成「先收集、再查 set」,search() 一個字都沒改。
測過再交:範例沒有 \(A\) 以外的字母
兩個範例的 \(S\) 都只用 \(A\) 的字母、\(|A| \ge 2\)、\(L \ge 2\)、答案都在中間。沒蓋到的:\(|A| = 1\)、\(L = 1\)、\(A\) 以外的字母把視窗切斷、答案是字典序最後一個,以及候選幾乎全部出現的大測資(驗速度)。
| 輸入 | 正確輸出 | 這一筆在測什麼 |
|---|---|---|
a/3/aaxaa |
aaa |
只有一個字母:x 把兩段 aa 隔開,aaa 沒有連續出現 |
abc/1/ab |
c |
\(L = 1\):每個字母自己就是一個視窗 |
ab/2/aazb |
ab |
a 和 b 中間隔著 z,不算連續出現的 ab |
ab/2/bbaa |
ab |
答案比 \(S\) 裡出現的 ba、bb 都小,而且最後一個視窗 aa 要算進去 |
ab/2/aabab |
bb |
答案是字典序最後一個候選 |
用程式造:\(A\) 是 abcdefghij、\(L = 5\),\(S\) 長 \(100003\),\(10^5\) 個候選裡只有 jjjjj 沒出現 |
jjjjj |
要檢查到最後一個候選:\(20\) 分版本站實測 \(3.2\) 秒,set 版 \(0.06\) 秒 |
六筆都親眼看過正確,這題就穩了。
另一種做法:把字串當成 K 進位的數
把 \(A\) 的第 \(i\) 個字母看成數字 \(i\),長度 \(L\) 的字串就是一個 \(L\) 位的 \(B\) 進位數(\(B = |A|\)),範圍 \(0 \sim B^L - 1\),而且數字的大小順序就是字典序。開一個 bool 陣列 appeared[] 記哪些數出現過,從 \(0\) 往上找第一個沒出現的,再把它還原成字串。
\(S\) 的視窗往右推一格,新的數是「舊的數乘 \(B\)、加上新字母、再對 \(B^L\) 取餘數」——取餘數剛好把最舊的那一位丟掉。碰到 \(A\) 以外的字母,視窗就斷了,要把累積的長度歸零重來;只有連續湊滿 \(L\) 個 \(A\) 的字母之後,算出來的數才是一個真的視窗。
#include <bits/stdc++.h>
using namespace std;
int main() {
string A, S;
int L;
cin >> A >> L >> S;
int B = A.size();
int total = 1;
for (int i = 0; i < L; i++) total *= B; // B^L 種候選字串,每一種對應一個編號
int digit[26];
for (int i = 0; i < 26; i++) digit[i] = -1; // -1 = 這個字母不在 A 裡
for (int i = 0; i < B; i++) digit[A[i] - 'a'] = i;
vector<bool> appeared(total, false);
int code = 0, run = 0; // code = 最近 L 個字母的編號,run = 連續幾個字母都在 A 裡
for (char ch : S) {
int d = digit[ch - 'a'];
if (d == -1) { // 不在 A 裡:視窗斷掉,重新累積
code = 0;
run = 0;
continue;
}
code = (code * B + d) % total; // 推進一格:最舊的那個字母被 % 掉
run++;
if (run >= L) appeared[code] = true; // 湊滿 L 個字母才算一個完整的視窗
}
int answer = 0;
while (appeared[answer]) answer++; // 題目保證答案存在,不會跑出範圍
string word(L, A[0]);
for (int i = L - 1; i >= 0; i--) { // 把編號還原成字串:從最後一位往前拆
word[i] = A[answer % B];
answer /= B;
}
cout << word << endl;
return 0;
}
\(O(|S| + B^L)\),本站實測 \(0.02\) 秒。兩種都是滿分;set 版不用管 \(A\) 以外的字母、也不用進位換算,進位版省掉 set 的空間與 \(\log\),而且不用寫遞迴。
常犯錯誤
- 每個候選都用
S.find找(\(20\) 分版硬交):候選幾乎全部出現的測資本站實測 \(3.1 \sim 3.2\) 秒,計分測資掛 \(3\) 筆(超時)。 - 進位版碰到 \(A\) 以外的字母只跳過、沒把視窗歸零:兩個範例都過,自測表第 3 列印
ba——隔著z的a、b被當成連續的ab。 - 進位版湊滿 \(L\) 個字母的第一個視窗沒算(
run > L):範例 1 印ac、範例 2 印ddd。 set版漏掉最後一個視窗(迴圈條件寫成i + L < |S|):範例 1 印cm、範例 2 印dpd。- 進位版還原字串時從第一位開始放(
answer % B放到最前面):整個字串左右顛倒,範例 1 印am。