【数据结构】栈与队列:定义、核心操作与代码解析

【数据结构】栈与队列:定义、核心操作与代码解析

前言 

在数据结构的学习中,栈和队列是绕不开的基础线性结构。它们本质上都是对数组或链表的"规则化封装"——通过限制数据的存取方式,满足不同场景的需求。栈遵循"先进后出(LIFO)",像弹匣一样只能从顶端操作;队列遵循"先进先出(FIFO)",像排队办事一样有序流转。

📚 初阶数据结构

【 时间复杂度+空间复杂度 】

【 顺序表 】

【 单链表 】

【 链表OJ题(上篇)】

【 链表OJ题(下篇)】


目录

一、概念:两种“秩序”的本质区别

1. 栈

2. 队列

二、栈的实现

1. 数组实现(顺序栈)

【顺序栈优缺点】

2. 链表实现(链栈)

【链式栈优缺点】

三、队列的实现

1、动态循环队列

2、链式队列

四、总结 


一、概念:两种“秩序”的本质区别

栈和队列的核心差异在于数据操作的顺序规则,这一规则直接决定了它们的应用场景和实现逻辑。

1. 栈

栈(Stack)遵循后进先出(Last In First Out, LIFO)原则,就像我们日常叠放的盘子——最后放上去的盘子,必须最先拿下来。这种结构对数据操作施加了严格限制:所有插入和删除操作都只能在栈的顶端(栈顶)完成,不支持随机访问中间元素。

生活中的“撤销操作”、程序中的函数调用栈,都是栈结构的典型体现:最新的操作或调用始终处于“栈顶”,需要时优先被处理。

2. 队列

队列(Queue)遵循先进先出(First In First Out, FIFO)原则,类似银行柜台前排队的人群——最先排队的人,最先办理业务。与栈不同,队列的操作分为两端:只能在队列的尾部(队尾)插入元素,在队列的头部(队头)删除元素,同样不支持随机访问。

操作系统的进程调度、打印机的任务队列,都依赖队列的“顺序处理”特性,确保任务按提交顺序公平执行。

二、栈的实现

栈的实现有两种经典方案:数组实现(顺序栈)和链表实现(链栈)。两者各有优劣,需根据实际场景选择——数组实现效率高但需预分配空间,链表实现动态扩容但有指针开销。

1. 数组实现(顺序栈)

数组实现的核心是用一个数组(定长的静态栈的结构,实际中一般不实用,所以我们只实现支持动态增长的栈)存储元素,并通过一个“栈顶指针”(通常是整数索引)标记栈顶位置。初始时栈顶指针为-1或者0(表示栈空),如果是-1代表top始终指向栈顶元素,如果是0表示top始终指向栈顶元素的下一个位置,压栈时指针加1,弹栈时指针减1。

由于这两个数据结构的实现都很简单,我就不分开实现接口了,具体的我在代码里注释

#include<stdio.h> #include<stdlib.h> #include<assert.h> #include<stdbool.h> // 栈存储的数据类型 typedef int STDataType; // 栈(基于动态数组实现) typedef struct stack { STDataType* a; // 动态数组,存储栈元素 int top; // 栈顶指针:指向栈顶元素的下一个位置 int capacity; // 当前栈的最大容量 }stack; // 栈的初始化 void STInit(stack* ps) { // 避免对空指针进行解引用 assert(ps); // 为数组开辟初始容量(4个元素)的空间 ps->a =(STDataType* ) malloc(sizeof(STDataType)*4); // 内存开辟失败时,打印错误并返回 if (ps->a == NULL) { perror("malloc"); return; } ps->capacity = 4; // 初始化栈容量为4 ps->top = 0; // 初始时栈空,top指向栈顶下一个位置 //ps->top = -1;// 若需改为“指向栈顶元素”,可启用此注释 } // 栈的销毁 void STDestroy(stack* ps) { assert(ps); // 避免空指针操作 free(ps->a); // 释放动态数组的内存 ps->a = NULL; // 指针置空,防止野指针 ps->capacity = 0; // 容量重置为0 ps->top = 0; // top重置为初始状态 } // 入栈(压栈) void STPush(stack* ps, STDataType x) { // 避免空指针操作 assert(ps); // 检查栈是否已满,满则扩容为原容量的2倍 if (ps->capacity == ps->top) { STDataType* tmp =(STDataType*)realloc(ps->a, sizeof(STDataType)*2*ps->capacity ); // 扩容失败时,打印错误并返回 if (tmp == NULL) { perror("realloc"); return; } ps->a = tmp; // 更新数组指针 ps->capacity = ps->capacity * 2; // 更新容量 } ps->a[ps->top++] = x; // 元素存入当前top位置,随后top后移 } // 出栈(弹栈) void STPop(stack* ps) { assert(ps); // 避免空指针操作 assert(!STEmpty(ps)); // 栈空时禁止出栈 ps->top--; // top前移,实现出栈(逻辑删除) } // 获取栈中有效元素个数 int STSize(stack* ps) { assert(ps); // 避免空指针操作 return ps->top; // top的值等于元素个数(因top指向栈顶下一个位置) } // 判断栈是否为空 bool STEmpty(stack* ps) { assert(ps); // 避免空指针操作 return ps->top == 0;// top为0时栈空 } // 获取栈顶元素 STDataType STTop(stack* ps) { assert(ps); // 避免空指针操作 assert(!STEmpty(ps)); // 栈空时无栈顶元素 return ps->a[ps->top - 1]; // 栈顶元素在top-1位置 }

【顺序栈优缺点】

优点:内存连续,缓存友好,访问速度快;核心操作(入 / 出栈、查栈顶)均为 O (1);

缺点:扩容依赖连续内存空间,异地扩容需拷贝数据,存在瞬时时间开销(原地扩容无此消耗);扩容后若实际元素远少于容量(如容量 100 仅用 55 个),易造成闲置内存浪费。

2. 链表实现(链栈)

链表实现的核心是用链表节点存储元素,以链表的头部作为栈顶(便于插入和删除)。无需预分配空间,元素个数可动态增减,从根本上避免了栈溢出问题。

// 链栈的定义 #include <stdio.h> #include <stdlib.h> #include <stdbool.h> #include<assert.h> // 链表节点结构 typedef struct StackNode { int data; // 节点存储的数据 struct StackNode *next; // 指向下一个节点的指针(链表特性) } StackNode; // 链栈结构:通过栈顶指针和大小维护整个栈 typedef struct { StackNode *top; // 栈顶指针(链栈核心:所有操作围绕栈顶),NULL表示栈空 int size; // 栈的元素个数(快速获取大小,避免遍历) } LinkStack; // 1. 初始化栈:将栈置为空状态 void StackInit(LinkStack *stack) { stack->top = NULL; // 栈顶置空表示空栈 stack->size = 0; // 大小初始化为0 } // 2. 压栈操作(链表头插法):新节点成为新栈顶 bool StackPush(LinkStack *stack, int value) { assert(stack); // 防止传入空指针 // 创建新节点并分配内存 StackNode *newNode = (StackNode *)malloc(sizeof(StackNode)); if (newNode == NULL) // 内存分配失败处理 { printf("内存分配失败,压栈失败!\n"); return false; } // 新节点数据赋值 newNode->data = value; // 关键:新节点指向原栈顶(头插法核心) newNode->next = stack->top; // 栈顶指针更新为新节点 stack->top = newNode; stack->size++; // 元素个数+1 return true; } // 3. 弹栈操作(链表头删法):移除栈顶节点 bool StackPop(LinkStack *stack) { // 空栈判断(栈顶为NULL则无法弹栈) if (stack->top == NULL) { printf("栈为空,弹栈失败!\n"); return false; } // 保存原栈顶的下一个节点(防止链表断裂) StackNode* next = stack->top->next; // 释放原栈顶节点内存 free(stack->top); // 栈顶指针更新为原栈顶的下一个节点 stack->top = next; stack->size--; // 元素个数-1 return true; } // 4. 查看栈顶元素:仅读取不修改栈 bool StackPeek(LinkStack *stack, int *value) { // 空栈判断 if (stack->top == NULL) { printf("栈为空,无栈顶元素!\n"); return false; } // 通过指针输出栈顶数据 *value = stack->top->data; return true; } // 5. 判断栈是否为空:通过栈顶指针直接判断 bool StackIsEmpty(LinkStack *stack) { return stack->top == NULL; // 栈顶为NULL即空栈 } // 6. 获取栈的大小:直接返回维护的size(O(1)效率) int StackGetSize(LinkStack *stack) { return stack->size; } // 销毁栈:释放所有节点内存(避免内存泄漏) void StackDestroy(LinkStack *stack) { StackNode *temp; // 临时指针保存待释放节点 // 循环删除栈顶节点,直到栈空 while (stack->top != NULL) { temp = stack->top; // 保存当前栈顶节点 stack->top = temp->next; // 栈顶后移 free(temp); // 释放当前节点 } stack->size = 0; // 大小重置为0 }

【链式栈优缺点】

优点:压栈 / 出栈仅需修改节点指针指向(链表头插 / 头删),无需移动大量元素(数组栈扩容时可能需要拷贝数据)。无需扩容 / 溢出风险:内存动态分配(每个节点独立申请),只要系统有空闲内存就能持续压栈,不存在数组栈的扩容操作或容量限制

缺点:存在指针空间开销:每个节点需额外存储next指针(比如 int 型数据占 4 字节,指针占 8 字节时,额外开销达 2 倍),内存利用率低于数组栈。缓存利用率低:节点内存分散存储,CPU 缓存命中率远低于数组栈的连续内存布局,访问效率受影响。实际操作效率略低:压栈需动态分配节点(malloc)、出栈需释放节点(free),比数组栈的 “直接读写内存” 多了调用开销

三、队列的实现

队列的实现同样有数组和链表两种方式。数组实现时,普通队列会出现“假溢出”问题——队尾已满但队头有空闲空间,因此实际中更常用循环队列(传统循环队列是固定容量的);链表实现则通过头尾指针轻松完成两端操作。

假溢出:普通数组队列里,队尾已经到数组末尾没法再存,但队头因为出队空了不少位置 —— 看着像满了,其实还有空间能用,就是普通队列用不了这些空位置。

1、动态循环队列

循环队列的核心是利用“取模运算”让数组首尾相连,形成逻辑上的环形空间。通过两个指针(front指向队头,rear指向队尾的下一个位置)控制操作,同时可引入size变量记录元素个数,capacity变量记录容量大小,这种方式能让判空和判满逻辑更直观——判空条件为size == 0,判满条件为size == capacity,无需通过指针关系间接判断,也避免了预留空间的问题。

#include <stdio.h> #include <stdlib.h> #include <stdbool.h> // 动态循环队列结构体 typedef struct { int* data; // 存储队列元素的数组 int front; // 队头指针(指向队头元素) int rear; // 队尾指针(指向队尾元素的下一个位置) int capacity; // 当前队列容量 int size; // 当前队列元素个数 }CircularQueue; // 初始化队列 CircularQueue* queueInit(CircularQueue* queue) { // 为队列数据分配初始内存(4个int大小) queue->data = (int*)malloc(sizeof(int) * 4); if (queue->data == NULL) { perror("Failed to allocate memory for queue data"); exit(1); // 内存分配失败则终止程序 } queue->front = 0; // 队头指针:指向队列第一个元素的位置 queue->rear = 0; // 队尾指针:指向队列最后一个元素的下一个位置(便于入队操作) queue->capacity = 4; // 队列初始容量(最多可存储4个元素) queue->size = 0; // 当前队列中的元素个数(初始为空) return queue; } // 扩容 void expandQueue(CircularQueue* queue) { if (queue == NULL) return; // 2倍扩容 int* tmp = realloc(queue->data, sizeof(int) * queue->capacity * 2); if (tmp == NULL) { perror("realloc"); exit(1); // 扩容失败则终止程序 } queue->data = tmp; // 更新数据指针到新内存地址 // 关键逻辑:处理环形队列的"折回"情况 // 当首尾重合(队列满)时, // 扩容后队尾指针需偏移原容量大小,避免新数据覆盖旧数据 queue->rear += queue->capacity; // 队尾指针移到新内存区域的对应位置 queue->capacity *= 2; // 更新队列容量为翻倍后的值 } // 判断队列是否为空:通过元素个数size是否为0判断(比指针比较更直观) bool isEmpty(CircularQueue* queue) { return queue->size == 0; } // 判断队列是否已满:元素个数等于队列容量时即为满 bool isFull(CircularQueue* queue) { return queue->size == queue->capacity; } // 入队操作:将元素添加到队尾,队列满时自动扩容 // 参数queue为队列指针,value为要入队的元素值,成功返回true,失败返回false bool enqueue(CircularQueue* queue, int value) { if (queue == NULL) return false; // 空指针保护 // 队列满时触发扩容(容量翻倍) if (isFull(queue)) { expandQueue(queue); } queue->data[queue->rear] = value; // 将元素存入队尾指针指向的位置 // 队尾指针循环后移:取模运算实现环形结构,指针到数组末尾后回到开头 queue->rear = (queue->rear + 1) % queue->capacity; queue->size++; // 元素个数加1 return true; } // 出队操作:移除队头元素(仅修改指针,不清理数据) // 队列为空时直接终止程序(也可根据需求改为返回错误码) void dequeue(CircularQueue* queue) { if (queue == NULL || isEmpty(queue)) { printf("Dequeue error: queue is NULL or empty\n"); exit(1); // 空队列出队属于非法操作,终止程序 } // 队头指针循环后移:指向下一个元素,实现队头元素的"移除" queue->front = (queue->front + 1) % queue->capacity; queue->size--; // 元素个数减1 } // 获取队头元素值:返回当前队头指针指向的元素 // 队列为空时终止程序 int getFront(CircularQueue* queue) { if (queue == NULL || isEmpty(queue)) { printf("Queue is empty\n"); exit(1); } return queue->data[queue->front]; // 直接返回队头指针指向的元素 } // 获取队列当前元素个数:直接返回size成员 int getSize(CircularQueue* queue) { return queue->size; } // 销毁队列:释放数据内存并重置所有成员,防止内存泄漏和野指针 void queueDestroy(CircularQueue* queue) { if (queue != NULL) { free(queue->data); // 释放数据数组的内存 queue->data = NULL; // 将指针置空,避免野指针访问 queue->capacity = 0; // 重置容量为0 queue->front = -1; // 重置队头指针(非0值标记已销毁) queue->rear = 0; // 重置队尾指针 queue->size = 0; // 重置元素个数 } } // 打印队列所有元素:从队头开始遍历,按顺序输出所有元素 // 处理环形结构,确保遍历顺序正确 void printQueue(CircularQueue* queue) { if (isEmpty(queue)) { printf("Queue is empty\n"); return; } printf("Queue elements: "); // 遍历逻辑:从队头指针开始,依次取size个元素 // (queue->front + i) % queue->capacity:实现环形遍历, // 即使队头不在数组开头,也能按顺序访问所有元素 for (int i = 0; i < queue->size; i++) { printf("%d ", queue->data[(queue->front + i) % queue->capacity]); } printf("\n"); }

循环队列解决了普通数组队列的“假溢出”问题,空间利用率高且核心操作时间复杂度均为O(1),但固定容量实现存在溢出风险,动态扩容时需处理元素拷贝,且相比链队列多了数组容量管理的成本。

2、链式队列

链队列的核心是用链表存储元素,维护两个指针:front指向队头节点,rear指向队尾节点。入队时在rear后插入新节点,出队时删除front指向的节点,操作简洁且无容量限制。

#include<stdlib.h> #include<assert.h> #include<stdio.h> typedef int QDataType; // 链式结构:表示队列 typedef struct QListNode { struct QListNode* _pNext; QDataType _data; }QNode; // 队列的结构 typedef struct Queue { QNode * _front; QNode* _rear; int size; }Queue; // 初始化队列:将队头、队尾指针置空,有效元素个数置0 void QueueInit(Queue* q) { assert(q); // 防止传入空指针 q->_front = q->_rear = NULL; // 空队列时头尾指针均指向NULL q->size = 0; // 初始元素个数为0 } // 队尾入队列:创建新节点,处理空队列/非空队列两种情况 void QueuePush(Queue* q, QDataType data) { assert(q); // 1. 创建新节点并初始化 QNode* newNode = (QNode*)malloc(sizeof(QNode)); if (newNode == NULL) // 内存分配失败处理 { perror("malloc fail\n"); return; } newNode->_data = data; newNode->_pNext = NULL; // 新节点作为队尾,next置空 // 2. 处理空队列(首次入队) if (q->_front == NULL) { assert(q->_rear == NULL); // 空队列时头尾指针必须同时为空 q->_front = q->_rear = newNode; // 头尾指针都指向新节点 } // 3. 处理非空队列(正常入队) else { q->_rear->_pNext = newNode; // 原队尾节点指向新节点 q->_rear = newNode; // 更新队尾指针为新节点 } q->size++; // 有效元素个数+1 } // 队头出队列:处理单个节点/多个节点两种情况,避免野指针 void QueuePop(Queue* q) { assert(q); assert(!QueueEmpty(q)); // 防止空队列出队 // 1. 处理队列中只有一个节点的情况 if (q->_front->_pNext == NULL) { free(q->_front); // 释放唯一节点 q->_front = q->_rear = NULL; // 重置头尾指针为空 } // 2. 处理队列中有多个节点的情况 else { QNode* next = q->_front->_pNext; // 保存队头的下一个节点 free(q->_front); // 释放队头节点 q->_front = next; // 更新队头指针为下一个节点 } q->size--; // 有效元素个数-1 } // 获取队列头部元素:直接返回队头节点的数据 QDataType QueueFront(Queue* q) { assert(q); assert(!QueueEmpty(q)); // 空队列无头部元素 return q->_front->_data; } // 获取队列队尾元素:直接返回队尾节点的数据 QDataType QueueBack(Queue* q) { assert(q); assert(!QueueEmpty(q)); // 空队列无尾部元素 return q->_rear->_data; } // 获取队列中有效元素个数:直接返回size成员 int QueueSize(Queue* q) { assert(q); return q->size; } // 检测队列是否为空:通过size是否为0判断(也可通过_front是否为NULL) int QueueEmpty(Queue* q) { assert(q); return q->size == 0; } // 销毁队列:遍历释放所有节点,重置指针和size void QueueDestroy(Queue* q) { assert(q); QNode* cur = q->_front; // 从队头开始遍历 while (cur) // 遍历所有节点 { QNode* next = cur->_pNext; // 保存下一个节点(防止释放后找不到) free(cur); // 释放当前节点 cur = next; // 移动到下一个节点 } q->_front = q->_rear = NULL; // 重置头尾指针 q->size = 0; // 重置元素个数 }

链队列的优点是动态扩容,无需处理溢出问题;缺点是节点指针带来额外空间开销,适合元素个数不确定的场景。

四、总结 

栈和队列是数据结构的“入门基石”,它们的核心价值并非复杂的结构,而是通过限制操作顺序,为问题提供清晰、高效的解决思路

栈的LIFO特性,适合处理“需要回溯”或“逆序处理”的场景,所有操作聚焦栈顶,时间复杂度均为O(1)。队列的FIFO特性,适合处理“需要顺序执行”或“分层遍历”的场景,两端操作同样保证O(1)的高效性能。实现选择上,数组实现高效紧凑,链表实现动态灵活,需根据实际需求(容量确定性、空间开销)权衡。

理解栈和队列的本质,不仅能解决算法问题,更能在程序设计中建立“秩序化”的思维,让数据操作更可控、更高效。

Read more

Flutter 三方库 collection — 鸿蒙应用全方位集合操作与算法增强利器,实现鸿蒙深度适配下的高效容器过滤与优先级队列实战全解析(适配鸿蒙 HarmonyOS Next ohos)

Flutter 三方库 collection — 鸿蒙应用全方位集合操作与算法增强利器,实现鸿蒙深度适配下的高效容器过滤与优先级队列实战全解析(适配鸿蒙 HarmonyOS Next ohos)

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.ZEEKLOG.net。 Flutter 三方库 collection — 鸿蒙应用全方位集合操作与算法增强利器,实现鸿蒙深度适配下的高效容器过滤与优先级队列实战全解析 前言 在鸿蒙(OpenHarmony)应用开发中,数据结构的选择往往决定了逻辑的成败。当标准的 List、Set、Map 无法满足更高级的需求(例如:需要一个自动按优先级排序的任务队列,或者需要判断两个深度嵌套的 Map 是否完全一致)时,开发者就需要引入更强大的集合支持。 collection 是 Dart 官方维护的最核心基础库之一。它不仅补充了大量缺失的容器类型(如 PriorityQueue、Heap),还为原生集合提供了极其丰富的扩展工具类(如 ListEquality、CanonicalizedMap)。在 Flutter for OpenHarmony 的底层架构实践中,它是处理复杂业务逻辑、优化检索效率的必备“基石”。 一、原理解析 / 概念介绍

By Ne0inhk
枚举问题的两大利器:深度优先搜索(DFS)与下一个排列(Next Permutation)算法详解(Java版本)(漫画解析)

枚举问题的两大利器:深度优先搜索(DFS)与下一个排列(Next Permutation)算法详解(Java版本)(漫画解析)

枚举问题的两大利器:深度优先搜索(DFS)与下一个排列(Next Permutation)算法详解 一、引言:枚举问题的核心挑战 在算法竞赛与工程实践中,暴力枚举常是解决排列/组合问题的兜底方案。然而,当问题规模扩大(如 n > 10)时,直接生成所有排列会导致 O(n!) 时间复杂度,极易超时。此时,DFS回溯与Next Permutation成为两大高效解法: * DFS:通过递归+剪枝实现灵活枚举,适合需动态过滤的场景 Next Permutation:原地生成字典序排列,空间高效且常数极小 典型场景:LeetCode : 46(全排列)、47(带重复元素的全排列)、31(下一个排列)、60(第k个排列) 二、深度优先搜索(DFS):回溯法的灵活枚举

By Ne0inhk
C语言指针与数组的深度关联及实战应用

C语言指针与数组的深度关联及实战应用

C语言指针与数组的深度关联及实战应用 💡 学习目标:掌握指针与数组的内在联系,熟练运用指针操作数组元素,解决实际开发中的数组遍历、数据交换等问题;学习重点:数组名的本质、指针算术运算操作数组、指针数组与数组指针的区别及应用。 38.1 数组名与指针的关系 在C语言中,数组和指针有着密不可分的联系。很多初学者会混淆数组名和指针变量的概念,其实二者既有关联,又有本质区别。 38.1.1 数组名的本质 💡 数组名在大多数情况下会被编译器隐式转换为指向数组首元素的常量指针。 我们来看一段简单的代码: #include<stdio.h>intmain(){int arr[5]={10,20,30,40,50};printf("数组首元素地址:%p\n", arr);printf("数组首元素地址:%p\n&

By Ne0inhk