栈(Stack)与队列(Queue)核心知识总结

在数据结构中,栈和队列是两种最基础、最常用的线性存储结构,二者均为"操作受限的线性表"------区别于普通线性表可在任意位置增删元素,栈和队列的操作被严格限制在固定端点,这也造就了它们独特的应用场景。本文全面拆解栈与队列的核心知识点,重点攻克循环队列的设计逻辑、实现细节及常见问题,帮大家彻底吃透这两个高频考点。


一、栈(Stack):先进后出的"线性容器"

1. 核心定义与特性

栈是一种遵循 先进后出(LIFO, Last In First Out) 规则的线性表。
形象地说,就像叠盘子------只能在最上面放盘子(入栈),也只能从最上面拿盘子(出栈),最先放的盘子会被压在最底层,最后才能取出。
核心特性补充:

  • 操作端点唯一:所有插入(入栈)、删除(出栈)、读取(取栈顶)操作,均只能在"栈顶"进行,栈底元素无法直接操作。
  • 无随机访问:不能像数组那样通过下标直接访问任意位置的元素,只能从栈顶依次向下遍历。
  • 空间分配:分为顺序栈(数组实现)和链式栈(链表实现),日常开发中顺序栈更常用(操作高效),链式栈适用于元素个数不确定、避免溢出的场景。

2. 结构体设计

(1)顺序栈(数组实现,最常用)

用数组存储栈元素,搭配一个"栈顶指针"(记录栈顶元素的下标),结构简单、操作高效(时间复杂度O(1)),但存在固定容量限制(易溢出)。

cpp 复制代码
#define MAXSIZE 100  // 栈的最大容量,可根据需求修改
typedef struct SqStack{
    int data[MAXSIZE];  // 存储栈元素的数组
    int top;            // 栈顶指针:指向栈顶元素的下标,初始值为-1(表示空栈)
} SqStack;  // 顺序栈结构体

(2)链式栈(链表实现,无溢出)

用单链表存储栈元素,栈顶对应链表的头节点,入栈即头插法,出栈即删除头节点,无需固定容量,适合元素个数动态变化的场景。

cpp 复制代码
// 链式栈节点结构体
typedef struct StackNode {
    int data;                  // 节点数据
    struct StackNode *next;    // 指向后一个节点的指针
} StackNode, *LinkStack;

// 链式栈(只需记录栈顶指针即可)
typedef struct {
    LinkStack top;  // 栈顶指针(指向头节点)
    int length;     // 栈的长度(可选,方便快速获取元素个数)
} LinkStackHead;

3. 核心操作代码

(1)顺序栈核心操作
cpp 复制代码
// 1. 初始化栈:将栈顶指针置为-1,代表空栈
void InitStack(SqStack *s) {
    s->top = -1;  

// 2. 判断栈是否为空:top == -1 即为空
int IsEmpty(SqStack *s) {
    return s->top == -1 ? 1 : 0;  // 1表示空,0表示非空
}

// 3. 判断栈是否为满:top == MAXSIZE - 1 即为满(数组下标从0开始)
int IsFull(SqStack *s) {
    return s->top == MAXSIZE - 1 ? 1 : 0;  // 1表示满,0表示未满
}

// 4. 入栈(push):将元素插入栈顶,先移动指针再存元素
int Push(SqStack *s, int val) {
    if (IsFull(s)) {
        printf("栈满,无法入栈!\n");
        return 0;  // 0表示入栈失败
    }
    s->top++;          // 栈顶指针上移(先移动,再存值)
    s->data[s->top] = val;  // 将元素存入栈顶位置
    return 1;  // 1表示入栈成功
}

// 5. 出栈(pop):将栈顶元素删除,先取元素再移动指针
int Pop(SqStack *s, int *val) {
    if (IsEmpty(s)) {
        printf("栈空,无法出栈!\n");
        return 0;  // 0表示出栈失败
    }
    *val = s->data[s->top];  // 先取出栈顶元素,存入val指针指向的地址
    s->top--;                // 栈顶指针下移(删除栈顶元素)
    return 1;  // 1表示出栈成功
}

// 6. 获取栈顶元素(peek):只读取,不删除
int GetTop(SqStack *s, int *val) {
    if (IsEmpty(s)) {
        printf("栈空,无栈顶元素!\n");
        return 0;
    }
    *val = s->data[s->top];  // 直接读取栈顶元素,不移动指针
    return 1;
}

// 7. 清空栈:将栈顶指针置为-1,无需清空数组(后续入栈会覆盖)
void ClearStack(SqStack *s) {
    s->top = -1;
}

// 8. 获取栈中元素个数
int StackLength(SqStack *s) {
    return s->top + 1;  // top从-1开始,元素个数 = top + 1
}
(2)链式栈核心操作
cpp 复制代码
// 1. 初始化链式栈:栈顶指针置空,长度置0
void InitLinkStack(LinkStackHead *ls) {
    ls->top = NULL;
    ls->length = 0;
}

// 2. 判断链式栈是否为空
int IsLinkStackEmpty(LinkStackHead *ls) {
    return ls->top == NULL ? 1 : 0;
}

// 3. 入栈(头插法):新节点作为新的栈顶
int PushLinkStack(LinkStackHead *ls, int val) {
    // 1. 创建新节点
    LinkStack newNode = (LinkStack)malloc(sizeof(StackNode));
    if (newNode == NULL) {
        printf("内存分配失败,入栈失败!\n");
        return 0;
    }
    // 2. 新节点数据赋值,next指向原栈顶
    newNode->data = val;
    newNode->next = ls->top;
    // 3. 栈顶指针指向新节点,长度+1
    ls->top = newNode;
    ls->length++;
    return 1;
}

// 4. 出栈(删除头节点)
int PopLinkStack(LinkStackHead *ls, int *val) {
    if (IsLinkStackEmpty(ls)) {
        printf("栈空,无法出栈!\n");
        return 0;
    }
    // 1. 保存原栈顶节点
    LinkStack temp = ls->top;
    // 2. 取出栈顶元素
    *val = temp->data;
    // 3. 栈顶指针指向原栈顶的下一个节点
    ls->top = temp->next;
    // 4. 释放原栈顶节点内存,长度-1
    free(temp);
    ls->length--;
    return 1;
}

// 5. 获取栈顶元素(不删除)
int GetLinkStackTop(LinkStackHead *ls, int *val) {
    if (IsLinkStackEmpty(ls)) {
        printf("栈空,无栈顶元素!\n");
        return 0;
    }
    *val = ls->top->data;
    return 1;
}

4. 栈的常见应用场景

  • 函数调用栈:程序执行时,函数的调用顺序遵循栈的规则(先调用的后返回)。
  • 括号匹配:判断表达式中的括号(()、[]、{})是否成对出现(如LeetCode 20题)。
  • 表达式求值:后缀表达式(逆波兰表达式)的计算,依赖栈实现。
  • 浏览器后退/编辑器撤销:记录用户操作,后退/撤销即出栈,前进即入栈。
  • 单调栈:解决"下一个更大元素""接雨水"等算法题(高频考点)。

二、队列(Queue):先进先出的"线性容器"

队列是一种遵循 先进先出(FIFO, First In First Out) 规则的线性表
类似日常生活中的排队------先排队的人先办事,后排队的人后办事,只能在队尾插入(入队),在队头删除(出队)。
队列的实现分为两种:顺序队列 (数组实现)和链式队列 (链表实现)。其中,顺序队列存在"假溢出 "问题,因此衍生出"循环队列",这也是面试中的核心考点------我们从普通队列入手,一步步拆解为什么需要设计循环队列,以及循环队列的完整实现。


第一部分:普通顺序队列(基础,存在致命缺陷)

1. 核心设计思路

用数组存储队列元素,搭配两个指针:

  • front(队头指针):指向队头第一个元素的下标。
  • rear(队尾指针):指向队尾最后一个元素的下一个位置(方便入队操作)。
    约定:队空时,front = rear;入队时,rear++;出队时,front++。

2. 结构体设计
cpp 复制代码
#define MAXSIZE 100  // 队列最大容量
typedef struct {
    int data[MAXSIZE];  // 存储队列元素的数组
    int front;          // 队头指针:指向队头第一个元素
    int rear;           // 队尾指针:指向队尾最后一个元素的下一个位置
} SqQueue;  // 普通顺序队列结构体
3. 核心操作代码
cpp 复制代码
// 1. 初始化队列:front和rear都置为0,代表空队列
void InitQueue(SqQueue *q) {
    q->front = 0;
    q->rear = 0;
}

// 2. 判断队列是否为空:front == rear 即为空
int IsQueueEmpty(SqQueue *q) {
    return q->front == q->rear ? 1 : 0;
}

// 3. 判断队列是否为满:rear == MAXSIZE 即为满
int IsQueueFull(SqQueue *q) {
    return q->rear == MAXSIZE ? 1 : 0;
}

// 4. 入队(enqueue):在队尾插入元素
int EnQueue(SqQueue *q, int val) {
    if (IsQueueFull(q)) {
        printf("队列满,无法入队!\n");
        return 0;
    }
    q->data[q->rear] = val;  // 存入元素(rear指向队尾下一个位置,直接赋值)
    q->rear++;               // 队尾指针后移
    return 1;
}

// 5. 出队(dequeue):在队头删除元素
int DeQueue(SqQueue *q, int *val) {
    if (IsQueueEmpty(q)) {
        printf("队空,无法出队!\n");
        return 0;
    }
    *val = q->data[q->front];  // 取出队头元素
    q->front++;                // 队头指针后移
    return 1;
}

// 6. 获取队头元素(不删除)
int GetQueueFront(SqQueue *q, int *val) {
    if (IsQueueEmpty(q)) {
        printf("队空,无队头元素!\n");
        return 0;
    }
    *val = q->data[q->front];
    return 1;
}

// 7. 获取队列元素个数
int QueueLength(SqQueue *q) {
    return q->rear - q->front;
}
4. 普通顺序队列的致命缺陷:假溢出(核心重点)

这是设计循环队列的根本原因,必须彻底理解:
假设队列容量为5(MAXSIZE=5),执行以下操作:

  1. 入队3个元素:rear从0→3,front=0(队列:[1,2,3,,])。
  2. 出队2个元素:front从0→2,rear=3(队列:[,,3,,])。
  3. 再入队3个元素:rear从3→4→5(此时rear=5 == MAXSIZE,判断为队列满)。
    此时,队列实际只有4个元素(3,4,5,6),数组前2个位置(下标0、1)是空的,但因为rear已经到达数组末尾,无法再入队------这种"空间未用完,但无法入队"的情况,就是假溢出

5. 为什么会出现假溢出?

因为普通顺序队列的指针只能向后移动,不能循环,导致数组前面的空闲空间无法被复用。
解决方案:将数组逻辑上变成"环形",让rear和front指针走到数组末尾后,能自动回到数组开头------这就是循环队列。


第二部分:循环队列(顺序队列的优化,面试重点)

1. 为什么必须设计循环队列?

除了解决普通队列的"假溢出"问题,循环队列还有两个核心优势:

  • 提升空间利用率:复用数组前面的空闲空间,避免空间浪费(普通队列假溢出时,空间利用率极低)。
  • 保证操作高效入队、出队操作的时间复杂度均为O(1),无需像"移动元素"的队列(如用数组实现时,出队后移动所有元素)那样消耗O(n)时间。

补充:有人会问"用链表实现队列不就没有假溢出了吗?"
确实,链式队列(链表实现)无需考虑假溢出,但顺序队列(数组实现)的访问速度比链式队列快(数组随机访问,链表需要遍历),因此循环队列是"顺序队列的最优解决方案",也是面试中考查的重点(链式队列相对简单,考查频率低)。


2. 循环队列的核心设计思路(关键难点)

核心是"环形逻辑",通过"取余运算 %"实现指针的循环移动,同时解决"队空"和"队满"的判断冲突。

(1)指针移动规则

无论front还是rear,移动时都采用公式:指针 = (指针 + 1) % MAXSIZE
举例:MAXSIZE=5,rear=4(数组最后一个下标),执行rear=(4+1)%5=0,rear回到数组开头,实现环形跳转。


(2)队空与队满的判断(核心难点,补充两种常用方式)

普通队列中,队空和队满的判断都是"front == rear",循环队列中必须区分,否则无法判断是"空"还是"满",常用两种方式:

  • 方式1(最常用,牺牲一个空间):
    • 队满:(rear + 1) % MAXSIZE == front
    • 队空:front == rear
  • 方式2(不牺牲空间):增加一个"count"变量,记录队列元素个数
    • 队空:count = 0
  • 队满:count = MAXSIZE
    重点讲解方式1(面试高频):牺牲一个空间,是为了避免队空和队满的判断冲突,代价是队列实际可用容量为MAXSIZE-1(例如MAXSIZE=5,实际最多存4个元素),但操作简单、高效,是工业界和面试中的首选。

3. 循环队列结构体设计(与普通顺序队列一致)
cpp 复制代码
#define MAXSIZE 100  // 队列最大容量
typedef struct CirQueue{
    int data[MAXSIZE];  // 存储队列元素的数组
    int front;          // 队头指针:指向队头第一个元素
    int rear;           // 队尾指针:指向队尾最后一个元素的下一个位置
} CirQueue;  // 循环队列结构体(与普通顺序队列结构体完全一致)

说明:循环队列的核心是"指针操作逻辑",而非结构体本身,因此无需重新定义结构体,复用普通顺序队列的结构体即可,降低代码冗余。

4. 循环队列核心操作代码
cpp 复制代码
// 1. 初始化循环队列:front和rear都置为0,代表空队列
void InitCirQueue(CirQueue *q) {
    q->front = 0;
    q->rear = 0;
}

// 2. 判断循环队列是否为空:front == rear(与普通队列一致)
int IsCirQueueEmpty(CirQueue *q) {
    return q->front == q->rear ? 1 : 0;
}

// 3. 判断循环队列是否为满(核心!牺牲一个空间)
int IsCirQueueFull(CirQueue *q) {
    // 关键公式:(rear + 1) % MAXSIZE == front,代表队满
    return (q->rear + 1) % MAXSIZE == q->front ? 1 : 0;
}

// 4. 入队(enqueue):循环入队,指针循环移动
int EnCirQueue(CirQueue *q, int val) {
    if (IsCirQueueFull(q)) {
        printf("循环队列满,无法入队!\n");
        return 0;
    }
    q->data[q->rear] = val;  // 存入元素(rear指向队尾下一个位置)
    q->rear = (q->rear + 1) % MAXSIZE;  // 队尾指针循环后移
    return 1;
}

// 5. 出队(dequeue):循环出队,指针循环移动
int DeCirQueue(CirQueue *q, int *val) {
    if (IsCirQueueEmpty(q)) {
        printf("循环队空,无法出队!\n");
        return 0;
    }
    *val = q->data[q->front];  // 取出队头元素
    q->front = (q->front + 1) % MAXSIZE;  // 队头指针循环后移
    return 1;
}

// 6. 获取队头元素(不删除)
int GetCirQueueFront(CirQueue *q, int *val) {
    if (IsCirQueueEmpty(q)) {
        printf("循环队空,无队头元素!\n");
        return 0;
    }
    *val = q->data[q->front];
    return 1;
}

// 7. 获取循环队列元素个数(补充,面试常考)
int CirQueueLength(CirQueue *q) {
    // 核心公式:(rear - front + MAXSIZE) % MAXSIZE
    // 避免rear < front时出现负数(如front=3,rear=1,MAXSIZE=5:(1-3+5)%5=3,正确)
    return (q->rear - q->front + MAXSIZE) % MAXSIZE;
}

// 8. 清空循环队列(补充)
void ClearCirQueue(CirQueue *q) {
    q->front = 0;
    q->rear = 0;  // 只需将两个指针置为0,无需清空数组(后续入队会覆盖)
}

// 9. 遍历循环队列(补充,实际应用常用)
void TraverseCirQueue(CirQueue *q) {
    if (IsCirQueueEmpty(q)) {
        printf("循环队空,无元素可遍历!\n");
        return;
    }
    int i = q->front;  // 从队头开始遍历
    while (i != q->rear) {  // 遍历到队尾(不包含rear指向的位置)
        printf("%d ", q->data[i]);
        i = (i + 1) % MAXSIZE;  // 循环移动
    }
    printf("\n");
}
5. 循环队列常见问题(补充,面试高频)
问题1:为什么循环队列要牺牲一个空间?

答:为了区分"队空"和"队满"。如果不牺牲空间,当队列满时,rear == front,与队空的条件一致,无法判断队列是"空"还是"满";牺牲一个空间后,队满条件变为 " (rear+1)%MAXSIZE == front ",与队空条件" front==rear " 完全区分,操作简单高效。


问题2:循环队列的元素个数计算公式为什么要加MAXSIZE再对MAXSIZE取余?

答:避免rear < front时出现负数。例如,MAXSIZE=5,front=3,rear=1(此时队列中有3个元素:data[3]、data[4]、data[0]),直接计算rear-front= -2,加MAXSIZE后为3,再取余MAXSIZE,得到正确的元素个数3。


问题3:循环队列和普通队列的核心区别是什么?

答:指针移动逻辑不同。普通队列的指针只能向后移动,无法循环,会产生假溢出;循环队列的指针通过取余运算实现循环移动,解决了假溢出问题,提升了空间利用率。


第三部分:链式队列

链式队列用单链表实现,队头对应链表头节点,队尾对应链表尾节点,无需考虑溢出问题,适合元素个数动态变化的场景,操作逻辑简单。

1. 结构体设计
cpp 复制代码
// 链式队列节点结构体
typedef struct QueueNode {
    int data;                  // 节点数据
    struct QueueNode *next;    // 指向后一个节点的指针
} QueueNode, *LinkQueue;

// 链式队列(记录队头和队尾指针,方便入队和出队)
typedef struct {
    LinkQueue front;  // 队头指针(指向头节点)
    LinkQueue rear;   // 队尾指针(指向尾节点)
    int length;       // 队列长度(可选)
} LinkQueueHead;
2. 核心操作代码(简要实现,避免冗余)
cpp 复制代码
// 1. 初始化链式队列:队头、队尾都置空,长度置0
void InitLinkQueue(LinkQueueHead *lq) {
    lq->front = NULL;
    lq->rear = NULL;
    lq->length = 0;
}

// 2. 判断链式队列是否为空
int IsLinkQueueEmpty(LinkQueueHead *lq) {
    return lq->front == NULL ? 1 : 0;
}

// 3. 入队(尾插法):新节点作为新的队尾
int EnLinkQueue(LinkQueueHead *lq, int val) {
    LinkQueue newNode = (LinkQueue)malloc(sizeof(QueueNode));
    if (newNode == NULL) {
        printf("内存分配失败,入队失败!\n");
        return 0;
    }
    newNode->data = val;
    newNode->next = NULL;  // 尾节点的next置空
    
    if (IsLinkQueueEmpty(lq)) {
        // 空队列时,队头和队尾都指向新节点
        lq->front = newNode;
        lq->rear = newNode;
    } else {
        // 非空队列时,尾节点的next指向新节点,队尾指针后移
        lq->rear->next = newNode;
        lq->rear = newNode;
    }
    lq->length++;
    return 1;
}

// 4. 出队(头删法):删除队头节点
int DeLinkQueue(LinkQueueHead *lq, int *val) {
    if (IsLinkQueueEmpty(lq)) {
        printf("队空,无法出队!\n");
        return 0;
    }
    LinkQueue temp = lq->front;  // 保存队头节点
    *val = temp->data;           // 取出队头元素
    
    if (lq->front == lq->rear) {
        // 只有一个元素时,出队后队列空,队头、队尾都置空
        lq->front = NULL;
        lq->rear = NULL;
    } else {
        // 多个元素时,队头指针指向原队头的下一个节点
        lq->front = lq->front->next;
    }
    
    free(temp);  // 释放原队头节点内存
    lq->length--;
    return 1;
}

三、栈与队列核心区别总结

|------|-----------------|--------------------------|
| 对比维度 | 栈(Stack) | 队列(Queue) |
| 核心规则 | 先进后出(LIFO) | 先进先出(FIFO) |
| 操作端点 | 仅栈顶(入栈、出栈、取栈顶) | 队尾(入队)、队头(出队、取队头) |
| 实现方式 | 顺序栈(数组)、链式栈(链表) | 普通顺序队列、循环队列(数组)、链式队列(链表) |
| 关键问题 | 顺序栈存在溢出问题 | 普通顺序队列存在假溢出,循环队列解决此问题 |
| 典型应用 | 括号匹配、函数调用、表达式求值 | 排队系统、消息队列、BFS遍历 |


四、总结(核心提炼)

  1. 栈和队列都是操作受限的线性表,核心区别在于"进出规则 ":栈先进后出,队列先进先出,操作端点均固定
  2. 栈的两种实现:顺序栈(高效但有容量限制)、链式栈(无溢出但访问稍慢),核心操作围绕栈顶指针展开。
  3. 队列的核心重点是循环队列:普通顺序队列的假溢出是设计循环队列的根本原因,通过"取余运算"实现指针循环,牺牲一个空间区分队空和队满,是顺序队列的最优实现。
  4. 面试高频考点:循环队列的队空/队满判断、元素个数计算、指针移动逻辑;栈与队列的应用场景及区别。

掌握以上知识点,就能轻松应对栈与队列的基础笔试和面试题,后续可以结合具体算法题(如栈的单调应用、队列的BFS应用)进一步巩固。

相关推荐
咖啡八杯2 小时前
GoF设计模式——抽象工厂模式
java·后端·spring·设计模式·抽象工厂模式
LSTM972 小时前
使用 C# 添加或读取 Excel 公式:完整指南
后端
码以致用2 小时前
FastAPI 从入门到实践:构建规范的 RESTful API 服务
后端·restful·fastapi
RainCity2 小时前
Java Swing 自定义组件库分享(四)
java·笔记·后端
技术崽崽2 小时前
Java多线程神器——ThreadForge ,让多线程从此简单
后端
谙弆悕博士2 小时前
【附C语言源码】从零实现命令行贪吃蛇游戏
c语言·开发语言·学习·游戏·游戏程序·小游戏·贪吃蛇
Leinwin2 小时前
OpenAI Daybreak实战指南:如何将AI安全检查嵌入你的开发流程
后端·python·flask
Little At Air2 小时前
LinuxOS阻塞队列模型(单生产者单消费者)
linux·数据结构·c++
Ting-yu3 小时前
SpringCloud快速入门(1)---- 微服务介绍
后端·spring·spring cloud