目录
[1. 使用 deque 实现玩家手牌管理](#1. 使用 deque 实现玩家手牌管理)
[2. 使用 vector 实现桌面牌堆管理](#2. 使用 vector 实现桌面牌堆管理)
[3. 使用数组映射实现牌面位置索引](#3. 使用数组映射实现牌面位置索引)
[4. 游戏轮转流程与 turn 状态管理](#4. 游戏轮转流程与 turn 状态管理)
[5. 游戏状态编码与字符串表示](#5. 游戏状态编码与字符串表示)
[6. 使用无序集合实现状态判重与循环检测](#6. 使用无序集合实现状态判重与循环检测)
[7. 终止条件](#7. 终止条件)
[Step 0:最小骨架](#Step 0:最小骨架)
[Step 1:把手牌建成队列结构](#Step 1:把手牌建成队列结构)
[Step 2:加入桌面牌堆 + 轮流出牌框架](#Step 2:加入桌面牌堆 + 轮流出牌框架)
[Step 3:加入快速判断桌面是否已有该牌](#Step 3:加入快速判断桌面是否已有该牌)
[Step 4:实现赢牌收回一段桌面牌](#Step 4:实现赢牌收回一段桌面牌)
[Step 5:补齐游戏结束条件和输出胜者手牌](#Step 5:补齐游戏结束条件和输出胜者手牌)
[Step 6:加入严格判环](#Step 6:加入严格判环)
前置知识
1. 使用 deque 实现玩家手牌管理
1. 目标与建模
使用双端队列(deque)对"玩家手牌"进行建模,实现从队头出牌和从队尾收牌的操作。要求能够实现从队头出牌和从队尾收牌的基本操作。
2. 核心操作(C++ STL deque)
初始化:deque<char> A(s.begin(), s.end());
将字符串或序列初始化为 deque。
访问队头元素:A.front()
返回队头元素的引用。
队头出队:A.pop_front()
移除队头元素。
队尾入队:A.push_back(x)
在队尾插入元素 x。
判断队列是否为空:A.empty()
返回布尔值表示 deque 是否为空。
3. 为什么使用 deque?
两端操作的高效性:玩家手牌需支持从队头出牌(pop_front)和从队尾收牌(push_back)
deque 在两端插入和删除的时间复杂度均为 O(1),而 vector 在头部删除(erase(begin()))需要移动所有后续元素,代价为 O(n)。因此,deque 天生适合此类场景。
4. 自测
初始手牌:
A = [K, 8, X](左侧为队头)
要求:
玩家出一张牌(牌值为队头元素 K),并将其从手牌中移除。
玩家依次收回两张牌 2 和 7(按顺序从队尾加入)
cpp
// 1. 出牌:从队头移除元素
char x = A.front(); // x = 'K'
A.pop_front(); // 移除队头,A 变为 [8, X]
// 2. 收牌:依次从队尾加入新牌
A.push_back('2'); // A 变为 [8, X, 2]
A.push_back('7'); // A 变为 [8, X, 2, 7]
2. 使用 vector 实现桌面牌堆管理
1. 目标与建模
使用 vector 对"桌面牌堆"进行建模,实现向桌面顶部压牌、查看顶部牌面、获取牌堆长度以及截取特定区间的操作。要求能够实现向桌面顶部压牌、查看顶部牌面、获取牌堆长度以及截取特定区间的操作。
2. 核心操作(C++ STL vector)
向桌面顶部压牌:table.push_back(x)
在 vector 尾部(牌堆顶部)插入元素 x。
查看桌面顶部牌面:table.back()
返回顶部元素的引用。
获取桌面牌堆长度:table.size()
返回当前牌堆中牌的张数。
截取牌堆:table.resize(k)
将牌堆大小调整为 k,若 k < 当前大小,则丢弃尾部的牌(从顶部开始移除)
随机访问:table[i]
返回索引 i 处元素的引用(i = 0 表示桌面最底部的牌)
3. vector 作为栈的语义
索引与位置对应关系:
table[0]:牌堆最底部的牌(最早放置)
table.back():牌堆最顶部的牌(最近放置)
截断操作的直观性:当需要"收走顶部若干张牌"时,牌堆剩余部分即为原始序列的前缀。使用 resize(k) 可直接截断尾部,保留前 k 张牌,操作自然高效。
4. 自测
初始牌堆:
table = [K, 2, 8, 7](K 为底部,7 为顶部)
操作要求:
向桌面顶部压入一张新牌 X。
将桌面牌堆截断,仅保留最底部的 3 张牌。
cpp
// 1. 压牌:向顶部添加新牌
table.push_back('X'); // table 变为 [K, 2, 8, 7, X]
// 2. 截断:保留前 3 张牌(索引 0, 1, 2)
table.resize(3); // table 变为 [K, 2, 8]
3. 使用数组映射实现牌面位置索引
1. 目标与建模
使用数组作为哈希表记录牌面在桌面上的位置信息。通过 pos[ch] 数组实现从牌面字符到其桌面位置的快速映射,要求能够正确初始化、更新和查询位置信息,并理解在游戏规则下的正确更新时机。要求能够正确初始化、更新和查询位置信息,并理解在游戏规则下的正确更新时机。
2. 核心操作
初始化数组:fill(pos, pos + 128, -1);
将数组所有元素初始化为 -1,表示该牌面当前不在桌面上。
位置更新:pos[ch] = index;
记录字符 ch 在桌面上的位置索引 index。
位置查询:int old = pos[(unsigned char)ch];
获取字符 ch 当前在桌面上的位置,若返回 -1 则表示该牌不在桌面。
3. 关键理解
字符作为数组下标:
C/C++ 中字符本质是整数,可直接作为数组下标。
数组作为哈希表的优势:
当键值范围固定且较小(ASCII 字符集)时,数组比 map 或 unordered_map 具有更好的缓存局部性和访问效率,时间复杂度为 O(1)。
更新时机的严谨性:
在游戏规则中,当桌面上出现重复牌面时,需要先根据旧位置触发收牌逻辑,然后才能更新位置信息。直接覆盖旧位置会导致收牌逻辑错误。
4. 自测
初始桌面:
table = [K, 2, 8, 7](索引分别为 0, 1, 2, 3)
维护的位置数组:
pos['K'] = 0
pos['2'] = 1
pos['8'] = 2
pos['7'] = 3
问题:
(1) 当前 pos['8'] 的值是多少?
(2) 若向桌面顶部压入一张 '8',在触发收牌规则前,是否应该立即将 pos['8'] 更新为新位置?
解答:
(1) pos['8'] = 2(对应桌面索引 2 的位置)
(2) 不应该直接更新。正确流程应为:
先查询旧位置:int old = pos['8'];(获取值 2)
根据游戏规则处理收牌逻辑(使用旧位置)
清理或更新相关牌面的位置信息
最后更新 pos['8'] 为新位置(如果新牌仍留在桌面)
4. 游戏轮转流程与 turn 状态管理
1. 目标与建模
游戏模拟的完整轮转流程包括正常出牌、收牌触发条件、手牌更新、桌面状态维护以及回合切换机制。要求能够准确描述并实现每一轮的标准操作流程。
2. 一轮标准流程
设当前玩家手牌队列为 me,对方玩家为 op:
假设在玩家打出 "2" 之前,游戏的初始状态是:
| 模块 | 具体内容 |
|---|---|
| 桌面(table) | [K(0), 2(1), 8(2), 7(3)](括号里是每个牌的索引,0 是最底部,3 是顶部) |
| 位置数组(pos) | pos['K']=0、pos['2']=1、pos['8']=2、pos['7']=3(记录每张牌在桌面的位置) |
| 当前玩家 | 假设是 "我(me)",手牌里有一张 "2"(准备打出) |
| 回合状态(turn) | 假设 turn=0 代表 "我" 的回合,turn=1 代表对手(op)的回合 |
第 1 步:出牌
char x = me.front(); // 获取队头牌面
me.pop_front(); // 移除队头
table.push_back(x); // 压入桌面顶部(table末尾)
最后一行代码可看上面2.3
执行后结果:
桌面(table)变成:[K(0), 2(1), 8(2), 7(3), 2(4)](新增的 2 在索引 4,是桌面最新 / 最顶部的牌)
手牌(me):少了一张 "2"
pos 数组:暂时没变化(还是 pos['2']=1)
第 2 步:查询历史位置
int old = pos[x]; // 查询该牌面是否已在桌面
关键判断:
old == -1:牌之前不在桌面,则不触发收牌,切换回合;
old >= 0:牌之前在桌面(这里 old=1≥0),则触发收牌,当前玩家继续出牌。
第 3 步:分支处理
情况 A:未触发收牌 (old == -1)
pos[x] = table.size() - 1; // 记录新位置
turn ^= 1; // 切换回合,轮到对方出牌
异或(^)的基础规则是两个二进制位相同则结果为 0,不同则结果为 1。
| turn 的当前值 | turn ^ 1 的结果 | 实际含义 |
|---|---|---|
| 0 | 0 ^ 1 = 1 | 从 "我的回合" 切到 "对手" |
| 1 | 1 ^ 1 = 0 | 从 "对手回合" 切回 "我" |
情况 B:触发收牌 (old >= 0)
子步骤 1:逆序收牌(把桌面的牌拿回自己手牌)
核心规则:从桌面最顶部(新 2,索引 4)到旧 2(索引 1)的所有牌,逆着拿回来(先拿顶部的,再拿下面的)
for (int i = table.size() - 1; i >= old; --i) {
me.push_back(table[i]); // 把table[i]加到自己手牌队尾
pos[table[i]] = -1; // 这些牌被收走了,pos标记为-1(不在桌面)
}
执行后结果:
手牌(me):队尾新增了 [2,7,8,2](逆序收牌的结果)
pos 数组:pos['2']=-1、pos['7']=-1、pos['8']=-1(只有 pos ['K']=0 还在)
子步骤 2:更新桌面状态(截断桌面)
核心动作:把桌面中 "被收走的牌" 删掉,只保留 old(1)之前的牌(索引 0)
table.resize(old); // old=1,所以table只保留前1个元素(索引0)
可看上面2.4,截断操作会包括自身,即old
执行后结果:
桌面(table)变成:[K(0)](只留了最开始的 K,其他都被收走了)
子步骤 3:回合控制(谁继续出牌)
核心规则:触发收牌时,当前玩家 "赢了这轮",继续出牌,所以turn不变。
初始 turn=0(我的回合),现在还是 turn=0 ,所以接下来还是我出牌。
3. 关键理解
回合切换条件:
未触发收牌时,回合切换 (turn ^= 1)
触发收牌时,回合不变(赢牌玩家继续出牌)
收牌顺序:必须从桌面顶部向底部(从新到旧)逆序收牌
状态同步:收牌时需要同步更新位置数组,将被收走的牌面标记为不在桌面 (pos[...] = -1)
5. 游戏状态编码与字符串表示
1. 目标与建模
将复杂游戏局面编码为字符串,用于判重检测和状态记录。要求能够将回合信息、玩家手牌序列和桌面牌堆组合成唯一的字符串标识。
2. 核心编码方法
cpp
string state; // 状态字符串
state.reserve(预估长度); // 预分配空间提升性能
// 1. 编码回合信息
state.push_back(char('0' + turn)); // turn 转换为字符 '0' 或 '1'
state.push_back('|'); // 分隔符
// 2. 编码玩家A手牌序列
state.append(A.begin(), A.end()); // 追加A的手牌
state.push_back('|'); // 分隔符
// 3. 编码玩家B手牌序列
state.append(B.begin(), B.end()); // 追加B的手牌
state.push_back('|'); // 分隔符
// 4. 编码桌面牌堆序列
state.append(table.begin(), table.end()); // 追加桌面牌堆
3. 关键设计原则
回合信息必须编码:相同的牌序但不同回合玩家会导致不同的游戏走向,因此必须包含 turn 信息
分隔符的必要性:使用分隔符避免序列边界歧义,确保编码的唯一性,比如"12|3" 与 "1|23" 代表完全不同的状态
顺序一致性:编码顺序应固定为:turn、A手牌、B手牌、桌面牌堆
4. 自测
游戏状态:
当前回合:turn = 1(玩家B的回合)
玩家A手牌:A = [K, 8]
玩家B手牌:B = [2]
桌面牌堆:table = [7, X]
编码实现:
cpp
string state;
state.reserve(10); // 预估长度
state.push_back('1'); // turn=1
state.push_back('|');
state.append("K8"); // A手牌
state.push_back('|');
state.push_back('2'); // B手牌(单个字符)
state.push_back('|');
state.append("7X"); // 桌面牌堆
生成的状态字符串:"1|K8|2|7X"
6. 使用无序集合实现状态判重与循环检测
1. 目标与建模
用哈希集合(unordered_set)进行游戏状态判重,通过检测重复状态来判定游戏进入无限循环,从而确保模拟算法的终止性。要求能正确实现基于集合的循环检测机制。
2. 核心实现方法
cpp
unordered_set<string> seen; // 全局状态集合
// 每轮生成状态字符串 state 后
auto result = seen.insert(state); // 尝试插入集合
if (!result.second) { // 插入失败,说明状态已存在
// 检测到重复状态,游戏进入无限循环
return -1; // 或根据题目要求返回特定结果
}
insert() 返回值:pair<iterator, bool>
second 为 true:状态首次出现,成功插入
second 为 false:状态已存在,插入失败
为什么状态重复 = 无限循环?
游戏的演变是 "确定性" 的:给定一个完整状态(turn、A/B 手牌、桌面牌),下一步的操作(谁出牌、出什么牌、是否收牌、状态如何变化)是唯一确定的。因此如果某个状态第二次出现,说明从这个状态开始的所有后续步骤都会和第一次完全一样,形成 "闭环",永远无法结束。
7. 终止条件
终止条件定义
根据游戏规则,游戏结束的唯一条件是:某一方手牌为空。表现为:
玩家在出牌后手牌变为空
游戏立即结束,对方获胜
检查时机的关键原则
核心原则:只有在没有触发赢牌且准备切换回合时,才检查当前出牌方是否手牌为空。
错误检查的后果:如果在赢牌后立即检查,会将本该继续的局面误判为结束,因为:
赢牌玩家收回了牌,手牌必然非空
根据规则,赢牌玩家应继续出牌
此时检查 empty() 无意义且会导致逻辑错误
代码演变
Step 0:最小骨架
目标:确认输入输出格式无误。
cpp
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string sA, sB;
cin >> sA >> sB;
// TODO: 模拟游戏
cout << -1 << "\n";
return 0;
}
Step 1:把手牌建成队列结构
变化:引入 deque<char> A,B,把字符串变成"队头出、队尾进"的手牌结构。
cpp
#include <bits/stdc++.h>
using namespace std;
string toStr(const deque<char>& d) {
return string(d.begin(), d.end());
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string sA, sB;
cin >> sA >> sB;
deque<char> A(sA.begin(), sA.end());
deque<char> B(sB.begin(), sB.end());
// 先看看转回字符串是否正常
// (后面真正输出胜者手牌也会用到)
// cout << toStr(A) << "\n" << toStr(B) << "\n";
cout << -1 << "\n";
return 0;
}
Step 2:加入桌面牌堆 + 轮流出牌框架
变化:
引入 vector<char> table 表示桌面(back() 是桌面最上面)。
引入 turn 控制轮到谁出牌。
先实现"轮流从队头出一张,压到桌面",暂时不处理赢牌规则。
cpp
#include <bits/stdc++.h>
using namespace std;
string toStr(const deque<char>& d) {
return string(d.begin(), d.end());
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string sA, sB;
cin >> sA >> sB;
deque<char> A(sA.begin(), sA.end());
deque<char> B(sB.begin(), sB.end());
vector<char> table;
int turn = 0; // 0:A出 1:B出
while (!A.empty() && !B.empty()) {
deque<char>& me = (turn == 0 ? A : B);
char x = me.front();
me.pop_front();
table.push_back(x); // 压到桌面最上面
// 暂不赢牌:直接换人
turn ^= 1;
}
// 暂时还不会输出正确答案,先占位
cout << -1 << "\n";
return 0;
}
Step 3:加入快速判断桌面是否已有该牌
变化:
用 pos[ch] 记录牌点 ch 在桌面中的下标;不在则 -1。
出牌时先读 old = pos[x],再把 pos[x] 更新为新位置。
仍然不做赢牌,只是把"能不能赢"的判断条件准备好。
cpp
#include <bits/stdc++.h>
using namespace std;
string toStr(const deque<char>& d) {
return string(d.begin(), d.end());
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string sA, sB;
cin >> sA >> sB;
deque<char> A(sA.begin(), sA.end());
deque<char> B(sB.begin(), sB.end());
vector<char> table;
int pos[128];
fill(pos, pos + 128, -1);
int turn = 0; // 0:A出 1:B出
while (!A.empty() && !B.empty()) {
deque<char>& me = (turn == 0 ? A : B);
char x = me.front();
me.pop_front();
int old = pos[x]; // 出牌前是否在桌面出现过
table.push_back(x);
pos[x] = table.size() - 1;
if (old == -1) {
// 桌面没这张牌 -> 换人
turn ^= 1;
} else {
// 桌面有这张牌 -> 下一步实现"收牌"
// 先占位不做
}
}
cout << -1 << "\n";
return 0;
}
Step 4:实现赢牌收回一段桌面牌
变化:当 old != -1 时:
赢牌方把桌面从顶端 table.back() 一直收回到 old(含)
收回顺序要与桌面相反,所以从 i=table.size()-1 递减到 old,逐张 push_back 到手牌队尾
同时把这些牌在 pos 中清掉(设回 -1)
table.resize(old) 截掉被收走的后缀
赢牌方继续出牌:turn 不变
cpp
#include <bits/stdc++.h>
using namespace std;
string toStr(const deque<char>& d) {
return string(d.begin(), d.end());
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string sA, sB;
cin >> sA >> sB;
deque<char> A(sA.begin(), sA.end());
deque<char> B(sB.begin(), sB.end());
vector<char> table;
int pos[128];
fill(pos, pos + 128, -1);
int turn = 0; // 0:A出 1:B出
while (!A.empty() && !B.empty()) {
deque<char>& me = (turn == 0 ? A : B);
char x = me.front();
me.pop_front();
int old = pos[x];
table.push_back(x);
pos[x] = table.size() - 1;
if (old == -1) {
// 没赢牌:换人
turn ^= 1;
} else {
// 赢牌:收回桌面后缀 [old .. end)
for (int i = table.size() - 1; i >= old; --i) {
char t = table[i];
me.push_back(t);
pos[t] = -1;
}
table.resize(old);
// 赢牌方继续:turn 不变
}
}
cout << -1 << "\n";
return 0;
}
Step 5:补齐游戏结束条件和输出胜者手牌
题意关键句:当某一方出掉手里最后一张牌,但无法从桌面上赢取牌时,游戏立即结束。
变化:在"没赢牌(old==-1)且换人之后",如果 me 已空,则立即结束,输出对手手牌。
另外,循环外也处理一下正常结束(某人空了)
cpp
#include <bits/stdc++.h>
using namespace std;
string toStr(const deque<char>& d) {
return string(d.begin(), d.end());
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string sA, sB;
cin >> sA >> sB;
deque<char> A(sA.begin(), sA.end());
deque<char> B(sB.begin(), sB.end());
vector<char> table;
int pos[128];
fill(pos, pos + 128, -1);
int turn = 0; // 0:A出 1:B出
while (!A.empty() && !B.empty()) {
deque<char>& me = (turn == 0 ? A : B);
deque<char>& op = (turn == 0 ? B : A);
char x = me.front();
me.pop_front();
int old = pos[x];
table.push_back(x);
pos[x] = table.size() - 1;
if (old == -1) {
// 没赢牌:换人
turn ^= 1;
// 若刚好打空且没赢牌:立刻结束,对手赢(桌面不归任何人)
if (me.empty()) {
cout << toStr(op) << "\n";
return 0;
}
} else {
// 赢牌:收回 [old..end)
for (int i = table.size() - 1; i >= old; --i) {
char t = table[i];
me.push_back(t);
pos[t] = -1;
}
table.resize(old);
}
}
// 正常情况:谁没牌谁输,输出另一个
cout << (A.empty() ? toStr(B) : toStr(A)) << "\n";
return 0;
}
在这一步题目案例已经可以正确通过,下面的Step6思路更加严谨
Step 6:加入严格判环
变化:
用 unordered_set<string> seen 记录局面。
每轮开头把 (turn | A | B | table) 编成字符串 state,如果已出现则进入循环,输出 -1。
cpp
#include <bits/stdc++.h>
using namespace std;
string toStr(const deque<char>& d){
return string(d.begin(), d.end());
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string sA, sB;
cin >> sA >> sB;
deque<char> A(sA.begin(), sA.end());
deque<char> B(sB.begin(), sB.end());
vector<char> table;
int pos[128];
fill(pos, pos + 128, -1);
int turn = 0; // 0:A出 1:B出
unordered_set<string> seen;
while (true) {
string state;
state.push_back(char('0' + turn));
state.push_back('|'); state += toStr(A);
state.push_back('|'); state += toStr(B);
state.push_back('|'); state.append(table.begin(), table.end());
if (!seen.insert(state).second) { cout << -1 << "\n"; return 0; }
deque<char> &me = (turn == 0 ? A : B);
deque<char> &op = (turn == 0 ? B : A);
char x = me.front();
me.pop_front();
int old = pos[x];
table.push_back(x);
pos[x] = table.size() - 1;
if (old == -1) {
turn ^= 1;
if (me.empty()) { cout << toStr(op) << "\n"; return 0; }
} else {
for (int i = table.size() - 1; i >= old; --i) {
char t = table[i];
me.push_back(t);
pos[t] = -1;
}
table.resize(old);
}
}
}
简单解法
cpp
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string sA, sB;
cin >> sA >> sB;
deque<char> A(sA.begin(), sA.end());
deque<char> B(sB.begin(), sB.end());
vector<char> table;
int turn = 0; // 0代表A
for (int step = 0; step < 200000; ++step) {
if (A.empty()) { cout << string(B.begin(), B.end()) << "\n"; return 0; }
if (B.empty()) { cout << string(A.begin(), A.end()) << "\n"; return 0; }
deque<char>& me = turn ? B : A;
char x = me.front();
me.pop_front();
table.push_back(x);
// 搜索相同牌
int k = -1;
for (int i = table.size() - 2; i >= 0; --i) {
if (table[i] == x) { k = i; break; }
}
if (k == -1) {
turn ^= 1; // 换人
} else {
// 收牌
for (int i = table.size() - 1; i >= k; --i) {
me.push_back(table[i]);
}
table.resize(k);
// 赢牌继续,turn不变
}
}
cout << -1 << "\n";
return 0;
}
题目数据范围小(桌面牌 ≤ 100)可以选择这种思路
table.size() - 2 这里为什么减去2?
table.push_back(x); 已经将新牌x放入了table
现在 table 的状态:
0\] \[1\] ... \[table.size()-2\] \[table.size()-1
↑ 这是刚放的牌x
所以当我们要搜索相同牌时:
刚放入的牌在 table.size() - 1 位置
我们要搜索之前是否有相同的牌
应该从 table.size() - 2 开始搜索