
链栈在C里算是一个绕不开的基础结构。很多人一开始接触数据结构用的都是顺序栈——一段连续数组顶一个计数器操作简单直观。但等到真正处理一些复杂场景比如需要在多个栈之间频繁切换、栈的最大深度根本没法事先估计或者栈本身数量很多但每个都很“瘦”的时候顺序栈就会暴露出不少尴尬。链栈的出现就是为了解决这一类问题它用链式存储的方式实现栈每个节点独立分配在堆上栈的容量只受内存限制不需要预估最大值也不存在数组扩容的浪费。这篇文章我会从设计思路、核心代码、内存管理、典型应用这几个角度把链栈这个结构彻底拆开来讲。适合刚学完“链表”和“栈”基础概念、想加深理解的初学者也适合正在准备面试、想快速把链栈实现和应用串起来的人。代码我会给出完整的模板类实现并标注哪些地方容易踩坑、为什么这样写方便你直接拿去跑或者作为自己实现时的参考。1. 链栈的整体设计思路1.1 为什么不用数组非要做成链式顺序栈用数组存数据靠一个 top 下标维护栈顶。它的优点是缓存友好、随机访问快、实现简单但问题也很明显数组大小是固定的小了会溢出大了浪费内存。虽然可以搞动态扩容但扩容意味着整体搬移数据频繁入栈出栈的场景下这个开销并不小。链栈的思路则完全不同。它把每一个元素存放在一个独立节点里节点之间用指针串起来形成一个“头部即栈顶”的单向链表。每次入栈就是头插一个新节点每次出栈就是摘掉头节点时间复杂度 O(1)。更重要的是链栈每个节点都是按需 new 出来的栈里有多少元素就占多少内存不存在预先分配一块大空间然后大部分闲置的情况。用生活化的类比来理解顺序栈像一摞固定数量的盘子架盘子放满了就要换更大的架子盘子太少又白白占地方链栈像一箱乐高积木需要几个就拼几个拆掉的时候也只拆对应那几块。这种“按需分配”的特性让链栈特别适合那些峰值不确定、或者栈长期处于低占用状态的场景。1.2 链栈的关键特征链栈本质上是一个受限版本的链表所以它有这几个明显特征只能在栈顶操作即链表的头部进行插入和删除。这也意味着链栈不需要维护 tail 指针单链表结构就足够。入栈和出栈都是 O(1)不需要遍历。这点和顺序栈持平但节点分配在堆上分配和释放本身有开销所以单次操作的实际耗时通常比顺序栈慢。容量理论上是无限的受系统内存限制不用关心扩容。这解决了顺序栈“初始容量设多大”的老大难问题。多栈共享内存空间时很友好。比如一个程序里同时维护三个栈顺序栈得给每个栈分别预留空间而链栈天然就是动态分配的各自按需增长互不干扰。1.3 适合用链栈的场景链栈并不是所有场景下都比顺序栈好但它有三个特别合适的落脚点栈深度无法预估。比如表达式求值中运算符栈的深度取决于表达式的复杂度写代码时根本不知道会有多深。频繁创建销毁多个栈。像某些编译器的作用域栈随着函数调用层级动态变化顺序栈会造成大量空间闲置。节点结构复杂、数据量大的场景。比如栈里存的是大对象顺序栈扩容时不仅复制指针还要复制对象代价极高链栈则只操作节点指针。反过来讲如果你能确定栈的最大深度并且操作极其高频、对性能有苛刻要求那顺序栈最好还是提前分配固定大小的通常更合适。这一点想清楚了你就知道链栈不是“标准答案”而是一个更适合特定场合的选项。2. 链栈的核心实现2.1 节点结构定义链栈的节点和单链表一模一样每个节点保存一个数据域和一个指向下一个节点的指针。我用模板类来写方便你存任意类型的数据。template typename T struct StackNode { T data; // 数据域 StackNodeT* next; // 指针域指向栈顶的下一个节点 explicit StackNode(const T value) : data(value), next(nullptr) {} };这里有一个值得注意的点我给构造函数加了 explicit目的是防止隐式类型转换。比如栈里要存 int如果不加 explicit某些情况下编译器可能会用临时对象隐式构造节点造成不必要的拷贝。加 explicit 之后节点的创建方式变得明确只能显式传值构造这个习惯在写链表类时值得长期保持。如果栈里的元素类型本身不支持拷贝比如 unique_ptr你可以在此基础上改造成移动构造版本或者用指针存数据。初学阶段先掌握基本版本后面遇到需要移动语义的情况再扩展。我也建议把 data 定义成 T 而不是 T*因为栈作为容器应该直接“持有”元素的值而不是持有一个指向外部对象的指针否则会遇到悬垂指针、生命周期管理等问题徒增复杂度。2.2 栈类骨架链栈类本身只需要维护一个指向栈顶节点的指针再加一个记录元素数量的变量。size 这个字段可加可不加但加上之后 empty 判断、遍历统计都会方便很多代价仅是一个 int 的内存几乎可以忽略不计。template typename T class LinkedStack { public: LinkedStack() : top_(nullptr), size_(0) {} ~LinkedStack() { Clear(); } // 禁掉拷贝防止浅拷贝导致 double free LinkedStack(const LinkedStack) delete; LinkedStack operator(const LinkedStack) delete; void Push(const T value); void Pop(); T Top(); const T Top() const; bool Empty() const; size_t Size() const; void Clear(); private: StackNodeT* top_; size_t size_; };关于拷贝构造函数和赋值运算符的删除这里要特别解释一下。如果不做任何处理C编译器会自动生成默认的浅拷贝新的栈对象会复制 top_ 指针结果两个栈指向同一串节点。析构时第一个栈释放了整条链第二个栈析构时再次释放同一块内存轻则 double free 崩溃重则内存破坏、程序行为异常。这个坑我见过的频率非常高尤其是新手在函数里按值传栈的时候。正确的做法要么是明确 delete 拷贝操作见上面的代码要么是完整实现深拷贝。对于学习性质的链栈我倾向于直接删掉拷贝让编译器在编译期就拦住误用这比运行时崩溃容易排查得多。等到你确实需要复制栈比如做算法题的备忘副本再单独实现深拷贝也不迟后面 2.4 节会给深拷贝版本。2.3 push、pop、top、empty 的实现入栈操作的核心是“头插法”。新节点永远插在链表的头部也就是成为新的栈顶。这个操作的顺序非常重要一定先把新节点的 next 指向当前的 top_再更新 top_顺序反了就会断链。template typename T void LinkedStackT::Push(const T value) { StackNodeT* new_node new StackNodeT(value); new_node-next top_; top_ new_node; size_; }出栈操作则是头节点的摘除。先把当前栈顶节点保存下来移动 top_ 指针到下一个节点再释放旧节点。这里有个常见的错误是提前 delete 了节点然后又去访问它的 next导致野指针访问。template typename T void LinkedStackT::Pop() { if (Empty()) { throw std::out_of_range(LinkedStack: Pop on empty stack); } StackNodeT* old_top top_; top_ top_-next; delete old_top; --size_; }注意我在 Pop 里做了空栈检查并抛出了异常。有些教材喜欢直接 assert或者在调用处让调用者自己保证非空。但真实项目中栈空时 Pop 属于未定义行为一旦发生问题往往在很晚才暴露排查成本很高。我建议至少扩张成“抛异常”的形式这样错误能立刻被发现而且错误信息里带上了上下文。如果你不喜欢异常也可以返回 bool由调用方决定怎么处理。Top 的实现比较简单但要区分 const 版本和非 const 版本。非 const 版本返回可变引用允许调用方直接修改栈顶元素const 版本则保证只读方便在只读场景使用。template typename T T LinkedStackT::Top() { if (Empty()) { throw std::out_of_range(LinkedStack: Top on empty stack); } return top_-data; } template typename T const T LinkedStackT::Top() const { if (Empty()) { throw std::out_of_range(LinkedStack: Top on empty stack); } return top_-data; } template typename T bool LinkedStackT::Empty() const { return top_ nullptr; } template typename T size_t LinkedStackT::Size() const { return size_; }Empty 判断的是 top_ 是否为 nullptr这是链栈判断空栈的唯一可靠依据。不要尝试根据 size_ 是否为 0 来判断虽然理论上两者等价但一旦哪一次忘记维护 size_bug 就会潜伏下来。数据结构内部的一致性关系到程序的长期稳定性size_ 只作为统计字段使用而 Empty 永远看指针。2.4 清空与深拷贝的正确写法Clear 相当于一次性的连续出栈删除所有节点。实现方式和 Pop 类似只是需要循环处理直到栈空。template typename T void LinkedStackT::Clear() { while (top_ ! nullptr) { StackNodeT* temp top_; top_ top_-next; delete temp; } size_ 0; }这条循环要反复确认逻辑temp 先保存当前节点top_ 移动到下一个最后删除 temp。顺序一旦颠倒比如先删了 top_ 再去取 top_-next就是典型的 use-after-free这类问题在内存调试工具下会暴露但在普通运行时可能“碰巧没崩”隐患极大。深拷贝版本则是做一次正向重建。由于链栈只能从栈顶访问直接遍历旧链会得到逆序所以常规做法是借助一个辅助顺序栈。先把旧栈元素依次弹出压入辅助栈再从辅助栈弹回新栈最终新栈的排列和旧栈保持一致。template typename T LinkedStackT::LinkedStack(const LinkedStack other) : top_(nullptr), size_(0) { if (other.Empty()) return; LinkedStackT temp; StackNodeT* cur other.top_; while (cur ! nullptr) { temp.Push(cur-data); cur cur-next; } while (!temp.Empty()) { Push(temp.Top()); temp.Pop(); } }这里还可以用递归来写深拷贝代码短但深度大栈本身就可能溢出不建议在生产代码中使用。两趟辅助栈的方法是稳妥方案代价是 O(n) 的额外临时节点开销对于学习版本完全可以接受。如果你追求更高效率可以考虑先逆序递归拷贝节点再反转链接但代码复杂度会上升初学阶段没必要。3. 内存管理与资源安全3.1 new 和 delete 的配对原则链栈每个 Push 都要 new 一个节点每个 Pop 或 Clear 都要 delete 一个节点。保证“每次 new 都有对应 delete”是链栈内存安全的第一原则。这句话说起来容易但实际操作中经常出问题。最典型的就是异常安全假如 Push 里 new 成功之后后面的节点操作抛出了异常那新分配的节点就成了泄漏点。虽然当前实现里 head insert 之后的步骤都是简单指针赋值不具备抛出异常的条件但如果你后续扩展代码在 Push 里增加可能抛异常的步骤就必须用 RAII 包装节点指针或者用 unique_ptr 临时持有确保异常路径也不会泄漏。实践中我更推荐的方式是在企业项目里直接用标准库的容器比如 deque 或者 vector 作为底层存储来模拟栈能避免这一步的裸内存管理。但学习链栈的意义就在于理解内存管理本身所以这里必须手动 new/delete把这个手感练出来以后写更复杂的链表结构才不至于处处漏内存。3.2 析构函数为什么必须逐节点释放很多新手写栈类时忘记写析构函数或者只把 top_ 指针 delete 了一下。前者会导致整条链表的所有节点全部泄漏后者只释放了栈顶一个节点其余节点仍然泄漏。正确的析构逻辑是从栈顶开始逐个向下释放所有节点。这正是前面 Clear 函数做的事情所以析构函数直接调用 Clear 即可。template typename T LinkedStackT::~LinkedStack() { Clear(); }为了让这个析构写法形成肌肉记忆你可以做一个简单的验证实验写一个析构函数里打印日志的节点类然后让栈对象离开作用域观察析构函数被调用的次数。正常情况下每个节点都会打印一次如果你只看到一次打印说明你的析构写漏了。还有一种场景是栈对象是全局变量或者静态变量程序结束时析构顺序不可控如果栈里的元素依赖其他全局对象那么析构时可能访问已经销毁的全局对象。这个坑在大型项目中比较隐蔽一般建议栈对象都在函数内部创建不要设置为全局。3.3 用 RAII 思想管理链栈节点RAIIResource Acquisition Is Initialization是 C 资源管理的核心思想。对链栈来说最简单的应用就是在节点的持有方式上引入智能指针。template typename T struct StackNode { T data; std::unique_ptrStackNodeT next; };这样一来当 top_ 指向的节点被释放时它的 next 节点会自动递归释放整条链会像多米诺骨牌一样依次析构。Pop 操作也简化成移动 unique_ptrtemplate typename T void LinkedStackT::Pop() { if (Empty()) throw std::out_of_range(Pop on empty stack); top_ std::move(top_-next); }这种做法在真实工程里非常推荐它能彻底消除“忘记 delete”这一点隐患。但作为教学演示我仍然建议先用裸指针完整手写一遍理解每一步资源释放的底层机制然后再用智能指针优化。两步都走通了你对内存管理的理解就是扎扎实实的。另外注意使用了 unique_ptr 后节点类的拷贝和赋值会自动被禁用这进一步避免了链栈拷贝时产生的 double free 问题算是一举两得。4. 链栈的典型应用场景4.1 括号匹配检查括号匹配是栈的经典入门应用。给定一个包含各种括号的字符串判断括号是否正确嵌套。算法的核心逻辑是遇到左括号就入栈遇到右括号就检查栈顶是否匹配。用链栈实现时代码很直接bool IsBalanced(const std::string expr) { LinkedStackchar st; for (char ch : expr) { if (ch ( || ch [ || ch {) { st.Push(ch); } else if (ch ) || ch ] || ch }) { if (st.Empty()) return false; char open st.Top(); st.Pop(); if ((ch ) open ! () || (ch ] open ! [) || (ch } open ! {)) { return false; } } } return st.Empty(); }这里的判断逻辑我刻意把“栈是否为空”的检查放在“取栈顶”之前。因为如果栈为空还去取 Top会触发我们之前定义的 out_of_range 异常而在这个场景里空栈遇到右括号只是“括号不匹配”而已不是程序错误直接返回 false 更合理。这个细节体现了栈应用的一个重要原则区分“正常逻辑判断”和“异常情况”不要把所有问题都抛成异常。4.2 表达式求值与逆波兰转换中缀表达式转后缀表达式或者直接对后缀表达式求值都需要一个运算符栈。运算符的优先级关系决定了何时入栈、何时弹栈。链栈的优势在于表达式长度不确定直接动态分配。这里我以“中缀转后缀”为例子int Precedence(char op) { if (op || op -) return 1; if (op * || op /) return 2; return 0; } std::string InfixToPostfix(const std::string expr) { LinkedStackchar ops; std::string result; for (char ch : expr) { if (std::isalnum(ch)) { result ch; } else if (ch () { ops.Push(ch); } else if (ch )) { while (!ops.Empty() ops.Top() ! () { result ops.Top(); ops.Pop(); } if (ops.Empty()) { throw std::runtime_error(Mismatched parentheses); } ops.Pop(); // 弹出 ( } else { while (!ops.Empty() Precedence(ops.Top()) Precedence(ch)) { result ops.Top(); ops.Pop(); } ops.Push(ch); } } while (!ops.Empty()) { result ops.Top(); ops.Pop(); } return result; }这段代码展示了栈在编译原理中的应用雏形。实际编译器里的表达式解析比这复杂得多涉及语法树构建、运算符结合性等但核心的栈逻辑完全一致。你要是把这段代码跑通了再看编译原理教材里的“算符优先分析”会感觉亲切很多。链栈在这里最大的好处就是不用预先估计表达式的复杂度表达式多长栈就长多长。4.3 函数调用栈与撤销机制其实每次函数调用本身就对应一条栈帧链C 运行时的调用栈做的事情和链栈是同一件事只是它用连续内存。你写的链栈完全可以模拟“撤销”功能每一次操作把旧状态压入栈CtrlZ 时弹栈恢复。很多文本编辑器和图形软件底层就是这么做的。用链栈做撤销机制的思路也很简单每做一次修改前把当前状态保存为一个副本并压栈撤销时从栈顶取出上一次的状态。实际工程里因为状态可能很大往往只保存操作差异而非完整快照但数据结构模型是相通的。如果你自己写一个绘图小程序或者一个文本处理的小工具用链栈实现 Undo 是最快的方式只需要在栈里存 std::shared_ptr 就能避免大量数据拷贝。5. 常见问题与调试思路5.1 访问了空栈的栈顶这是一个高频错误而且触发方式防不胜防。比如在一段复杂的业务逻辑里连续多次 Pop 后忘了检查栈是否为空下一次调 Top 就会触发异常如果你像我一样写了异常检查或者直接产生未定义行为如果你裸读 top_-data。排查这类问题的核心手段是“断言状态”。我建议在调试阶段每次 Push/Pop/Top 之前都打印当前栈的 Size或者用调试器观察 top_ 指针是否为 null。下面的日志模板可以作为参考void DebugPrint(const LinkedStackint st) { std::cout size st.Size() (st.Empty() ? [empty] : [non-empty]) std::endl; }实践中很多“栈溢出”报错其实不是空间不足而是逻辑错误导致死循环 Push最终把堆内存耗尽被系统终止。排查时先检查循环条件里是否有出栈操作再看 Pop 后是否有状态未更新通常能找到问题。5.2 内存泄漏的两种常见测试方法C 没有自动垃圾回收内存泄漏必须借助工具或者手动计数来发现。第一种是工具法。在 Linux 下用 Valgrind 直接跑valgrind --leak-checkfull ./your_program如果输出显示 “definitely lost” 或 “indirectly lost”说明存在泄漏。配合参数 --show-leak-kindsall 可以看到每种泄漏的调用栈定位到具体代码行非常方便。Visual Studio 下则可以用 CRT 堆检测启用 _CRTDBG_MAP_ALLOC 宏后程序退出时调试输出窗口会列出未释放的内存块。第二种是手动计数法。在节点构造函数和析构函数里分别递增、递减一个全局计数器程序结束时检查计数是否为 0。这个方法不依赖外部工具适合临时验证逻辑int g_node_count 0; template typename T StackNodeT::StackNode(...) : ... { g_node_count; } template typename T StackNodeT::~StackNode() { --g_node_count; }程序结束打印 g_node_count 必须为 0。这种方法虽然少了但在没有工具环境且想快速验证同学作业、笔试代码时很实用。5.3 链栈和顺序栈的选择与性能对照从大 O 复杂度看链栈和顺序栈的 Push/Pop/Top 都是 O(1)但常数因子差异很明显。链栈的 Push 涉及堆分配一次 new 的时间往往是一个简单的数组指针写入的几十倍甚至更多。如果你做百万次入栈出栈操作顺序栈能明显快过链栈。不过这并不意味着链栈没有价值。我整理了下面的对照表方便你根据实际情况取舍维度顺序栈链栈空间分配一次性分配可能浪费按需分配无浪费容量上限受数组大小限制受堆内存限制单次 Push快赋值即可慢需要 new缓存友好性高连续内存低节点分散实现复杂度简单稍复杂多栈共存需要分别预留空间互相不干扰给你一个决策建议如果你能确定栈的容量较小且固定用顺序栈如果容量不确定、或者需要长期存少量数据但又有可能短时间暴涨用链栈如果追求极致性能且不在乎空间浪费用顺序栈并提前 reserve 足够容量。算法竞赛里几乎都用顺序栈或数组模拟栈但工程代码里链栈的出场率要高得多因为实际业务的数据规模很难提前预测。6. 结语与个人经验分享链栈这个结构虽然代码量不大但它像是数据结构和内存管理的一个交叉路口。你把链栈彻底写明白了链表的插入删除、栈的 LIFO 语义、堆内存的分配释放、RAII 和异常安全这些概念就串起来了。这也是为什么几乎每一本数据结构教材都会花专门篇幅讲它它简单却能承载大量核心思想。我个人实际写过多次链栈之后最大的体会是一定要把内存所有权想清楚。谁负责释放节点什么时候释放栈对象被拷贝时到底要发生什么这些问题在写代码之前就要有明确答案而不是边写边碰运气。很多线上崩溃、内存泄漏事后追查根因都是这些看似不起眼的小结构没写严谨。最后再分享一个小技巧在你自己的测试代码里给 LinkedStack 加一个打印函数每做一次 Push/Pop 就把整条链从头到栈底的地址和数据都打出来。这是观察和理解链栈内部变化的最佳方法比任何调试器都直观。等你能一眼看出某次操作后链的状态是否正确这个知识点就算真正吃透了。