【C语言数据结构】手把手带你拿捏“栈“!从概念到实现的保姆级教程

【C语言数据结构】手把手带你拿捏"栈"!🚀 从概念到实现的保姆级教程

摘要:🤔 还在为数据结构中的"栈"感到头疼吗?别怕!本文将用最通俗易懂的语言,从“干饭”的生活实例讲起 🍚,带你一步步深入理解栈(Stack)的LIFO原则。我们将一起探讨为何动态数组是实现栈的“天选之子”👍,并手把手、逐行代码地用C语言构建一个功能完备的动态栈。从初始化、销毁到入栈、出栈,每一步都有详尽的解析和避坑指南 ⚠️。准备好了吗?让我们一起开启数据结构的探险之旅,彻底征服“栈”这座高地!

一、 "栈"是什么?从生活说起 🍽️

想象一下你正在食堂干饭,桌上有一摞干净的餐盘。

  1. 放盘子 📥:阿姨清洗完一个新盘子,总是会把它放在最上面
  2. 拿盘子 📤:你来打饭,自然也是从最上面拿走一个盘子。

这摞盘子就是一个典型的“栈”结构。后面放上去的盘子,总会最先被拿走。再比如我们吃筒装的薯片,是不是也是从最上面开始吃,最后吃到的才是最开始放进去的?

这就是栈的核心思想:后进先出 (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;
}

重点解析

  1. 检查容量if (ps->top == ps->capacity) 是扩容的触发条件。当栈内元素数量(top)等于已分配容量(capacity)时,说明空间已满。
  2. 计算新容量ps->capacity == 0 ? 4 : ps->capacity * 2 这是一个非常经典的扩容策略。
    • 当栈首次入栈时 (capacity为0),我们先分配4个元素的空间。
    • 之后每次需要扩容,都将容量翻倍 🚀。这种指数级增长可以有效减少扩容操作的频率。
  3. 安全地使用 realloc
    • 注意:这里必须使用 realloc 而不是 mallocrealloc 可以在原有内存块的基础上进行扩容,并保留原有的数据
    • ⚠️ 避坑指南realloc 如果失败会返回 NULL。我们绝不能写成 ps->a = realloc(...),因为一旦失败,ps->a 会被置为 NULL,导致原有数据全部丢失!正确的做法是使用一个临时指针 tmp 接收返回值,判断 tmp 是否为 NULL,确认成功后再赋给 ps->a
  4. 插入元素:确保空间充足后,将新元素 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 扩容是关键:掌握安全的动态扩容机制是实现动态栈的核心技术。

希望这篇保姆级的教程能帮你彻底搞定“栈”。数据结构的学习就是这样,理解概念,然后亲手实现它,你会发现其中的乐趣和奥妙。

下一步去哪儿? ➡️ 不妨用你亲手打造的栈去解决一些实际问题吧,比如:

  • 括号匹配问题
  • 逆波兰表达式(后缀表达式)求值
  • 用两个栈实现一个队列

那将是更有成就感的一步!感谢阅读,如果觉得有帮助,不妨点个赞支持一下吧!👍

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值