栈与队列:顺序存储、链式存储与循环队列

栈和队列,这两个数据结构,说白了就是操作受限的线性表。我刚开始学的时候觉得它们太简单了,不就是数组加几个限制条件嘛。后来在项目中写解析器、做任务调度,才发现这两个东西用好了,代码能写得又稳又漂亮。今天咱们就把它们的顺序存储、链式存储,还有循环队列这个经典设计,一次性讲透。

栈的顺序存储

栈的特点是后进先出(LIFO)。顺序存储就是用数组来实现。我习惯用一个整型变量 top 来标记栈顶位置,初始时 top = -1,表示空栈。

#define MAXSIZE 100
typedef struct {
    int data[MAXSIZE];
    int top;
} SeqStack;

// 初始化
void InitStack(SeqStack *s) {
    s->top = -1;
}

// 入栈
int Push(SeqStack *s, int x) {
    if (s->top == MAXSIZE - 1) {
        return 0;  // 栈满
    }
    s->data[++s->top] = x;
    return 1;
}

// 出栈
int Pop(SeqStack *s, int *x) {
    if (s->top == -1) {
        return 0;  // 栈空
    }
    *x = s->data[s->top--];
    return 1;
}

小提示:入栈时先移动 top 再赋值,出栈时先取值再移动 top。这个顺序搞反了,数据就乱了。我刚开始写的时候犯过这个错,调试了半天才发现。

栈的链式存储

链式存储的栈,说白了就是用链表来实现。入栈和出栈都在链表头部操作,这样时间复杂度是 O(1)。我个人习惯用带头结点的单链表,操作起来更统一。

typedef struct StackNode {
    int data;
    struct StackNode *next;
} StackNode;

typedef struct {
    StackNode *top;
} LinkStack;

// 入栈
void Push(LinkStack *s, int x) {
    StackNode *node = (StackNode *)malloc(sizeof(StackNode));
    node->data = x;
    node->next = s->top;
    s->top = node;
}

// 出栈
int Pop(LinkStack *s, int *x) {
    if (s->top == NULL) {
        return 0;
    }
    StackNode *p = s->top;
    *x = p->data;
    s->top = p->next;
    free(p);
    return 1;
}

注意:链式栈出栈后一定要 free 掉节点。我曾经在一个嵌入式项目里忘了释放内存,跑了三天后系统直接崩了。内存泄漏在栈这种频繁操作的结构里特别容易发生。

队列的顺序存储

队列是先进先出(FIFO)。顺序存储用数组加两个指针:front 指向队头,rear 指向队尾的下一个位置。初始时 front = rear = 0

#define MAXSIZE 100
typedef struct {
    int data[MAXSIZE];
    int front, rear;
} SeqQueue;

// 入队
int EnQueue(SeqQueue *q, int x) {
    if (q->rear == MAXSIZE) {
        return 0;  // 队满
    }
    q->data[q->rear++] = x;
    return 1;
}

// 出队
int DeQueue(SeqQueue *q, int *x) {
    if (q->front == q->rear) {
        return 0;  // 队空
    }
    *x = q->data[q->front++];
    return 1;
}

你想想看,这种实现有个大问题:出队后 front 往前移动,数组前面的空间就浪费了。明明数组还有空间,rear 却已经到末尾了,这就是“假溢出”。

循环队列

为了解决假溢出,我们把数组首尾相连,形成一个环。这就是循环队列。判断队满的条件变成了 (rear + 1) % MAXSIZE == front。为什么要留一个空位?因为如果不留,队空和队满的条件都是 front == rear,就分不清了。

typedef struct {
    int data[MAXSIZE];
    int front, rear;
} CircularQueue;

// 初始化
void InitQueue(CircularQueue *q) {
    q->front = q->rear = 0;
}

// 入队
int EnQueue(CircularQueue *q, int x) {
    if ((q->rear + 1) % MAXSIZE == q->front) {
        return 0;  // 队满
    }
    q->data[q->rear] = x;
    q->rear = (q->rear + 1) % MAXSIZE;
    return 1;
}

// 出队
int DeQueue(CircularQueue *q, int *x) {
    if (q->front == q->rear) {
        return 0;  // 队空
    }
    *x = q->data[q->front];
    q->front = (q->front + 1) % MAXSIZE;
    return 1;
}

// 求长度
int QueueLength(CircularQueue *q) {
    return (q->rear - q->front + MAXSIZE) % MAXSIZE;
}

核心要点:循环队列中,rear 永远指向下一个空位。队满时数组里其实还有一个空位,这个空位就是用来区分队空和队满的。我建议你画个环形图来理解,比死记公式管用多了。

队列的链式存储

链式队列需要两个指针:front 指向队头节点,rear 指向队尾节点。入队操作在 rear 后面加节点,出队操作删除 front 指向的节点。

typedef struct QNode {
    int data;
    struct QNode *next;
} QNode;

typedef struct {
    QNode *front;
    QNode *rear;
} LinkQueue;

// 初始化
void InitQueue(LinkQueue *q) {
    q->front = q->rear = (QNode *)malloc(sizeof(QNode));
    q->front->next = NULL;
}

// 入队
void EnQueue(LinkQueue *q, int x) {
    QNode *node = (QNode *)malloc(sizeof(QNode));
    node->data = x;
    node->next = NULL;
    q->rear->next = node;
    q->rear = node;
}

// 出队
int DeQueue(LinkQueue *q, int *x) {
    if (q->front == q->rear) {
        return 0;  // 队空
    }
    QNode *p = q->front->next;
    *x = p->data;
    q->front->next = p->next;
    if (q->rear == p) {
        q->rear = q->front;  // 删除最后一个节点
    }
    free(p);
    return 1;
}

避坑指南:链式队列出队时,如果删除的是最后一个节点,一定要把 rear 重新指向头节点。我曾经漏掉这个判断,结果 rear 变成了野指针,程序跑着跑着就莫名其妙地崩溃了。

知识体系总览

下面这张图把栈和队列的四种存储方式串起来了,你可以对照着看,心里有个整体框架。

栈与队列知识体系 栈(LIFO) 顺序存储 链式存储 数组 + top指针 链表头插/头删 队列(FIFO) 顺序存储 链式存储 数组 + front/rear 链表 + front/rear 循环队列(解决假溢出) (rear + 1) % MAXSIZE == front 判满

四种存储方式对比

结构 优点 缺点 适用场景
顺序栈 实现简单,访问快 大小固定,可能溢出 函数调用、表达式求值
链式栈 动态扩展,无溢出 需要额外指针空间 数据量不确定的场景
循环队列 空间利用率高,无假溢出 判满条件需要留一个空位 缓冲区、消息队列
链式队列 动态扩展,操作灵活 需要管理内存 任务调度、BFS遍历

嗯,到这里栈和队列的四种存储方式就讲完了。我个人觉得,循环队列是这里面最巧妙的设计,用取模运算就把线性数组变成了环形结构。你在写代码的时候,多想想实际场景:数据量固定就用顺序存储,数据量变化大就用链式存储。选对了结构,代码写起来顺手,跑起来也稳当。

公众号:蓝海资料掘金营,微信 deep3321