ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C语言实现顺序栈与链式栈:从LIFO原理到栈溢出调试

C语言实现顺序栈与链式栈:从LIFO原理到栈溢出调试 直接说吧这篇文章绕不开一个场景:你写了个递归函数一跑就崩gdb里敲一行bt屏幕上唰唰列出一串调用关系main调了foofoo调了barbar又蹦进了baz……这一摞谁调了谁的嵌套调用链就是栈stack最直观的体现。学数据结构的时候后进先出四个字背起来不过十秒钟但真到了用C语言手写顺序栈和链式栈问题马上变得具体top指针初始化为-1还是0栈满怎么判断链式栈到底要不要头结点扩容为什么是翻倍而不是加1这篇文章就把这些问题彻底聊透。我会从栈的底层逻辑讲起给出两套可运行顺序栈和链式栈的完整代码再延伸到括号匹配、表达式求值、栈溢出回溯这些经典场景最后把调试中容易踩的坑一并列出来。适合正在学数据结构的本科生、准备考研或者校招的开发者也适合任何想通过C语言把基础打扎实的人。1. 栈的本质后进先出到底解决什么问题1.1 一叠盘子理解LIFO的直觉想象一叠盘子叠在桌上。你新洗好的盘子只能放在最顶上要用的时候也只能从最顶上拿走。最后放上去的盘子永远是最先被拿走的那个。这就是栈的核心规则后进先出Last In First Out简称LIFO。对应的队列则是先进先出像食堂排队打饭来得早的人先走这两者是最基础也最容易混淆的一对线性结构。为什么后进先出这个看似简单的规则如此重要因为现实世界里有大量问题天然带有这种嵌套结构做一件事的过程中插入了另一件事完成插入的事之后必须回到刚才那个现场继续做。比如阅读文章时看到脚注你翻到页脚读完注释再回到正文的句子位置继续读。这里的回到刚才的位置就是一次出栈操作而记住我在哪里就是一次入栈操作。计算机处理嵌套函数调用时的思路和这个几乎一模一样。对C语言来说栈更是一个绕不开的话题。程序运行时操作系统会在进程的地址空间里划出一块叫栈区的内存专门用来存放函数调用过程中产生的局部变量、参数、返回地址。每次函数调用相当于往这块栈区压入一帧新的数据函数返回这一帧就被弹出。这也是很多初学者困惑的地方按理说栈区的变量没有被你手动free为什么函数一返回就自动失效了因为它的生命周期就是跟着栈帧走的出栈即销毁不需要你操心。这个机制在后面的栈帧形成过程里还会展开。1.2 栈在计算机世界里的几个常见身影除了函数调用栈栈还藏在很多你看得见或者看不见的角落表达式求值编译器解析1 2 * 3这类表达式时需要处理运算符优先级中缀转后缀、后缀求值都依赖栈。后面我会用单独一个小节讲思路。括号匹配编辑器、IDE里检查{}、[]、()是否配对经典解法就是栈。浏览器的后退按钮打开A页面进入B页面再进入C页面后退按钮依次回到B、A这就是一个访问历史栈。编辑器的撤销操作你做的每一步操作被压进撤销栈CtrlZ就是不断出栈。深度优先搜索DFS不管是二叉树的先序遍历还是图的深度优先遍历递归版本依赖系统栈非递归版本往往需要自己维护一个显式栈。backtrace栈回溯gdb调试时bt命令打印的调用栈正是从栈帧里还原出来的。嵌入式arm平台上排查死机问题时也常用backtrace这类技术还原调用链定位崩在哪个函数。所以栈不是一个只能在卷子上画“Push、Pop”的知识点它是真实系统里最活跃的数据结构之一。1.3 顺序栈和链式栈实现LIFO的两种风格既然栈本身是一种逻辑结构那用什么存储介质去实现它就有了不同的选择。用连续内存去存就是顺序栈底层是数组用分散的节点加指针去串就是链式栈底层是链表。两者遵循的LIFO规则完全一致对外提供的接口也基本一样区别在于内部的组织方式以及由此带来的性能和容量上的差异。这篇文章接下来的主线就是两条先讲顺序栈的完整实现和细节再讲链式栈的完整实现和细节最后做一次全面的对比。很多人在学校里会把这两种实现背得很熟但真到自己写的时候总是差那么一点细节比如栈顶指针在为空时到底该指向哪。我建议你跟着代码一行一行过最好动手敲一遍这些细节才有体感。2. 顺序栈用数组实现后进先出2.1 结构体设计data数组和top指针顺序栈的本质就是一块连续内存加上一个顶部标记。在C语言里最自然的结构体设计是这样typedef struct { int *data; // 存放栈元素的连续内存数组 int top; // 栈顶指针指向栈顶元素的位置 int capacity; // 当前分配的总容量 } SqStack;有些教材用固定数组int data[MAX_SIZE]那是一种静态顺序栈好处是简单劣势是容量写死塞满了就无能为力。我这里用int *data配合capacity做成动态顺序栈容量不够时可以自动扩容这才是工程中比较常用的形态。这里的top是整个结构的灵魂。它有两种约定top -1表示空栈top指向栈顶元素的下标。入栈时先top再写入出栈时先取元素再top--。top 0表示空栈top指向栈顶元素的下一个位置。入栈时先写入再top出栈时先top--再取。两种约定都能实现LIFO只要你保持一致。我习惯用top -1这种因为它和数组下标的直觉一致data[0]是第一个元素top -1表示一个元素都没有。一旦混用比如初始化为0却在入栈时先写入再自增就会出现第一个元素写到data[0]但top却变成了1的错乱这在初学者代码里太常见了。2.2 入栈、出栈、取栈顶三个核心操作顺序栈的核心操作就是入栈Push、出栈Pop、取栈顶GetTop。配合上判空和判满一个基本可用的栈就成型了。入栈的逻辑先检查是否已满满了就扩容然后data[top] val。出栈的逻辑先检查是否为空空了就报错然后return data[top--]。取栈顶就更简单只读不弹返回data[top]。这里有两个关键点需要说明。第一为什么入栈用top而不是top因为我们的约定是top指向栈顶元素那新元素进来必须落在top1这个位置同时新的栈顶需要更新为top1所以top一次完成。第二为什么出栈只是top--而不需要清空原来的位置元素还在那块内存里躺着但top变小以后它已经不属于栈了。下次入栈时新元素直接覆盖它没有任何问题。这个逻辑上删除的思路在数组结构里非常普遍。2.3 扩容策略动态数组的均摊分析当top达到capacity-1数组满了。这时候必须扩容。最常见的做法是把容量翻倍void Expand(SqStack *s) { int newCap s-capacity * 2; int *newData (int *)realloc(s-data, sizeof(int) * newCap); if (newData NULL) { printf(扩容失败\n); exit(1); } s-data newData; s-capacity newCap; }为什么扩容倍数选择2而不是每次加10个、加100个这背后是一个均摊复杂度的问题。每次扩容都要把现有的元素全部搬到新内存里这是一次O(n)的操作。如果每次固定加K个那么插入n个元素的过程中大约要扩容n/K次每次搬运当前所有元素平均下来每个元素都要被搬运O(n/K)次整体均摊是O(n)。而翻倍扩容时总共扩容大概log n次越大的数组搬运次数越少所有元素被搬运的总次数加起来是O(n)均摊到每个元素上就是O(1)。这也是各个动态数组实现比如C的vector普遍采用倍数扩容的原因。当然倍数不是越大越好2倍是空间和时间比较均衡的选择有些实现会用1.5倍来减少内存浪费。用realloc而不是自己malloc新内存再memcpy一方面代码更简洁另一方面realloc在原有内存块后方空间充足时会原地扩大省去一次搬运就算需要搬家它也会自动完成拷贝和释放旧块。记得把返回值赋回给s-data同时检查是否返回NULL。2.4 顺序栈完整代码与测试下面是一份完整的动态顺序栈代码可以直接编译运行#include stdio.h #include stdlib.h #define INIT_CAPACITY 4 typedef struct { int *data; int top; int capacity; } SqStack; void InitStack(SqStack *s) { s-data (int *)malloc(sizeof(int) * INIT_CAPACITY); if (s-data NULL) { printf(内存分配失败\n); exit(1); } s-top -1; s-capacity INIT_CAPACITY; } int IsEmpty(SqStack *s) { return s-top -1; } int IsFull(SqStack *s) { return s-top s-capacity - 1; } void Expand(SqStack *s) { int newCap s-capacity * 2; int *newData (int *)realloc(s-data, sizeof(int) * newCap); if (newData NULL) { printf(扩容失败\n); exit(1); } s-data newData; s-capacity newCap; } void Push(SqStack *s, int val) { if (IsFull(s)) { Expand(s); } s-data[s-top] val; } int Pop(SqStack *s) { if (IsEmpty(s)) { printf(栈空无法出栈\n); return -1; } return s-data[s-top--]; } int GetTop(SqStack *s) { if (IsEmpty(s)) { printf(栈空\n); return -1; } return s-data[s-top]; } void DestroyStack(SqStack *s) { free(s-data); s-data NULL; s-top -1; s-capacity 0; } int main(void) { SqStack s; InitStack(s); for (int i 0; i 10; i) { Push(s, i * 10); } while (!IsEmpty(s)) { printf(%d , Pop(s)); } printf(\n); DestroyStack(s); return 0; }初始化容量是4但循环里压入了10个元素中途自动扩容。输出结果应该是从90倒序输出到0。这份代码把扩容、判空、判满都串进去了建议你实际跑一遍然后故意把INIT_CAPACITY改成1观察它扩容更加频繁时的表现。2.5 顺序栈的三个常见坑第一个坑栈满判断缺失。静态数组版本里特别容易犯Push里不检查IsFull直接data[top]元素一多就越界写到了不属于数组的内存里。这种越界错误在现场调试时非常隐蔽因为它的崩溃点往往在很远之后甚至不出错只把旁边的数据覆盖了。动态扩容版本避开这个问题但代价是你必须记得调用Expand。第二个坑top初始化不一致。我在前面已经强调过-1和0是两种完全不同的约定。如果你初始化成-1入栈用data[top]没问题但你要是入栈用data[top]第一个元素就会写到data[-1]这是妥妥的越界。写代码时把top的语义用注释钉死top指向栈顶元素位置空栈为-1。第三个坑忘记销毁。C语言没有垃圾回收Stack里的data是malloc出来的程序结束前必须free。用完就DestroyStack并把data置NULL防止出现野指针被二次free。很多看起来玄乎的问题其实都是这种基础资源管理不到位造成的。3. 链式栈让栈摆脱容量限制3.1 为什么需要链式栈顺序栈虽然好但有一个天生的问题它的大小受到连续内存的限制。一旦需要扩容要么用realloc整体搬家要么就得承受容量写死的痛苦。如果栈的使用场景中元素规模完全无法预估或者元素本身很大、频繁推入弹出顺序栈的连续内存空间要求反而成了负担。链式栈换了思路每个元素都是独立分配的节点节点之间用指针串起来。新增一个元素就malloc一个节点删除一个元素就free一个节点。没有整体扩容的概念内存按需索取理论上只受堆空间的限制。这也让链式栈成为处理数量未知、变化剧烈场景时的自然选择。3.2 链式栈的结构体设计一个头结点的学问链式栈的结构体有两种典型写法。一种是不带头结点直接用top指针指向栈顶节点typedef struct StackNode { int data; struct StackNode *next; } StackNode; typedef struct { StackNode *top; } LinkStack;另一种是带头结点多用一个链表头结点来统一操作逻辑。对于栈来说我推荐不带头结点因为栈的操作只发生在栈顶一端不需要像链表插入删除那样频繁处理头结点特殊状态。top就直接记录栈顶节点地址空栈时top NULL逻辑非常清晰。有人会问为什么不用尾插法如果入栈放在链表尾部出栈也从尾部弹出那初始化和判空都简单可问题是链表尾部操作需要先遍历到末节点或者额外维护一个尾指针。就算维护了尾指针删除尾节点时你依然拿不到它的前驱节点除非改成双向链表。这一切复杂度都是不必要的。栈只需要在一端操作链表的头部天然就是栈顶头插法和头删法完美匹配。3.3 入栈用头插、出栈用头删为什么O(1)链式栈的入栈操作是把新节点插到链表头部同时让top指向新节点。出栈操作则是取头部节点记录数据后让top指向它的next然后free掉原头节点。两步操作都只涉及常数个指针修改时间复杂度是O(1)和顺序栈一样高效。这背后其实是个很朴素的思想我们选链表通常是因为它插入删除灵活但别忘了灵活的前提是你要插在适当的位置。在数组里在头部插入是O(n)的所有后续元素都得往后挪但在链表里只要你知道头指针头插就只是两行指针赋值完全不涉及其他节点。所以链式栈天生就应该用头插头删而不是像普通链表那样考虑尾部插入。3.4 链式栈完整代码与测试完整的链式栈代码如下#include stdio.h #include stdlib.h typedef struct StackNode { int data; struct StackNode *next; } StackNode; typedef struct { StackNode *top; } LinkStack; void InitStack(LinkStack *s) { s-top NULL; } int IsEmpty(LinkStack *s) { return s-top NULL; } void Push(LinkStack *s, int val) { StackNode *node (StackNode *)malloc(sizeof(StackNode)); if (node NULL) { printf(内存分配失败\n); exit(1); } node-data val; node-next s-top; s-top node; } int Pop(LinkStack *s) { if (IsEmpty(s)) { printf(栈空无法出栈\n); return -1; } StackNode *tmp s-top; int val tmp-data; s-top tmp-next; free(tmp); return val; } int GetTop(LinkStack *s) { if (IsEmpty(s)) { printf(栈空\n); return -1; } return s-top-data; } void DestroyStack(LinkStack *s) { while (!IsEmpty(s)) { Pop(s); } } int main(void) { LinkStack s; InitStack(s); for (int i 0; i 10; i) { Push(s, i * 10); } while (!IsEmpty(s)) { printf(%d , Pop(s)); } printf(\n); DestroyStack(s); return 0; }注意DestroyStack没有外层free(s)因为s本身是栈变量不是malloc出来的整体只有每个内部节点需要逐个释放。如果你在函数内部malloc过LinkStack本身那才需要额外free。3.5 链式栈的四个易错点第一个易错点Pop时忘了free(tmp)。C语言里malloc出来的节点不会自动回收你只是把top改到下一个节点但原节点还残留在堆上。如果程序不断入栈出栈又入栈出栈内存泄漏会肉眼可见地增长。我用过一段跑在嵌入式上的代码因为出栈不free跑一晚上内存就耗光了。第二个易错点空栈时访问GetTop。top已经是NULL了你还要去读top-data必然段错误。所以GetTop和Pop的第一行都必须是判空。这是链式栈最常见的崩溃原因没有之一。第三个易错点Push时malloc失败不处理。虽然现在计算机内存普遍够用但在资源受限的嵌入式环境或者长时间运行的服务器进程里malloc返回NULL是真实可能发生的。不判断就直接用会当场崩掉。第四个易错点混淆节点变量和栈结构体变量。LinkStack本身只有一个top指针而StackNode才是持有数据加next的结构。赋值、传参时别搞反了否则编译警告满天飞运行起来更是逻辑混乱。4. 顺序栈 vs 链式栈一张表看清取舍4.1 五个角度的硬对比把两种栈放在一起对比很多特性会变得很清楚对比维度顺序栈链式栈底层存储连续数组按需扩容分散节点指针串联入栈/出栈时间复杂度O(1)O(1)空间占用数组本身可能空闲扩容时可能有浪费每个节点多一个next指针按需分配分配与释放一次性大块少数几次malloc/free每次push/pop都malloc/free频繁系统调用缓存友好性高数组连续CPU缓存命中率高低节点地址分散指针跳转多实现复杂度简单但要注意扩容逻辑简单但要注意节点内存管理展开解释一下缓存友好性。CPU读取内存时不是按字节读的而是按缓存行通常64字节一次加载。顺序栈的数组元素紧紧挨着你访问data[i]时它附近的元素大概率也在同一缓存行里后续访问几乎就是命中。链式栈的节点每次malloc分配的地址往往是分散的相邻两次push的节点在内存中很可能相隔很远CPU每次都要重新加载缓存行cache miss率明显上升。这个差异在几百万次栈操作级别的循环里会被放大得很明显。4.2 不同场景下怎么选工程中怎么选其实比考试时背的顺序栈优点、链栈优点要更具体的多。如果你的栈操作非常频繁而且元素规模在一个可预估的范围内顺序栈是更好的选择。比如编译器解析表达式、CPU模拟器里的操作数栈通常都有明确的上界用数组实现最快最省。反过来如果栈的元素数量完全不可预知或者每个元素本身是大小不定的结构体链式栈的按需分配就更有优势。因为顺序栈无论元素多少都要为整个capacity分配内存而链式栈只为自己实际用到的节点分配空间。还要考虑一个容易被忽略的因素malloc/free的系统开销。每次调用malloc和free都牵扯到堆管理器的锁竞争和空闲链表遍历在高并发或者实时性要求高的场景下这种开销不可小视。顺序栈每次扩容才一次realloc平时零分配链式栈每次Push要malloc、每次Pop要free操作多了差距就出来了。所以选择不是绝对的而是权衡。4.3 工程中的栈不止这两种形态聊到这里多说一句在真正的工程系统里你还经常会碰到和栈相关的其他概念。比如进程地址空间里那块由编译器自动维护的运行时栈它是每一个函数调用都会在栈上压入栈帧函数返回时弹出变量随之销毁。这就是为什么局部变量不需要你手动释放而malloc出来的堆内存需要。再比如嵌入式系统里的中断处理中断服务程序执行时也会使用独立的栈来处理嵌套中断有人叫它中断栈。栈回溯技术比如gdb里的bt本质就是沿着栈帧上的返回地址链逐层还原调用关系。理解了数据结构课本里的栈你再去看这些运行时机制会发现它们的内核逻辑是同一个。5. 栈的经典应用与踩坑实录5.1 括号匹配一个完整的综合小项目括号匹配是栈最经典的应用。比如(ab)*[c-{d/e}]是合法的而(ab]*[c-d]虽然左右括号数量一样多但类型对不上非法。算法的核心思想就是从左到右扫描表达式遇到左括号(、[、{就入栈遇到右括号)、]、}先从栈顶弹出一个左括号检查类型是否匹配如果栈已经空了说明右括号多余不合法扫描结束后栈非空说明有左括号没有得到配对不合法。这里我用一个基于字符数组的动态顺序栈来实现代码是完整的可以直接跑#include stdio.h #include stdlib.h #include string.h #define INIT_SIZE 8 typedef struct { char *data; int top; int capacity; } CharStack; void InitStack(CharStack *s) { s-data (char *)malloc(sizeof(char) * INIT_SIZE); if (s-data NULL) { printf(内存分配失败\n); exit(1); } s-top -1; s-capacity INIT_SIZE; } int IsEmpty(CharStack *s) { return s-top -1; } void Push(CharStack *s, char c) { if (s-top s-capacity - 1) { int newCap s-capacity * 2; char *newData (char *)realloc(s-data, sizeof(char) * newCap); if (newData NULL) { printf(扩容失败\n); exit(1); } s-data newData; s-capacity newCap; } s-data[s-top] c; } char Pop(CharStack *s) { if (IsEmpty(s)) { printf(栈空\n); return \0; } return s-data[s-top--]; } char GetTop(CharStack *s) { if (IsEmpty(s)) { printf(栈空\n); return \0; } return s-data[s-top]; } void DestroyStack(CharStack *s) { free(s-data); s-data NULL; s-top -1; s-capacity 0; } int IsValid(const char *expr) { CharStack s; InitStack(s); int len strlen(expr); for (int i 0; i len; i) { char ch expr[i]; if (ch ( || ch [ || ch {) { Push(s, ch); } else if (ch ) || ch ] || ch }) { if (IsEmpty(s)) { DestroyStack(s); return 0; } char top GetTop(s); if (!((top ( ch )) || (top [ ch ]) || (top { ch }))) { DestroyStack(s); return 0; } Pop(s); } } int ok IsEmpty(s); DestroyStack(s); return ok; } int main(void) { const char *test1 (ab)*[c-{d/e}]; const char *test2 (ab]*[c-d]; const char *test3 ((ab); printf(%s - %d\n, test1, IsValid(test1)); printf(%s - %d\n, test2, IsValid(test2)); printf(%s - %d\n, test3, IsValid(test3)); return 0; }三个测试用例应该依次输出1、0、0。你可以把test1里的任意一个括号改成不同类型再跑一遍马上就能体会类型不匹配和数量不配对是两种独立的错误而栈恰好能把这两类问题一次性检测出来。这个题也是很多学校课程里练过的类似PTA上的字符串处理、翁恺老师C语言课里的练习题核心思路大多能迁移到栈上。我当时练这个题的时候写了三版一版用固定数组一版用动态扩容一版用链式栈每写一遍对栈的理解都会深一层。5.2 表达式求值中缀转后缀的思路括号匹配只是栈应用的开胃菜。表达式求值就更有意思了。我们平时写的是中缀表达式3 4 * 5但计算机直接做中缀求值很别扭因为要反复比较运算符优先级。标准解法是先把中缀表达式转成后缀表达式也就是逆波兰表达式然后对后缀表达式求值。中缀转后缀的思路可以概括为扫描中缀表达式遇到操作数直接输出遇到运算符时把它与栈顶运算符比较优先级如果栈顶优先级更高或相等就弹出栈顶运算符直到栈顶优先级更低然后把当前运算符入栈遇到左括号直接入栈遇到右括号就把栈内直到左括号的运算符全部弹出。后缀表达式求值则更简单扫描后缀表达式遇到操作数入栈遇到运算符就弹出两个操作数运算结果再入栈。整个表达式扫描完栈里剩下的就是最终结果。整个过程用的还是那套Push、Pop、GetTop的组合拳。所以说栈的威力不在于操作本身多复杂而在于它能精确地承载嵌套的、需要记住前一个状态的计算过程。5.3 认识栈溢出与调用栈回溯栈溢出是和栈这个概念形影不离的话题。写递归函数不小心把终止条件漏了或者递归深度大得离谱运行时就会把系统栈空间耗尽程序直接崩溃常见报错里就有stack overflow之类的字样。比如处理深度递归时有些语言的运行时报错是protect(): protection stack overflow现象虽然五花八门本质都一样递归调用一层层往里压栈帧系统划分给栈区的内存总归有上限压满了还没等到出栈自然就爆了。C语言里排查这类问题最好的工具就是gdb。拿到一个崩溃的程序先gdb ./a.out让它跑崩然后在gdb里执行btbacktrace。gdb会沿着栈帧上的返回地址一层层把当前调用链打印出来最顶上是最深处的函数往下是它的调用者再往下是调用者的调用者。看到这条链你通常立刻就能发现递归是不是卡在某个循环里出不来或者某个函数是不是被某个参数反复误调用。我实际排查过一个递归求斐波那契的程序n稍微大一点就崩。当时用gdb一bt才意识到递归树展开的深度远超我预想而且每个栈帧里还有大数组栈空间瞬间被榨干。后来改成动态规划迭代问题瞬间消失。这让我非常深刻地体会到栈是有上限的你以为反正能压但系统的护栏比你想的更矮。5.4 常见问题与排查技巧速查表把实际调试中频繁出现的问题整理成一张速查表遇到类似现象时可以快速对照症状可能原因排查方向Push后程序崩溃栈未初始化或top越界检查InitStack是否调用确认top初值和数组下标约定一致出栈顺序反了Push和Pop位置逻辑错乱打印top和data[top]逐步追踪栈里出现垃圾数据top初始化错误或GetTop未判空统一top约定判空后再读data[top]链式栈程序内存缓涨Pop时没有free节点检查出栈分支确保free(tmp)程序退出时内存泄漏DestroyStack没有调用使用valgrind定位泄漏点递归程序直接崩递归深度过大或递归无终止条件gdb里bt查看调用链改为迭代或检查终止条件频繁入栈出栈性能差链式栈malloc/free开销过大考虑改成顺序栈或用内存池管理节点这张表里的每一条都是我或者身边的同事朋友在实际调试中真正碰到过的。尤其是内存泄漏那条我当时在Windows的Visual Studio里跑一个小工具任务管理器里看到内存占用刷刷往上涨一开始以为是系统问题后来用调试器一步步跟进才发现是出栈时漏了free。C语言里的内存问题往往是分配了、忘记释放这种看起来不起眼的小事积累成大事。5.5 调试栈代码的几个实用小技巧调试栈相关代码时我习惯给自己留一套现场勘查的办法。第一在Push和Pop里临时加打印输出top的值和当前操作的元素这样每步操作都能看到栈的实时状态。第二自己在纸上画栈的变化从空栈开始入栈画一格出栈抹一格遇到逻辑对不上往往几分钟就能找出问题。第三用gdb给Push和Pop打断点断点命中时用print s-top查看栈顶再用x/8 s-data[0]查看前八个数据元素比print一个个看要直观很多。另外一个真实经验是不管顺序栈还是链式栈都建议把空栈状态对应的行为想清楚。空栈时的GetTop返回什么空栈时的Pop怎么办这些边界行为你在写代码前就定好规则能少大量debug时间。比如我上面代码里空栈Pop返回-1但这个约定只适用于int类型的栈如果是字符栈就得改成\0如果是更复杂的结构体可能需要返回一个特殊标志位。没有完美的通用约定但必须有约定。6. 收尾栈的延伸方向栈这两个字从数据结构课的四字定义到操作系统栈帧、编译器表达式求值、调试工具backtrace回溯再到我写过的每一个回调函数、每一段递归算法几乎无处不在。我自己的体会是光看教材你可能永远觉得栈是抽象的但当你手写过顺序栈和链式栈跑过括号匹配再用gdb看过一次真实的调用栈回溯你会意识到它就是嵌套世界的物理载体。如果你学完本文还想继续深入我有三个方向可以推荐。第一把上面的顺序栈改成双栈共享一块数组的结构也就是栈底分别在数组两端、栈顶相向生长这对理解空间分配很有帮助。第二用栈实现一个简易计算器支持加减乘除和括号把前缀、中缀、后缀三种表达式都跑通。第三去看一看系统为每个程序自动维护的调用栈和栈式数据结构的关系你会发现在C语言里当我们说栈的时候其实是在说一个跨越数据结构、系统原理、调试工具的宏大主题。最后分享一个我要求自己每次写完栈后的固定动作用valgrind跑一遍程序确认没有任何内存泄漏再在debug模式下开启所有编译警告把每一个warning都当成potential bug。这个方法治好了我大半的C语言内存问题。你也不妨试试。
返回列表