【C语言数据结构】手把手带你拿捏"栈"!🚀 从概念到实现的保姆级教程
摘要:🤔 还在为数据结构中的"栈"感到头疼吗?别怕!本文将用最通俗易懂的语言,从“干饭”的生活实例讲起 🍚,带你一步步深入理解栈(Stack)的LIFO原则。我们将一起探讨为何动态数组是实现栈的“天选之子”👍,并手把手、逐行代码地用C语言构建一个功能完备的动态栈。从初始化、销毁到入栈、出栈,每一步都有详尽的解析和避坑指南 ⚠️。准备好了吗?让我们一起开启数据结构的探险之旅,彻底征服“栈”这座高地!
一、 "栈"是什么?从生活说起 🍽️
想象一下你正在食堂干饭,桌上有一摞干净的餐盘。
- 放盘子 📥:阿姨清洗完一个新盘子,总是会把它放在最上面。
- 拿盘子 📤:你来打饭,自然也是从最上面拿走一个盘子。
这摞盘子就是一个典型的“栈”结构。后面放上去的盘子,总会最先被拿走。再比如我们吃筒装的薯片,是不是也是从最上面开始吃,最后吃到的才是最开始放进去的?
这就是栈的核心思想:后进先出 (Last-In, First-Out),简称 LIFO。
在计算机世界里,栈简直无处不在 🤯:函数的调用(形成调用栈)、浏览器的“后退”按钮、文本编辑器的“撤销”(Undo)操作……都离不开栈这个强大的数据结构。
二、 栈的核心概念与结构 💡
2.1 概念与LIFO原则 🔄
- 🎯 栈 (Stack):一种特殊的线性表,它只允许在固定的一端进行元素的插入和删除操作。
- 🔝 栈顶 (Top):允许进行插入和删除操作的这一端。
- 👇 栈底 (Bottom):与栈顶相对的另一端。
- 📥 入栈 (Push):向栈顶添加元素,也叫压栈。
- 📤 出栈 (Pop):从栈顶移除元素。
请牢记这个唯一的黄金法则:LIFO (Last In First Out)。
2.2 底层结构选型:为何偏爱数组?
实现栈,我们通常有两种选择:链表 或 数组。
- 🤔 链表:实现起来也可以,但每次入栈都需要
malloc
新节点,出栈需要free
节点,操作相对频繁,且有额外的指针域开销。 - 👍 数组:特别是动态数组,是实现栈的绝佳选择。为什么?因为栈的所有操作都集中在“尾部”(栈顶)。而数组在尾部进行插入和删除,时间复杂度是 O(1)O(1)O(1),效率极高!我们只需要一个指针(或下标)来追踪栈顶位置即可,非常简洁高效。
因此,本次实现我们将采用动态数组作为栈的底层结构。
三、 C语言实现栈:代码详解 💻
我们将分三个文件来构建项目:
stack.h
:栈的头文件,包含结构体定义和函数声明。stack.c
:栈函数的具体实现。test.c
:用于测试我们实现的栈功能是否正确。
3.1 准备工作:stack.h
头文件 📜
一个好的习惯是先声明好我们将要实现的所有接口。
// stack.h
#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); // 获取栈顶元素
int STSize(ST* ps); // 获取栈中有效元素个数
bool STEmpty(ST* ps); // 判断栈是否为空
3.2 结构体定义:栈的“蓝图” 🏗️
让我们仔细看看这个结构体:
typedef struct Stack
{
STDataType* a; // 指向动态数组的指针
int top; // 栈顶位置的下标
int capacity; // 数组的容量
} ST;
a
: 一个指向STDataType
类型的指针。它将指向我们在堆上动态申请的一块内存,作为存储数据的数组。top
: 这是栈的灵魂! 我们定义top
为栈顶元素的下一个位置的下标。这意味着top
的值也恰好是栈中元素的个数(STSize
)。capacity
: 当前动态数组已申请的总空间大小。当top == capacity
时,就意味着栈满了,需要扩容。
3.3 核心功能实现:stack.c
🛠️
接下来,我们逐一实现 stack.h
中声明的函数。
🌱 初始化与销毁 🗑️ (STInit & STDestroy)
// stack.c
#include "Stack.h"
// 初始化
void STInit(ST* ps)
{
// 断言确保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
:将所有成员初始化为0或NULL
,创建一个干净的、不指向任何内存的空栈。STDestroy
:核心任务是free(ps->a)
,释放动态申请的内存,防止内存泄漏 💧。随后将指针和变量归零是一个好习惯,可以避免悬挂指针(Dangling Pointer)问题。
🧠 入栈操作 (STPush) - 动态扩容是关键!
STPush
是我们栈实现中最核心、最复杂的函数。
// 入栈---栈顶
void STPush(ST* ps, STDataType x)
{
assert(ps);
// 检查是否需要扩容
if (ps->top == ps->capacity) {
// 🔍 重点解析:计算新容量:初始为4,之后翻倍
int newCapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
// ⚠️ 避坑指南:使用realloc进行扩容,并用临时指针接收
STDataType* tmp = (STDataType*)realloc(ps->a, newCapacity * sizeof(STDataType));
if (tmp == NULL) {
perror("realloc fail!"); // 打印错误信息
exit(1); // 内存申请失败,程序退出
}
ps->a = tmp; // 扩容成功,更新指针
ps->capacity = newCapacity; // 更新容量
}
// 存入数据,并移动top指针
ps->a[ps->top++] = x;
}
重点解析:
- 检查容量:
if (ps->top == ps->capacity)
是扩容的触发条件。当栈内元素数量(top
)等于已分配容量(capacity
)时,说明空间已满。 - 计算新容量:
ps->capacity == 0 ? 4 : ps->capacity * 2
这是一个非常经典的扩容策略。- 当栈首次入栈时 (
capacity
为0),我们先分配4个元素的空间。 - 之后每次需要扩容,都将容量翻倍 🚀。这种指数级增长可以有效减少扩容操作的频率。
- 当栈首次入栈时 (
- 安全地使用
realloc
:- 注意:这里必须使用
realloc
而不是malloc
。realloc
可以在原有内存块的基础上进行扩容,并保留原有的数据。 - ⚠️ 避坑指南:
realloc
如果失败会返回NULL
。我们绝不能写成ps->a = realloc(...)
,因为一旦失败,ps->a
会被置为NULL
,导致原有数据全部丢失!正确的做法是使用一个临时指针tmp
接收返回值,判断tmp
是否为NULL
,确认成功后再赋给ps->a
。
- 注意:这里必须使用
- 插入元素:确保空间充足后,将新元素
x
放入ps->a[ps->top]
位置,然后将top
加一,指向下一个可用的空位。一行代码ps->a[ps->top++] = x;
搞定!
👆 出栈与获取栈顶元素 (STPop & STTop)
// 出栈——栈顶
void STPop(ST* ps)
{
// 确保栈不为空
assert(!STEmpty(ps));
// 只需要移动top指针即可
ps->top--;
}
// 取栈顶元素
STDataType STTop(ST* ps)
{
// 确保栈不为空
assert(!STEmpty(ps));
// top指向的是下一个空位,所以栈顶元素是 top-1
return ps->a[ps->top - 1];
}
STPop
:出栈非常简单!我们不需要真正地去“删除”内存中的数据。只需要将top
减一,逻辑上这个元素就不在栈中了。下次STPush
时,它所在的位置会被新数据覆盖。STTop
:由于我们的top
定义为栈顶元素的下一个位置,所以真正的栈顶元素下标是top - 1
。assert(!STEmpty(ps))
:在这两个操作前,必须断言确保栈不为空,否则对空栈操作会导致程序崩溃(数组越界)。
📏 辅助函数 (STSize & STEmpty)
这两个函数非常直观。
// 获取栈中有效元素个数
int STSize(ST* ps)
{
assert(ps);
return ps->top;
}
// 栈是否为空
bool STEmpty(ST* ps)
{
assert(ps);
return ps->top == 0;
}
STSize
:根据我们对top
的定义,它的大小天然就是栈中元素的个数。STEmpty
:当top
为 0 时,栈自然就是空的。
四、 实战演练:测试我们的栈 ✅
光说不练假把式,写好代码一定要测试!
// test.c
#include "Stack.h"
#include <stdio.h>
void test01()
{
ST st;
STInit(&st); // 1. 初始化
// 2. 连续入栈 1, 2, 3, 4, 5
printf("Pushing: 1 2 3 4 5\n");
STPush(&st, 1);
STPush(&st, 2);
STPush(&st, 3);
STPush(&st, 4);
STPush(&st, 5);
printf("Current stack size: %d\n", STSize(&st));
printf("Top element: %d\n", STTop(&st));
// 3. 依次出栈并打印,验证LIFO
printf("Popping elements: ");
while (!STEmpty(&st))
{
// 1. 获取栈顶元素
int top = STTop(&st);
printf("%d ", top);
// 2. 将该元素出栈
STPop(&st);
}
printf("\n");
printf("Stack size after popping all elements: %d\n", STSize(&st));
// 4. 销毁栈,释放内存
STDestroy(&st);
}
int main()
{
test01();
return 0;
}
预期输出:
Pushing: 1 2 3 4 5
Current stack size: 5
Top element: 5
Popping elements: 5 4 3 2 1
Stack size after popping all elements: 0
这个输出完美地验证了我们的栈遵循 后进先出 (LIFO) 的原则:最后压入的 5
最先被弹出。
五、 总结与展望 🎉
恭喜你!到这里,你已经成功地从零到一构建了一个健壮、高效的C语言动态栈。
让我们回顾一下关键点:
- ✨ 核心是 LIFO:后进先出,所有操作都在栈顶进行。
- ✨ 动态数组是优选:利用数组尾部操作的高效性(O(1)O(1)O(1))实现栈。
- ✨
top
指针是灵魂:top
指向下个可用位置,其值即为栈大小。 - ✨
realloc
扩容是关键:掌握安全的动态扩容机制是实现动态栈的核心技术。
希望这篇保姆级的教程能帮你彻底搞定“栈”。数据结构的学习就是这样,理解概念,然后亲手实现它,你会发现其中的乐趣和奥妙。
下一步去哪儿? ➡️ 不妨用你亲手打造的栈去解决一些实际问题吧,比如:
- 括号匹配问题
- 逆波兰表达式(后缀表达式)求值
- 用两个栈实现一个队列
那将是更有成就感的一步!感谢阅读,如果觉得有帮助,不妨点个赞支持一下吧!👍