☰
C++手写链栈:结构设计、内存管理与三/五法则
2026/10/1 10:45:45 网站建设 项目流程

" C++ 链栈"这个东西,我是在啃完指针和类之后才真正用明白的。以前刷算法题,栈多半用数组模拟,简单是简单,可一旦数据量没法提前估准,要么预判尺寸踩内存,要么反复扩容白耗性能。链栈就是为这种场景准备的:用链表把元素串起来,来一个就 new 一个节点,完全不关心上限。这篇内容想从一个手写实现的角度,把链栈的结构设计、完整代码、常见内存陷阱一次讲透。适合刚学完 C++ 基础的初学者,也适合面试前想快速巩固栈实现细节的开发者。

1. 链栈整体设计与思路拆解

1.1 链栈是什么,什么时候非它不可

栈是一种只允许在一端进行插入和删除的线性表,这一端叫栈顶,另一端叫栈底。链栈就是用链表作为底层存储的栈,入栈和出栈都发生在链表头部,因此天然具备 O(1) 的入栈、出栈、取栈顶能力。

我说个最常见的坑:你写一个网络协议解析器,收到的数据包里有若干条子记录,条数由报文内容决定。你根本不知道最多会有多少条,开一个 1024 大小的数组怕不够,开 10 万个又怕浪费。这时候顺序栈的"固定容量"就成了累赘。有人会说我用动态数组 vector 不就行了?可以,但 vector 扩容时要把旧数据搬到新内存,搬移本身有代价,还会产生内存碎片。链栈的思路是按需分配,来一条数据就 new 一个节点,用完了再释放,数据规模无论多大,只要堆内存够就永远不会溢出。

栈的典型应用远不止协议解析。函数调用栈、括号匹配、表达式求值、数制转换、浏览器的前进后退、文本编辑器的撤销重做、深度优先搜索的辅助结构,全部是栈的天下。在这些场景里,你很难预估操作深度的上限,用链栈从设计上就消除了"栈满"这个状态。

从学习的角度看,链栈是理解指针操作的绝佳载体。它比链表更简单,因为只操作头部,避开了单链表最麻烦的"找前驱"问题;它又比简单的数组栈更接近底层,你能直观感受到 new 和 delete 的配对关系、悬空指针的成因、内存泄漏的隐蔽性。所以我一直建议初学者不要只满足于std::stack,至少手写一遍链栈。

1.2 顺序栈与链栈怎么选

我把两种实现的差异整理成了表格,方便对照:

对比维度顺序栈链栈
底层空间一段连续内存离散节点,靠指针连接
扩容方式容量不够时要搬迁数据每次入栈分配一个节点
访问效率缓存局部性好指针跳转,缓存命中率相对低
内存占用需要预留容量,可能浪费每个节点额外存一个 next 指针
插入删除O(1),扩容时触发搬迁O(1),且稳定
适用场景数据规模已知且稳定规模未知或波动剧烈

选型逻辑很直白:如果你能估算出栈的深度上限,并且这个上限和实际使用量差距不大,顺序栈明显更好。连续内存意味着更少的 cache miss,遍历和批量操作都快。反过来,如果数据规模不可预估,或者栈的对象很大、频繁进出,链栈更省心,因为不会为了少数几次扩容而整体搬迁。

还有一点容易被忽略:链栈的每个节点包含 next 指针,在 64 位系统下是 8 字节。如果你存的本来就是 int 这类 4 字节元素,链栈实际要多花两倍的内存。这个代价能否接受,得具体场景具体分析。我的习惯是:刷题和写小型工具用顺序栈,系统级、长时间运行且容量不可控的程序用链栈。

1.3 手写链栈的三种可行方案

在 C++ 里实现链栈,至少有三种路线。

第一种,裸指针手写单链表。代码量最大,但对理解内存管理最有效。你需要自己管理节点的构造、析构、拷贝、赋值,稍不留神就是悬空指针或内存泄漏。可正是这些坑让人真正学会 C++ 的内存语义。

第二种,基于std::forward_list封装。forward_list 是 C++ 标准库里的单向链表,用push_front模拟入栈,pop_front模拟出栈,front取栈顶。代码很短,但封装之后你对底层发生了什么都不敏感,而且 forward_list 的接口设计本来就有点别扭,不如手写直观。

第三种,直接使用std::stack<T>。它的底层默认是std::deque,双端队列,实际是一个分段连续的结构。业务开发里我强烈建议用这个,标准库容器成熟、异常安全、迭代器齐全,没必要自己造轮子。

但这篇文章我要讲的是第一种。原因很简单:学习数据结构的意义不在于"会用栈",而在于"理解栈"。你亲手写出 push、pop、析构,才能明白为什么标准库的pop不返回被弹出的元素,为什么拷贝一个没写拷贝构造的容器会崩。这些知识在面试和实际调试中非常值钱。

2. 核心结构定义与细节解析

2.1 节点结构:链表的基本单元

链栈的基石是节点。每个节点保存一份用户数据和指向下一个节点的指针:

struct Node { T data; Node* next; };

两个成员都有讲究。data直接存对象本身,而不是存对象指针,这样你在入栈时传一个对象进来,节点构造时自动拷贝一份。如果改成T* data,你就得额外管理指针指向的内存的释放,每个元素会多一次堆分配,性能差还容易漏。

next指向下一个节点。因为是栈,我们只需要单向链表,不需要双向。栈顶在链表头部,head 指针就是栈顶指针,不需要知道栈底在哪,所以单链表足够。

这里有个小细节:为什么我把 Node 定义在 LinkStack 类的私有区域?因为 Node 是内部实现细节,外部代码不应该直接操作节点。把 Node 放在 private 里,可以让LinkStack<T>::Node不对外暴露,防止有人绕过栈接口改链表结构。你也许会想放在类外,那也不是不行,但尽量让它成为类的私有嵌套类型,语义更干净,也符合封装原则。

2.2 栈类的成员设计

一个最小可用的链栈类,至少需要两个成员:

Node* top_; // 栈顶指针,也就是链表头指针 std::size_t size_; // 栈中元素个数

top_必须存在,否则不知道从哪里入栈出栈。size_可能有人觉得多余,毕竟判断空栈可以直接看top_ == nullptr。但是加上它在很多场合更方便:取元素个数时不用遍历整个链表,时间复杂度从 O(n) 降到 O(1)。比如你在调试时想打印栈内元素数量,或者在写业务逻辑时需要快速判断栈的大小,这个成员就很有价值。

再一个问题:链栈要不要带头结点?我看到不少教材里的链表都带头结点,理由是统一处理"在空链表头部插入"和"在非空链表头部插入"的逻辑。但对链栈来说,这个理由不成立。链栈的所有操作都在头部,无论链表是否为空,top_指针的更新方式完全一致:入栈时新节点的 next 指向旧 top,再让 top 指向新节点;出栈时 top 指向 next。不存在"在链表中间插入时需要找前驱"这种麻烦。带头结点反而会让栈对象多一个无意义的节点,判断空栈的逻辑也变复杂。因此我在实现里不带头结点。

2.3 内存生命周期与拷贝控制为什么重要

手写链栈最绕不开的话题是内存生命周期。每个new出来的节点,必须由同一个栈对象在某个时刻delete掉。如果栈对象析构时没有释放所有节点,这些节点就泄漏了。如果两个栈对象共享同一串节点,析构时就会释放两次,直接崩溃。

C++ 里有一条著名的规则叫"三/五法则":一旦类需要自定义析构函数,那么大概率也需要自定义拷贝构造函数和拷贝赋值运算符,因为这三个函数通常一起出现,共同管理同一块资源。

具体到链栈:默认析构函数不会释放节点,这导致内存泄漏;默认拷贝构造函数执行浅拷贝,两个对象的top_指向同一串节点,其中一个析构后,另一个再析构就是 double free。后面我会给出一份完整的拷贝控制代码,这里先记住结论:任何拥有裸指针资源的类,都要认真考虑三/五法则,而不是依赖编译器的默认行为。

移动语义在 C++11 之后也很重要。如果你的链栈能移动构造,那么返回一个局部栈对象时就不会发生逐节点拷贝,性能好很多。移动构造的本质是把对方的top_指针"偷"过来,然后把对方的指针置空。这部分我也会在实现里补全。

3. 实操过程与核心环节实现

3.1 基础结构:从节点到栈类

先把一个最小可用的链栈类完整写出来。这份代码可以在任意 C++11 及以上的编译器上编译运行:

#include <cstddef> #include <stdexcept> #include <utility> template <typename T> class LinkStack { private: struct Node { T data; Node* next; }; public: LinkStack() : top_(nullptr) , size_(0) { } ~LinkStack() { clear(); } void push(const T& value) { Node* new_node = new Node{value, top_}; top_ = new_node; ++size_; } void pop() { if (empty()) { throw std::out_of_range("LinkStack::pop: stack is empty"); } Node* old = top_; top_ = top_->next; delete old; --size_; } const T& top() const { if (empty()) { throw std::out_of_range("LinkStack::top: stack is empty"); } return top_->data; } bool empty() const { return size_ == 0; } std::size_t size() const { return size_; } void clear() { while (top_ != nullptr) { Node* old = top_; top_ = top_->next; delete old; } size_ = 0; } private: Node* top_; std::size_t size_; };

这段代码的骨架很清晰:一个模板类、一个私有嵌套节点、五个核心操作。模板让它可以装 int、double、string、自定义对象,复用性拉满。我在 main 里会用LinkStack<std::string>演示,你可以随意替换类型。

3.2 入栈出栈取顶判空:逐个手写顺便讲清原理

入栈 push。我采用的是头插法,也就是永远在新节点插到链表最前面。为什么不用尾插?因为栈顶在头部,头插才能保证入栈是 O(1)。如果尾插,你得维护一个尾指针,出栈时还要找尾节点的前驱,单链表找前驱只能从头遍历,效率直接变成 O(n)。头插法的更新逻辑非常干净:

Node* new_node = new Node{value, top_}; top_ = new_node; ++size_;

注意一个顺序:先 new 出节点,然后在更新top_。如果new抛出异常,旧的栈还保持原样,不会产生坏状态,这是最基本的异常安全。有些初学写法是先top_ = new Node{value, top_},这句在语义上也差不多,但显式写出中间变量更容易看出"先分配、后连接"的过程。

出栈 pop。标准库stack::pop的惯例是不返回被弹出的元素,我们的实现也遵循这个约定。原因是,如果 pop 既要移除元素又要返回元素,就会出现两难:返回引用的话,节点在返回后就被删除,引用悬空;返回拷贝的话,拷贝过程可能抛异常,被弹出的元素去哪了说不清。所以正确用法是先调top()拿值,再调pop()移除。

Node* old = top_; top_ = top_->next; delete old; --size_;

这里移动top_一定在delete old之前。如果你先 delete 再取top_->next,那就是访问一块已经释放的内存,属于未定义行为,通常表现为随机崩溃或读到脏数据。我在问题实录部分会专门展开。

取栈顶 top。返回const T&而不是值,是为了避免不必要的拷贝。如果返回 T 值,每次调用都会复制一个对象,对大型对象来说代价不小。const修饰符表示这个方法不修改栈,所以 const 对象也能调用它。

const T& top() const { if (empty()) { throw std::out_of_range("LinkStack::top: stack is empty"); } return top_->data; }

空栈时我选择抛std::out_of_range。这是防御式做法,让调用者能快速定位问题。如果你在写高性能的内核代码,抛异常的开销可能不可接受,那可以用 assert 或者直接约定"调用者保证栈非空"。

判空和取 size。都是 const 成员函数:

bool empty() const { return size_ == 0; } std::size_t size() const { return size_; }

empty基于size_判断,复杂度 O(1)。如果只写成top_ == nullptr也一样,但有了size_后empty的实现会自然一些。

析构和 clear。clear负责释放所有节点,循环删除头部节点直到空:

void clear() { while (top_ != nullptr) { Node* old = top_; top_ = top_->next; delete old; } size_ = 0; }

析构函数直接调用clear(),保证栈对象在生命周期结束时自动回收所有堆内存。这一步一旦漏掉,程序运行时间越长越容易内存暴涨,而且是那种难以察觉的泄漏。

3.3 在主函数中跑通全流程

写完类之后,写一个 main 验证它是否正常工作:

#include <iostream> #include <string> int main() { LinkStack<std::string> st; st.push("first"); st.push("second"); st.push("third"); std::cout << "size: " << st.size() << "\n"; std::cout << "top: " << st.top() << "\n"; while (!st.empty()) { std::cout << st.top() << "\n"; st.pop(); } return 0; }

运行结果:

size: 3 top: third third second first

可以看到入栈顺序是 first、second、third,出栈顺序恰好相反,这就是栈的后进先出语义。每次从栈顶取出一个元素,再让它出栈,直到空栈。这个循环是使用栈最经典的姿势,很多算法题里的"弹出全部元素"都是这个模式。

这里顺带讲一个使用技巧:栈天然可以用来反转序列。比如你把一串字符依次压栈,再依次弹出,得到的顺序就是原序列的逆序。很多题目里的"倒序输出""括号匹配""表达式求值"本质都是这个特性。

编译环境方面,如果你在 Linux 或 macOS 上,直接执行:

g++ -std=c++11 -Wall -pedantic main.cpp -o main

在 Windows 上如果用 Visual Studio,新建控制台项目把代码贴进去即可。加-Wall能帮你在编译期揪出一些粗心错误,我建议新手一直开着。

3.4 补全拷贝控制,让类能安全复制

基础版本能跑,但还不能复制。执行下面这段代码会崩:

LinkStack<int> a; a.push(10); LinkStack<int> b(a); // 默认拷贝构造,浅拷贝

因为b.top_和a.top_指向同一个节点,析构时 double free。要解决这个问题,得自己写拷贝构造和拷贝赋值。我用的是链式拷贝构造加 copy-and-swap 赋值:

LinkStack(const LinkStack& other) : top_(nullptr) , size_(0) { Node** tail = &top_; for (Node* cur = other.top_; cur != nullptr; cur = cur->next) { *tail = new Node{cur->data, nullptr}; tail = &((*tail)->next); ++size_; } } LinkStack& operator=(const LinkStack& other) { if (this != &other) { LinkStack tmp(other); std::swap(top_, tmp.top_); std::swap(size_, tmp.size_); } return *this; }

拷贝构造采用尾插法重建整条链。tail是一个指向Node*的指针,初始指向top_,每次新建节点后,让tail指向新节点的 next 成员,这样链子就能一路接下去。这个写法稍微绕一点,但比维护一个prev变量更简洁,也不容易写错。

赋值运算符用 copy-and-swap:先复制一份临时对象,再交换指针和 size。临时对象会在函数结束时析构,自动释放原来的节点。这样写还能顺便获得异常安全:如果构造 tmp 时抛异常,原来的对象纹丝不动。

再补上移动构造和移动赋值,C++11 之后可以让链栈在返回时避免深拷贝:

LinkStack(LinkStack&& other) noexcept : top_(other.top_) , size_(other.size_) { other.top_ = nullptr; other.size_ = 0; } LinkStack& operator=(LinkStack&& other) noexcept { if (this != &other) { clear(); top_ = other.top_; size_ = other.size_; other.top_ = nullptr; other.size_ = 0; } return *this; }

移动构造的语义是"偷"走对方的指针,然后把对方置空。这样临时对象析构时不会释放我们已经拿走的内存。移动赋值同理,先释放自己的旧节点,再接住对方的节点。这里的noexcept很重要:它告诉标准库这个操作不会抛异常,这样std::vector<LinkStack<T>>扩容时愿意用移动而不是拷贝,性能会好很多。

加到这些代码后,链栈类就具备完整的资源管理能力了,可以放心放进各种容器里。

4. 常见问题与排查技巧实录

4.1 先 delete 再取 next,崩溃当场

这是初写弹栈操作时最容易犯的错误,错误写法长这样:

void pop() { Node* old = top_; delete old; // 先释放 top_ = top_->next; // 访问已释放内存 }

释放之后top_指向的是已回收的内存,top_->next读取的是一块游离内存的字节。系统未必立刻崩溃,有时候那块内存的数据还留在原地,程序照样能跑,于是你产生"这样写其实没事"的错觉。等到数据量变大、堆管理器复用了内存,程序才在某次 pop 时突然段错误。这种随机性非常难查。

正确顺序必须是先移动指针,再释放节点:

Node* old = top_; top_ = top_->next; delete old;

原则就一条:还没读完一个对象的数据,就永远不要释放它。顺带一提,clear()循环里也遵循同样的逻辑。

4.2 不写析构函数,泄漏无声发生

如果你没定义析构函数,编译器会生成一个隐式析构函数,而这个隐式版本对裸指针什么都不做。也就是说,你每new一个节点,都不会被回收。进程长时间运行,堆内存持续上涨,最终可能被杀掉或 OOM。

排查的办法很多。Linux 下最常用的是 valgrind:

valgrind --leak-check=full ./main

Windows 的 Visual Studio 调试环境里可以调用_CrtDumpMemoryLeaks()。注意调用时机:链栈对象必须已经析构,所以别把它直接写在 main 函数结尾的子代码段外。更稳的做法是把业务逻辑放进独立函数:

#include <crtdbg.h> void demo() { LinkStack<int> st; st.push(1); } int main() { demo(); _CrtDumpMemoryLeaks(); return 0; }

demo结束的瞬间,st已经析构。如果此时_CrtDumpMemoryLeaks还报泄漏,说明析构或 clear 逻辑有问题。这对验证链栈实现的正确性特别有用。

4.3 默认拷贝导致 double free

这也是高频崩溃点。你写了一个链栈,用默认拷贝构造复制了一份,程序运行到结尾,两个对象依次析构。第一个析构释放了整条链表,第二个析构时top_还指向同一个节点,于是第二次 delete 同一块内存。轻则程序崩溃,重则堆元数据被破坏,崩溃时机完全随机。

解决方式在 3.4 已经给出:写深拷贝构造和拷贝赋值。如果你明确不希望链栈被复制,也可以直接删除拷贝方法:

LinkStack(const LinkStack&) = delete; LinkStack& operator=(const LinkStack&) = delete;

面试时如果被问道"为什么没写拷贝构造会崩",大概率就是这个知识点。回答时最好能提到"浅拷贝导致多个对象共享同一资源,析构时多次释放",一句话就能让对方知道你理解了根因。

4.4 空栈操作与 const 修饰符遗漏

空栈调用 pop 或 top 是未定义行为。在我的实现里,pop 和 top 都主动抛std::out_of_range,所以空栈操作会立刻暴露问题。如果你用标准库的std::stack,空的 stack 上 pop 是不检查的,调用者必须负责保证非空。这是教学实现和标准库实现的一个风格差异。

const 修饰符是另一个容易踩的点。如果empty()、size()、top()没有写 const,那么当你有一个 const 引用时,这些方法无法调用。例如:

const LinkStack<int>& ref = st; if (ref.empty()) { // 编译错误:empty 不是 const 成员函数 }

几乎所有不修改内部状态的成员函数都应该写成 const 成员函数。写类的时候先想清楚这个方法会不会改变对象状态,会改的写非 const,不会改的加 const,这样的类用起来才顺手。

4.5 链栈问题速查表

我把实战里最常遇到的几类问题整理成速查表,方便你排错时一眼定位:

症状可能原因处理方式
pop 之后程序随机崩溃先 delete 再读 next先移动 top_ 指针,再释放旧节点
程序内存持续增长缺析构函数或 clear 未调用补析构并确保 clear 释放所有节点
复制后两端都析构时崩溃默认拷贝构造导致浅拷贝自定义深拷贝/赋值,或禁止拷贝
const 对象调用 empty 编译失败成员函数漏写 const所有只读接口统一加 const
空栈 top 返回随机值未判空直接访问 top_接口内判空并抛异常
入栈后旧数据丢失调整 top_ 前未把旧 top 接到新节点先 new 新节点,让 next 指向旧 top_
拷贝赋值后自身数据泄漏赋值前未释放旧节点使用 copy-and-swap 模式

这张表里的坑,几乎每一个我都在实际代码里踩过。最值得反复强调的还是这三条:new 和 delete 必须严格配对,浅拷贝是万恶之源,空栈操作一定要有防御。

链栈本身是个很小的结构,但认真手写一遍,收获远不止"会写一个栈"那么简单。你在 push 里学会了异常安全的构造顺序,在 pop 里学会了悬空指针的预防,在析构里学会了资源回收的重要性,在拷贝控制里学会了深拷贝和移动语义的价值。这些能力放到任何 C++ 项目里都派得上用场。

我建议你写完之后,用 valgrind 或_CrtDumpMemoryLeaks跑一遍,确认没有任何泄漏,然后试着扩展几个练习:给链栈加一个getMin()方法,实现括号匹配,或者用两个链栈模拟一个队列。等你把这些都写顺了,回头看最初那个用数组模拟栈的代码,会发现思路完全不一样了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询