水排序深搜解法

文章讲述了如何使用深度优先搜索(深搜)结合剪枝策略解决一款试管倒水游戏的问题。作者首先分析了游戏的玩法和策略,然后提出了记忆化搜索以避免重复搜索,同时详细介绍了剪枝的思路,包括无效倒水的避免和颜色的高效转移。最后,给出了C++实现的水管(Tube)类和游戏(Game)类的关键代码,包括试管的状态管理、颜色倒入倒出、状态去重等功能。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

        有一段时间沉迷这个游戏,玩游戏没什么诀窍,多试,每种路径走一次总能找到一个合适的。试久了有点烦躁,想说这不就是深搜吗,我为什么要人工搜,写个代码搜不就完了。于是打算自己实现。

方案选择

        上网找了一圈方案,基本都是启发式搜索。按大学时期对这个算法的学习,感觉有点用,但又有点随缘,加上启发式搜索实在复杂,于是还是坚持打算尝试用暴力深搜水一水。这个问题使用深搜的主要问题是可能出现的颜色分布情况太多(粗略估计是10^48数量级),不可能深搜穷举完,肯定是要根据玩法策略进行剪枝的。

        在实际运用中,发现深搜+剪枝也会出现耗时较长、爆栈等问题。打印了搜索过程后发现实际上大量颜色分布情况都在重复出现,只是瓶子的顺序有区别,因此决定加上状态记录,避免重复搜索。加上这步搜索快了很多,基本30+次尝试即可找到结果。所以最终使用的策略是记忆化搜索+深搜+剪枝

剪枝思路

根据实际玩的经验,倒水的思路主要有两个:

  1. 每次倒水需要把最上层颜色全部倒完,因为不暴露出下一个颜色,那么这次倒水实际上是无效的。
  2. 先整合非空瓶的颜色,即发现两个非空瓶最上层颜色相同并且满足1,优先合并这两个颜色;当非空瓶的都不能合并时,才尝试将非空瓶最上层转移到空瓶中。
  3. 空瓶、同种颜色满瓶的试管不用再合并。
  4. 当从非空瓶向空瓶转移颜色时,不仅需要转移选定瓶子最上层的颜色,还要将所有非空瓶中,可转移的该颜色都转移至同一空瓶中。该步骤也是玩的过程总结出的经验,暴露更多下层颜色永远是转移当前颜色的目标。

以上这种方案有一个问题,对于以下情况无法处理。目前并未发现需要实现该步骤才能解的情况,可能遇到该情况可以通过优先合并其他颜色解决,或通过继续搜索避免出现以下情况最终实现

|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++加载游戏数据等

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值