《栈、队列、串基础实现(C 语言)》这篇内容,其实我最早是因为带几个刚转行学嵌入式的朋友,发现他们学完C语言语法之后,一碰到“数据结构”这块就卡壳。链表还好说,好歹能顺着指针摸下去;但栈和队列这两个东西,名字听着抽象,代码一写就晕:栈顶指针到底怎么动、队尾指针要不要回绕、字符串结尾符要不要占数组空间……这些问题看起来小,但每一个都决定程序能不能跑对。我后来索性整理了一份基于C语言的完整实现笔记,把栈、队列、串三种线性结构从头到尾手写了一遍。这篇文章就是那套东西的精华版,适合正在学数据结构的学生、刚入行的嵌入式开发者,以及所有想把C语言基本功打扎实的人。
1. 内容整体设计与思路拆解
1.1 为什么用C语言实现这三种结构
很多人会问:现在写业务代码都用Python、Java,或者干脆用STL里的std::stack、std::queue、std::string,为什么还要用C语言把栈、队列、串从头实现一遍?我的看法是:正因为在高级语言里这些结构已经被封装成现成的类和模板,你反而看不清它们内部是怎么运转的。而C语言几乎没有提供任何数据结构封装,你只能自己管理内存、自己控制指针、自己设计存储布局——这个过程恰恰能把数据结构最底层的逻辑看得明明白白。
拿栈来说,Python里写stack.append(x)和stack.pop(),你只能感受到“后进先出”这个行为,但栈顶指针到底指向哪里、数组满了怎么办、为什么有的实现top从-1开始、有的从0开始,这些藏在表象之下的细节,在C语言里全都藏不住。同样的道理也适用于队列的“环形缓冲区”设计,以及串(字符串)在C语言中“字符数组+结尾符”的存储约定。
所以我始终觉得,C语言是学习数据结构最佳的语言,没有之一。它能逼着你去思考每一个字节的排放、每一处边界条件的处理。你现在觉得麻烦,等以后去写嵌入式驱动、去实现网络协议帧解析,或者去调一个并发队列的线程安全问题,就会发现当年用C语言手写这些基础结构花的功夫,全部会加倍回报你。
1.2 线性结构的共性认知
栈、队列、串,本质都属于线性结构——数据元素之间是一对一的线性关系。你可以把它们理解成同一条“数据管子”上做不同操作的变体:栈限制为只能在管子的一端进出,队列限制为一端进另一端出,而串则是数据元素被限定为字符的线性表。
从学习逻辑上,我会建议按“线性表 → 栈 → 队列 → 串”的顺序推进。先理解最一般的线性表是怎么回事,再看栈和队列如何通过限制操作位置来演化出特定的行为,最后过渡到元素类型为字符的特殊线性表。掌握这层关系之后,你会发现栈、队列、串代码里的很多操作(初始化、判空、取元素)是有极大相似性的,真正不同的地方往往只有一两处,把这一两处吃透,整个知识体系就通了。
1.3 存储方案选型:顺序存储 vs 链式存储
C语言实现这三种结构时,首先面临一个问题:用数组做顺序存储,还是用结构体+指针做链式存储?
顺序存储的核心优势在于连续内存访问,CPU缓存的命中率更高,而且不存在指针操作的繁琐和出错风险。缺点也明显:数组容量固定,扩容需要重新申请内存并搬运旧数据;插入删除(以串和线性表为例)往往需要大量移动元素。
链式存储则相反,它的内存可以零散分布,通过指针串联,插入删除只需要改指针指向,理论上不受容量限制。但每个节点都要额外存储指针域,内存开销变大,而且频繁的malloc/free容易造成内存碎片,访问效率也低于数组。
在实际工程选择中,我个人的经验是:
- 如果数据规模可预估且操作以“访问、存取”为主,用顺序存储。栈、队列大多数场景下采用顺序存储就够了,尤其是嵌入式开发里,数组就放在那里,内存可控、实时性有保障。
- 如果数据规模不断变化,频繁在中间插入或删除,用链式存储。比如实现一个通用的任务链表,节点的添加和移除非常频繁,就不适合用一个大数组去硬扛。
这篇文章为了让你看清本质,栈和队列都采用顺序存储实现,串采用定长顺序存储。我会在合适的地方穿插讲解如果改成链式方案,哪些代码需要调整。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 栈的C语言实现与实操细节
2.1 栈的核心特征:后进先出
栈在现实中最形象的类比就是“一摞盘子”。你洗完一个盘子,总是把它放在最上面;要用的时候,也总是从最上面取。这个“最上面”在代码里就是栈顶指针top。
栈的官方定义是:只允许在同一端(栈顶)进行插入和删除操作的线性表。另一端叫栈底,固定不动。正因为这个限制,栈天然具备“后进先出(LIFO, Last In First Out)”的特性。这一特性让栈在处理“嵌套”“回溯”“递归”等问题时成为首选结构——因为嵌套的本质是后进入的层级需要先退出,和栈的行为完全一致。
2.2 顺序栈的结构体设计
C语言实现顺序栈,第一步是设计存储结构。我通常使用三个字段:
c复制#define MAX_SIZE 100
typedef struct {
int data[MAX_SIZE];
int top; // 栈顶指针,始终指向栈顶元素
} SeqStack;
这里的top是整个栈实现里最需要理清的概念。它有两种常见约定:
top指向栈顶元素的位置,栈空时top = -1。入栈时先top++,再赋值data[top] = x;出栈时先取data[top],再top--。top指向栈顶元素的下一个空位,栈空时top = 0。入栈时先赋值data[top] = x,再top++;出栈时先top--,再取data[top]。
这两种方案没有绝对的对错,都能用,但你必须始终如一,不能混。我个人习惯用第一种(top指向栈顶元素,空栈为-1),因为出栈、入栈的逻辑更直观,和数组下标的思考方式一致,而且判空条件直接判断top == -1即可,可读性更强。
2.3 核心操作:入栈、出栈、取栈顶
栈的基本操作必须实现:初始化、判空、判满、入栈(push)、出栈(pop)、取栈顶元素(getTop)。
c复制// 初始化
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;
}
// 入栈
int push(SeqStack *s, int x) {
if (isFull(s)) {
printf("栈已满,无法入栈\n");
return 0;
}
s->data[++(s->top)] = x;
return 1;
}
// 出栈
int pop(SeqStack *s, int *x) {
if (isEmpty(s)) {
printf("栈为空,无法出栈\n");
return 0;
}
*x = s->data[(s->top)--];
return 1;
}
// 取栈顶元素
int getTop(SeqStack *s, int *x) {
if (isEmpty(s)) {
printf("栈为空\n");
return 0;
}
*x = s->data[s->top];
return 1;
}
注意两个细节:
- 出栈和取栈顶都用指针参数
*x把结果带出函数,而不是直接return数据。因为C语言函数只能返回一个值,而我希望返回值表达“操作是否成功”,这就让调用者既能判断结果,又能拿到数据。这是C语言里非常经典的一种接口设计。 - 入栈操作
data[++(s->top)]是前置自增,意味着先移动指针再赋值。我见过很多初学者在这里写成data[s->top++] = x,结果栈顶永远比实际元素多一位或者错一位,排查半天才发现问题。
2.4 栈的经典应用场景:函数调用、括号匹配、表达式求值
顺序栈实现完之后,建议你用几个经典题目去检验栈的理解,而不是停留在跑通就完事。
第一,函数调用和递归。每一次函数调用,系统都会把返回地址、局部变量、参数压入系统栈,函数返回时再弹出。递归之所以能层层深入又逐层返回,靠的就是这个机制。理解了栈,你就理解了为什么递归深度过大会“栈溢出”。
第二,括号匹配检查。给定一串()、[]、{}混合的括号串,判断是否合法。做法是顺序扫描,遇到左括号就压栈,遇到右括号就弹出栈顶并判断是否匹配。若不匹配或扫描完栈非空,则括号串非法。我的编辑器里检查代码括号闭合的工具,底层就是类似逻辑。
第三,表达式求值。中缀表达式转后缀表达式,或者直接利用两个栈实现中缀求值,都是栈的经典应用。操作数的顺序、运算符优先级的比较和回退,放在栈里处理非常自然。
我顺手写过一个小例子,用栈判断回文串(只考虑字符序列):
c复制int isPalindrome(char *s) {
int len = strlen(s);
SeqStack stack;
initStack(&stack);
for (int i = 0; i < len; i++) {
push(&stack, s[i]);
}
for (int i = 0; i < len; i++) {
int c;
pop(&stack, &c);
if (c != s[i]) {
return 0;
}
}
return 1;
}
这个例子虽然简单,但把栈和串(字符串)两个知识点串起来了。你可以试着用这个思路再去做“逆波兰表达式求值”或者“简化路径”的题目。
3. 队列的C语言实现与关键细节
3.1 队列的核心特征:先进先出
队列对应的现实场景是“排队买票”:先来的先服务,后来的排在队尾。因此它的操作被限制为:只能在队尾插入(入队),只能在队头删除(出队)。这种“先进先出(FIFO, First In First Out)”的特性,使得队列成为一切需要“按顺序公平处理”的场景的基础结构。
从热搜词里我能看到很多人搜索“阻塞队列”“消息队列”“线程池的阻塞队列选择”,其实这些高级话题的内核都是队列本身,只是加入了并发、阻塞、优先级等条件。你先把基础队列吃透,后面接触消息队列中间件时,就能一眼认出它内部是哪种队列结构。
3.2 顺序队列的“假溢出”问题
如果直接用数组模拟队列,初始时队头front和队尾rear都指向数组开头,入队时rear++,出队时front++。这样实现固然简单,但存在一个致命问题。
请看这个场景:数组容量是5,你连续入队5个元素,此时rear指向5,队满。接着你出队2个元素,front变到2。现在数组前两个位置空着,逻辑上队列还剩3个元素,还有空间可以入队,但是rear已经指向数组末尾了,再入队就会越界。
这就叫“假溢出”:数组没满,但队尾指针已经到了边界,导致无法继续入队。如果出队时把元素真正地向前移动,又会导致时间复杂度过高,每次出队都是O(n)的数据搬移。
解决思路通常有两个:一是链式队列,出队时释放节点,天然没有这个问题;二是将数组的首尾逻辑上连接成环,也就是环形队列。
3.3 环形队列的设计与空/满判断
环形队列的思路是在逻辑上把数组的最后一个位置和第一个位置连起来,队尾指针到达数组末尾时,通过对数组长度取模回绕到0号位置。判断front和rear:
- 入队:
rear = (rear + 1) % MAX_SIZE - 出队:
front = (front + 1) % MAX_SIZE - 判空:
front == rear - 判满:
(rear + 1) % MAX_SIZE == front
等一下,这里有个很多初学者逃不过的陷阱:如果判空是front == rear,那么当队列真正满时,由于绕圈,front和rear也会相遇,变成front == rear,和判空条件冲突。所以环形队列必须“牺牲一个存储单元”来区分空和满:当(rear + 1) % MAX_SIZE == front时认为队满,此时队列最多只能存MAX_SIZE - 1个元素,让rear永远不追上front。
3.4 环形队列的完整实现
c复制#define MAX_SIZE 100
typedef struct {
int data[MAX_SIZE];
int front; // 队头下标,指向队头元素
int rear; // 队尾下标,指向队尾元素的下一个位置
} SeqQueue;
// 初始化
void initQueue(SeqQueue *q) {
q->front = 0;
q->rear = 0;
}
// 判空
int isEmpty(SeqQueue *q) {
return q->front == q->rear;
}
// 判满
int isFull(SeqQueue *q) {
return (q->rear + 1) % MAX_SIZE == q->front;
}
// 入队
int enQueue(SeqQueue *q, int x) {
if (isFull(q)) {
printf("队列已满\n");
return 0;
}
q->data[q->rear] = x;
q->rear = (q->rear + 1) % MAX_SIZE;
return 1;
}
// 出队
int deQueue(SeqQueue *q, int *x) {
if (isEmpty(q)) {
printf("队列为空\n");
return 0;
}
*x = q->data[q->front];
q->front = (q->front + 1) % MAX_SIZE;
return 1;
}
我在这里再强调一次约定:初始化时front = rear = 0;入队时元素放在rear位置,rear向后移动;出队时取出front位置的元素,front向后移动。因此rear指向的是下一个空位,front指向的是队头元素本身。空队列的判断条件front == rear成立的时候,实际上front和rear重合,没有一个元素被包含在这个区间里。
3.5 队列的实际应用:阻塞队列、消息队列与缓冲区
基础队列实现之后,应用场景可以从两个方向拓展。
第一个方向是操作系统和并发编程中的阻塞队列。当队列满时,入队操作被阻塞;当队列空时,出队操作被阻塞。线程池的任务队列、生产者-消费者模型中传递数据的管道,本质都是阻塞队列。Java里的ArrayBlockingQueue、LinkedBlockingQueue就是带阻塞功能的环形队列和链式队列。你理解了环形队列的空满判断,才能看懂为什么它的容量要初始化、为什么用两个条件锁去分别唤醒生产者线程和消费者线程。
第二个方向是网络通信中的缓冲区。网卡收包时数据到达速度不均匀,驱动将报文放入一个内核队列中,应用层再从队列中读取;CPU和外设速度不匹配时,也常用FIFO队列做缓冲。我以前调过一套串口通信程序,收发的环形缓冲区如果不用队列思想,就很容易丢包或者覆盖未读数据。
在学习基础时,我建议你做一个“约瑟夫环”或者“杨辉三角层序生成”的小实验,把队列用起来,比单纯看代码印象深得多。
4. 串(字符串)的C语言实现与模式匹配
4.1 C语言中“串”的特殊性:结尾符和数组容量
“串”就是字符串,是内容限定为字符的线性表。但C语言处理字符串,有一点和其他语言截然不同:它没有原生字符串类型,串本质上是一个字符数组,并约定以'\0'(空字符)作为结尾标志。
这就意味着,你申请一个能存10个字符的数组,实际最多只能存9个业务字符,因为第10个位置必须留给'\0'。许多初学者犯过的经典错误,是在循环里遍历到第10个元素然后越界写数据,破坏了相邻内存。我自己也踩过这种坑——曾经在一个解析通信协议帧的函数里,拼接字符串时忘了预留结尾符位置,结果打印出来的字符串后面多了一串乱码。
4.2 串的三种存储表示
C语言实现串,存储上有三种思路:
- 定长顺序存储。用固定长度的字符数组表示串,超过容量就需要截断或报错。这种方案简单可控,适合长度能预估的串。
- 堆分配存储。用
malloc动态申请一块内存存储串,长度可变,用完用free释放。C语言标准库的很多字符串函数就倾向于往这个方向设计。 - 块链存储。一个节点存多个字符,节点之间用指针相连,类似链表。这种方案相对冷门,主要用于对插入删除操作极端频繁的场景。
实际工程中,堆分配存储最灵活,但需要注意内存管理;定长顺序存储在嵌入式环境中非常常见,因为你不希望系统在运行过程中做动态内存分配。
4.3 串的基本操作:求长、拼接、比较、截取
串的基本操作包括赋值、求串长、串拼接、求子串、串比较和定位。
以定长顺序存储为例,我定义这样一个结构体:
c复制#define MAX_LEN 256
typedef struct {
char ch[MAX_LEN];
int length; // 实际串长,不含'\0'
} SString;
注意这里的length字段:C语言的字符数组可以不含结尾符,只要你知道这个串有多长。这是另一种表示串的方式,在传输二进制数据(数据里可能包含0x00字节)时非常有意义,因为二进制数据里不允许用'\0'去判断终点。
拼接操作Core函数实现如下:
c复制int concat(SString *t, SString *s1, SString *s2) {
if (s1->length + s2->length > MAX_LEN) {
return 0; // 溢出
}
for (int i = 0; i < s1->length; i++) {
t->ch[i] = s1->ch[i];
}
for (int i = 0; i < s2->length; i++) {
t->ch[s1->length + i] = s2->ch[i];
}
t->length = s1->length + s2->length;
return 1;
}
如果你用普通的char[]而不是结构体,那么拼接需要用strcpy和strcat,并自己保证目标数组足够大。但strcat这类函数存在缓冲区溢出风险,所以很多现代C代码指南建议使用strncat并显式传入长度。
4.4 模式匹配:朴素算法与BF思路详解
串最核心的算法是模式匹配,也就是在一个主串中查找一个子串(模式串)出现的位置。C语言标准库的strstr函数做的就是这件事。
最简单的实现是朴素匹配(Brute Force,BF):从主串的每一个位置开始,依次比较模式串的每一个字符,如果某一位不匹配,主串指针就回退到本次起点的下一位,模式串指针回到0,重新比较。
c复制int indexBF(SString *s, SString *sub) {
int i = 0, j = 0;
while (i < s->length && j < sub->length) {
if (s->ch[i] == sub->ch[j]) {
i++;
j++;
} else {
i = i - j + 1; // 主串回退到本次起点的下一个位置
j = 0;
}
}
if (j == sub->length) {
return i - j; // 匹配成功,返回子串起始下标
}
return -1;
}
这段代码的巧妙之处在于回退逻辑:当一次比较在第j个字符(从0开始)失败时,这次匹配已经消耗了j次成功的字符匹配,所以主串当前位置是i,这轮比较的起点是i - j,下一轮应该从i - j + 1开始匹配。
朴素匹配的时间复杂度是O(m*n),其中m是主串长度、n是模式串长度。在文本搜索、编辑器查找等场景下,如果主串和模式串都比较长,朴素匹配会非常慢。这就是为什么需要更高效的算法。
4.5 从朴素到KMP:next数组到底在解决什么问题
KMP算法(Knuth-Morris-Pratt算法)的主要改进点在于:当某次字符比较失败时,主串的i指针不必回退,而是利用已经匹配的信息,只把模式串的j指针滑动到一个合适的位置继续比较。
KMP能这么做,靠的是一个关键结论:在失配位置之前,你已经知道主串的一段子串和模式串的一段前缀是完全相等的。既然模式串自身的部分匹配信息已经确定,那么可以在失配发生时把模式串向右滑动若干位,跳过那些必定不可能匹配的位置。
这个“滑动到哪”的信息预先计算成数组,存放在next数组中。计算公式可以这样描述:
next[0] = -1,表示模式串第一个字符就失配时,主串需要前进一个字符再和模式串头比较。- 对于
j > 0,next[j]的值等于模式串的前j个字符组成的子串中,“最长相同真前缀和真后缀的长度”。 - 当失配发生时,令
j = next[j],如果j是-1,则主串前进一位,j变为0。
具体next数组推导我建议你手动推一遍。以模式串"ABABC"为例:
next[0] = -1- 前1个字符
"A",最长相等前后缀长度0,next[1] = 0 - 前2个字符
"AB",最长相等前后缀长度0,next[2] = 0 - 前3个字符
"ABA",前缀A和后缀A相同,长度1,next[3] = 1 - 前4个字符
"ABAB",前缀AB和后缀AB相同,长度2,next[4] = 2
有了next数组,KMP主串指针不回退,一趟线性扫描即可完成匹配,时间复杂度为O(m+n)。对于搜索引擎、文本编辑器这种高频查找场景,KMP的价值非常大。
不过我在初学KMP时最大的困惑在于:next数组为什么是“部分匹配值”而不是别的?后来自己推导一遍失配过程才意识到,所谓“滑动”并不是随意的跳跃,而是利用“后缀等于前缀”的性质保证滑动后前面的字符已经天然匹配,不需要重新比较。想明白这点,KMP就不玄乎了。
4.6 串的实际应用:回文串判断、文本处理与数据解析
回到热搜词里频繁出现的“回文串”。回文串是指正着读和反着读完全一样的字符串,比如"madam"、"上海自来水来自海上"。判断回文串的朴素做法是双指针从两端向中间比较,这个思路同时也可以配合栈来实现,正如我在2.4里写的那样。
在实际项目中,串的应用更多体现在文本解析、协议解析和日志处理中。比如你拿到一串HTTP报文,要通过查找\r\n\r\n来切分header和body;比如解析CSV文件,你需要在逗号和引号之间截取子串。这些操作本质上就是求子串、模式匹配和串分割的组合。掌握好串的基本实现,对你后续封装自己的字符串处理库特别有帮助。
5. 常见问题与排查技巧实录
5.1 为什么我的栈越用越乱:栈顶指针类的边界错误
在调试栈相关代码时,最常见的报错后果是“入栈后取出来的值不对”或者“栈空判断失灵”。我总结这类问题的排查顺序:
- 先检查
top初始化值。如果你采用top = -1方案,就不要混用top = 0方案的入栈语句。 - 入栈时检查是不是先移动了指针再赋值。
data[++top] = x和data[top++] = x、以及data[++top] = x写成data[top] = x; top += 1(这里自增方向不同)完全是不同行为。 - 出栈时检查是不是先取值再减指针,顺序反了会把栈顶元素越过。
isFull的判断条件统一为top == MAX_SIZE - 1,不要写成top >= MAX_SIZE,否则你在越界之前可能已经写坏了数组外面的一片内存。
5.2 环形队列“判空判满”翻车排查经验
环形队列的经典报错是:队列明明还没满,但入队提示已满;或者队列已经空了,出队还能取出一个脏值。
我在调试时有一个习惯:把front和rear的值在关键的入队、出队瞬间打印出来,观察它们的变化是否符合预期。很多次问题出在初始化上:有人把front和rear都设成0,这是标准的环形队列空态;但也有人把front设为0、rear设为-1,这其实是另一种队列实现的约定,如果你把逻辑和这种初始化混在一起,必然出错。
另外记得,环形队列牺牲一个单元作为满标志,意味着MAX_SIZE的数组实际存储能力是MAX_SIZE - 1。如果业务上要求必须存满MAX_SIZE个元素,你需要额外加一个count字段记录当前元素个数,或者改用链式队列,用元素个数直接区分空和满。
5.3 字符串结尾符被遗忘的“灵异事件”
C语言的串实现中,最常见的坑就是结尾符。有次我写一个拼接函数,把两个缓冲区内容拼到一起,然后printf("%s", buf),结果输出的内容后面跟着一长串乱码字符。排查之后发现,拼接后的字符数组末尾没有手工写入'\0',printf按照%s格式一直读到内存里第一个碰巧出现的0x00才停止。
在C语言里,只要使用printf("%s")、strlen、strcpy这类依赖结尾符的函数,就必须保证字符数组末尾有'\0'。如果你是手工逐字符填充数组,填充完后记得用buf[len] = '\0';收尾。如果是定长顺序存储并且用length字段记录长度,打印时可以用printf("%.*s", s.length, s.ch);按长度输出,不依赖结尾符。
5.4 KMP算法next数组求错时的自查清单
KMP的代码往往能在网上抄到,真正动手自己写的时候,next数组求错是最普遍的。常见错误包括:
- 忘记给
next[0]赋-1,导致第一个字符失配时无法前进主串。 - 求next时,把自己给自己比较的情况绕进去了。比如模式串
"AAAAB",当求next[4]时,要从长到短尝试匹配真前缀和真后缀,而不是简单地把前面字符的next值+1。 - 对模式串长度为0或1的边界情况没处理。
我这里给个通用的next求解模板,供你对照:
c复制void getNext(SString *sub, int next[]) {
int i = 0, j = -1;
next[0] = -1;
while (i < sub->length) {
if (j == -1 || sub->ch[i] == sub->ch[j]) {
i++;
j++;
next[i] = j;
} else {
j = next[j];
}
}
}
这段代码里的i表示当前正在处理的模式串位置,j表示已匹配的最长相等前缀长度。j == -1的分支处理的是首字符失配需要从头开始的情况。推KMP建议配合小例子手写一两遍,光是看代码很容易“假懂”。
栈、队列、串的C语言实现,本质就是C语言基本功和数据结构的交汇点。我每次重写一遍这套代码,都能发现一些新的体会,比如循环队列空满设计里的取舍、下标和长度的相互转换、KMP算法里“充分利用已经匹配的信息”这一思想的普适性。这些东西在书面上看是一行行代码,在面试笔试题里是各种变形应用,在真正的工程里则是你在设计缓冲队列或字符串解析时下意识的选择。
如果你正在学数据结构或者C语言,我的建议很直接:别看一遍代码觉得会了就往下走,最好在IDE里亲手敲一遍。敲完栈和队列,改一版链式存储;敲完朴素匹配,试着自己把KMP的next数组推出来。这种“从眼到到手再到脑”的转换,才是真正把知识变成能力的过程。
