尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

栈数据结构全解析:从LIFO原理到数组/链表实现与应用场景

栈数据结构全解析:从LIFO原理到数组/链表实现与应用场景 1. 栈程序世界的“叠盘子”艺术如果你写过代码哪怕只是几行“Hello World”你也已经在不知不觉中使用过栈了。它不像链表那样需要你手动串珠子也不像树那样需要你构建复杂的层级关系。栈更像是一种约定俗成的规则一种嵌入到编程语言骨髓里的本能。想象一下餐厅里清洗干净的餐盘服务员总是把新洗好的盘子叠在最上面而厨师取用时也总是从最上面拿走——这就是栈最朴素、最直观的模型后进先出。这个简单的规则却支撑起了程序世界中无数关键而精妙的操作。从你写的函数调用、到浏览器的前进后退、再到表达式求值甚至游戏里的撤销功能栈的身影无处不在。今天我们就抛开教科书上那些干巴巴的定义从一个一线开发者的视角彻底拆解栈这个数据结构。我会带你看看它到底是怎么“叠盘子”和“取盘子”的在内存里又是如何安家的以及在实际编码时有哪些教科书上不会告诉你的“坑”和“骚操作”。2. 栈的核心哲学与运作机制2.1 理解“后进先出”的现实映射“后进先出”听起来有点抽象我们把它翻译成更直白的场景。除了叠盘子再想想你平时用的**CtrlZ撤销**功能。你依次输入了字符A、B、C那么你的操作历史栈就是[A, B, C]C是最后进来的。当你第一次按下CtrlZ时最后进来的操作C被撤销出栈栈变成[A, B]。这就是LIFO最后被记录的操作最先被回退。在程序内部函数调用是栈最经典的应用。当main()函数调用funcA()时计算机需要记住main()执行到哪了等funcA()干完活得回来继续。于是它把main()的“现场”包括返回地址、局部变量等压入一个叫“调用栈”的地方。然后funcA()开始执行它又调用了funcB()那么funcA()的现场也被压栈funcB()的现场位于栈顶。当funcB()执行完毕它从栈顶弹出恢复到funcA()的现场继续执行。这个过程完美契合了LIFO最后被调用的函数funcB()最先结束并返回。所以栈的核心哲学是关于顺序与回退的。它管理的是一个有严格时序关系的序列你关心的是“最近”发生的事情并且处理顺序与发生顺序严格相反。2.2 栈的ADT我们与栈的约定在动手实现之前我们要先和栈定好“契约”也就是抽象数据类型。一个栈通常支持以下核心操作Push压栈/入栈把一个新元素放到栈顶。就像把新盘子叠上去。Pop弹栈/出栈移除并返回栈顶的元素。就像把最上面的盘子拿走。如果栈是空的这个操作通常应该报错或返回一个特定值。Peek / Top获取栈顶元素看一眼栈顶是哪个元素但不拿走它。这对于做决策很有用比如判断下一个该处理谁。IsEmpty判空检查栈里还有没有盘子。Size / GetSize获取大小看看栈里叠了多少个盘子。这些操作的时间复杂度都应该是O(1)也就是说无论栈里有一万个盘子还是只有一个盘子我执行一次push或pop所花的时间应该是一样的。这是衡量一个栈实现是否合格的金线。注意有些教学材料或初级实现会提供一个遍历栈的操作。从栈的抽象定义和常用场景来看这通常是一个“坏味道”。栈的设计初衷就不是让你遍历其中所有元素的它的接口应该强制保持LIFO的访问限制。如果你发现自己频繁需要遍历栈很可能你选错了数据结构应该考虑换成列表或数组。3. 栈的两种实现方式数组栈与链表栈知道了栈要做什么接下来就是怎么做了。主流的实现方式有两种它们各有各的脾气适用于不同的场合。3.1 数组栈简单粗暴的定长选手用数组实现栈意味着我们在内存中预先划出一块连续的空间。我们需要一个数组data[]一个整型变量topIndex来指向栈顶的位置。初始化topIndex -1表示栈空。为什么是-1而不是0这是为了操作的一致性。当栈空时topIndex指向一个无效的索引这样在执行peek或pop前可以先检查topIndex -1来判断栈是否为空。入栈push(x)检查栈是否已满topIndex capacity - 1。如果满了需要处理——要么报错要么动态扩容这是另一个话题。topIndex自增1topIndex。将元素x放入data[topIndex]。出栈pop()检查栈是否为空topIndex -1。为空则报错或返回空值。获取栈顶元素result data[topIndex]。topIndex自减1topIndex--。返回result。查看栈顶peek()检查栈是否为空。直接返回data[topIndex]。数组栈的优缺点分析优点实现极其简单代码量少不易出错。内存连续缓存友好。CPU在读取data[topIndex]时很可能已经把附近的数据也加载到高速缓存里了后续操作速度很快。没有存储指针的额外开销。每个元素就是元素本身的大小。缺点容量固定。这是最大的痛点。如果初始化时数组开小了栈容易满开大了又浪费内存。虽然可以通过“动态扩容”策略当栈满时申请一个更大的新数组把旧数据拷贝过去来缓解但扩容操作本身是O(n)的耗时且可能引发内存拷贝。不适合元素数量波动极大的场景。一个C的简易数组栈实现要点class ArrayStack { private: int* data; // 动态数组指针 int topIdx; // 栈顶索引初始为-1 int capacity; // 数组容量 public: ArrayStack(int cap) : capacity(cap), topIdx(-1) { data new int[capacity]; // 分配空间 } ~ArrayStack() { delete[] data; // 释放空间 } bool push(int x) { if (topIdx capacity - 1) { // 栈满可以在这里实现扩容逻辑 return false; // 示例中简单返回失败 } data[topIdx] x; // 先topIdx再赋值 return true; } bool pop() { if (isEmpty()) { return false; } topIdx--; // 逻辑上移除栈顶元素 return true; } int peek() { if (isEmpty()) { // 可以抛出异常或返回一个错误值 return -1; // 示例 } return data[topIdx]; } bool isEmpty() const { return topIdx -1; } int size() const { return topIdx 1; } };实操心得在像C这样的语言中使用数组实现栈要格外小心内存管理。上面的例子为了简单用了int类型和裸指针。在生产环境中你应该使用std::vector作为底层容器它会自动处理扩容和内存释放安全又省心。或者直接使用标准库的std::stack它的默认底层容器就是std::deque兼具了数组的缓存友好和灵活扩容的优点。3.2 链表栈灵活自如的变长选手用链表实现栈我们只在需要的时候才为元素分配内存。栈顶就是链表的头节点。这样push就是在链表头部插入新节点pop就是删除链表头节点。初始化创建一个空的头指针topNode nullptr。入栈push(x)创建一个新节点newNode其数据域为x。将newNode的next指针指向当前的topNode。将topNode更新为newNode。出栈pop()检查栈是否为空topNode nullptr。保存当前头节点的值result topNode-value。将topNode指向其下一个节点topNode topNode-next。释放原头节点的内存在手动管理内存的语言中至关重要。返回result。查看栈顶peek()检查栈是否为空。直接返回topNode-value。链表栈的优缺点分析优点理论上容量无限只受限于总内存。每次push才申请内存非常灵活。没有扩容开销。插入删除都是常数时间且没有数组扩容时的大规模数据拷贝。缺点内存不连续缓存不友好。节点散落在内存各处CPU预取效果差访问速度可能慢于数组栈。每个元素都有额外开销。需要存储指向下一个节点的指针在64位系统上是8字节对于存储小数据类型如int、char来说这个开销比例很高。实现稍复杂涉及指针操作容易出错如内存泄漏、空指针解引用。一个C的简易链表栈实现要点class ListNode { public: int value; ListNode* next; ListNode(int val) : value(val), next(nullptr) {} }; class LinkedListStack { private: ListNode* topNode; public: LinkedListStack() : topNode(nullptr) {} ~LinkedListStack() { // 析构时释放所有节点内存防止内存泄漏 while (topNode ! nullptr) { ListNode* temp topNode; topNode topNode-next; delete temp; } } void push(int x) { ListNode* newNode new ListNode(x); newNode-next topNode; // 新节点指向原栈顶 topNode newNode; // 更新栈顶为新节点 } bool pop() { if (isEmpty()) { return false; } ListNode* temp topNode; topNode topNode-next; // 栈顶下移 delete temp; // 释放原栈顶节点 return true; } int peek() { if (isEmpty()) { return -1; // 示例错误值 } return topNode-value; } bool isEmpty() const { return topNode nullptr; } // 注意获取大小需要遍历链表是O(n)操作 int size() const { int count 0; ListNode* current topNode; while (current ! nullptr) { count; current current-next; } return count; } };踩坑记录链表栈的size()操作是O(n)的因为你需要遍历整个链表才能数出有多少个节点。这与栈ADT期望的O(1)操作相悖。一个常见的优化方法是在栈类内部维护一个count变量在push时count在pop时count--这样size()就能在O(1)时间内返回。这是教科书上常常忽略的工程细节。3.3 如何选择数组栈 vs 链表栈这没有绝对答案取决于你的具体场景选数组栈如果你能预估栈的最大容量或者容量上限是明确的、可接受的。你追求极致的性能特别是对缓存敏感的应用。你使用的语言如C有std::vector这样优秀的动态数组容器可以轻松解决扩容问题。栈中存储的是基础数据类型或小型对象。选链表栈如果栈的容量完全不可预知可能非常大且你不希望一开始就分配大量可能用不到的内存。你的元素是大型对象拷贝成本很高数组扩容时需要拷贝。链表只需要移动指针。你更关注实现的灵活性和内存的按需分配。对于绝大多数现代编程任务直接使用标准库提供的栈如C的std::stackJava的java.util.StackPython的list用作栈是最佳选择。这些库的实现经过了千锤百炼通常基于动态数组或双端队列在内存、速度和易用性上取得了很好的平衡。4. 栈的实战应用场景剖析理解了栈怎么实现我们来看看它到底能干什么。栈的应用是理解其价值的关键。4.1 场景一函数调用栈与递归这是栈与生俱来的使命。操作系统或运行时环境为每个线程维护一个调用栈。每次函数调用就压入一个栈帧里面包含了返回地址函数执行完后该回到哪里。参数传给函数的实参。局部变量函数内部定义的变量。一些寄存器的值用于恢复现场。递归函数是栈的极致体现。计算阶乘factorial(n)的递归调用factorial(5)其调用栈的演变如下调用开始: 栈空 调用 factorial(5): 栈帧[5] 入栈 调用 factorial(4): 栈帧[4] 入栈 调用 factorial(3): 栈帧[3] 入栈 调用 factorial(2): 栈帧[2] 入栈 调用 factorial(1): 栈帧[1] 入栈 (递归基) factorial(1) 返回 1: 栈帧[1] 出栈 factorial(2) 计算 2*12 返回: 栈帧[2] 出栈 factorial(3) 计算 3*26 返回: 栈帧[3] 出栈 factorial(4) 计算 4*624 返回: 栈帧[4] 出栈 factorial(5) 计算 5*24120 返回: 栈帧[5] 出栈栈完美地保存了每一层递归的“现场”使得返回后能继续正确计算。这也解释了**递归深度过大会导致“栈溢出”**的原因调用栈的空间是有限的通常几MB如果递归层数太多栈帧把空间耗尽了程序就会崩溃。4.2 场景二表达式求值中缀转后缀我们人习惯写的表达式如3 4 * 2是中缀表达式运算符在中间。计算机直接计算这个很麻烦因为要考虑运算符优先级。栈可以帮我们把中缀表达式转换成计算机更容易处理的后缀表达式也叫逆波兰表达式。算法核心调度场算法使用一个栈来存放运算符。从左到右扫描中缀表达式。遇到数字直接输出。遇到运算符如 - * /如果栈空或栈顶是左括号(或当前运算符优先级高于栈顶运算符则当前运算符入栈。否则不断将栈顶优先级不低于当前运算符的运算符弹出并输出直到满足入栈条件再将当前运算符入栈。遇到左括号(直接入栈。遇到右括号)不断将栈顶运算符弹出并输出直到遇到左括号(左括号弹出但不输出。表达式扫描完后将栈中剩余所有运算符弹出并输出。以3 4 * 2为例扫描 3: 输出 3 扫描 : 栈空入栈。 栈: [] 扫描 4: 输出 4。 输出: 3 4 扫描 *: *优先级高于栈顶*入栈。 栈: [, *] 扫描 2: 输出 2。 输出: 3 4 2 结束: 弹出栈中所有符号并输出。 弹出*输出弹出输出。 最终后缀表达式: 3 4 2 * 得到后缀表达式后再用一个栈来计算就非常简单了遇到数字就入栈遇到运算符就弹出栈顶两个数字进行计算结果再入栈。4.3 场景三括号匹配检查一段代码或文本中的括号(),[],{}是否匹配是栈的招牌应用。算法创建一个空栈。从左到右扫描字符串。如果遇到左括号(,[,{将其压入栈中。如果遇到右括号),],}检查栈是否为空。若空则缺少左括号不匹配。弹出栈顶元素检查它是否与当前右括号匹配即(对)[对]{对}。若不匹配则不匹配。扫描结束后检查栈是否为空。若不为空则说明有多余的左括号不匹配。这个算法清晰高效时间复杂度O(n)。很多代码编辑器和IDE的语法高亮、错误提示功能背后都有这个算法的影子。4.4 场景四浏览器的前进与后退浏览器用两个栈来实现这个功能后退栈存放你访问过的历史页面。前进栈存放你后退后可以再前进的页面。操作流程访问新页面将新页面URL压入后退栈并清空前进栈因为新的访问路径开始了。点击后退从后退栈弹出栈顶页面当前页面并将其压入前进栈。然后显示后退栈新的栈顶页面。点击前进从前进栈弹出栈顶页面并将其压入后退栈。然后显示这个页面。这个设计巧妙地用两个栈模拟了线性的历史记录并且保证了操作的顺序性。4.5 场景五深度优先搜索在图和树的遍历中深度优先搜索天然地使用栈递归调用隐式使用了系统栈非递归实现则显式使用一个栈。非递归DFS伪代码栈S初始放入起点。 while (S不为空) { 顶点v S.pop(); // 弹出栈顶 如果v未被访问过 标记v为已访问。 处理顶点v例如打印。 将v的所有未访问邻接点压入栈S。 }栈保证了我们总是先探索最近发现的路径的深处符合DFS“一条路走到黑走不通再回头”的特性。5. 栈的边界、陷阱与高级话题5.1 栈溢出不只是递归的专利我们熟知递归过深会导致栈溢出。但在非递归场景下如果你错误地编写了循环导致元素只入栈不出栈或者出栈逻辑有误同样会耗尽栈空间。例如在解析一个极度复杂的嵌套结构时如果忘记在合适时机弹出元素栈就会无限增长。诊断与避免设定容量上限对于自己实现的栈尤其是数组栈一定要在push前检查容量。逻辑审查仔细检查循环和递归的终止条件确保push和pop是成对、平衡的。使用迭代替代深度递归对于一些可能很深的算法如树的遍历考虑使用显式栈的迭代法有时比递归更容易控制内存。5.2 线程安全当多个执行流同时操作栈如果你的栈会被多个线程同时访问压栈、弹栈那么它就是非线程安全的。两个线程可能同时执行push导致一个线程的写入被另一个覆盖。或者一个线程在pop时刚读取了栈顶指针另一个线程就修改了它导致数据错乱或崩溃。解决方案互斥锁在push、pop、peek等操作前后加锁。这是最直接的方法但锁的争用会影响性能。无锁栈使用原子操作如CAS, Compare-And-Swap来实现栈。这非常复杂但性能极高适用于高性能并发场景。Java中的ConcurrentLinkedDequeC中的std::stack配合原子操作或特定的内存序可以实现无锁或细粒度锁。实操心得在绝大多数业务开发中如果遇到并发栈的需求首先考虑是否可以通过任务队列等更高层次的抽象来规避。如果必须用优先使用语言标准库或成熟第三方库提供的并发容器不要自己轻易尝试实现无锁数据结构那是一个充满“坑”的领域。5.3 单调栈解决“下一个更大元素”的利器单调栈是栈的一种特殊用法它保持栈内元素通常是索引的单调性递增或递减。它常用于解决一类“寻找每个元素右边/左边第一个比它大/小的元素”的问题。经典问题每日温度。给定一个温度列表要求返回一个列表表示对于每一天你至少需要等待多少天才能等到一个更暖和的温度。如果等不到用0表示。暴力解法是对于每一天i向后遍历找第一个比temperatures[i]大的temperatures[j]时间复杂度O(n²)。单调栈解法O(n) 我们维护一个栈栈里存放的是索引且索引对应的温度值是单调递减的。遍历温度数组。对于当前索引i的温度T[i]如果栈为空或T[i] 栈顶索引对应的温度则将i入栈。否则T[i]比栈顶的温度高。这意味着栈顶元素假设索引为idx找到了下一个更高温度的日子即i。那么result[idx] i - idx。然后弹出栈顶继续用T[i]与新的栈顶比较直到栈空或T[i]不大于栈顶温度。最后将i入栈。这个过程保证了栈内索引对应的温度是递减的。一旦遇到一个更高的温度它就能“清算”栈里所有比它低的温度。vectorint dailyTemperatures(vectorint temperatures) { int n temperatures.size(); vectorint result(n, 0); stackint stk; // 栈里存索引保证索引对应的温度单调递减 for (int i 0; i n; i) { // 当前温度比栈顶温度高说明栈顶元素找到了下一个更高温度 while (!stk.empty() temperatures[i] temperatures[stk.top()]) { int idx stk.top(); stk.pop(); result[idx] i - idx; // 计算等待天数 } stk.push(i); // 当前索引入栈 } // 栈中剩余的元素右边没有更高的温度了result中已经是0无需处理 return result; }单调栈的思想非常巧妙它将原本需要嵌套循环的问题通过维护一个单调性在一次遍历中就解决了。类似的问题还有“柱状图中最大的矩形”、“接雨水”等都是面试和算法竞赛中的高频题目。栈这个看似简单的数据结构其内涵和应用远比“叠盘子”丰富。从底层系统调用的基石到高层应用逻辑的巧妙实现它无处不在。理解栈不仅仅是记住push和pop更是理解一种“后进先出”的思维模式这种模式是解决许多顺序相关、状态回退、深度优先问题的钥匙。在实际开发中当你遇到需要“临时保存、稍后处理、且处理顺序与保存顺序相反”的场景时不妨先想想这里是不是该用个栈
返回列表