【数据结构】栈

【数据结构】栈

概览

  • 栈的定义与后进先出
  • 数组实现与链表实现的取舍
  • 静态栈与动态栈
  • 扩容策略与装载因子
  • 七个接口的实现
  • 栈在系统里的三处用法
  • 用栈把递归改成非递归
  • 实测数据与 valgrind 检查

核心知识

一、栈是什么

栈是一种只允许在一端插入和删除 的线性表。允许操作的那一端叫栈顶 ,另一端叫栈底。

这个结构可以拿一摞盘子来理解:新洗好的盘子码在最上面,取用的时候也从最上面拿。从底下抽一个出来的话,整摞盘子都会塌下来。栈遵循的就是这条规矩,它的学名叫后进先出(Last In First Out,LIFO)。

常用术语:

术语 含义
入栈 / 压栈 把元素放到栈顶,栈里元素个数加一
出栈 / 弹栈 把栈顶元素拿走,栈里元素个数减一
取栈顶 只看栈顶元素的值,不拿走
栈空 栈里一个元素都没有,这时不能出栈也不能取栈顶
栈满 只在固定容量的栈里才有这个概念

入栈与出栈的顺序关系比看上去要绕一些。 如果依次入栈 1、2、3,出栈的顺序只能是 3、2、1。如果允许入栈和出栈交替进行,能够得到的输出序列就不止一种,例如入 1、出 1、入 2、入 3、出 3、出 2,得到的就是 1、3、2。判断一个出栈序列是否合法是一道经典笔试题,遇到这类题时照着上面的过程模拟一遍就能得出结论。

图 1 栈的后进先出

二、数组实现还是链表实现

栈是逻辑结构,底层用什么容器来装是另一回事,常见的选择有两种。

数组栈用一块连续空间存放元素,再用一个下标记住栈顶的位置。入栈就是往该下标处写入再让下标加一,出栈就是下标减一,两个操作都是 O(1),并且没有额外的内存开销。

链表栈用单链表实现,把链头当作栈顶。入栈是头插,出栈是头删,两者同样是 O(1)。

复杂度打平之后,选择取决于常数项和缓存局部性。数组栈的入栈只是写一个内存位置,链表栈的入栈要先 malloc 一个结点再改两个指针,每次操作都多出一次内存分配;数组栈的元素连续存放,取栈顶就是取缓存行里的一个值,链表栈还要顺着指针跳一次。实测中数组栈比链表栈快了几倍,差距就来自这两处开销。

工程上还有一条要考虑:数组栈可以一次申请一大块空间,此后所有操作都不再访问分配器;链表栈的每个元素都要单独申请、单独释放,产生内存碎片的概率大得多。

栈默认用数组实现,只有在元素大小差异极大,或者必须严格按需分配内存的场景下才考虑链表。

三、静态栈和动态栈

数组栈的容量从哪里来,又可以分成两种做法。

静态栈把数组写死在结构体里:

c 复制代码
typedef struct Stack
{
	STDataType a[100];   //容量写死
	int top;             //栈顶下标
} ST;

使用静态栈不必关心内存的申请与释放,代价和静态顺序表完全一样:容量定小了装不下,定大了浪费空间,栈满之后没有合适的处理办法。

所谓没有合适的处理办法,落到代码上有四种结局,一种比一种难以排查。

第一种做法是在入栈前先判满,满了就直接 return。STPush 的返回值是 void,调用者拿不到任何反馈,要压入的那个元素凭空消失,程序继续往下执行,等到后面某处发现数据少了一份,离出错的位置已经很远。

第二种做法是判满之后返回错误码,把 STPush 的签名从 void 改成 bool。这个做法本身没有问题,代价是容量检查要铺到每一个调用点上,只要有一处忘了判断返回值,前面那些检查就全部失效。

第三种做法是用断言 assert(top < 100)。栈满时程序当场 abort,暴露得足够早,但如果编译时带上了 NDEBUG,断言会被整段删除,程序接着往下写,越界写就此发生。

第四种做法是干脆不判断。此时 a[100] 已经写到结构体外面,被破坏的是相邻的内存,valgrind 能否报出来取决于编译器把结构体放在了什么位置。

把容量定成 100 同样没有正确答案。容量是否合适,取决于这段代码将来要处理多大规模的数据,而这个信息在写结构体的时候通常还不存在。静态栈真正的问题不在于浪费那几百字节,而在于容量这个决定被提前到了一个信息最少的时刻。

动态栈用一个指针指向申请来的空间,另外记录一个容量:

c 复制代码
typedef struct Stack
{
	STDataType* a;   //指向申请的数组
	int top;         //栈顶下标,也等于元素个数
	int capacity;    //容量
} ST;

它的扩容方式和动态顺序表一样,栈满时申请一块两倍大的空间,把原有数据搬过去。

第一次申请给 4 个位置,这个数字是估出来的,但并非随意选取。 初始化时容量是 0,代码写 capacity == 0 ? 4 : capacity * 2。如果给 1,压入第 2 个元素时要扩一次,压入第 3 个元素时又要扩一次,头几次入栈几乎全花在 realloc 上;搬运的数据量虽然很小,函数调用和分配器内部的开销却一次都省不掉。给 4 是为了让头几个元素先放进来,把扩容的频次从每个元素一次降到每四个元素一次。给 8 或者 16 也不会出错,只是空栈会多占几十个字节。

装载因子。 元素个数除以容量,这个比值有专门的名字,叫装载因子(load factor)。入栈 40 次、容量 64,40 / 64 = 62.5%,就是后面回显里出现的那个数。装载因子接近 1,说明下一次入栈必然触发扩容;接近 0,说明大量空间闲置。翻倍扩容让装载因子在 50% 到 100% 之间往返:容量 32 装满时是 100%,扩到 64 之后立刻降到 50%,再随着入栈逐步回到 100%。均摊 O(1) 就藏在这个循环里,每次扩容搬运的那些元素,由它前面半个区间内的入栈操作共同分摊。

装载因子只在扩容这一侧起作用,缩容并不参考它。全部弹完之后元素个数是 0、容量仍然是 64,装载因子为 0%,代码也不会把空间还给系统。

大部分场景下动态栈更合适,因为栈的深度往往要到运行时才能确定。

图 2 数组栈的结构

四、接口实现

接口一共七个,比顺序表少两个,因为栈没有任意位置的插入删除,也没有查找。

c 复制代码
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<stdbool.h>

typedef int STDataType;

typedef struct Stack
{
	STDataType* a;
	int top;          //栈顶元素的下一个位置,也等于元素个数
	int capacity;
} ST;

//初始化与销毁
void STInit(ST* ps);
void STDestroy(ST* ps);

//入栈与出栈
void STPush(ST* ps, STDataType x);
void STPop(ST* ps);

//取栈顶、判空、取大小
STDataType STTop(ST* ps);
bool STEmpty(ST* ps);
int STSize(ST* ps);

top 的含义要在写代码之前定死。 这里让它指向栈顶元素的下一个位置 ,因此它天然等于元素个数:判空就是判断 top 是否等于 0,取大小就是直接返回 top,入栈是写 a[top] 再让 top 加一。七个接口里只有取栈顶需要减一,三处边界都落在同一个数上,读代码时不必在脑子里做加减。

另一种约定是让 top 指向栈顶元素本身,空栈记成 -1,这个 -1 会牵动一串地方:判空写 top == -1,取大小写 top + 1,入栈要先把 top 加一再写 a[top],扩容的触发条件变成 top == capacity - 1。换来的是取栈顶可以直接写 a[top],少一次减法。两种约定的运算次数只差一个常数。

真正会出问题的是两种约定混用:一处按第一种写判断,另一处按第二种写下标,编译不会报错,只在恰好空栈或者恰好满栈的那一次出错,而这两种输入在测试用例里往往刚好没有覆盖到。

同一个工程里只能用一种约定,这篇全篇采用第一种。

初始化与销毁
c 复制代码
void STInit(ST* ps)
{
	assert(ps);
	ps->a = NULL;
	ps->top = 0;
	ps->capacity = 0;
}

void STDestroy(ST* ps)
{
	assert(ps);
	free(ps->a);
	ps->a = NULL;
	ps->top = 0;
	ps->capacity = 0;
}

和顺序表一样,初始化阶段不申请空间,第一块空间等到第一次入栈时再申请。

初始化不申请空间的理由。 栈的深度属于运行时的信息,初始化那一刻通常还不知道。确实存在能提前确定容量的场景,做法是把接口改成 STInit(ST* ps, int capacity),多传一个容量参数,代价是每个调用点都要跟着改,而且传进来的值小于实际需要时照样要扩容,等于多绕了一圈。

不申请空间还带来一个不容易注意到的好处:STInit 不会失败。一旦要申请空间,就必须处理申请失败的情况,要么返回错误码,要么直接退出,一个赋值语句立刻变成一段需要调用者判断的逻辑。

销毁之后要把三个成员清零。 free 之后 a 的值仍然保留,但那块内存已经不属于这个栈,留着它就是一个悬垂指针。清零并不会让程序变得正确,但能让「销毁之后又用了一次」从随机崩溃变成空指针解引用,定位起来快得多。

扩容
c 复制代码
static void STCheckCapacity(ST* ps)
{
	assert(ps);
	if (ps->top == ps->capacity)          //满了
	{
		int newCapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
		STDataType* tmp = (STDataType*)realloc(ps->a, sizeof(STDataType) * newCapacity);
		if (tmp == NULL)
		{
			perror("realloc fail");
			exit(1);
		}
		ps->a = tmp;
		ps->capacity = newCapacity;
	}
}

判断条件写成 top == capacity:top 就是元素个数,它追上容量的时候栈就满了。

先用临时指针接住返回值的原因。 realloc 失败时返回 NULL,原来那块内存既不会被移动,也不会被释放。如果直接写 ps->a = realloc(ps->a, ...),失败那一次 ps->a 就被 NULL 覆盖,原地址彻底丢失,既无法使用也无法 free,销毁时还会再漏掉一块。用 tmp 接住的写法在失败时仍然保留 ps->a 里原来的地址,至少还能正常销毁。

关于直接 exit(1) 这种处理方式。 它比较草率,对练习程序和命令行程序来说却可以接受。库代码更应当让 STCheckCapacity 返回 bool、STPush 跟着返回 bool,把决定权交给调用者,代价是每个调用点都要判断返回值。realloc 失败意味着进程已经拿不到内存,这里选择直接退出。

把扩容单独抽成一个函数。 在当前规模下只有 STPush 会调用它,抽不抽都不影响运行。抽出来的意义在于触发条件只写在一个地方,将来要改成 1.5 倍或者加上容量上限,改一处就够了。

入栈与出栈
c 复制代码
void STPush(ST* ps, STDataType x)
{
	assert(ps);
	STCheckCapacity(ps);      //先确认空间够
	ps->a[ps->top] = x;       //写在栈顶位置
	ps->top++;                //元素个数加一
}

void STPop(ST* ps)
{
	assert(ps);
	assert(ps->top > 0);      //空栈不能出栈
	ps->top--;                //元素个数减一
}

入栈只有两步,出栈只有一步。

图 3 入栈与出栈两个动作

出栈只是把 top 减一,栈顶那个位置上的数据并没有被清除,它仍然留在内存中,只是不再属于这个栈了。这一点和顺序表的删除是同一个道理。

出栈的断言不能省略:空栈出栈会让 top 变成负数,此后所有以 top 为界的操作全部错乱。

入栈那两句的顺序不能颠倒。 先写 a[top] 再让 top 加一,元素才会落在当前栈顶;反过来先加一再写,第一个元素会落在 a1 上,a0 永远空着。判空和取大小会跟着偏一位,程序不会立刻崩溃,只有在取栈顶的时候会读到别人的数据。扩容和写入的顺序同理,必须先确认空间再写入,反过来就会在容量刚好满的那一次写到数组外面。

出栈不清理那块内存。 清理要多写一次内存,而这个位置已经不属于栈了,下一次入栈会直接覆盖它。代价是那块内存里仍然留着旧值,绕过接口直接读数组的代码会读到已经出栈的数据,这属于调用方越界,不是接口的问题。

出栈不返回弹出值,这是一个有意的选择。 如果改成返回 STDataType,调用者可以用一行写出出栈加取值。代价是空栈时没有合法的值可返回,函数体里只剩下断言,或者用一个没有意义的返回值兜底,调用者还很容易忘记判空。拆成先 STTop 再 STPop 之后,读代码时能一眼看出哪里只是查看、哪里真正改动了栈。

取栈顶、判空、取大小
c 复制代码
STDataType STTop(ST* ps)
{
	assert(ps);
	assert(ps->top > 0);      //空栈没有栈顶
	return ps->a[ps->top - 1];   //注意这里要减一
}

bool STEmpty(ST* ps)
{
	assert(ps);
	return ps->top == 0;
}

int STSize(ST* ps)
{
	assert(ps);
	return ps->top;
}

取栈顶写的是 a[top - 1] 而不是 a[top] ,因为 top 指向的是栈顶的下一个位置。这个减一是七个接口里最容易漏掉的一处,漏掉的后果不是崩溃,而是静默读错:扩容之后 a[top] 那个位置是从未写过的新内存,值是随机的;没有扩容时它是上一次出栈留下的旧值。两种情况都不报错,取出来的栈顶一直是错的,程序其余部分照常运行。

判空既可以直接写 top == 0,也可以返回 STSize(ps) == 0。两种实现对调用者来说是同一件事,选定一种固定下来即可。

用指针判空会在三个场景下失效。 ps->a == NULL 看起来很正常,初始化的时候它确实是 NULL。压入一个元素再全部弹出之后,a 不是 NULL,栈却是空的;容量扩到 8 之后一直出栈到只剩一个元素,a 更不是 NULL;如果哪天换成静态数组版本,结构体里根本没有这个指针。空栈是一个逻辑状态,判空只能看元素个数,与底层用什么容器存放没有关系。

判空和取大小同样加上 assert(ps)。 这两个函数本身并不解引用 ps,不加断言也能运行。加它是为了让七个接口的入口检查保持一致:调用者传入 NULL 是调用者的错误,就在这一层拦住,不要让它变成后面某个函数里的段错误。

STSize 返回 int 而不是 size_t。 返回 size_t 的话,STSize(ps) - 1 一不小心就会绕回一个极大的正数,比负数更难排查。元素个数在这个场景里到不了 INT_MAX,int 足够表示。

五、栈在系统里的三处影子

栈的重要性在于系统本身就在使用它。

第一处是函数调用栈。 每次调用一个函数,系统都会在栈上压入一帧,函数返回时这一帧被弹出。递归之所以能够一层层返回,依靠的正是这个栈:每一层的现场都压在栈上,最内层返回之后,上一层的现场仍然在原处。

一帧里装的东西比通常提到的两个要多。参数和返回地址之外,还有调用者的栈底指针(x86-64 上是 rbp)、被调用者需要负责保存的几个寄存器、函数里所有局部变量占用的空间,以及为对齐留出的空隙。参数也不是一律压栈,x86-64 上前六个整型参数走寄存器,寄存器用完时才会落到栈上;开启优化之后能够一直留在寄存器里的局部变量,可能根本不占栈空间,这也是同一个函数加不加 -O2、一帧大小会发生变化的原因之一。

栈帧从高地址向低地址增长,压栈时栈顶指针向下移动,出栈时向上移动。画图时栈底画在上面还是下面都能见到,取决于图里画的是地址还是生长方向,看图之前先确认这一点。

爆栈就是这整块空间被用完。Linux 上线程的栈默认是 8 MB,ulimit -s 既能查看也能修改。一帧占几十到几百字节,8 MB 除以每帧大小就是能够递归的层数上限:每帧 64 字节的函数大约能进入十万层,每帧带一个 1 KB 局部数组的函数只能进入八千层左右。爆栈时内核发出 SIGSEGV,看到的是 Segmentation fault,在 gdb 里是一长串同名的帧。它和堆上的内存耗尽不是一回事:堆内存不足时 malloc 返回 NULL,既可以判断也可以处理;栈空间不足时没有返回值可以接,进程直接挂掉。递归的深度不能只看输入规模,还要看一帧有多大。

图 4 函数调用栈

第二处是表达式求值。 人们习惯的中缀写法把运算符夹在两个操作数中间,例如 1 + 2 * 3。这种写法写起来很自然,机器照着从左往右扫一遍却算不出来:谁先计算由优先级和括号决定,而这些信息要读到后面才能知道,从左往右扫一遍无法立刻决定手里这个加号要不要先算。

中缀转后缀的思路,就是把这个决定推迟到能够决定的时候。遇到数字直接输出;遇到运算符,先把栈里优先级不低于它的运算符弹出并输出,再把自己压入栈;遇到左括号直接压栈,遇到右括号就把栈里的运算符一路弹出并输出,直到弹掉那个左括号。转换完成后后缀串里没有括号,运算符的先后顺序已经写进了位置里。

后缀求值只需要一个栈:从左往右扫描,遇到数字就压栈,遇到运算符就弹出两个数、算完再压回去,扫描结束时栈里剩下的就是答案。整个过程不必考虑优先级,也不必考虑括号。

括号在转换过程中起的作用可以单独看一下:左括号入栈之后把栈里已有的运算符全部挡住,右括号到来时才放行,相当于把中间这一段临时提升到最高优先级。

运算符和操作数分开处理的原因。 操作数一读到就能使用,运算符要等到后面的信息才能决定什么时候使用,而且运算符之间还要比较优先级,需要按后进先出的顺序回退。把两类东西塞进同一个栈,就必须额外记住每个位置存放的是数字还是符号,判断逻辑立刻翻倍。分开之后,操作数栈只负责压和弹,运算符栈只负责优先级比较,各自的规则都是单一的。具体写起来,一个输出串加一个运算符栈就能完成转换,需要两个栈的是那些一边扫描一边计算的版本。

第三处是括号匹配。 编译器检查括号、写代码的工具给括号配对着色,用的都是栈:遇到左括号入栈,遇到右括号就检查栈顶是不是配对的左括号,是就弹出,不是就报错,扫描结束后栈必须是空的。

三种失败情况的判别顺序不能打乱,顺序错了会把一类错误报成另一类。

第一种在遇到右括号的那一刻判断:栈已经是空的。这说明左边没有任何括号等着与它配对,多出来的是一个右括号。这个判断必须排在最前面,因为栈空时根本没有栈顶可取,先去看栈顶读到的是越界的位置。

第二种同样在遇到右括号的那一刻判断,前提是栈非空:栈顶那个左括号和当前的右括号类型不一样。{[(])} 扫到第 4 个字符 ] 时,栈顶是 (,圆括号还在等待闭合,方括号却先来了。嵌套关系从这里断开,前面那个 ( 再也等不到自己的右括号。

第三种要等整个串扫描结束才能确定:栈里还剩下左括号,剩下几个就是有几个没有配上。这些剩下的左括号恰好按入栈顺序排列,最后压进去的就在最上面。

判别顺序定为「先判空、再判类型、最后判剩余」,原因在于前两种在扫描过程中就能确定,第三种只有扫描结束才知道。如果反过来先判剩余,前两种错误都会拖到串尾才暴露,报出来的位置从出错的字符变成串尾,回头定位只能靠人工。

计数器只在只有一种括号的时候有效。那种情况下它等价于只关心栈的深度,不关心栈里存放的是什么;一旦出现两三种括号,深度相同而类型不同的两个序列会得到相同的结果,例如 (] 和 () 在计数器看来没有区别。

这三处的实测都放在后面的实例数据里,括号匹配给出了完整的用例,递归转非递归也用显式栈演示了一遍。

六、用栈实现队列、用队列实现栈

这两道题在面试里出现的频率非常高,具体的代码实现我们在OJ题篇章中有详细解答。

用两个栈模拟队列。 队列先进先出(First In First Out,FIFO),栈后进先出,单独一个栈无法模拟队列,元素进出的顺序从一开始就是相反的。两个栈能够把这个反序再反转一次。这里约定一个栈只负责入队,记作 inStack,另一个只负责出队,记作 outStack。

入队的动作最简单:直接压进 inStack,不做其他处理。这时候队头元素可能正压在 inStack 的最底下,暂时取不到。

出队分两种情况。outStack 非空时直接弹出它的栈顶,那个元素就是队头,因为它属于更早一批倒过来的元素。outStack 为空时,把 inStack 里的元素逐个弹出再压进 outStack,倒完之后 inStack 最底下那个(也就是最早入队的元素)跑到了 outStack 的顶上,弹出来正好是队头。被倒空的 inStack 可以继续接受新的入队。

把上面的动作写成伪代码只有四行:

  • 入队:执行 inStack.push(x),不需要额外处理。
  • 出队:先判断 outStack 是否为空,为空就执行 while (inStack 不空) outStack.push(inStack.pop()),然后 return outStack.pop()。
  • 取队头:倒栈的逻辑和出队完全一样,区别在最后一步只看不弹,返回 outStack.top()。
  • 判空:两个栈都空才算空,写 return inStack.empty() && outStack.empty()。

复杂度要分清单次和均摊两种情况。在只入队不出队的那一批之后,第一次出队要把 inStack 里所有元素搬一遍,单次是 O(n),其中 n 是当前元素个数。这次搬运并不是每次出队都会发生:一轮倒栈之后,接下来连续 n 次出队都直接从 outStack 弹出,每次 O(1),直到 outStack 再次耗尽才需要下一轮。

每个元素在整个过程中最多被搬两次。入队时压进 inStack 一次;倒栈时从 inStack 弹出一次、压进 outStack 一次;出队时从 outStack 弹出一次。压和弹都是常数时间,n 次操作总共 O(n),均摊到每次是 O(1)。这一点和动态栈的翻倍扩容是同一个套路:把一次昂贵操作的代价分摊到它前面那批开销很小的操作上。空间是 O(n),两个栈合起来最多装 n 个元素,没有其他开销。

用两个队列模拟栈。 这个方向的难度更高一些,因为队列只能从队头出、从队尾进,没有栈顶那种直接取最后一个元素的操作。

一种做法依靠两个队列之间的来回搬运:设主队列 q1 和辅助队列 q2。入栈直接执行 q1.push(x)。出栈时,q1 里最先入队的是栈底元素,最后入队的才是栈顶,要把栈顶取出来,就得把 q1 里除最后一个之外的元素全部搬到 q2,留在 q1 里的那一个就是栈顶,弹出它,然后把 q2 里的元素再搬回 q1。搬回来这一步不能省略,否则下一次操作要处理的数据会留在 q2 里,两个队列的角色就乱了。

另一种做法只需要一个队列:入栈时先把新元素从队尾压进去,然后把它前面的 n 个元素依次出队再重新入队,转完一圈之后新元素被挤到了队头,下一次出队弹出的就是它。这样做省掉了一个队列,代价是每次入栈都要转动 n 次。

两种写法的复杂度正好相反。两个队列那版是出栈 O(n)、入栈 O(1);一个队列那版是入栈 O(n)、出栈 O(1)。两个队列那版更值得讲清楚,因为它靠搬运临时造出栈顶这个位置,而不是换用另一种容器。空间上两个队列合起来仍然是 O(n)。

这两道题经常考的原因。 第一个原因是栈和队列无法互相替代。两者的差别只在从哪一头取元素,但就是这一头之差,决定了它们能够表达的时序完全不同:栈是最近优先,队列是最早优先。函数调用的现场用栈,进程调度用队列,选错了会产生很难复现的问题。

第二个原因是这两道题一次覆盖三件事:接口设计(几个容器如何分工、哪些操作需要判空)、复杂度分析(单次 O(n) 和均摊 O(1) 的区别能否讲清)、边界处理(倒栈的触发时机、两个栈都空的时候)。

第三个原因是均摊分析。写出正确代码的人不少,被追问「出队不是 O(n) 吗」时答不上来的人也不少;反过来把均摊 O(1) 说成单次 O(1),同样是错误的。

单个栈做不到的原因也能用此讲清楚。把 1、2、3 三个元素全部压进去之后再取,栈只能按 3、2、1 的顺序给出,而队列必须按 1、2、3 的顺序给出。想让 1 先出来,就得在压入 2 之前把 1 取走,可队列的语义要求三个元素都已经在队列里,这时候一个栈没有任何办法把它们按入队顺序交出来。两个栈之所以能够做到,是因为倒栈这个动作把顺序翻转了第二次,翻转两次等于没有翻转。

实例代码

工程结构

text 复制代码
ds06/
├── stack.h            动态数组栈的结构体与接口声明
├── stack.c            实现
├── test.c             功能测试
├── stack_list.c       链表栈,用来做性能对照
└── exp/
    ├── grow.c         扩容轨迹
    ├── bracket.c      括号匹配
    ├── recursion.c    用栈把递归改成非递归
    └── speed.c        数组栈与链表栈的耗时对比

编译命令统一使用 gcc -std=c99 -Wall -Wextra -g,速度测试那个程序另外加上 -O2。

完整代码

头文件如下:

c 复制代码
# 文件:/home/cocatrice/ds06/stack.h
#pragma once

#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>

typedef int STDataType;

// 动态数组栈
// top 指向栈顶元素的下一个位置,所以 top 同时也就是栈里元素的个数
typedef struct Stack
{
	STDataType* a;    // 指向堆上申请的数组
	int top;          // 栈顶元素的下一个位置(等于元素个数)
	int capacity;     // 数组容量
} ST;

// 初始化与销毁
void STInit(ST* ps);
void STDestroy(ST* ps);

// 入栈与出栈
void STPush(ST* ps, STDataType x);
void STPop(ST* ps);

// 取栈顶元素
STDataType STTop(ST* ps);

// 判空与元素个数
bool STEmpty(ST* ps);
int STSize(ST* ps);

实现文件如下:

c 复制代码
# 文件:/home/cocatrice/ds06/stack.c
#include "stack.h"

// 初始化:一开始不申请空间,等第一次入栈再申请
void STInit(ST* ps)
{
	assert(ps);
	ps->a = NULL;
	ps->top = 0;
	ps->capacity = 0;
}

// 销毁:把数组还给堆,三个成员一起清零
void STDestroy(ST* ps)
{
	assert(ps);
	free(ps->a);
	ps->a = NULL;
	ps->top = 0;
	ps->capacity = 0;
}

// 入栈:先看空间够不够
void STPush(ST* ps, STDataType x)
{
	assert(ps);

	if (ps->top == ps->capacity)
	{
		// 容量为 0 时先给 4 个位置,之后每次翻倍
		int newCapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
		STDataType* tmp = (STDataType*)realloc(ps->a, newCapacity * sizeof(STDataType));
		if (tmp == NULL)
		{
			perror("realloc fail");
			exit(1);
		}
		ps->a = tmp;
		ps->capacity = newCapacity;
	}

	ps->a[ps->top] = x;
	ps->top++;
}

// 出栈:空栈是非法操作,用 assert 拦住
void STPop(ST* ps)
{
	assert(ps);
	assert(!STEmpty(ps));

	ps->top--;
}

// 取栈顶元素:空栈同样拦住
STDataType STTop(ST* ps)
{
	assert(ps);
	assert(!STEmpty(ps));

	return ps->a[ps->top - 1];
}

// 判空
bool STEmpty(ST* ps)
{
	assert(ps);
	return ps->top == 0;
}

// 元素个数
int STSize(ST* ps)
{
	assert(ps);
	return ps->top;
}

正常情况

七个接口测试情况:

bash 复制代码
# 所属目录:/home/cocatrice/ds06
gcc -std=c99 -Wall -Wextra -g stack.c test.c -o test_demo
./test_demo
text 复制代码
初始化后: 元素个数 0, 是否为空: 是, 栈顶: 无, 栈内容(栈底->栈顶): []
入栈 1 后: 元素个数 1, 是否为空: 否, 栈顶: 1, 栈内容(栈底->栈顶): [1]
入栈 2 后: 元素个数 2, 是否为空: 否, 栈顶: 2, 栈内容(栈底->栈顶): [1 2]
入栈 3 后: 元素个数 3, 是否为空: 否, 栈顶: 3, 栈内容(栈底->栈顶): [1 2 3]
入栈 4 后: 元素个数 4, 是否为空: 否, 栈顶: 4, 栈内容(栈底->栈顶): [1 2 3 4]
入栈 5 后: 元素个数 5, 是否为空: 否, 栈顶: 5, 栈内容(栈底->栈顶): [1 2 3 4 5]

出栈一次,弹出的是 5
出栈后: 元素个数 4, 是否为空: 否, 栈顶: 4, 栈内容(栈底->栈顶): [1 2 3 4]
出栈一次,弹出的是 4
出栈后: 元素个数 3, 是否为空: 否, 栈顶: 3, 栈内容(栈底->栈顶): [1 2 3]
出栈一次,弹出的是 3
出栈后: 元素个数 2, 是否为空: 否, 栈顶: 2, 栈内容(栈底->栈顶): [1 2]

取栈顶(不出栈): 2,取完元素个数还是 2

全部弹完后: 元素个数 0, 是否为空: 是, 栈顶: 无, 栈内容(栈底->栈顶): []
这时候 STEmpty 返回 true,STSize 返回 0

后进先出的顺序在回显里很清楚:入栈 1 到 5 之后,出栈顺序是 5、4、3,取栈顶不影响元素个数,弹空之后判空为真、大小为 0。

链表栈用同样的用例跑一遍,输出与数组栈一一对应,只是打印顺序不同(它按栈顶到栈底打印),另外它的入栈不需要扩容:

text 复制代码
入栈 1 后: 元素个数 1, 是否为空: 否, 栈顶: 1, 栈内容(栈顶->栈底): [1]
入栈 2 后: 元素个数 2, 是否为空: 否, 栈顶: 2, 栈内容(栈顶->栈底): [2 1]
入栈 3 后: 元素个数 3, 是否为空: 否, 栈顶: 3, 栈内容(栈顶->栈底): [3 2 1]

常见报错

空栈上出栈
bash 复制代码
# 所属目录:/home/cocatrice/ds06
gcc -std=c99 -Wall -Wextra -g _empty_pop.c stack.c -o empty_pop
./empty_pop
text 复制代码
先压一个 1,元素个数 1
弹出之后元素个数 0,是否为空 1
empty_pop: stack.c:49: STPop: Assertion `!STEmpty(ps)` failed.
退出码 134

程序在出栈的断言上直接停住。如果没有这句断言,top 会从 0 减到 -1,此后取大小返回 -1、判空返回假、再入栈就会写到 a-1 上,一路越界下去,等到某个不相干的地方崩溃再回头查找,就很难定位了。取栈顶那里也有一句同样的断言。

括号匹配的三种失败
bash 复制代码
# 所属目录:/home/cocatrice/ds06/exp
gcc -std=c99 -Wall -Wextra -g bracket.c ../stack.c -o bracket
./bracket
text 复制代码
输入: {[()]}
输出: 匹配(每个右括号都配上了对应的左括号,扫完栈正好是空的)
输入: {[(])}
输出: 不匹配(第 4 个字符 ']' 要配 '[',可栈顶是 '(',类型对不上)
输入: (((
输出: 不匹配(整个串扫完了,栈里还剩 3 个左括号没人配,最后压进去的是 '(')
输入: )))
输出: 不匹配(第 1 个字符 ')' 是右括号,但栈已经空了,左边没有括号跟它配)
输入: (空串)
输出: 匹配(每个右括号都配上了对应的左括号,扫完栈正好是空的)
输入: (((((((((((((((((((())))))))))))))))))))
输出: 匹配(每个右括号都配上了对应的左括号,扫完栈正好是空的)

三种失败的原因各不相同,程序把属于哪一种也打印了出来:类型对不上、左括号有剩余、右括号多余。只用一个计数器无法区分它们。

图 5 括号匹配的三种失败情况

边界情况

空栈出栈和取栈顶。 两个操作都会被断言拦住,报的是同一个栈空判断:栈里一个元素都没有,这两个操作都没有合法的操作对象。当场拦住比悄悄放过更安全。断言消息里带着函数名和文件行号,看回显就能直接定位到是 STPop 还是 STTop 被用在了空栈上。

初始化时不申请空间。 第一次入栈才申请 4 个位置,一个元素都不压的栈不占堆内存,销毁也不会出错,因为 free(NULL) 是合法的,它什么也不做。这一点在 valgrind 的账上也能看出来,只初始化不使用的驱动 allocs 和 frees 都是 0。反过来要注意,STInit 之后的 a 是 NULL,任何绕过 STPush 直接对 a 取下标或者解引用的写法都会立刻出问题。

弹空之后容量不会收缩。 40 次入栈把容量涨到 64,全部弹完之后容量还是 64,装载因子掉到 0% 也不会归还空间。缩容需要额外记录一个曾经达到过的水位,而且元素个数在缩容线附近来回波动时会反复申请和释放,比不做缩容还慢,因此这里不做缩容。代价是一个长期只使用几个元素的栈会一直持有扩容时申请到的那块内存,容量不超过 64 的情况下可以接受。

扩容之后旧数据要搬运。 翻倍扩容一共搬了 60 个元素,均摊到 40 次入栈上是 1.5 个,仍然是 O(1)。这个 1.5 是搬运总量除以入栈次数,并不是每次入栈都搬 1.5 个:前四次入栈一个都不用搬,第 5 次一次搬 4 个,第 9 次一次搬 8 个。每次扩容都更贵,但两次扩容之间能够容纳的入栈次数也成倍增加,两边涨得一样快,比值就稳定下来了,这就是均摊分析里代价翻倍、间隔也翻倍那句话的阐释。

实测数据

扩容轨迹。

bash 复制代码
# 所属目录:/home/cocatrice/ds06/exp
gcc -std=c99 -Wall -Wextra -g grow.c ../stack.c -o grow
./grow
text 复制代码
初始状态: 元素个数 0, 容量 0(初始化时没有申请空间)

第  1 次入栈: 元素个数  1, 容量 0 -> 4   <-- 扩容
...
第  5 次入栈: 元素个数  5, 容量 4 -> 8   <-- 扩容
...
第  9 次入栈: 元素个数  9, 容量 8 -> 16   <-- 扩容
...
第 17 次入栈: 元素个数 17, 容量 16 -> 32   <-- 扩容
...
第 33 次入栈: 元素个数 33, 容量 32 -> 64   <-- 扩容
...
第 40 次入栈: 元素个数 40, 容量 64

---------- 扩容账本 ----------
一共扩容 5 次:
  第  1 次入栈时,容量 0 -> 4,这次要搬 0 个旧元素
  第  5 次入栈时,容量 4 -> 8,这次要搬 4 个旧元素
  第  9 次入栈时,容量 8 -> 16,这次要搬 8 个旧元素
  第 17 次入栈时,容量 16 -> 32,这次要搬 16 个旧元素
  第 33 次入栈时,容量 32 -> 64,这次要搬 32 个旧元素

最终: 元素个数 40, 容量 64, 装载因子 62.5%
翻倍扩容总共搬了 60 个元素;如果每次只扩 1 个位置,要搬 780 个。
入栈 40 次,每次入栈平均搬 1.50 个元素,所以均摊下来还是 O(1)。
全部弹完之后: 元素个数 0, 容量 64(容量不会自动缩回去)

容量序列是 0、4、8、16、32、64,五次的搬运量加起来是 60 个。如果每次只扩一个位置,同样入栈 40 次要搬 780 个元素,两者相差十多倍,翻倍扩容的意义就体现在这个对比里。

数组栈与链表栈的耗时对比。

bash 复制代码
# 所属目录:/home/cocatrice/ds06/exp
gcc -std=c99 -O2 -Wall -Wextra -g speed.c ../stack.c -o speed
./speed
text 复制代码
每个规模都是:连续入栈 N 次,再连续出栈 N 次(边出边把值加起来,顺便校验结果)
计时用 clock_gettime(CLOCK_MONOTONIC),编译加 -O2
---------- N = 100000(10 万)----------
数组栈: 入栈    0.424 ms, 出栈    0.314 ms, 合计    0.739 ms
链表栈: 入栈    2.150 ms, 出栈    0.809 ms, 合计    2.959 ms
链表栈合计是数组栈的 4.0 倍
两边出栈元素之和: 数组栈 4999950000, 链表栈 4999950000, 理论值 4999950000, 一致: 是
---------- N = 1000000(100 万)----------
数组栈: 入栈    4.346 ms, 出栈    3.240 ms, 合计    7.586 ms
链表栈: 入栈   26.738 ms, 出栈    8.928 ms, 合计   35.666 ms
链表栈合计是数组栈的 4.7 倍
两边出栈元素之和: 数组栈 499999500000, 链表栈 499999500000, 理论值 499999500000, 一致: 是
---------- N = 10000000(1000 万)----------
数组栈: 入栈   51.319 ms, 出栈   35.237 ms, 合计   86.556 ms
链表栈: 入栈  250.136 ms, 出栈   82.624 ms, 合计  332.760 ms
链表栈合计是数组栈的 3.8 倍
两边出栈元素之和: 数组栈 49999995000000, 链表栈 49999995000000, 理论值 49999995000000, 一致: 是
校验和(防止整段被优化掉): 99999990000000
内存账:1000 万个 int 在数组栈里是 38.1 MB;
链表栈一个结点 sizeof = 16 字节,1000 万个结点光结点本身就要 152.6 MB,
而且每个结点都是一次单独的 malloc,每块还有额外的管理开销。

三个规模下链表栈的耗时都是数组栈的四倍左右,差距主要出现在入栈那一列:数组栈入栈只是写一个内存位置,链表栈要先 malloc 一个结点。出栈的差距小一些,因为两边都只是改一个下标或者改一个指针。

内存上的差距更加悬殊:同样是一千万个整数,数组栈占 38.1 MB,链表栈仅结点就占 152.6 MB,多出来的部分全部是 next 指针和每次 malloc 的管理开销。

用栈把递归改成非递归

把递归求阶乘改成显式栈,递归和栈之间的关系就能清楚地展开。

bash 复制代码
# 所属目录:/home/cocatrice/ds06/exp
gcc -std=c99 -Wall -Wextra -g recursion.c ../stack.c -o recursion
./recursion
text 复制代码
---------- 递归版 5! 的展开 ----------
FactRec(5) = 5 * FactRec(4)
           = 5 * 4 * FactRec(3)
           = 5 * 4 * 3 * FactRec(2)
           = 5 * 4 * 3 * 2 * FactRec(1)
           = 5 * 4 * 3 * 2 * 1
           = 120
---------- 非递归版 5! 的完整过程 ----------
递的过程(把每一层的参数压栈,和递归的调用顺序一样,先压 n):
  压入  5 之后,栈里(栈底->栈顶): [5]
  压入  4 之后,栈里(栈底->栈顶): [5 4]
  压入  3 之后,栈里(栈底->栈顶): [5 4 3]
  压入  2 之后,栈里(栈底->栈顶): [5 4 3 2]
  压入  1 之后,栈里(栈底->栈顶): [5 4 3 2 1]
归的过程(每弹一层就乘进结果):
  弹出  1,乘完结果是 1,栈里还剩 4 层没算
  弹出  2,乘完结果是 2,栈里还剩 3 层没算
  弹出  3,乘完结果是 6,栈里还剩 2 层没算
  弹出  4,乘完结果是 24,栈里还剩 1 层没算
  弹出  5,乘完结果是 120,栈里还剩 0 层没算
非递归版 5! = 120
---------- 两个版本逐项对比 ----------
n =  1: 递归版                   1, 非递归版                   1, 一致
n =  2: 递归版                   2, 非递归版                   2, 一致
...
n = 20: 递归版 2432902008176640000, 非递归版 2432902008176640000, 一致
1 到 20 全部对比完,两个版本的结果: 完全一致

「递」的过程是把参数一层层压栈,「归」的过程是把它们一层层弹出并相乘,顺序正好是后进先出。

图 6 用栈把递归改成非递归

1 到 20 的阶乘两个版本完全一致,20 的阶乘是 2432902008176640000(这个数已经超出 32 位 int,用的是 long long)。

valgrind 检查

四个程序各跑一次 valgrind:

text 复制代码
stack.c(单独驱动): 10 allocs, 10 frees, 8,192 bytes    0 errors
test.c:              3 allocs,  3 frees,    64 bytes    0 errors
bracket.c:           7 allocs,  7 frees,   288 bytes    0 errors
recursion.c:        54 allocs, 54 frees, 2,160 bytes    0 errors

四个程序的 in use at exit 都是 0 bytes、All heap blocks were freed,说明每一次 malloc 都有对应的 free。

踩坑点

  • 栈只允许在栈顶插入和删除,因此入栈和出栈都是 O(1),过程中不需要搬动其他元素。
  • 后进先出:依次入栈 1、2、3,出栈顺序只能是 3、2、1;入栈和出栈交替进行时可以得到多种输出序列,判断某个序列是否合法要在纸上模拟一遍。
  • top 的约定全篇必须统一:让它指向栈顶的下一个位置,取栈顶就要写 atop - 1;让它指向栈顶本身,空栈要记成 -1。两种约定混用必然出现边界错误。
  • top 指向栈顶的下一个位置时,元素个数、判空和取栈顶三个操作都能直接用 top 算出来,不需要再单独记一个变量。
  • 取栈顶时忘记减一,读到的是栈顶后面的空位,值是没有意义的垃圾数据。
  • 出栈只把 top 减一,数据仍然留在内存里,这不是缺陷,但要知道它并不等于清零。
  • 空栈出栈和空栈取栈顶都要用断言拦住,否则 top 会变成负数,后面的操作全部错乱。
  • 入栈之前必须先扩容,顺序颠倒会在容量刚好满的那一次发生越界写。
  • 扩容策略和顺序表一样:第一次给 4 个位置,之后每次翻倍。
  • realloc 的返回值要先接住再赋值,直接赋给 a 会在申请失败时丢掉原来的地址。
  • 静态栈的容量写死,栈满之后没有其他办法,只能报错;动态栈要记得销毁。
  • 栈可以复用顺序表那一套扩容和销毁的写法,接口比顺序表少两个。
  • 数组栈和链表栈的复杂度相同,但数组栈的常数更小,默认选数组实现。
  • 括号匹配的判断要分三种情况:右括号到来时栈空、栈顶不配对、扫描结束之后栈不空。
  • 用栈把递归改成非递归时,压栈的顺序要反过来,这样才能保证出栈时按原来的顺序处理。
  • 系统里的函数调用栈、表达式求值、括号匹配这三处,用的都是这个结构。

本篇总结(模拟面试问题)

问:栈的特点是什么,为什么入栈出栈都是 O(1)?

核心要点:栈只允许在栈顶插入和删除,另一端封死。入栈就是往栈顶位置写一个值再让栈顶指针加一,出栈就是栈顶指针减一,两者都不需要搬动其他元素,因此都是 O(1)。这也是它和顺序表的区别:顺序表可以在任意位置插入,代价是搬动数据。

问:栈用数组实现还是链表实现好?

核心要点:默认用数组。两者的入栈出栈都是 O(1),但数组栈只写一个内存位置,链表栈要申请结点再改指针,每次操作多一次内存分配;数组的元素连续存放,取栈顶能够命中缓存,链表还要顺着指针跳一次。只有在元素特别大、或者必须严格按需分配内存时才考虑链表。

问:栈能不能用来判断出栈序列是否合法?

核心要点:能,照着过程模拟一遍就能判断。用一个辅助栈,按输入序列依次入栈,每入一个就检查栈顶是不是当前该出栈的元素,是就弹出。如果输入序列能按这个规则全部处理完,这个出栈序列就是合法的,否则不合法。

问:栈和递归是什么关系?

核心要点:递归是系统用函数调用栈替你维护现场。每次递归调用都会压入一帧,帧里装着参数、返回地址和局部变量,返回时弹出。因此任何递归都能改成用显式栈加循环来写,代价是由自己管理压栈和出栈。

问:为什么递归太深会爆栈,栈有多大?

核心要点:函数调用栈是操作系统在创建线程时分配好的一块固定大小的内存,Linux 上默认是 8 MB。每调用一层函数就压一帧,帧里装着参数、返回地址和局部变量,一层占几十到几百字节。层数乘上每层的大小超过这块空间,就是栈溢出。可以用 ulimit -s 改大小,也可以在代码里把递归改成循环。

问:括号匹配为什么必须用栈,用计数器行不行?

核心要点:只有一种括号的时候,计数器就能处理:遇到左括号加一、右括号减一,全程不能为负、最后必须归零。但多种括号就不行了,因为不同的左括号需要区分,而它们必须按照后进先出的顺序被闭合,例如 {()} 里的 ] 必须对应最近的那个 [。这种「最近的先配对」正好是栈的语义。

问:用两个栈实现一个队列,思路是什么?

核心要点:一个栈负责入,一个栈负责出。入队直接压进入栈;出队的时候如果出栈是空的,就把入栈里的元素全部倒进出栈,这样最早进来的元素就跑到了出栈的栈顶,弹出来就是队头。每个元素最多被倒两次,均摊下来每次操作还是 O(1)。

问:表达式求值为什么用栈?

核心要点:运算符有优先级,括号还能改变优先级,而运算符的处理顺序是「后面遇到的先算」,这正是后进先出。做法是把中缀表达式转成后缀,或者用两个栈一边扫描一边计算:运算符栈里按优先级压栈,遇到优先级不高于栈顶的运算符就先弹出栈顶算一轮。数字存放在操作数栈里。

问:依次入栈 1、2、3、4,出栈序列可能是 2、4、3、1 吗?

核心要点:可能。入 1、入 2、出 2、入 3、入 4、出 4、出 3、出 1,正好得到 2、4、3、1。

问:一个栈的入栈序列是 1、2、3、4、5,以下哪个不可能是出栈序列:A 5 4 3 2 1,B 1 2 3 4 5,C 4 5 3 2 1,D 4 3 5 1 2?

核心要点:D。要出 4,得先入 1 到 4;接着出 3,再入 5 出 5,这时栈里剩下 1 和 2,栈顶是 2,所以下一个只能是 2,不可能是 1。

问:栈的 top 指向栈顶元素的下一个位置,栈里有三个元素时 top 等于多少?

核心要点:3。这种约定下 top 天然就是元素个数。

问:为什么用栈实现的括号匹配要求最后栈必须为空?

核心要点:栈不空说明还有左括号没有找到对应的右括号,例如 ((( 这种情况,扫描结束之后栈里还剩三个左括号,所以整个串是不匹配的。反过来说,如果中途遇到右括号时栈已经空了,说明右括号多余,同样是不匹配。

问:下面这段出栈的代码有什么问题?

text 复制代码
void STPop(ST* ps)
{
    assert(ps);
    ps->top--;
}

核心要点:少了空栈检查。栈空的时候 top 是 0,减一之后变成 -1,之后取大小返回 -1、入栈会写到 a-1 上,全部越界。要么加一句 assert(ps->top > 0),要么返回一个错误码让调用者判断。

参考

相关推荐
LRL_1 小时前
【云原生】Oracle Linux 9.6 离线(Air-Gapped)环境下安装 Longhorn 及其底层依赖(iSCSI/NFS)完全指南
linux·云原生·oracle
无糖可乐没有灵魂1 小时前
用一篇文章来精通“进程/线程/协程/锁“多并发任务
开发语言·php
2601_962885721 小时前
如何用 Python 分析股票的日历效应?(星期效应与月份效应,附数据挖掘陷阱)
开发语言·python·数据挖掘
“AI国潮设计-小江”1 小时前
Python实战 | SDXL批量生成“带炸英歌”国潮IP:从海报到门店落地(附核心Prompt与授权思路)
开发语言·人工智能·python·prompt·aigc
字节渡客1 小时前
PHP项目复盘:foreach 引用残留造成的诡异数据错乱
开发语言·php
高山有多高1 小时前
【Linux笔记】线程同步与互斥
linux·笔记
我就是不信1 小时前
深入理解 Python 虚拟机:字典(dict)的优化
开发语言·python·哈希算法
我就是不信1 小时前
C语言排序问题详解:从冒泡到快排的完整指南
c语言·算法·排序算法