C++实现碰手指游戏:揭秘后手必胜策略与博弈算法

该文章已生成可运行项目,

1. 项目概述:一个看似简单却暗藏玄机的博弈游戏

最近在整理一些经典的小游戏项目时,翻到了一个非常有意思的“碰手指游戏”。这个游戏的规则简单到令人发指:两个人轮流报数,每次可以报1到3个数,谁先报到10谁就输。听起来是不是有点像简化版的“抢30”?但它的策略核心却截然不同,而且有一个反直觉的结论—— 在这个“到10即消”的规则下,实际上是后手玩家拥有必胜策略 。这个结论让很多第一次接触的朋友都感到惊讶,明明感觉先手更有主动权,怎么就后手必胜了呢?

为了把这个有趣的数学博弈和编程实践结合起来,我决定用C++完整地实现一遍这个游戏,并且把必胜策略的逻辑也内置到AI对手中。这样,无论是想理解背后的数学原理,还是想获得一个可运行、可学习的C++小游戏源码,都能得到满足。这个小项目非常适合C++初学者用来练手,它涵盖了基本的输入输出、控制流、函数封装以及简单的算法逻辑,代码清晰,没有复杂的第三方依赖,在任何一个C++开发环境里都能轻松跑起来。

2. 游戏规则与必胜策略的数学原理拆解

2.1 “到10即消”的核心规则解析

我们先把这个游戏的规则说得再明确一些,这是所有分析的起点。

  1. 参与方 :两名玩家,可以是人vs人,人vs电脑,或者电脑vs电脑。
  2. 数字序列 :一个从1开始,按顺序递增的整数序列。我们关注的是报到数字“10”的那一刻。
  3. 行动规则 :双方轮流进行。在自己的回合,必须且只能选择报出 1个、2个或3个 连续的数字。例如,当前数字报到了5,那么当前玩家可以报“6”、或“6,7”、或“6,7,8”。
  4. 胜负判定 谁报出数字“10”,谁就立即输掉游戏 。注意,是“报出”数字10,而不是“累计报到”10。比如,当前数字是9,轮到玩家A,如果他选择报“10”,那么他直接告负;如果他选择报“9,10”,同样也是输,因为10包含在其中。

这个规则的关键在于“到10即输”,它把10变成了一个“必败点”。我们的目标,就是避免自己踏入这个必败点,同时想方设法把对手逼进去。

2.2 逆向推导:为什么后手必胜?

很多博弈问题都可以通过“逆向归纳法”来寻找必胜策略。我们从终点(输的点)开始,倒着推哪些点是“安全”的。

  1. 确定必败点(N点) :首先,数字10是一个绝对的必败点(记作 N-Position)。因为谁走到10,轮到他报数,他就输了。
  2. 寻找必胜点(P点) :如果一个点,可以通过一次合法操作(报1-3个数)将对手送入一个必败点(N点),那么这个点就是必胜点(P-Position)。
  3. 逆向推导过程
    • 数字10是N点(必败)。
    • 看数字9:从9出发,可以报“10”(1个数),直接将对手送入必败点10。所以, 9是P点(必胜) 。谁走到9,谁就赢了。
    • 看数字8:从8出发,可以报“9”(1个数),将对手送入必胜点9,这对我们不利。可以报“8,9”(2个数)或“8,9,10”(3个数),都会包含9或10。我们发现,无论怎么走,都会把对手送到9(P点)或者自己直接报10(N点)。所以, 8是N点(必败)
    • 看数字7:从7出发,可以报“8”(1个数),将对手送入必败点8。所以, 7是P点(必胜)
    • 看数字6:从6出发,可以报“7”(1个数,送对手到P点),报“6,7”(2个数,送对手到P点),报“6,7,8”(3个数,送对手到N点8)。 存在一种选择(报3个数)可以将对手送入N点 ,所以6是P点。
    • 看数字5:从5出发,报“6”(送P点),“5,6”(送P点),“5,6,7”(送P点)。所有选择都指向对手的P点,所以 5是N点
    • 依次类推:
      • 4:可以报“5”(送N点),所以4是P点。
      • 3:报“4”(送P点),“3,4”(送P点),“3,4,5”(送N点)。存在送N点的选择,所以3是P点。
      • 2:报“3”(送P点),“2,3”(送P点),“2,3,4”(送P点)。全是P点,所以2是N点。
      • 1:报“2”(送N点),所以1是P点。
      • 0(游戏起点):可以看作报“1”(送P点),“1,2”(送N点),“1,2,3”(送P点)。存在送N点的选择,所以起点0是P点。

注意 :这里的“点”指的是游戏开始前或某一轮开始前,下一个将要报的数字的前一个数字。例如,当数字7刚被报出,下一个该从8开始报,我们说当前“站在”点7上。

推导结论表格

数字点 类型 说明
0 P点 (必胜) 游戏起点
1 P点
2 N点 (必败)
3 P点
4 P点
5 N点
6 P点
7 P点
8 N点
9 P点
10 N点 (终局必败)

这个表格揭示了规律: N点(必败点)是2、5、8、10 。它们之间相差3。而10是最终的N点。

后手必胜策略 :游戏从0开始,0是P点(必胜点)。先手玩家从0开始行动,他一定会将局面从一个P点变为一个N点(因为从P点出发,所有选择都包含走向N点的可能,但先手可能不知道策略而走错)。而后手玩家的任务就是, 无论先手怎么走,都通过自己的操作,将局面重新拉回到下一个N点(2,5,8)上,直到最后将先手逼到10这个终极N点

例如:

  • 先手报“1”(走到点1,N点?不,点1是P点,他离开了起点0这个P点,但没走到N点)。后手应该报“2,3”(2个数),走到点3?不对,这样局面是点3(P点),策略不对。正确后手策略是:先手从0报“1”后,当前数字是1,下一个该报2。后手应该报“2”(1个数),这样报出的数字是2,局面走到点2(N点)。完美。
  • 先手报“1,2”(走到点2,N点)。后手发现局面已经是N点(对自己不利),此时他无法一步将局面扭转到下一个N点,因为从N点出发,所有走法都会送对手到P点。所以,如果先手第一步就走到了N点(如2),后手就失去了必胜保障,先手反而有机会赢。但先手如果不懂策略,很难第一步就精准走到N点。

因此, 在双方都完全理性、知晓必胜策略的情况下,游戏起点(0)是必胜点,所以先手玩家是必胜的。但是,我们通常假设先手玩家不了解这个策略,而后手玩家(AI)知道。那么后手AI就可以通过策略保持不败,并且一旦先手犯错,后手就能必胜。 这就是“后手必胜”说法的常见语境:面对一个不知道策略的玩家,后手的AI是必胜的。

2.3 策略算法化:如何让AI“聪明”起来

根据上面的推导,AI(后手)的核心策略可以总结为:

  1. 目标 :总是试图在自己行动后,使累计报出的最后一个数字是“必败点N”(即2,5,8,10)。因为10是终局,所以前期目标是2,5,8。
  2. 方法 :计算当前距离下一个必败点还有多少个数。设当前最后一个已报数字为 current ,下一个必败点序列为 [2,5,8,10] 。找到第一个大于 current 的必败点 target 。计算差值 diff = target - current
    • 如果 diff 的值在1到3之间,那么AI就报 diff 个数,正好走到 target
    • 如果 diff 大于3,说明当前离下一个必败点还远,AI可以随机报1-3个数(或者采用一种保守策略,比如总是报1个数),等待对手犯错。因为只要对手不走到必败点,局面就还在AI的控制节奏之外,但AI也没犯错误。
    • 如果 diff 等于0?这不可能,因为如果 current 已经是必败点,那应该是对手的回合,AI不会在必败点上行动。
  3. 随机性 :当 diff 大于3时,AI的随机行动可以增加游戏的可玩性,避免模式固定。同时,如果玩家也懂得策略,那么游戏就会进入“拼失误”的环节,谁先偏离必败点序列谁就输。

3. C++游戏实现详解与源码剖析

接下来,我们把这个策略用C++实现出来。项目结构清晰,主要包含游戏状态管理、玩家回合处理、AI逻辑和主循环。

3.1 游戏核心类的设计

我们将游戏逻辑封装在一个类中,这样状态清晰,易于管理。

// FingerTouchGame.h
#ifndef FINGERTOUCHGAME_H
#define FINGERTOUCHGAME_H

#include <vector>
#include <string>

class FingerTouchGame {
public:
    // 游戏模式枚举
    enum class GameMode {
        PvP,    // 玩家对玩家
        PvC,    // 玩家对电脑 (玩家先手)
        CvC     // 电脑对电脑 (演示用)
    };

    // 构造函数,初始化游戏
    explicit FingerTouchGame(GameMode mode = GameMode::PvC);

    // 开始游戏主循环
    void StartGame();

    // 获取当前最后一个数字
    int GetCurrentNumber() const;

    // 获取当前玩家 (1 或 2)
    int GetCurrentPlayer() const;

    // 判断游戏是否结束
    bool IsGameOver() const;

    // 获取赢家 (0: 未结束, 1: 玩家1, 2: 玩家2)
    int GetWinner() const;

private:
    GameMode mode_;          // 游戏模式
    int currentNumber_;      // 当前最后一个已报出的数字,初始为0
    int currentPlayer_;      // 当前行动玩家 (1 或 2)
    bool gameOver_;          // 游戏结束标志
    int winner_;             // 获胜者

    // 必败点序列
    const std::vector<int> losingPositions_ = {2, 5, 8, 10};

    // 处理玩家回合 (人类)
    void ProcessHumanTurn();

    // 处理电脑回合 (AI)
    void ProcessComputerTurn();

    // 执行报数操作,并更新状态
    // 参数count: 本次要报的数字个数 (1,2,3)
    // 返回值: 是否操作成功 (例如,报数后是否导致游戏结束)
    bool PerformMove(int count);

    // AI决策:根据策略返回应该报的数字个数 (1,2,3)
    int MakeAIDecision() const;

    // 显示当前游戏状态
    void DisplayGameState() const;

    // 显示获胜信息
    void DisplayWinner() const;

    // 验证输入是否合法 (1,2,3 且不超过10)
    bool IsValidMove(int count) const;
};

#endif // FINGERTOUCHGAME_H

设计思路解析

  • 状态集中管理 :所有游戏状态(当前数字、当前玩家、是否结束等)都作为类的成员变量,避免使用全局变量,提高了代码的封装性和可维护性。
  • 模式支持 :通过 GameMode 枚举支持三种模式,方便测试和演示。PvC模式是我们重点,其中电脑作为后手应用必胜策略。
  • 核心数据 losingPositions_ 存储了计算好的必败点序列,AI决策函数会查询这个序列。
  • 操作与显示分离 PerformMove 只负责更新内部状态, DisplayGameState 负责显示,符合单一职责原则。

3.2 核心逻辑的实现

让我们看看关键函数的实现,特别是AI决策和游戏主循环。

// FingerTouchGame.cpp
#include “FingerTouchGame.h”
#include <iostream>
#include <cstdlib>
#include <ctime>
#include <limits>

// 初始化随机种子
namespace {
    bool srandCalled = false;
    void InitRandom() {
        if (!srandCalled) {
            std::srand(static_cast<unsigned int>(std::time(nullptr)));
            srandCalled = true;
        }
    }
}

FingerTouchGame::FingerTouchGame(GameMode mode)
    : mode_(mode), currentNumber_(0), currentPlayer_(1), gameOver_(false), winner_(0) {
    InitRandom(); // 确保随机数生成器已初始化
}

void FingerTouchGame::StartGame() {
    std::cout << “\n========== 碰手指游戏 (到10即输) ==========\n”;
    std::cout << “规则:两人轮流报数,每次可报1-3个连续数字。谁报到数字10谁输!\n”;

    while (!IsGameOver()) {
        DisplayGameState();

        switch (mode_) {
            case GameMode::PvP:
                ProcessHumanTurn(); // 总是人类回合
                break;
            case GameMode::PvC:
                if (currentPlayer_ == 1) {
                    ProcessHumanTurn(); // 玩家1是人类
                } else {
                    ProcessComputerTurn(); // 玩家2是电脑
                }
                break;
            case GameMode::CvC:
                ProcessComputerTurn(); // 两个都是电脑
                break;
        }

        // 切换玩家
        if (!gameOver_) {
            currentPlayer_ = (currentPlayer_ == 1) ? 2 : 1;
        }
    }

    DisplayWinner();
}

// AI决策的核心函数
int FingerTouchGame::MakeAIDecision() const {
    // 找到下一个必败点
    int target = -1;
    for (int pos : losingPositions_) {
        if (pos > currentNumber_) {
            target = pos;
            break;
        }
    }

    // 如果找到了下一个必败点,计算差值
    if (target != -1) {
        int diff = target - currentNumber_;
        if (diff >= 1 && diff <= 3) {
            // 完美!可以一步走到必败点
            return diff;
        }
    }

    // 如果无法一步走到必败点,或者已经过了所有必败点(理论上不会,因为到10游戏结束),则随机走
    // 这里可以加入一些简单策略,比如避免直接报出9(送对手到必胜点)
    // 简单实现:随机1-3
    return (std::rand() % 3) + 1; // 生成1,2,3的随机数
}

void FingerTouchGame::ProcessComputerTurn() {
    std::cout << “\n-> 玩家” << currentPlayer_ << “ (电脑) 正在思考...\n”;
    // 可以加一个短暂延迟,模拟思考过程
    // std::this_thread::sleep_for(std::chrono::milliseconds(800));

    int move = MakeAIDecision();
    std::cout << “电脑决定报 ” << move << “ 个数。” << std::endl;

    PerformMove(move);
}

void FingerTouchGame::ProcessHumanTurn() {
    int move = 0;
    bool validInput = false;

    while (!validInput) {
        std::cout << “\n-> 玩家” << currentPlayer_ << “,请输入要报的数字个数 (1, 2, 3): “;
        std::cin >> move;

        // 处理非数字输入导致的cin错误状态
        if (std::cin.fail()) {
            std::cin.clear(); // 清除错误状态
            std::cin.ignore(std::numeric_limits<std::streamsize>::max(), ‘\n’); // 忽略错误输入行
            std::cout << “输入错误!请输入一个数字。\n”;
            continue;
        }
        std::cin.ignore(std::numeric_limits<std::streamsize>::max(), ‘\n’); // 忽略换行符

        if (IsValidMove(move)) {
            validInput = true;
        } else {
            std::cout << “无效的输入!请输入1, 2 或 3,并且报数后不能超过10。\n”;
        }
    }

    PerformMove(move);
}

bool FingerTouchGame::PerformMove(int count) {
    // 报数
    std::cout << “报数: “;
    for (int i = 1; i <= count; ++i) {
        std::cout << (currentNumber_ + i);
        if (i < count) std::cout << “, “;
    }
    std::cout << std::endl;

    currentNumber_ += count;

    // 检查游戏是否结束
    if (currentNumber_ >= 10) {
        gameOver_ = true;
        winner_ = (currentPlayer_ == 1) ? 2 : 1; // 当前报出10的玩家输,所以赢家是另一方
        std::cout << “\n玩家” << currentPlayer_ << “ 报出了数字10!\n”;
        return true;
    }
    return false;
}

// 其他辅助函数实现...
bool FingerTouchGame::IsValidMove(int count) const {
    if (count < 1 || count > 3) return false;
    if (currentNumber_ + count > 10) return false; // 报数后不能超过10
    return true;
}

void FingerTouchGame::DisplayGameState() const {
    std::cout << “\n——————————\n”;
    std::cout << “当前数字: “ << currentNumber_ << std::endl;
    std::cout << “下一个数字从 ” << (currentNumber_ + 1) << “ 开始\n”;
    std::cout << “轮到玩家 ” << currentPlayer_;
    if (mode_ == GameMode::PvC && currentPlayer_ == 2) {
        std::cout << “ (电脑)”;
    }
    std::cout << “ 行动\n”;
}

代码要点解析

  1. AI决策 ( MakeAIDecision ) :这是实现必胜策略的核心。它遍历预设的必败点序列,找到第一个比当前数字大的点作为目标。计算差值 diff ,如果在1-3之间,就返回 diff ,实现最优走法。否则,随机返回1-3。这个简单的逻辑使得后手AI在面对不懂策略的玩家时几乎立于不败之地。
  2. 输入验证与鲁棒性 :在 ProcessHumanTurn 中,我们不仅检查输入数字是否在1-3且不超过10,还处理了 std::cin.fail() 的情况(比如用户输入了字母)。这是编写交互式控制台程序时必须注意的细节,能防止程序因意外输入而崩溃。
  3. 游戏状态更新 ( PerformMove ) :这个函数负责核心的状态转移。它输出报数过程,更新 currentNumber_ ,并检查是否触发游戏结束条件( currentNumber_ >= 10 )。注意,这里用的是 >=10 而不是 ==10 ,是一种防御性编程,防止意外逻辑错误导致数字跳过10。
  4. 主循环 ( StartGame ) :清晰地根据游戏模式分发回合处理逻辑,并在每轮结束后切换当前玩家,直到游戏结束。

3.3 主函数与编译运行

一个简单的 main.cpp 来启动游戏:

// main.cpp
#include “FingerTouchGame.h”
#include <iostream>

int main() {
    int choice = 0;
    std::cout << “选择游戏模式:\n”;
    std::cout << “1. 玩家 vs 电脑 (推荐,体验后手AI策略)\n”;
    std::cout << “2. 玩家 vs 玩家\n”;
    std::cout << “3. 电脑 vs 电脑 (演示)\n”;
    std::cout << “请输入选择 (1-3): “;
    std::cin >> choice;

    FingerTouchGame::GameMode mode;
    switch (choice) {
        case 1: mode = FingerTouchGame::GameMode::PvC; break;
        case 2: mode = FingerTouchGame::GameMode::PvP; break;
        case 3: mode = FingerTouchGame::GameMode::CvC; break;
        default:
            std::cout << “无效选择,默认使用玩家vs电脑模式。\n”;
            mode = FingerTouchGame::GameMode::PvC;
            break;
    }

    FingerTouchGame game(mode);
    game.StartGame();

    std::cout << “\n游戏结束,谢谢游玩!\n”;
    return 0;
}

编译与运行 : 这是一个标准的C++控制台项目。你可以使用任何喜欢的IDE或命令行工具编译。

  • GCC/Clang命令行 g++ -std=c++11 main.cpp FingerTouchGame.cpp -o finger_game
  • Visual Studio :创建一个空项目,添加这三个 .cpp 文件,编译运行即可。

运行后,选择模式1(玩家vs电脑),你就可以作为先手,挑战那个拥有“后手必胜”策略的AI了。看看你能坚持多久!

4. 项目扩展思路与编程技巧探讨

实现基础版本后,我们可以从多个角度对这个项目进行扩展,这不仅能增加趣味性,也能深化对C++和博弈论的理解。

4.1 游戏规则变体与策略泛化

“到10即消”只是这类“取物游戏”或“减法游戏”的一个特例。我们可以抽象出一个更通用的模型:

  • 总数字N :不是10,可以是任意正整数N(比如21点游戏)。
  • 每次可取范围 :不是1-3,可以是1到k(k为任意正整数)。
  • 胜负目标 :可以是“取到最后一个输”(Misère规则,如本项目),也可以是“取到最后一个赢”(Normal规则)。

对于通用模型(总数为N,每次取1~k个,取最后一个输),其必胜策略的规律是: 第一个必败点是 (k+1) 的倍数吗? 不一定。对于“取最后一个输”,其必败点序列的通项公式会更复杂一些,但依然可以通过动态规划逆向推导出来。我们可以修改程序,让用户输入N和k,然后程序自动计算并应用必胜策略。这涉及到算法从“硬编码”到“动态计算”的升级。

4.2 AI难度分级与机器学习初探

目前的AI是“完美”的(在它知道策略的情况下)。我们可以引入难度分级:

  • 简单AI :完全随机走法。
  • 中等AI :在基础策略上加入一定概率的“失误”,比如有20%的概率不执行最优走法,而是随机走,这样人类玩家有机会赢。
  • 困难AI :即当前的完美策略AI。
  • 学习型AI(挑战) :可以尝试用简单的强化学习(如Q-learning)来训练AI。状态是当前数字,动作是报1/2/3个数,奖励在输的时候为负,赢的时候为正。让AI自我对弈成千上万局,最终它也能学习到类似的必胜策略。这对于C++初学者来说是个不小的挑战,但可以作为了解AI入门的一个有趣方向。

4.3 代码优化与工程化实践

对于这个小项目,我们还可以在代码质量上做些文章:

  • 使用智能指针管理资源 :虽然当前项目没有动态资源,但可以设想如果游戏状态更复杂,引入 std::unique_ptr 来管理某些对象是良好的习惯。
  • 将策略算法抽象为接口 :定义一个 IGameStrategy 纯虚类,然后派生出 RandomStrategy PerfectStrategy LearningStrategy 等。游戏主类持有一个策略接口的指针,通过注入不同的策略实现来改变AI行为。这大大增强了代码的扩展性和可测试性。
  • 加入单元测试 :使用Google Test等框架,为 IsValidMove PerformMove MakeAIDecision 等核心函数编写测试用例,确保逻辑正确。特别是AI决策函数,可以测试其在各种局面下(当前数字为1,4,7等)是否做出了预期中的最优选择。
  • 图形化界面 :用Qt、SFML或甚至控制台图形库(如ncurses)为游戏制作一个简单的图形界面,用进度条或高亮数字显示当前状态,提升用户体验。

4.4 常见的编译与运行问题排查

对于初学者,在尝试编译运行此代码时可能会遇到一些问题,这里集中列举一下:

  1. 头文件找不到 :确保 FingerTouchGame.h FingerTouchGame.cpp main.cpp 在同一个目录下,或者正确配置了项目的包含路径。
  2. std::this_thread::sleep_for 未定义 :如果取消注释AI思考延迟的那行代码,需要包含 <thread> <chrono> 头文件,并且编译器需要支持C++11及以上标准。编译时加上 -std=c++11 参数。
  3. 随机数每次一样 :我们使用了 std::srand(std::time(nullptr)) 来初始化随机种子。如果在极短时间内多次运行程序, time(nullptr) 可能返回相同的值,导致随机序列相同。这在演示中问题不大。对于要求更高的场景,可以考虑使用C++11的 <random> 库。
  4. 输入缓冲问题 :我们在 ProcessHumanTurn 中使用了 std::cin.ignore(...) 来清除输入缓冲区。这是为了避免之前输入残留的换行符影响后续读取。如果发现输入行为异常,可以检查这里的逻辑。
  5. 游戏逻辑感觉不对 :请再次核对规则“谁报到10谁输”。有时容易和“谁报到10谁赢”混淆。必胜策略是完全相反的。确保你的理解和代码中的判定条件( currentNumber_ >= 10 )匹配的是“输”的规则。

这个“碰手指游戏”项目虽小,却串联起了数学博弈、算法策略和C++面向对象编程。通过动手实现它,你不仅能获得一个有趣的游戏,更能深入理解“必胜策略”背后的逆向推导思维,以及如何将数学模型转化为可靠的代码。希望这份详细的源码和解析能给你带来启发。

本文章已经生成可运行项目
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值