前言
贪吃蛇的核心数据是蛇身:一串按顺序排列的格子,头部每次向一个方向增加一格,尾部在没吃到食物时删除一格。这个「头插尾删」的模式与链表天然契合;又因为蛇头撞到蛇身就游戏结束,需要判断新头是否与身体重合,把链表接成环形可以让遍历和判重更统一。
常见的误解是「循环链表只是为了省一个尾指针」。实际上循环链表在这里的价值是:tail->next永远指向头,插入新头时不需要单独维护「尾指向空」;删除尾时也能从任意节点绕一圈找到前驱。代价是遍历必须用「回到起点」作为终止条件,而不能用nullptr,一旦写成while (p != nullptr)就会死循环。
另一个误解是「吃到食物只需要不删尾」。不删尾确实让长度加一,但新头已经插入,尾指针仍指向原来的尾,链表结构是对的;真正容易错的是「没吃到食物时删尾」的前驱查找,以及「新头撞到尾巴」的判定——尾巴马上要移走,撞上去应当允许。
本文以 C++17 为准,实现一个只含逻辑层的贪吃蛇:循环链表、移动、增长、自撞检测、食物生成。控制台的实时按键属于平台相关部分,单独说明。
一、为什么用循环链表
蛇身需要支持三种操作:
- 在头部插入新节点(每次移动)。
- 在尾部删除节点(没吃到食物时)。
- 判断某个坐标是否被身体占据(自撞检测、食物生成)。
用std::vector也能做,但头部插入是O(n),删除尾部虽然均摊O(1),整体不如链表直接。用单链表(非循环)时,删除尾节点需要找前驱,而只有头指针的话要遍历整条链;循环链表让尾节点的next指回头,配合一个尾指针,插入和删除都只差一次前驱查找(仍是O(n),但代码结构统一)。
| 结构 | 头插 | 尾删 | 判重遍历 | 终止条件 |
|---|
std::vector | O(n) | O(1) | O(n) | 下标 |
| 单链表 | O(1) | O(n)找前驱 | O(n) | nullptr |
| 循环链表 | O(1) | O(n)找前驱 | O(n) | 回到头 |
严格来说,若想尾删也O(1),可以用双向循环链表,或者额外维护尾节点的前驱。本文选择单向循环链表,重点是把指针操作讲清楚;对贪吃蛇这种规模,O(n)的尾删完全可以接受。
二、移动、增长与自撞
设当前头节点为head、尾节点为tail,新头坐标为(head->x + dx, head->y + dy)。
移动步骤:
- 计算新头坐标,先做自撞检测。
newNode->next = head; tail->next = newNode; head = newNode;把新头接在旧头前面。- 如果没吃到食物,删除旧尾:从
head出发找到p->next == oldTail的节点p,令p->next = head,tail = p,delete oldTail。
自撞检测:从head->next开始遍历,回到head停止。若某个节点坐标与新头相同:
- 如果没吃到食物,且该节点正是
tail,允许——尾巴这一步会移走,新头占据它的位置是合法的。 - 否则判定撞身。
增长:吃到食物时不执行第 3 步,旧尾保留,长度自然加一。
这里有一个容易忽略的细节:自撞检测必须在插入新头之前做。若先插入再检测,新头自己也在链表里,遍历时必然撞到自己。
三、完整实现
// 适用于 C++17
#include <cstddef>
#include <iostream>
#include <random>
#include <string>
#include <vector>
class Snake {
public:
Snake(int x, int y, int length) {
head_ = new Node{x, y, nullptr};
head_->next = head_;
tail_ = head_;
for (int i = 1; i < length; ++i) {
Node* node = new Node{x - i, y, head_};
tail_->next = node;
tail_ = node;
}
}
~Snake() {
if (head_ == nullptr) return;
Node* p = head_->next;
while (p != head_) {
Node* next = p->next;
delete p;
p = next;
}
delete head_;
}
Snake(const Snake&) = delete;
Snake& operator=(const Snake&) = delete;
bool move(int dx, int dy, bool eat) {
const int nx = head_->x + dx;
const int ny = head_->y + dy;
if (hitsBody(nx, ny, eat)) return false;
Node* node = new Node{nx, ny, head_};
tail_->next = node;
head_ = node;
if (!eat) {
Node* oldTail = tail_;
Node* p = head_;
while (p->next != oldTail) {
p = p->next;
}
p->next = head_;
tail_ = p;
delete oldTail;
}
return true;
}
bool occupies(int x, int y) const {
const Node* p = head_;
do {
if (p->x == x && p->y == y) return true;
p = p->next;
} while (p != head_);
return false;
}
int headX() const { return head_->x; }
int headY() const { return head_->y; }
private:
struct Node {
int x;
int y;
Node* next;
};
bool hitsBody(int x, int y, bool eat) const {
const Node* p = head_->next;
while (p != head_) {
if (p->x == x && p->y == y) {
if (!eat && p == tail_) return false; // 尾巴会移走,允许
return true;
}
p = p->next;
}
return false;
}
Node* head_ = nullptr;
Node* tail_ = nullptr;
};构造函数先建头,再逐个把新节点接到尾部,next指回head_,最后tail_->next自然形成环。析构从head_->next开始删到回到head_,最后删头,避免边遍历边释放导致的悬垂。
move中的顺序值得逐行核对。插入新头后,链表是newHead -> oldHead -> ... -> oldTail -> newHead。删除旧尾时从newHead出发,找p->next == oldTail的p;由于oldTail->next == newHead,这个查找一定能在环内完成。若长度为 1,p就是newHead,令p->next = head_后形成自环,仍是合法的单节点循环链表。
四、游戏循环与渲染
// 适用于 C++17
#include <chrono>
#include <thread>
void render(const Snake& snake, int width, int height, int fx, int fy) {
std::vector<std::string> canvas(height, std::string(width, ' '));
for (int x = 0; x < width; ++x) {
canvas[0][x] = '#';
canvas[height - 1][x] = '#';
}
for (int y = 0; y < height; ++y) {
canvas[y][0] = '#';
canvas[y][width - 1] = '#';
}
canvas[fy][fx] = '@';
canvas[snake.headY()][snake.headX()] = 'O';
for (int y = 0; y < height; ++y) {
std::cout << canvas[y] << '\n';
}
}
int main() {
const int width = 20;
const int height = 12;
Snake snake(width / 2, height / 2, 3);
int fx = 5;
int fy = 5;
std::mt19937 rng(std::random_device{}());
std::uniform_int_distribution<int> distX(1, width - 2);
std::uniform_int_distribution<int> distY(1, height - 2);
int dx = 1;
int dy = 0;
char cmd = 0;
while (std::cin >> cmd) {
int ndx = dx;
int ndy = dy;
if (cmd == 'w') { ndx = 0; ndy = -1; }
else if (cmd == 's') { ndx = 0; ndy = 1; }
else if (cmd == 'a') { ndx = -1; ndy = 0; }
else if (cmd == 'd') { ndx = 1; ndy = 0; }
else if (cmd == 'q') break;
else continue;
if (ndx == -dx && ndy == -dy) continue; // 禁止 180 度掉头
const int nx = snake.headX() + ndx;
const int ny = snake.headY() + ndy;
if (nx <= 0 || nx >= width - 1 || ny <= 0 || ny >= height - 1) {
std::cout << "撞墙,游戏结束\n";
break;
}
const bool eat = (nx == fx && ny == fy);
if (!snake.move(ndx, ndy, eat)) {
std::cout << "撞到自己,游戏结束\n";
break;
}
if (eat) {
bool placed = false;
for (int attempt = 0; attempt < 200 && !placed; ++attempt) {
const int x = distX(rng);
const int y = distY(rng);
if (!snake.occupies(x, y)) {
fx = x;
fy = y;
placed = true;
}
}
if (!placed) {
std::cout << "没有空位放食物,胜利\n";
break;
}
}
dx = ndx;
dy = ndy;
render(snake, width, height, fx, fy);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
return 0;
}std::mt19937与std::uniform_int_distribution来自<random>,比rand() % n分布更均匀,也不依赖全局种子。食物生成最多尝试 200 次;若蛇几乎占满地图,找不到空位就判定胜利。
输入采用「每次读一个字符、回车确认」的模式,因此不需要<conio.h>。若想要不按回车就响应,必须使用平台 API:Windows 的_kbhit/_getch在<conio.h>中,POSIX 需要 termios 修改终端模式。二者都不是标准 C++,移植时要单独封装输入层。
常见坑点
1. 遍历时用nullptr判断结束
❌for (Node* p = head_; p != nullptr; p = p->next),循环链表永远不返回空,死循环。 ✅ 用p != head_或do { ... } while (p != head_)。
2. 删尾时找不到前驱
❌delete tail_; tail_ = tail_->next;,删完还访问已释放内存,UB。 ✅ 先从新头遍历到p->next == oldTail,改p->next,更新tail_,再删。
3. 吃到食物也删尾
❌ 无条件删尾,长度不增长,蛇始终原地踏步。 ✅ 用eat分支控制,吃到时保留旧尾。
4. 自撞检测把尾巴算进去
❌ 新头进入尾巴格直接判负,但没吃到食物时尾巴本该移走。 ✅ 未吃食物且目标节点是tail_时放行。
5. 允许 180 度掉头
❌ 蛇向右时输入a,新头直接撞上第二节身体。 ✅ 记录当前方向,拒绝与当前方向完全相反的方向。
6. 食物生成在蛇身上
❌ 随机坐标不与蛇身判重,食物被身体覆盖,永远吃不到。 ✅ 生成后用occupies检查,冲突则重试。
7. 使用rand() % n生成坐标
❌ 模运算带来分布偏差,且全局种子影响其他模块。 ✅ 用std::mt19937+std::uniform_int_distribution。
8. 忘记释放链表
❌ 只delete head_,其余节点泄漏。 ✅ 析构沿环遍历,逐个delete,最后删头;或改用std::unique_ptr管理。
总结
| 操作 | 指针动作 | 注意点 |
|---|
| 构造 | 逐个尾插,next指回头 | 保持环闭合 |
| 移动 | 新节点插在头前 | 先判自撞,再插入 |
| 增长 | 跳过删尾 | eat为真时保留尾巴 |
| 缩短 | 找尾前驱,改链,删尾 | 不能用tail_->next直接前进 |
| 遍历 | 回到head_停止 | 不要用nullptr |
| 析构 | 从head_->next删到head_ | 最后删头 |
循环链表实现贪吃蛇,关键是把「环的闭合」贯穿到插入、删除、遍历、析构四个动作里。插入时让tail->next指向新头,删除时让前驱指回新头,遍历时以回到头为终止,析构时沿环走一圈。把这四条写对,蛇身的增删就不会出现断链或悬垂;剩下的方向控制、食物生成和渲染只是外围逻辑。