Wordle 與 信息論
前言
最近,wordle 游戲在朋友圈中大火。那么 wordle 究竟是什么呢?其實就是一個猜單詞游戲,每次輸入一個長度為 \(5\) 的單詞,系統會告訴你,在你的猜測中,哪些字母的位置是正確的,哪些字母出現在目標單詞中但是位置不正確。根據這些提示,你需要在 \(5\) 次機會之內找到這個單詞。
本人在看了 3b1b 的視頻之后,發現 NOIWC2022T3 猜詞恰好正是 wordle 游戲,於是開始嘗試。
原題鏈接:https://www.luogu.com.cn/problem/P8079
視頻鏈接:https://www.bilibili.com/video/BV1zZ4y1k7Jw?share_source=copy_web
如何解決?
首先考慮最朴素的思想,每次在剩余的單詞庫中隨機一個單詞輸入,根據得到的反饋在單詞庫中刪除不合法的單詞,不斷執行這個操作直到結束為止。這個算法好像能夠拿到 \(80\) 分。
朴素的思想有什么瓶頸呢?主要是“隨機”過於隨便了,完全是賭運氣,我們若能夠加入一些啟發式的想法,就能讓這個隨機變的更加有章法可依,從而提高正確率。
要有啟發式的想法,那肯定要引入一個啟發式估價函數。具體地,在本題的條件之下,我們需要在眾多的備選項之中,挑選出能盡可能多地篩除干擾項的那個單詞進行猜測,而不是純隨機的從備選中找一個單詞進行猜測。
怎么用代數化的語言定義“盡可能多地篩除干擾項”呢?這時候就需要引入信息熵的概念了,這個值又稱作為香農熵。如果我們通過一次“篩查”,能夠排除 \(\dfrac 12\) 的干擾項,那么對於占比為 \(P\) 的信息,我們需要 \(I\) 次“篩查”,\(I\) 滿足 \(\left(\dfrac 12\right)^I = P\),化簡得到 \(I= -\log_2 P\),這里的 \(I\) 就稱作為“信息量”,可以發現,信息量越大,能夠篩除的干擾項也就越多。有了信息量的概念,我們就能夠自然地得到信息熵的定義式,我們只要計算信息量的期望就是信息熵:
回到本問題中,我們定義猜測特定單詞的好壞為,對於所有 \(3^5\) 種可能的回饋,計算信息熵。具體地,設 \(cnt[S]\) 表示 \(S\) 回饋后剩下的單詞庫數量,\(T\) 為當前總共單詞庫數量,則有:
如果這么算的話,會非常慢,計算一個單詞的時間復雜度就為 \(O(3^5 \times 5^2\times T)\),常數有點太大了。
我們重新想想,狀態 \(S\) 和單詞的對應關系,給定猜測的單詞和一個狀態 \(S\),我們或許能夠找到多個匹配的單詞,但如果反過來,給定匹配的單詞和猜測的單詞,我們必定只能找到一個狀態 \(S\)。也就是說,我們剛才暴力枚舉所有狀態和單詞庫的時候有着極大的冗余,我們不妨反過來計算每個單詞庫中的單詞對 \(cnt[S]\) 的貢獻,這樣計算信息熵的復雜度能夠順利地降為 \(O(5^2 T)\)。
這樣下來,利用信息熵的啟發性優化,我們能夠拿到 \(92\) 分。這部分是第一天寫的,當我第二天回過頭來看時,發現只拿 \(92\) 分是有原因的。。。我們考慮一件事情,我們當前最佳的猜測單詞不一定非要從篩選下來的單詞庫中去找,比如我們已經肯定了某個位置是 \(a\) 了,我們不一定下次這個位置要填 \(a\),也可以填其他字母去試其他的位置,這樣做獲得的信息熵或許會更高。所以,每次搜索應當在全局單詞庫中搜索最大信息熵。然而,這樣做還是不夠優秀,當我們搜索范圍擴大到全局時,我們的程序在篩選后的單詞庫中進行猜測的概率會相應減少,換言之,就是過於注重更大程度地篩除干擾項,而變得不太敢猜測了。這種情況其實比較好解決,只需要給篩選過的單詞庫中的熵加權即可,即給一個激勵。顯然,當篩選后的單詞庫越小,我們就更應該激勵程序去大膽猜想。於是,我定義的激勵函數為 \(\tanh (\dfrac 1{\log_2 T})\),這里我們使用激活函數 \(\tanh\) 對 \((0, +\infty)\) 的值進行一波平滑處理。
本題出現的單詞是隨機挑選的,沒有使用的頻率等特征。3b1b 視頻中給出了這樣一個優化,如果不同單詞的使用頻率不同,我們就能夠給每個單詞加上一個權重,即我們對 \(cnt[S]\) 算貢獻時,不是單純的加 \(1\) 了,而是加上每個單詞的權值,這個權值可大可小,你大可設一個單詞的權值為 \(10\),而另一個不常用單詞的權值為 \(0.001\)。與此同時,我們也可以嘗試定義期望猜測次數來作為估價函數,表達式為(文字表述)當前猜的詞成為答案的概率 乘以 當前步數 加上 一減去上述概率 乘以 處理當前大小的信息量期望多少步。
反思與總結
這種做法可以勉強過題,但是實現還不夠精細。之后或許能夠使用決策樹等操作進一步優化,如果算力足夠,還可以預測兩步之后的信息熵,這樣的決策一定更加精確。
wordle 游戲讓我能夠稍微入門一些信息論?寫完代碼還是挺有成就感的。代碼如下:
展開查看代碼
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef double db;
vector<string> rem, rem2, trem, irem;
const char *bstInitial[26] = {"slier", "lares", "lares", "tores", "tarns", "arles", "lares", "lares", "snare", "ousel", "ranis", "nares", "tares", "aides", "tries", "lares", "raise", "aides", "plate", "nares", "snare", "riles", "nares", "cones", "kanes", "aeons"};
int cnt[245], gold[5], silver[5], buc[30];
db shannonEntropy(string guess) {
db ret = 0; memset(cnt, 0, sizeof(cnt));
for (int i = 0; i < rem.size(); i++) {
for (int j = 0; j < 5; j++) gold[j] = silver[j] = 0;
for (int j = 0; j < 5; j++) gold[j] = (rem[i][j] == guess[j]);
for (int j = 0; j < 5; j++) if (!gold[j])
for (int k = 0; k < 5; k++) if (j != k && !gold[k])
silver[j] |= (guess[j] == rem[i][k]);
int S = 0;
for (int j = 0, bs = 1; j < 5; j++, bs *= 3) {
if (gold[j]) S += 2 * bs;
else if (silver[j]) S += 1 * bs;
}
cnt[S]++;
}
for (int S = 0; S < 243; S++) {
if (!cnt[S]) continue;
db P = (db)cnt[S] / rem.size();
ret = ret - P * log2(P);
}
return ret;
}
void init(int num_scramble, const char *scramble) {
for (int i = 0; i < num_scramble; i++) {
string tmps; tmps.resize(5);
for (int j = 0; j < 5; j++) tmps[j] = scramble[i * 5 + j];
rem.push_back(tmps); irem.push_back(tmps);
}
}
string lstGuess;
const char *guess(int num_testcase, int remaining_guesses, char initial_letter, bool *gold, bool *silver) {
if (remaining_guesses == 5) {
rem.clear(); rem2.clear();
for (int i = 0; i < irem.size(); i++) {
if (irem[i][0] == initial_letter) rem.push_back(irem[i]);
else rem2.push_back(irem[i]);
}
lstGuess = bstInitial[initial_letter - 'a'];
return lstGuess.c_str();
} else {
for (int i = 0; i < rem.size(); i++) {
int flg = 1;
for (int j = 0; j < 5; j++) if (gold[j] && rem[i][j] != lstGuess[j]) flg = 0;
for (int j = 0; j < 5; j++) if (silver[j] && rem[i][j] == lstGuess[j]) flg = 0;
for (int j = 0; j < 5; j++) if (silver[j]) {
int flg2 = 0;
for (int k = 0; k < 5; k++) if (!gold[k] && lstGuess[j] == rem[i][k]) flg2 = 1;
flg &= flg2;
}
for (int j = 0; j < 5; j++) if (!gold[j] && !silver[j])
for (int k = 0; k < 5; k++) if (!gold[k] && rem[i][k] == lstGuess[j])
flg = 0;
if (flg) trem.push_back(rem[i]);
}
swap(rem, trem); trem.clear();
}
//cout << "rem ========" << endl;
//for (int i = 0; i < rem.size(); i++) cout << rem[i] << endl;
//cout << "========= rem" << endl;
db mx1 = -1, mx2 = -1; string bstGuess1, bstGuess2;
for (int i = 0; i < rem.size(); i++) {
db tmp = shannonEntropy(rem[i]);
if (tmp > mx1) mx1 = tmp, bstGuess1 = rem[i];
}
mx1 = mx1 + 2 * tanh(1 / log2(rem.size()));
//cout << 2 * tanh(1 / log2(rem.size())) << endl;
for (int i = 0; i < rem2.size(); i++) {
db tmp = shannonEntropy(rem2[i]);
if (tmp > mx2) mx2 = tmp, bstGuess2 = rem2[i];
}
if (mx1 >= mx2) lstGuess = bstGuess1;
else lstGuess = bstGuess2;
return lstGuess.c_str();
}