有一段时间沉迷这个游戏,玩游戏没什么诀窍,多试,每种路径走一次总能找到一个合适的。试久了有点烦躁,想说这不就是深搜吗,我为什么要人工搜,写个代码搜不就完了。于是打算自己实现。
方案选择
上网找了一圈方案,基本都是启发式搜索。按大学时期对这个算法的学习,感觉有点用,但又有点随缘,加上启发式搜索实在复杂,于是还是坚持打算尝试用暴力深搜水一水。这个问题使用深搜的主要问题是可能出现的颜色分布情况太多(粗略估计是10^48数量级),不可能深搜穷举完,肯定是要根据玩法策略进行剪枝的。
在实际运用中,发现深搜+剪枝也会出现耗时较长、爆栈等问题。打印了搜索过程后发现实际上大量颜色分布情况都在重复出现,只是瓶子的顺序有区别,因此决定加上状态记录,避免重复搜索。加上这步搜索快了很多,基本30+次尝试即可找到结果。所以最终使用的策略是记忆化搜索+深搜+剪枝
剪枝思路
根据实际玩的经验,倒水的思路主要有两个:
- 每次倒水需要把最上层颜色全部倒完,因为不暴露出下一个颜色,那么这次倒水实际上是无效的。
- 先整合非空瓶的颜色,即发现两个非空瓶最上层颜色相同并且满足1,优先合并这两个颜色;当非空瓶的都不能合并时,才尝试将非空瓶最上层转移到空瓶中。
- 空瓶、同种颜色满瓶的试管不用再合并。
- 当从非空瓶向空瓶转移颜色时,不仅需要转移选定瓶子最上层的颜色,还要将所有非空瓶中,可转移的该颜色都转移至同一空瓶中。该步骤也是玩的过程总结出的经验,暴露更多下层颜色永远是转移当前颜色的目标。
以上这种方案有一个问题,对于以下情况无法处理。目前并未发现需要实现该步骤才能解的情况,可能遇到该情况可以通过优先合并其他颜色解决,或通过继续搜索避免出现以下情况最终实现
|a| | | | |
|a| |a| |a|
|b| |c| |d|
|b| |c| |d|
代码实现
1. 水管类
主要存储与试管相关的信息,以及维护试管增加、减少颜色、生成去重用的key等
class Tube {
public:
int a[4]; // 下标3~0表示从下到上4个颜色的值
bool isSame;
int cur; // 当前最上层颜色所属下标
Tube::Tube();
void Tube::Clear();
bool Tube::IsFullAndSame();
bool Tube::IsFull();
bool Tube::IsEmpty();
int Tube::Top();
bool operator< (const Tube &t);
string GetHashKey();
void Add(int x, int n = 1);
void Pop(int n = 1);
int MoveTo(Tube &tb);
bool MoveBack(Tube &tb, int ms);
};
1.1 初始化、清空等操作及常规判断函数
// 初始化
Tube::Tube() {
memset(a, 0, sizeof(a));
isSame = true;
cur = 4;
}
// 清空试管
void Tube::Clear() {
memset(a, 0, sizeof(a));
isSame = true;
cur = 4;
}
// 是否是完成状态(试管是满的并且都是同种颜色)
bool Tube::IsFullAndSame() {
return IsFull() && isSame;
}
bool Tube::IsFull() {
return cur == 0;
}
bool Tube::IsEmpty() {
return cur == 4;
}
// 获取试管最上层颜色
int Tube::Top() {
if (IsEmpty()) return -1;
return a[cur];
}
1.2 试管颜色倒入和倒出
// 向试管内倒水,颜色为x,数量为n
void Tube::Add(int x, int n = 1) {
while(n--) {
a[--cur] = x; // 往试管中填充颜色
if (cur <= 2) { // 填充颜色的过程中更新isSame,这样判断isSame的时候不用挨个颜色扫一遍
if (isSame && a[cur] != a[cur+1]) {
isSame = false;
}
}
}
}
// 将试管中的水导出,数量为n
void Tube::Pop(int n = 1) {
cur += n; // 更新当前最上层颜色的位置,已经填充的可以不用清空
isSame = true; // 从新的最上层颜色开始扫一遍更新isSame
for (int i=cur+1; i<4;i++){
if (a[i-1] == a[i]) continue;
else isSame = false;
}
}
1.3 两个试管间的倒出和倒回操作
// 尝试将当前试管上层颜色倒入到tb中,返回表示倒出的水量,返回为0表示不满足条件
int Tube::MoveTo(Tube &tb) {
if (!tb.IsEmpty() && Top() != tb.Top()) {
return 0;
}
int sa = 1, sb = tb.cur; // sa:当前试管最上层颜色的数量 sb:tb试管当前的空位数量
for (int i=cur+1; i<4;i++) {
if (a[i] == a[cur]) sa++;
else break;
}
if (sa <= sb) { // 确定当前试管最上层颜色可以全部移动到tb中
tb.Add(Top(), sa); // tb增加颜色
Pop(sa); // 当前减少颜色
return sa;
}
return 0;
}
// 将当前试管最上层颜色倒入ta中,数量是ms
bool Tube::MoveBack(Tube &tb, int ms) {
Add(tb.Top(), ms); // 将tb最上层颜色加到当前试管中,数量为ms
tb.Pop(ms); // tb试管倒出颜色
return true;
}
1.4 用于生成游戏当前状态的一些辅助函数
// 给试管定义一个比较大小的方式,取消试管顺序对去重的影响
bool Tube::operator< (const Tube &t) const {
int i = cur, j = t.cur;
while(i < 4 && j<4) {
if (a[i] != t.a[j]) return a[i] < t.a[j];
i++; j++;
}
if (i==4) return true;
return false;
}
// 用两个bytes(char)表示一个试管的颜色状态
// 目前游戏中最多出现12种颜色,因此每个位置可以用4bit表示它的颜色,1根试管4个颜色位,即2个bytes即可表示。
// 最终最多14*2bytes
string Tube::GetHashKey() {
int s;
for (int i = 3; i>=0; i--) {
int curA = (i <= cur)?a[i]:0; // 空位存0,非空位存储
s = (s << 4) | curA;
}=
string r;
char ch = (s >> 8);
r += ch;
ch = (s|0xFF);
r+= ch;
return r;
}
2. 游戏类
class Game {
public:
vector<Tube> tubes; // 存储游戏中各个试管的颜色状态,按照实际游戏,最多14个试管
bool solved; // 当找到任意解时退出搜索
vector<pair<int, int>> step; // 存放解题步骤,最后输出
set<string> status; // 解题过程中游戏的状态列表,用于判断当前各个试管状态是否是之前搜索过的
int FindEmpty();
string GetHashKey();
void Solve();
};
2.1 查找空瓶
// 找到游戏中第一个空试管
int Game::FindEmpty() {
for (int i=0; i<tubes.size(); i++) {
if (tubes[i].IsEmpty()) return i;
}
return -1;
}
2.2 状态生成
// 将当前所有试管中颜色的分布信息转换成一个string,方便存储或查询该状态是否存在
string Game::GetHashKey() {
vector<Tube> tem = {tubes.begin(), tubes.end()};
// 先排序,因为hash过程是跟试管的顺序有关的,但实际上仅试管顺序不同,每个试管颜色分布相同的两种状态,对于解题来说是一样的,因此需要通过排序来排除瓶子顺序的因素
sort(tem.begin(), tem.end());
// 使用前面提到的GetHashKey函数,获取每个试管的状态,再拼在一次就是游戏当前的状态
string s;
for (int i = 0; i<tem.size(); i++) {
s += tem[i].GetHashKey();
}
return s;
}
2.3 解法搜索,即主逻辑
void Game::Solve() {
if (solved) return;
// 先尝试放在非空瓶
bool allSolved = true;
for (int i=0; i<tubes.size(); i++) { // 对每个试管,尝试将不同的颜色移到其他试管
if (tubes[i].IsEmpty() || tubes[i].IsFullAndSame()) continue; // 空瓶或者已经完成的,不用转移颜色
allSolved = false;
for (int j = 0; j<tubes.size(); j++) { // 对每个试管,尝试tubes[i]最上层颜色移到该试管
if (i == j || tubes[j].IsEmpty() || tubes[j].IsFull()) continue; // 满瓶、空瓶都不可转移
int ms = tubes[i].MoveTo(tubes[j]); // 尝试将tubes[i]最上层颜色转移到tubes[j]
if (ms > 0) { // 如果可以转移
string s = GetHashKey(); // 生成状态
if (status.find(s) != status.end()) { // 该状态已经搜索过了
tubes[i].MoveBack(tubes[j], ms); // 转移回来,继续搜索
continue;
}
status.insert(s); // 记录状态
step.push_back(make_pair(i, j)); // 记录步骤
Solve(); // dfs 搜索下一步
if (solved) {
return;
}
Tube::MoveBack(tubes[i], tubes[j], ms); // 转移回来
step.pop_back(); // 删除步骤
}
}
}
if (allSolved) { // 中途如果发现搜索到了任意解,立即退出
solved = true;
return;
}
// 放在空瓶
bool isVis[tubes.size()+1] = {}; // 因为转移时是多个试管的颜色转移到同一个试管,所以对于已经尝试转移过的颜色就可以直接跳过,避免重复搜索
for (int i=0; i<tubes.size(); i++) {
if (tubes[i].IsEmpty() || tubes[i].IsFullAndSame()) continue; // 空瓶或者已经完成的,不用转移颜色
if (isVis[tubes[i].Top()]) continue; // 该试管顶层的颜色已经尝试转移过了,不需再尝试
int curColor = tubes[i].Top();
isVis[curColor] = true;
int eIdx = FindEmpty(); // 找到一个空试管
if (eIdx == -1) {
return;
}
vector<pair<int, int> > pos;
for (int j=i; j < tubes.size(); j++) { // 尝试将第i个试管开始所有顶层是curColor的颜色都转移到空试管(第0~i-1个试管不用遍历是因为前面已经遍历过了)
if (j!=eIdx && !tubes[j].IsEmpty() && tubes[j].Top() == curColor) { // 可以转移(不会出现eIdx试管空间不够的情况)
int ms = Tube::MoveColor(tubes[j], tubes[eIdx]);
pos.push_back(make_pair(j, ms));
step.push_back(make_pair(j, eIdx));
}
}
// 将所有满足可转移的颜色都转移了,再生成状态key
string s = GetHashKey();
if (status.find(s) != status.end()) { // 状态去重,如果状态已经存在
for (int j = 0; j<pos.size(); j++) { // 按照之前记录的步骤移回来
Tube::MoveBack(tubes[pos[j].first], tubes[eIdx], pos[j].second);
step.pop_back();
}
continue;
}
status.insert(s); // 记录状态
Solve(); // 进行下一轮搜索
if (solved) return;
for (int j = 0; j<pos.size(); j++) {// 按照之前记录的步骤移回来
Tube::MoveBack(tubes[pos[j].first], tubes[eIdx], pos[j].second);
step.pop_back();
}
}
}
除此之外,完整代码还包括一些其他部分:使用opencv识别游戏图片颜色,c++加载游戏数据等