計蒜客 - 情報加密
蒜頭君是我軍的情報專家,現在我軍有一份重要情報要發送出去,這是一份特別重要的情報,一旦被敵軍截獲,裏面有一些重要的片段會暴露我們的身份,所以蒜頭君需要改變一些字符,這樣即使敵軍截獲了我們的情報,也無法獲得正確信息。
比如,情報中如果包含 AAB
, ABC
和 CAB
就會暴露我們的身份,但是情報中的 AABCAB
把這三個片段都包含了,不過我們只需改變兩個字符使其變成 ABBCAB
,這樣就不會暴露任意一個關鍵片段信息了。注意,我們的情報中只包含 A
, B
, C
, D
這四個字母,我們修改時也只能使用這四個字母。
你要幫助蒜頭君通過改變最少的字符來加密情報。
輸入格式
第一行一個整數 ,表示可能關鍵的情報段數量。
接下來 行,每行一個長度不超過 的字符串,由 A
,B
,C
和 D
構成。
最後一行,一個長度不超過 的字符串是,由 A
,B
,C
和 D
構成。
2
A
DB
DBAADB
輸出格式
一行一個整數,表示最少修改字符的數量。若無解,則輸出 。
4
這道題是想要我們求出對於一個給定的字符串,至少修改多少次可以使得該字符串不出現任何一個字典中的子串。
解決方法是使用 AC 自動機 + 動態規劃。
這題字符範圍只有 A
~ D
,所以 MAXC = 4
,其餘的 AC 自動機的部分不需要修改,直接套用模板,依然只需要修改 solve()
函數。
我們定義 dp[][]
數組用來動態規劃。
狀態的定義 dp[i][p]
表示對於情報中前 i
個字符組成的串,在結點 p
的時候,最少需要修改多少次才能滿足要求。
初始化只需要設定 dp[0][0] = 0
,因爲對於空串,顯然不需要修改。
下面我們考慮狀態轉移。
現在我站在 p
這個節點,手上已經有了情報的前 i
個字符,而且已經知道了修改多少次能夠使情報更安全。爲了構造一個不包含關鍵情報的串,我現在可以在前綴樹中沿着邊走,即選擇 child[p]
中的一者,走下去,走一步,情報長度就會加一。當遇到某個結點 child[p][C]
是一個單詞的結束時(sta[child[p][C]] == true
),說明不能走這條路,要換一條,即可以嘗試 child[p][A]
或者 child[p][B]
等。
那麼,i
只能轉移到 i + 1
,p
只能轉移到 child[p]['A']
~ child[p]['D']
四者之一。
其中,如果 sta[child[p][x]]
爲 true
,說明那裏是一個關鍵情報,不能選擇這條路,需要繞過,continue
。
這樣,對於大多數的情況,dp[i][child[p][x]]
的值顯然應該是等於 dp[i][p] + 1
。
可是,如果下一個(第 i + 1
個字符)恰好是我選擇的那個 child[p]
呢,比如,我現在已經擁有了 ABB
,我決定往 C
走,而原來的情報就是 ABBC
,那麼就可以直接用原來的情報,不需要修改也可以了。
在狀態轉移的過程中始終保留 dp[i][j]
的最小值。
int len = strlen(ch + 1);
for (int i = 1; i <= len; i++) {
for (int p = 0; p < tot; p++) {
if (sta[p]) {
// 如果當前節點是一個關鍵情報,則不能走這條路
continue;
}
for (int x = 0; x < MAXC; x++) {
if (sta[child[p][x]]) {
// 如果走下去的方向是關鍵情報,則需要換一條路
continue;
}
// 到這裏,就是一個安全的路,可以做一次狀態轉移
// 如果走過去的那個方向,與情報中下一個字符是一樣的,就不需要一次修改
int modify = ch[i] == 'a' + x ? 0 : 1;
dp[i + 1][child[p][x]] = min(dp[i + 1][child[p][x]], dp[i][p] + modify);
}
}
}
最後只需要遍歷 dp[len + 1][]
數組中最小值即可。注意動態規劃數組的第一個下標是 len + 1
是因爲我們需要確保整個情報都不包含關鍵信息。
由於可能出現無解的情況,而我們又需要記錄最小值,所以我們應該把 dp[][]
數組初始化爲無窮大,並且在遍歷的過程中跳過所有值爲無窮大的結點,這樣,最後返回的時候就可以直接判斷出是不是無解了。
int solve(char *ch) {
cout << strlen(ch + 1) << endl;
// 初始化 dp 數組
memset(dp, 0x3F, sizeof(dp));
dp[1][0] = 0; // 字符串下標從 1 開始
int len = strlen(ch + 1);
for (int i = 1; i <= len; i++) {
for (int p = 0; p < tot; p++) {
if (sta[p] || dp[i][p] == INF) {
// 如果當前節點是一個關鍵情報,則不能走這條路
continue;
}
for (int x = 0; x < MAXC; x++) {
if (sta[child[p][x]]) {
// 如果走下去的方向是關鍵情報,則需要換一條路
continue;
}
// 到這裏,就是一個安全的路,可以做一次狀態轉移
// 如果走過去的那個方向,與情報中下一個字符是一樣的,就不需要一次修改
int modify = ch[i] == 'A' + x ? 0 : 1;
dp[i + 1][child[p][x]] = min(dp[i + 1][child[p][x]], dp[i][p] + modify);
}
}
}
int ans = INF;
for (int i = 0; i < tot; i++) {
// 注意是 len + 1
ans = min(ans, dp[len + 1][i]);
}
return ans == INF ? -1 : ans;
}
但是別急,還有一點點小問題。
在使用 AC 自動機進行動態規劃的時候,要特別注意,算 fail
要把單詞結尾標記傳遞下來,避免出現某個後綴是另外一個單詞的情況。
if (sta[fail[p]]) {
sta[p] = 1;
}
// 即 sta[p] |= sta[fail[p]]
具體的分析可以參考這一篇文章:https://b.ejq.me/2017/03/01/aczi-dong-ji-yong-yu-dong-tai-gui-hua-shi-de-biao-ji-chuan-di/
#include <bits/stdc++.h>
using namespace std;
const int MAXC = 4; // 這裏字符範圍只有 A ~ D
const int MAXN = 1007;
const int INF = 0x3F3F3F3F;
int child[MAXN][MAXC], fail[MAXN], sta[MAXN], Q[MAXN];
int tot;
int dp[MAXN][MAXN];
/**
* AC 自動機
*/
struct AC_Automaton {
/**
* 清空
*/
void clear() {
memset(child, 255, sizeof(child));
memset(fail, 0, sizeof(fail));
tot = 0;
memset(sta, 0, sizeof(sta));
}
/**
* 插入單詞
* @param ch 單詞,該單詞下標從 1 開始
*/
void insert(char *ch) {
int p = 0, l = strlen(ch + 1);
for (int i = 1; i <= l; i++) {
if (child[p][ch[i] - 'A'] == -1) child[p][ch[i] - 'A'] = ++tot;
p = child[p][ch[i] - 'A'];
}
sta[p] = 1; // 以結點 p 的字符串是否存在,由於本題只需要是否存在,設置 true 就好了,像上一題還需要統計個數,可以改爲 sta[p]++,表示有多少個
}
/**
* 對插入了單詞的前綴樹構造失敗指針
*/
void build() {
int l = 0, r = 0;
for (int i = 0; i < MAXC; i++)
if (child[0][i] == -1)
child[0][i] = 0;
else
Q[++r] = child[0][i];
while (l < r) {
int p = Q[++l];
if (sta[fail[p]]) {
sta[p] = 1;
}
for (int i = 0; i < MAXC; i++)
if (child[p][i] == -1)
child[p][i] = child[fail[p]][i];
else {
fail[child[p][i]] = child[fail[p]][i];
Q[++r] = child[p][i];
}
}
}
/**
*
* @param ch 字符串,下標從 1 開始
* @return 最小修改次數
*/
int solve(char *ch) {
// 初始化 dp 數組
memset(dp, 0x3F, sizeof(dp));
dp[1][0] = 0; // 字符串下標從 1 開始
int len = strlen(ch + 1);
for (int i = 1; i <= len; i++) {
for (int p = 0; p < tot; p++) {
if (sta[p] || dp[i][p] == INF) {
// 如果當前節點是一個關鍵情報,則不能走這條路
continue;
}
for (int x = 0; x < MAXC; x++) {
if (sta[child[p][x]]) {
// 如果走下去的方向是關鍵情報,則需要換一條路
continue;
}
// 到這裏,就是一個安全的路,可以做一次狀態轉移
// 如果走過去的那個方向,與情報中下一個字符是一樣的,就不需要一次修改
int modify = ch[i] == 'A' + x ? 0 : 1;
dp[i + 1][child[p][x]] = min(dp[i + 1][child[p][x]], dp[i][p] + modify);
}
}
}
int ans = INF;
for (int i = 0; i < tot; i++) {
// 注意是 len + 1
ans = min(ans, dp[len + 1][i]);
}
return ans == INF ? -1 : ans;
}
} T;
int main() {
// 構造 AC 自動機
auto ac = new AC_Automaton();
ac->clear();
int n;
scanf("%d", &n);
// 讀入單詞,加入前綴樹
char *s = (char *) malloc(sizeof(char) * MAXN);
for (int i = 0; i < n; i++) {
// 字符串下標從 1 開始
scanf("%s", s + 1);
ac->insert(s);
}
// 根據前綴樹中的單詞構造失敗指針,即構造字典
ac->build();
// 給定的文章,下標從 1 開始
char *t = (char *) malloc(sizeof(char) * MAXN);
scanf("%s", t + 1);
// 執行操作,輸出結果
printf("%d\n", ac->solve(t));
return 0;
}
歡迎關注我的個人博客以閱讀更多優秀文章:凝神長老和他的朋友們(https://www.jxtxzzw.com)
也歡迎關注我的其他平臺:知乎( https://s.zzw.ink/zhihu )、知乎專欄( https://s.zzw.ink/zhuanlan )、嗶哩嗶哩( https://s.zzw.ink/blbl )、微信公衆號( 凝神長老和他的朋友們 )