ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C++控制台循环链表实现贪吃蛇

C++控制台循环链表实现贪吃蛇

前言


贪吃蛇的核心数据是蛇身:一串按顺序排列的格子,头部每次向一个方向增加一格,尾部在没吃到食物时删除一格。这个「头插尾删」的模式与链表天然契合;又因为蛇头撞到蛇身就游戏结束,需要判断新头是否与身体重合,把链表接成环形可以让遍历和判重更统一。


常见的误解是「循环链表只是为了省一个尾指针」。实际上循环链表在这里的价值是:tail->next永远指向头,插入新头时不需要单独维护「尾指向空」;删除尾时也能从任意节点绕一圈找到前驱。代价是遍历必须用「回到起点」作为终止条件,而不能用nullptr,一旦写成while (p != nullptr)就会死循环。


另一个误解是「吃到食物只需要不删尾」。不删尾确实让长度加一,但新头已经插入,尾指针仍指向原来的尾,链表结构是对的;真正容易错的是「没吃到食物时删尾」的前驱查找,以及「新头撞到尾巴」的判定——尾巴马上要移走,撞上去应当允许。


本文以 C++17 为准,实现一个只含逻辑层的贪吃蛇:循环链表、移动、增长、自撞检测、食物生成。控制台的实时按键属于平台相关部分,单独说明。


一、为什么用循环链表


蛇身需要支持三种操作:



  1. 在头部插入新节点(每次移动)。

  2. 在尾部删除节点(没吃到食物时)。

  3. 判断某个坐标是否被身体占据(自撞检测、食物生成)。


用std::vector也能做,但头部插入是O(n),删除尾部虽然均摊O(1),整体不如链表直接。用单链表(非循环)时,删除尾节点需要找前驱,而只有头指针的话要遍历整条链;循环链表让尾节点的next指回头,配合一个尾指针,插入和删除都只差一次前驱查找(仍是O(n),但代码结构统一)。




结构头插尾删判重遍历终止条件



std::vectorO(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)。


移动步骤:



  1. 计算新头坐标,先做自撞检测。

  2. newNode->next = head; tail->next = newNode; head = newNode;把新头接在旧头前面。

  3. 如果没吃到食物,删除旧尾:从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指向新头,删除时让前驱指回新头,遍历时以回到头为终止,析构时沿环走一圈。把这四条写对,蛇身的增删就不会出现断链或悬垂;剩下的方向控制、食物生成和渲染只是外围逻辑。




返回列表