ARTICLE DETAIL

资讯详情

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

栈数据结构详解:从原理到C语言实现与核心应用场景

栈数据结构详解:从原理到C语言实现与核心应用场景 1. 从“叠盘子”到程序世界栈的直观理解如果你在餐厅后厨打过工或者在家里收拾过碗柜那你对“栈”这个概念一定不陌生。想象一下你刚洗好一摞盘子是不是总是从下往上一个一个地摞起来当厨师需要盘子时他是不是总是从最上面直接拿走一个他绝不会从中间或者最底下抽一个出来因为那样整个摞好的盘子可能会倒掉。这个“后进先出”的取放规则就是栈Stack这种数据结构最核心、最生动的体现。在计算机科学里栈是一个极其基础且重要的概念。它就像程序世界里的“临时工作台”专门用来存放那些需要暂时保存、并且使用顺序有严格要求的“中间数据”。比如你在写一个函数函数里又调用了另一个函数那么第一个函数的执行现场比如变量值、返回地址就需要被“压”入栈中保存起来等第二个函数执行完毕再把这些现场信息从栈顶“弹”出来恢复第一个函数的执行。这个过程就是著名的“函数调用栈”。几乎所有编程语言从C、Java到Python其底层运行机制都离不开栈的支持。所以当我们谈论栈的“压栈/入栈”Push和“弹栈/出栈”Pop时本质上就是在描述这个“临时工作台”的两种基本操作往最上面放一个东西或者从最上面拿走一个东西。而“获取栈顶元素”Peek/Top则只是看一眼最上面是什么并不把它拿走。这三个操作构成了栈最核心的API。理解它们不仅是学习数据结构的敲门砖更是理解程序如何运行、如何管理内存的钥匙。无论你是正在备战期末考试的学生还是希望夯实基础的开发者彻底搞懂栈的来龙去脉都至关重要。2. 栈的抽象定义与核心特性在正式动手操作之前我们必须从抽象层面把栈的定义和规矩讲清楚。栈是一种操作受限的线性表。所谓“线性表”就是数据元素排成像一条线一样的序列比如我们熟悉的数组和链表。而“操作受限”指的是这种结构只允许在一端进行插入和删除操作。这一端被称为栈顶Top。相应地另一端则被称为栈底Bottom。这个设计决定了栈独一无二的行为特性后进先出Last In, First Out, LIFO。最后被放入栈的元素会最先被取出来。这和我们日常的电梯有点类似假设电梯只有一扇门最后进电梯的人往往在电梯到达时最先出去。栈支持的基本操作通常包括初始化创建一个空栈。判断栈空检查栈中是否没有任何元素。入栈将一个新元素放入栈顶。出栈将栈顶元素移除并返回这个元素。获取栈顶元素仅返回栈顶元素的值但不移除它。获取栈大小返回栈中当前元素的个数。这里需要特别强调“获取栈顶元素”和“出栈”的区别这是初学者容易混淆的地方。Peek或Top操作只是“读取”栈的状态没有任何改变元素数量不变。而Pop操作是“读取并删除”执行后栈顶元素被移除栈的大小减一。用一个生活类比Peek就像你看一眼桌上最上面一本书的名字Pop则是你把那本书拿起来读并且从书堆里移走。栈的底层实现通常有两种顺序栈基于数组和链式栈基于链表。顺序栈就像预定好大小的储物格存取速度快但容量固定。链式栈则像用绳子串起来的储物盒可以动态增长但每个元素需要额外的空间来存储“绳子”即指针。选择哪种实现取决于你对性能和内存灵活性的权衡。3. 顺序栈的实现与操作细节顺序栈是使用数组或类似数组的连续内存空间来实现的。我们需要维护一个数组data[]来存储元素一个整型变量top来指示栈顶的位置。3.1 栈的初始化与状态判断初始化时我们分配一个固定大小的数组并将top指针设置为-1这是一种常见的约定表示栈为空。为什么是-1而不是0因为数组索引从0开始。当top -1时栈空当top arraySize - 1时栈满。#define MAX_SIZE 100 // 预定义栈的最大容量 typedef struct { int data[MAX_SIZE]; int top; // 栈顶指针 } SeqStack; // 初始化栈 void InitStack(SeqStack *S) { S-top -1; // 栈空标志 } // 判断栈是否为空 int IsEmpty(SeqStack *S) { return S-top -1; } // 判断栈是否已满 int IsFull(SeqStack *S) { return S-top MAX_SIZE - 1; }注意top指针指向的是当前栈顶元素的位置。top -1代表空栈top 0代表栈中有一个元素它存储在data[0]。这种设计使得入栈和出栈的操作非常直观。3.2 入栈操作边界检查与数据写入入栈操作核心就是两步先检查栈是否已满然后将元素放入top指针的下一个位置并更新top。// 入栈操作 int Push(SeqStack *S, int value) { if (IsFull(S)) { printf(栈已满无法入栈\n); return 0; // 入栈失败 } S-top; // 栈顶指针先上移 S-data[S-top] value; // 新元素放入栈顶 return 1; // 入栈成功 }这里有一个关键细节为什么是先S-top再赋值因为top指向的是当前栈顶元素的位置。对于空栈top-1要放入第一个元素我们必须先将指针移动到有效索引0的位置然后再存放数据。这个顺序不能颠倒否则你会尝试向data[-1]写入数据导致内存错误。3.3 出栈操作状态检查与数据返回出栈是入栈的逆过程先检查栈是否为空然后取出top指针当前位置的元素最后将top指针下移。// 出栈操作 int Pop(SeqStack *S, int *value) { if (IsEmpty(S)) { printf(栈为空无法出栈\n); return 0; // 出栈失败 } *value S-data[S-top]; // 获取栈顶元素值 S-top--; // 栈顶指针下移 return 1; // 出栈成功 }实操心得出栈时从逻辑上讲元素已经被“移除”了但物理上它可能还残留在数组data[S-top1]的位置。不过因为top指针已经下移这个位置被标记为“可覆盖”下次入栈时就会被新数据覆盖。所以我们不需要也不应该手动去清空那个位置的数据那是一种无意义的性能损耗。3.4 获取栈顶元素只读不删这个操作最简单但也最容易出错。它必须检查栈是否为空然后直接返回data[top]的值绝对不修改top指针。// 获取栈顶元素 int GetTop(SeqStack *S, int *value) { if (IsEmpty(S)) { printf(栈为空无栈顶元素\n); return 0; } *value S-data[S-top]; return 1; }我曾经在初学时就犯过一个错误在GetTop函数里习惯性地写了S-top--导致后续操作全部错乱。记住Peek和Pop是兄弟但性格迥异一个温和只读一个霸道读写。4. 链式栈的实现与内存考量当栈的最大容量无法预估或者需要频繁动态变化时顺序栈的固定数组就显得力不从心了。这时链式栈是更好的选择。链式栈的本质就是一个单链表只不过我们规定所有的插入入栈和删除出栈操作都只能在链表的头部进行。链表的头指针就扮演了top指针的角色。4.1 节点定义与栈的初始化typedef struct StackNode { int data; struct StackNode *next; } StackNode; typedef struct { StackNode *top; // 栈顶指针指向链表头节点 int size; // 栈的长度非必需但很有用 } LinkStack; // 初始化链栈 void InitLinkStack(LinkStack *S) { S-top NULL; S-size 0; } // 判断链栈是否为空 int IsLinkStackEmpty(LinkStack *S) { return S-top NULL; // 或者 S-size 0 }链式栈的“空”状态就是top指针为NULL非常直观。4.2 链式栈的入栈头插法链式栈的入栈对应的是单链表的头插法。创建一个新节点让其指向当前的头节点即当前的栈顶然后更新栈顶指针指向这个新节点。// 链式栈入栈 int LinkPush(LinkStack *S, int value) { StackNode *newNode (StackNode*)malloc(sizeof(StackNode)); if (newNode NULL) { printf(内存分配失败\n); return 0; } newNode-data value; newNode-next S-top; // 新节点指向原栈顶 S-top newNode; // 栈顶指针更新为新节点 S-size; return 1; }这个过程就像给一列火车加一个新的车头。新节点newNode就是新车头它的next挂钩挂上原来的火车头S-top然后整个火车的标识S-top指向这个新车头。4.3 链式栈的出栈释放头节点出栈操作就是删除单链表的头节点。需要先保存头节点的数据和下一个节点的地址然后释放头节点内存最后更新栈顶指针。// 链式栈出栈 int LinkPop(LinkStack *S, int *value) { if (IsLinkStackEmpty(S)) { printf(栈为空无法出栈\n); return 0; } StackNode *temp S-top; // 临时保存待删除的栈顶节点 *value temp-data; // 获取栈顶数据 S-top temp-next; // 栈顶指针指向下一个节点 free(temp); // 释放原栈顶节点内存 S-size--; return 1; }重要注意事项这里有一个经典的内存管理坑。一定要先用一个临时指针temp保存S-top然后再更新S-top S-top-next。如果先更新S-top你就丢失了原来栈顶节点的地址无法再free它导致内存泄漏。顺序很重要4.4 链式栈的获取栈顶与销毁获取栈顶元素和顺序栈逻辑一致只是访问方式不同。而链式栈由于使用了动态内存必须提供一个额外的Destroy函数来遍历释放所有节点内存防止内存泄漏。// 获取链式栈栈顶元素 int GetLinkTop(LinkStack *S, int *value) { if (IsLinkStackEmpty(S)) { printf(栈为空\n); return 0; } *value S-top-data; return 1; } // 销毁链式栈 void DestroyLinkStack(LinkStack *S) { while (S-top ! NULL) { StackNode *temp S-top; S-top S-top-next; free(temp); } S-size 0; }5. 栈的核心应用场景剖析理解了栈的基本操作我们来看看它到底能干什么。栈的应用无处不在下面几个是其中最经典、面试最高频的场景。5.1 场景一函数调用栈与递归这是栈最根本的应用。每次函数调用系统都会在栈上分配一块内存称为“栈帧”用来保存函数的参数、局部变量、返回地址等信息。当函数调用另一个函数时当前函数的栈帧被压入栈中暂停新函数开始执行创建自己的栈帧。当新函数返回时其栈帧出栈系统根据之前保存的返回地址回到上一个函数继续执行。递归函数是这种机制的极致体现。递归的每一层都对应一个独立的栈帧。以计算阶乘factorial(n)为例factorial(3) 调用 factorial(2) - factorial(2) 调用 factorial(1) - factorial(1) 返回 1 - factorial(2) 收到1计算 2*12 返回 - factorial(3) 收到2计算 3*26 返回这个过程就像一叠便签最上面是当前正在处理的任务factorial(1)下面压着所有未完成的上层任务。递归深度过深导致的“栈溢出”错误就是因为栈帧数量超过了系统为线程预留的栈空间大小。5.2 场景二表达式求值与括号匹配编译器如何计算(1 2) * (3 - 4)这样的表达式它需要两个栈一个操作数栈一个运算符栈。从左到右扫描表达式。遇到数字压入操作数栈。遇到运算符与运算符栈顶的运算符比较优先级。如果当前运算符优先级更高直接压入运算符栈。如果更低或相等则从运算符栈弹出栈顶运算符从操作数栈弹出两个操作数进行计算将结果压回操作数栈然后继续比较当前运算符与新的栈顶运算符。遇到左括号(直接压入运算符栈。遇到右括号)不断弹出运算符栈顶的运算符并计算直到弹出左括号为止。表达式扫描完后清空运算符栈进行剩余计算。括号匹配是表达式求值的一个子问题。算法更简单遍历字符串遇到左括号(, [, {就入栈遇到右括号) , ], }就检查栈顶的左括号是否与之匹配如果匹配则出栈否则说明不匹配。最后如果栈为空则括号完全匹配。5.3 场景三浏览器的前进与后退这个场景完美体现了栈的LIFO特性。我们使用两个栈Stack A和Stack B。访问新页面将新页面URL压入Stack A同时清空Stack B因为有了新的浏览路径旧的后退记录失效。点击后退将Stack A的栈顶页面出栈并压入Stack B。然后显示Stack A新的栈顶页面。点击前进将Stack B的栈顶页面出栈并压入Stack A。然后显示Stack A新的栈顶页面即刚出栈的那个。Stack A可以看作是“已访问页面的历史栈”栈顶是当前页面。Stack B是“后退栈”存放着从Stack A中后退出去的页面。这个双栈模型清晰、高效地管理了线性的浏览历史。5.4 场景四深度优先搜索与回溯算法在图和树的遍历中深度优先搜索DFS天然地使用栈来记录访问路径。以二叉树的前序遍历为例递归版本隐式使用了系统调用栈// 非递归前序遍历显式使用栈 void preOrderTraversal(TreeNode* root) { if (root NULL) return; SeqStack S; InitStack(S); Push(S, root); // 根节点入栈 while (!IsEmpty(S)) { TreeNode* node; Pop(S, node); // 出栈访问 printf(%d , node-val); // 注意右孩子先入栈左孩子后入栈这样才能保证出栈顺序是根-左-右 if (node-right ! NULL) Push(S, node-right); if (node-left ! NULL) Push(S, node-left); } }在回溯算法如八皇后、迷宫问题中栈用来保存当前的尝试路径。当走到死胡同时通过出栈操作回退到上一个决策点尝试其他可能性。栈在这里充当了“后悔药”的角色。6. 栈的边界条件与常见“坑点”在实际编码和面试中栈相关的错误大多源于对边界条件处理不当。下面我总结几个最容易踩坑的地方。6.1 栈空时执行出栈或获取栈顶操作这是最经典的运行时错误。无论是顺序栈还是链式栈在执行Pop或GetTop操作前必须检查栈是否为空。对于顺序栈空栈时top是-1如果尝试访问data[-1]会引发数组越界。对于链式栈空栈时top是NULL如果尝试访问NULL-data会导致程序崩溃空指针解引用。防御性编程永远把IsEmpty检查作为Pop和GetTop函数的第一行。在团队协作中甚至可以约定这些函数返回一个布尔值表示操作是否成功并通过指针参数返回实际数据就像我们前面代码示例中做的那样。6.2 栈满时执行入栈操作针对顺序栈顺序栈有容量限制。当top MAX_SIZE - 1时栈已满。此时再执行Push操作如果直接写入data[MAX_SIZE]会导致缓冲区溢出破坏相邻内存的数据这是非常严重的安全隐患著名的栈溢出攻击就利用了类似的原理。解决方案严格检查入栈前必须调用IsFull。动态扩容更健壮的做法是实现动态顺序栈。当栈满时申请一个更大的数组比如原大小的2倍将旧数据拷贝过去然后释放旧数组。这增加了复杂度但提供了灵活性。C中的std::vector、Java中的ArrayList在背后就是这样做的。6.3 链式栈的内存泄漏这是链式结构特有的问题。每次Push都需要malloc每次Pop都需要free。如果只Pop不free或者整个栈使用完后没有遍历free所有节点就会造成内存泄漏。在长时间运行的服务中微小的泄漏累积起来可能导致内存耗尽。排查与习惯在Pop函数中确保在更新top指针之前用临时变量保存待删除节点的地址。为链式栈提供一个DestroyStack函数并在栈的生命周期结束时调用它。使用Valgrind等内存检测工具定期检查程序。6.4 多线程环境下的栈操作栈本身不是线程安全的数据结构。如果多个线程同时操作同一个栈一个在Push另一个在Pop而没有同步机制会导致数据竞争引发不可预知的结果比如数据丢失或程序崩溃。常见策略互斥锁在栈的每个操作函数开始处加锁结束处解锁。这是最简单粗暴的方法但会降低并发性能。使用线程安全的数据结构如Java中的java.util.concurrent.ConcurrentLinkedDeque可以当作栈来用它使用了更高效的CASCompare-And-Swap等无锁算法。7. 栈、堆与函数栈帧深入内存模型“栈”和“堆”是程序员口中常说的两个词但它们指代的是完全不同的概念极易混淆。这里结合函数栈帧做一个彻底的厘清。特性栈 (Stack)堆 (Heap)管理方式由编译器/系统自动分配和释放。函数调用时分配栈帧函数返回时自动回收。由程序员手动申请和释放如C的malloc/freeC的new/delete。生长方向通常从高地址向低地址生长“自上而下”。通常从低地址向高地址生长。分配效率速度快仅需移动栈指针。速度慢需要寻找合适的内存块并可能引发碎片整理。内存大小大小有限。每个线程的栈空间是预先设定好的如Linux默认8MB。大小受限于系统虚拟内存总量理论上很大。存储内容函数调用栈帧局部变量、参数、返回地址等。动态分配的对象、大型数组等。碎片问题无内存碎片。有内存碎片问题。线程安全每个线程有自己的栈线程私有天然安全。堆是进程内所有线程共享的需要同步控制。函数栈帧是栈内存中的一个逻辑块。当一个函数被调用时会压入一个新的栈帧。这个栈帧里通常包含返回地址函数执行完后应该回到调用它的下一条指令地址。调用者的栈帧基址用于在函数返回后恢复调用者的栈环境。函数的参数从右向左依次压栈取决于调用约定。函数的局部变量在栈帧内分配空间。临时存储区用于保存寄存器值或中间计算结果。以一段简单的C代码为例int add(int a, int b) { int result a b; return result; } int main() { int x 5, y 10; int sum add(x, y); return 0; }当main调用add时系统会将参数y10和x5压入栈或存入寄存器取决于ABI。将main函数中call add指令的下一条地址返回地址压栈。跳转到add函数的代码。在add的栈帧中为局部变量result分配空间。执行计算将结果存入result。函数返回前将返回值通常通过特定寄存器如EAX设置好。弹出add的栈帧根据返回地址跳回main函数。main函数从栈或寄存器中获取返回值赋给变量sum。理解栈帧对于调试至关重要。当程序崩溃产生“核心已转储”文件时调试器如GDB就是通过分析栈帧的链式结构栈回溯来告诉你函数调用链从而定位问题所在的。8. 栈在算法竞赛与面试中的实战技巧栈不仅是基础数据结构更是解决一系列特定算法问题的利器。掌握下面几个经典栈算法模板能让你在面试和竞赛中游刃有余。8.1 单调栈寻找下一个更大/更小元素单调栈是指栈内元素保持单调递增或单调递减的栈。它常用于解决“寻找每个元素右边第一个比它大或小的元素”这类问题时间复杂度可以优化到O(n)。问题给定一个数组nums返回一个等长的数组answer其中answer[i]是nums[i]右边第一个比它大的元素的下标如果没有则为-1。思路与代码 维护一个栈底到栈顶单调递减的栈栈里存放的是元素的下标因为我们需要知道位置信息。 遍历数组如果当前元素nums[i]小于等于栈顶下标对应的元素说明当前元素不破坏单调性直接将其下标i入栈。如果当前元素nums[i]大于栈顶下标对应的元素说明我们找到了栈顶元素的下一个更大元素。弹出栈顶下标topIndex并设置answer[topIndex] i。重复此过程直到栈空或当前元素不再大于栈顶元素。遍历结束后栈中剩余的下标对应的元素其右边都没有更大的元素将它们的answer值设为-1。void nextGreaterElement(int nums[], int n, int answer[]) { int stack[n]; // 用数组模拟栈存储下标 int top -1; for (int i 0; i n; i) { answer[i] -1; // 先初始化为-1 } for (int i 0; i n; i) { // 当前元素大于栈顶下标对应的元素 while (top ! -1 nums[i] nums[stack[top]]) { int topIndex stack[top]; top--; // 出栈 answer[topIndex] i; // 找到了下一个更大元素的位置 } stack[top] i; // 当前下标入栈 } // 栈中剩余的元素answer值保持为-1 }实战心得单调栈的难点在于想清楚栈里应该存什么值还是下标以及维护单调性的判断条件是还是。对于“下一个更小元素”问题只需将判断条件中的改为并维护一个单调递增栈即可。多画图模拟过程是掌握它的不二法门。8.2 使用栈实现队列这是一个经典的面试题考察对栈和队列性质的理解。队列是FIFO先进先出栈是LIFO后进先出。用栈实现队列需要两个栈来“扭转”顺序。思路 设置两个栈stackIn和stackOut。入队所有新元素都压入stackIn。出队/查看队首如果stackOut不为空则直接从stackOut弹出或查看栈顶元素。如果stackOut为空则将stackIn中的所有元素依次弹出并压入stackOut。这样最早进入stackIn的元素即队首就位于stackOut的栈顶了然后对其进行出栈或查看操作。typedef struct { SeqStack stackIn; SeqStack stackOut; } MyQueue; void myQueuePush(MyQueue* obj, int x) { Push((obj-stackIn), x); // 入队直接压入输入栈 } int myQueuePop(MyQueue* obj) { // 如果输出栈为空则将输入栈的所有元素倒入输出栈 if (IsEmpty((obj-stackOut))) { while (!IsEmpty((obj-stackIn))) { int temp; Pop((obj-stackIn), temp); Push((obj-stackOut), temp); } } int value; Pop((obj-stackOut), value); // 从输出栈弹出 return value; } int myQueuePeek(MyQueue* obj) { // 同Pop只是获取而不删除 if (IsEmpty((obj-stackOut))) { while (!IsEmpty((obj-stackIn))) { int temp; Pop((obj-stackIn), temp); Push((obj-stackOut), temp); } } int value; GetTop((obj-stackOut), value); return value; }复杂度分析虽然Pop和Peek操作在最坏情况下是O(n)需要倒栈但每个元素只会从stackIn进入stackOut一次因此摊还时间复杂度是O(1)。这是一个非常重要的分析角度面试时一定要能说出来。8.3 最小栈在常数时间内检索到最小元素设计一个栈除了支持常规的Push、Pop、Top还能在**O(1)**时间内检索到栈中的最小元素。思路使用一个辅助栈minStack与主栈dataStack同步操作。Push(x)时将x压入dataStack。同时比较x与当前minStack栈顶元素minTop。如果x minTop注意是而不是是为了处理多个相同最小值的情况则将x也压入minStack。Pop()时从dataStack弹出栈顶元素topValue。如果topValue等于当前minStack栈顶元素则minStack也弹出栈顶。GetMin()时直接返回minStack的栈顶元素。typedef struct { SeqStack dataStack; SeqStack minStack; // 辅助栈栈顶始终是当前数据栈中的最小值 } MinStack; void minStackPush(MinStack* obj, int x) { Push((obj-dataStack), x); // 如果辅助栈为空或者x小于等于辅助栈栈顶 if (IsEmpty((obj-minStack)) || x GetTopValue((obj-minStack))) { Push((obj-minStack), x); } } void minStackPop(MinStack* obj) { if (IsEmpty((obj-dataStack))) return; int topValue; Pop((obj-dataStack), topValue); int minTop; GetTop((obj-minStack), minTop); if (topValue minTop) { int dummy; Pop((obj-minStack), dummy); } } int minStackGetMin(MinStack* obj) { int minValue; GetTop((obj-minStack), minValue); return minValue; }关键点辅助栈minStack的栈顶元素永远是当前dataStack中所有元素的最小值。当最小值被弹出时minStack也随之弹出新的栈顶就是次小值或另一个相同的最小值。这个设计巧妙地用空间O(n)最坏情况换取了时间O(1)查询最小值。
返回列表