数据结构(三)栈和队列(上)栈:计算机世界的“叠叠乐”

个人主页:
wengqidaifeng

✨ 永远在路上,永远向前走

个人专栏:
数据结构
C语言
嵌入式小白启动!


文章目录


前言:数据结构的起点

在计算机科学这个庞大的知识体系中,数据结构犹如建筑的基石。当我们谈论数据结构时,栈(Stack)无疑是其中最经典、最优雅的概念之一。它简洁、直观,却蕴含着深刻的计算思想,贯穿于从底层硬件到高级软件开发的每一个层面。

栈的概念并非计算机科学家们的凭空创造,它源自我们日常生活中司空见惯的现象------餐厅里叠放的餐盘、书桌上堆叠的书籍、行李箱中层层叠放的衣服。这种"后进先出"的组织方式如此自然,以至于当我们将其抽象为计算模型时,几乎立刻能感受到它的力量和美感。

栈的历史溯源

从数学到计算机

栈的概念最早可以追溯到20世纪50年代。德国计算机科学家克劳斯·山姆尔森和弗里德里希·L·鲍尔在研究ALGOL 60语言的编译问题时,首次提出了"栈"这个术语。他们发现,在解析嵌套的算术表达式时,需要一个数据结构来保存中间结果和操作顺序,而这种需求正好对应着现实中的堆叠模型。

"栈"这个名称本身就很形象------它来自英文"stack",意为"堆叠",恰如其分地描述了数据的组织方式:就像叠盘子一样,你只能从最上面放入或取出。

硬件与栈的共生

有趣的是,栈的概念很快就从软件领域延伸到了硬件设计。1964年发布的Burroughs B5000计算机是第一台直接将栈支持集成到硬件架构中的商用计算机。这台机器的设计者认识到,许多编程问题------特别是子程序调用和中断处理------都能通过栈得到优雅的解决。

为什么栈如此重要?

计算本质的体现

栈的重要性首先体现在它反映了计算的本质特性。计算机程序执行时,函数调用、表达式求值、控制流程管理都天然地遵循着"后进先出"的原则。当你调用一个函数时,系统需要记住返回的位置;当这个函数又调用其他函数时,新的返回地址被压在旧的上面;当函数执行完毕,它从栈顶弹出,程序回到之前的执行点。

这种嵌套式的执行流程,正是栈的完美应用场景。正如计算机科学先驱高德纳所说:"栈是计算过程最基本的结构之一,它体现了计算的递归本质。"

抽象的力量

栈向我们展示了抽象的力量。通过定义简单的两个操作------入栈(push)和出栈(pop)------我们创造了一个可以解决无数复杂问题的工具。这种接口的简洁性与其能力的强大性形成了鲜明对比,这正是优秀设计的标志。

在本博客中,我们将深入探索栈的每一个方面:

  1. 栈的基本概念与ADT - 从抽象数据类型的角度理解栈
  2. 栈的实现艺术 - 数组实现与链表实现的对比与分析
  3. 栈的经典应用 - 从括号匹配到函数调用栈
  4. 递归与栈的深层联系 - 如何用栈理解递归的本质
  5. 栈在算法中的应用 - 深度优先搜索、回溯算法等
  6. 系统栈与调用约定 - 看看计算机底层如何使用栈

在随后的"下册"中,我们将探讨栈的姊妹结构------队列,分析它与栈的异同,以及它们如何共同构成了控制流和数据流管理的基石。

当我们开始这次栈的探索之旅时,不妨带着这样的思考:在纷繁复杂的计算问题中,哪些模式本质上就是"栈"的模式?我们生活中的哪些场景也遵循着"后进先出"的规律?

准备好了吗?让我们开始这场从餐厅餐盘到计算机内存的奇妙旅程,一起探索栈这个简单而又深奥的结构如何支撑起整个计算世界的基础。


一. 栈

1.1 栈的概念及结构

栈:一种特殊的线性表,其只允许在固定的一端进行插入和删除元素操作。进行数据插入和删除操作的一端称为栈顶,另一端称为栈底。 栈中的数据元素遵守后进先出LIFO(Last In First Out)的原则。

压栈:栈的插入操作叫做进栈/压栈/入栈,入数据在栈顶。

出栈:栈的删除操作叫做出栈。出数据也在栈顶。

二. 栈的实现

1.1 栈的不同实现形式

栈的实现一般可以使用数组或者链表实现,相对而言数组的结构实现更优一些。因为数组在尾上插入数据的代价比较小。

1.2 数组实现:简洁而高效的经典方案

1.2.1 核心数据结构设计

c 复制代码
#define MAX_SIZE 100  // 栈的最大容量

typedef struct {
    int data[MAX_SIZE];  // 存储栈元素的数组
    int top;             // 栈顶指针,指向当前栈顶位置
} ArrayStack;

1.2.2 初始化栈

c 复制代码
void initStack(ArrayStack *stack) {
    stack->top = -1;  // -1表示栈为空
    // 可选:初始化数组元素为0
    memset(stack->data, 0, sizeof(stack->data));
}

1.2.3 核心操作实现

入栈操作(Push)
c 复制代码
int push(ArrayStack *stack, int value) {
    if (stack->top == MAX_SIZE - 1) {
        printf("栈已满!\n");
        return 0;  // 失败
    }
    stack->data[++(stack->top)] = value;  // 栈顶指针上移并存入数据
    return 1;  // 成功
}
出栈操作(Pop)
c 复制代码
int pop(ArrayStack *stack, int *value) {
    if (stack->top == -1) {
        printf("栈为空!\n");
        return 0;  // 失败
    }
    *value = stack->data[(stack->top)--];  // 取出数据后栈顶指针下移
    return 1;  // 成功
}
查看栈顶(Peek)
c 复制代码
int peek(ArrayStack *stack, int *value) {
    if (stack->top == -1) {
        return 0;  // 失败
    }
    *value = stack->data[stack->top];
    return 1;  // 成功
}
判断栈状态
c 复制代码
int isEmpty(ArrayStack *stack) {
    return stack->top == -1;
}

int isFull(ArrayStack *stack) {
    return stack->top == MAX_SIZE - 1;
}

1.2.4 完整工作流程示例

复制代码
初始化: top = -1, data = [_, _, _, ...]

push(10): top = 0,  data[0] = 10
push(20): top = 1,  data[1] = 20  
push(30): top = 2,  data[2] = 30

当前栈: [10, 20, 30]  // 索引0为栈底,索引2为栈顶

peek(): 返回30 (不改变栈)
pop():  返回30, top = 1
pop():  返回20, top = 0
pop():  返回10, top = -1 (栈空)

1.3 数组实现栈的五大优势

1.内存访问高效

  • 连续内存布局,CPU缓存友好
  • 随机访问特性,定位栈顶元素时间复杂度O(1)
  • 无指针开销,内存利用率高

2.实现简单直观

  • 代码量少,逻辑清晰
  • 调试和维护容易
  • 适合教学和快速原型开发

3.性能卓越

复制代码
操作     时间复杂度  空间复杂度
push()    O(1)        O(1)
pop()     O(1)        O(1)
peek()    O(1)        O(1)
isEmpty() O(1)        O(1)

4.空间局部性好

  • 数据连续存储,减少缓存缺失
  • 预分配内存,无动态分配开销

5.确定性内存占用

  • 编译期确定最大空间
  • 无内存碎片问题
  • 适合内存受限的嵌入式系统

1.4 数组实现栈的四大局限

1.容量固定

c 复制代码
// 硬编码的限制
#define MAX_SIZE 100  // 无法动态调整

// 可能的问题场景
for(int i = 0; i < 1000; i++) {
    push(&stack, i);  // 第101次push将失败
}

2.空间浪费风险

复制代码
情况1:过度分配
MAX_SIZE = 1000
实际最多使用:50个元素
空间浪费:95%

情况2:分配不足  
MAX_SIZE = 50
实际需要:200个元素
程序崩溃或数据丢失

3.不支持动态扩展

c 复制代码
// 无法像动态数组那样自动扩容
// 需要手动实现重新分配逻辑
int* new_data = (int*)realloc(stack.data, 2*MAX_SIZE);
// 但这样会破坏栈的简洁性

4.插入删除限制

  • 只能在栈顶操作
  • 无法在中间插入或删除(这是栈的特性,但数组实现强化了这种限制)

选择数组实现的适用场景

推荐使用数组栈的情况

  1. 栈大小可预测:如编译器中的符号表(通常有固定上限)
  2. 性能要求极高:游戏引擎、实时系统
  3. 内存受限环境:嵌入式系统、单片机
  4. 简单应用场景:教学示例、算法竞赛
  5. 栈操作模式简单:主要是push/pop,很少需要遍历

不建议使用数组栈的情况

  1. 栈大小变化大:网络数据包处理
  2. 需要频繁调整容量:文本编辑器undo/redo
  3. 内存要求灵活:通用库开发
  4. 栈深度不可预测:递归算法(可能深度很大)

对比表

特性 数组栈 链表栈
插入时间复杂度 O(1) O(1)
删除时间复杂度 O(1) O(1)
空间开销 固定预分配 每个节点额外指针
缓存友好性 优秀 一般
内存利用率 可能浪费 按需分配
实现复杂度 简单 中等
动态扩展 困难 容易

栈的数组实现不仅是一种技术方案,更是一种设计哲学的体现:通过约束获得效率,通过简化实现可靠。这种思想,正是优秀软件设计的核心所在。

2.1 链表实现:灵活优雅的动态方案

1. 核心节点设计

c 复制代码
// 链表节点结构
typedef struct StackNode {
    int data;               // 存储数据
    struct StackNode *next; // 指向下一个节点
} StackNode;

// 栈管理结构
typedef struct {
    StackNode *top;         // 栈顶指针
    int size;               // 当前栈大小(可选)
} LinkedStack;

2. 初始化栈

c 复制代码
void initLinkedStack(LinkedStack *stack) {
    stack->top = NULL;      // 空栈,top指向NULL
    stack->size = 0;        // 初始大小为0
}

3. 核心操作实现

入栈操作(Push) - 头插法
c 复制代码
int push(LinkedStack *stack, int value) {
    // 创建新节点
    StackNode *newNode = (StackNode*)malloc(sizeof(StackNode));
    if (newNode == NULL) {
        printf("内存分配失败!\n");
        return 0;  // 失败
    }
    
    // 初始化新节点
    newNode->data = value;
    newNode->next = stack->top;  // 新节点指向原栈顶
    
    // 更新栈顶指针
    stack->top = newNode;
    stack->size++;
    
    return 1;  // 成功
}
出栈操作(Pop)
c 复制代码
int pop(LinkedStack *stack, int *value) {
    if (stack->top == NULL) {
        printf("栈为空!\n");
        return 0;  // 失败
    }
    
    // 保存要弹出的节点
    StackNode *temp = stack->top;
    *value = temp->data;       // 获取栈顶数据
    
    // 更新栈顶指针
    stack->top = temp->next;
    stack->size--;
    
    // 释放内存
    free(temp);
    
    return 1;  // 成功
}
查看栈顶(Peek)
c 复制代码
int peek(LinkedStack *stack, int *value) {
    if (stack->top == NULL) {
        return 0;  // 失败
    }
    *value = stack->top->data;
    return 1;  // 成功
}
判断栈状态
c 复制代码
int isEmpty(LinkedStack *stack) {
    return stack->top == NULL;  // 或者 return stack->size == 0;
}

// 链表栈理论上没有"满"的概念(除非内存耗尽)
int isFull(LinkedStack *stack) {
    // 尝试分配一个新节点来测试
    StackNode *test = (StackNode*)malloc(sizeof(StackNode));
    if (test == NULL) {
        free(test);  // 虽然分配失败,但安全释放
        return 1;    // 内存耗尽,栈"满"
    }
    free(test);
    return 0;  // 内存充足
}

4. 完整工作流程示例

复制代码
初始化: top = NULL, size = 0

push(10): 创建节点A(data=10)
          A->next = NULL
          top = A
          size = 1

push(20): 创建节点B(data=20)
          B->next = A
          top = B
          size = 2

push(30): 创建节点C(data=30)
          C->next = B
          top = C
          size = 3

当前栈结构: C → B → A → NULL
            ↑
           top

peek(): 返回30 (C节点数据)
pop():  返回30, 释放C, top指向B
pop():  返回20, 释放B, top指向A
pop():  返回10, 释放A, top = NULL (栈空)

2.2 链表实现栈的六大优势

1. 动态内存管理

c 复制代码
// 按需分配,无需预定义大小
while (有数据需要处理) {
    push(&stack, data);  // 自动分配节点
}

// 内存使用与实际需求完全匹配
// 栈大小只受系统内存限制

2. 无容量限制

c 复制代码
// 可以处理任意深度的递归或嵌套
// 例如:深度优先搜索、递归解析
void processDeepStructure() {
    // 栈深度可能达到数千甚至更多
    // 链表栈可以轻松应对
}

3. 高效的插入删除

复制代码
操作     时间复杂度  说明
push()    O(1)       头部插入,只需修改指针
pop()     O(1)       头部删除,直接释放

4. 空间利用率高

复制代码
数组栈:预分配100个位置,只用了10个 → 90%浪费
链表栈:用了10个节点 → 0%浪费(不考虑指针开销)

5. 灵活的扩展性

c 复制代码
// 容易添加额外功能
typedef struct EnhancedStackNode {
    int data;
    struct EnhancedStackNode *next;
    struct EnhancedStackNode *prev;  // 双向链表
    time_t timestamp;                // 额外信息
} EnhancedStackNode;

2.3 链表实现栈的五大局限

1. 内存开销较大

c 复制代码
// 每个节点需要额外存储指针
sizeof(StackNode) = sizeof(int) + sizeof(pointer)

// 在32位系统中:
// int: 4字节,指针: 4字节 → 8字节/节点
// 数组栈:每个元素只需4字节

// 内存开销增加约100%(不考虑数组预分配浪费)

2. 缓存不友好

复制代码
数组栈:连续内存,缓存命中率高
[10][20][30][40][50]  ← 一次缓存加载多个元素

链表栈:内存分散,缓存命中率低
[10]→[20]→[30]→[40]→[50]
  ↑     ↑     ↑     ↑     ↑
不同内存区域,需要多次缓存加载

3. 内存分配开销

c 复制代码
// 每次push都需要malloc
push()操作包含:
1. malloc分配内存:系统调用,相对较慢
2. 初始化节点
3. 链接节点

// 对比数组栈的push:
1. 检查边界
2. 赋值

4. 内存泄漏风险

c 复制代码
// 必须手动释放所有节点
void destroyStack(LinkedStack *stack) {
    int value;
    while (pop(stack, &value)) {
        // 逐一出栈释放
    }
    // 或者直接遍历释放
    StackNode *current = stack->top;
    while (current != NULL) {
        StackNode *temp = current;
        current = current->next;
        free(temp);
    }
    stack->top = NULL;
    stack->size = 0;
}

5. 代码复杂度增加

c 复制代码
// 需要处理指针操作
// 更多的边界条件检查
// 内存管理责任由程序员承担

2.4 选择链表实现的适用场景

推荐使用链表栈的情况

  1. 栈大小不可预测

    c 复制代码
    // 递归深度未知的算法
    int solvePuzzle(State state) {
        // 深度可能从1到10000不等
        push(&stack, currentState);
        // ...
    }
  2. 内存使用需要高度灵活

    c 复制代码
    // 内存受限但使用模式多变的系统
    // 避免为最坏情况预分配大量内存
  3. 需要频繁调整容量

    c 复制代码
    // 文本编辑器undo/redo功能
    // 用户可能进行数千次操作
    // 但大多数时间只使用最近几十次
  4. 实现通用容器库

    c 复制代码
    // 库函数需要适应各种使用场景
    // 无法假设用户的栈深度
  5. 栈元素大小可变

    c 复制代码
    // 存储不同大小的数据
    typedef struct {
        void *data;        // 任意类型数据
        size_t dataSize;   // 数据大小
        struct Node *next;
    } GenericStackNode;

不建议使用链表栈的情况

  1. 对性能要求极高

    • 实时系统、游戏引擎、高频交易
  2. 栈深度固定且已知

    • 编译器符号表(通常有上限)
    • 固定深度的状态机
  3. 内存极度受限

    • 嵌入式系统、单片机
    • 指针开销不可接受
  4. 需要频繁随机访问

    • 虽然栈本身不支持,但数组实现更容易扩展

链表实现栈体现了动态与灵活的编程思想。它放弃了数组栈的内存连续性和缓存友好性,换来了无限制的扩展能力和精确的内存控制。

链表栈不仅是技术实现,更是一种设计态度的体现:宁愿付出一些性能代价,也要保证系统的灵活性和适应性。这种思想在构建可扩展、可维护的系统时尤为重要。


在掌握了数组和链表两种实现方式后,我们可以根据具体场景做出明智的选择。有时候,最佳方案可能是两者的结合------比如动态数组栈,或者带有节点池的链表栈。这正是软件工程的精髓:没有银弹,只有权衡。


总结

栈作为一种基础数据结构,遵循后进先出(LIFO)原则,仅允许在栈顶进行插入(入栈/push)和删除(出栈/pop)操作。其核心价值在于简洁、高效地处理具有嵌套或后到先服务特性的问题。

实现方式主要有两种:

  1. 数组实现:基于连续内存,通过栈顶指针管理。优点是访问高效、实现简单、缓存友好且内存占用确定;缺点是容量固定,可能造成空间浪费或溢出,缺乏动态扩展能力。
  2. 链表实现:基于动态节点链接,通过头插法维护。优点是容量动态、按需分配内存、无预设限制;缺点是内存开销较大(含指针)、缓存局部性差、内存分配与释放带来额外开销及复杂度。

选择建议

  • 数组栈适用于栈大小可预测、对性能要求高或内存受限的场景(如嵌入式系统、算法竞赛)。
  • 链表栈适用于栈深度变化大、需要灵活内存管理或实现通用库的场景。

栈的设计体现了计算机科学中"通过约束获得效率"与"通过抽象管理复杂度"的核心思想,是理解递归、函数调用等计算模型的基础。实际应用中应结合具体需求在性能、内存与灵活性之间权衡选择。
最后,感谢各位大佬的观看!

相关推荐
数据知道7 小时前
PostgreSQL性能优化:内存配置优化(shared_buffers与work_mem的黄金比例)
数据库·postgresql·性能优化
静听山水7 小时前
Redis核心数据结构
数据结构·数据库·redis
流㶡8 小时前
MySQL 常用操作指南(Shell 环境)
数据库
im_AMBER8 小时前
Leetcode 115 分割链表 | 随机链表的复制
数据结构·学习·算法·leetcode
数智工坊8 小时前
【数据结构-树与二叉树】4.7 哈夫曼树
数据结构
!!!!8138 小时前
蓝桥备赛Day1
数据结构·算法
七点半7708 小时前
linux应用编程部分
数据结构
VekiSon8 小时前
Linux内核驱动——设备树原理与应用
linux·c语言·arm开发·嵌入式硬件
静听山水8 小时前
Redis核心数据结构-Hash
数据结构·redis·哈希算法