Wordle 与 信息论
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();
}