队列的定义与顺序实现:从排队说起

大家好,我是你们的嵌入式系统讲师。今天我们来聊聊队列——这个在嵌入式开发中几乎天天打交道的结构。

队列是什么?说白了,就是排队。你去银行办业务,先来的人先办,后来的人后办。这就是队列的核心思想:先进先出(FIFO)

我记得刚入行那会儿,有个老工程师跟我说:「小伙子,搞嵌入式,队列搞不明白,你连串口数据都收不利索。」当时我不信,后来真吃了亏——一次项目里,串口接收缓冲区没用好,数据全乱套了。从那以后,我对队列就格外上心。

队列的概念与特性

队列是一种操作受限的线性表。它只允许在一端插入(叫队尾),在另一端删除(叫队头)。

你想想看,生活中哪些场景是队列?

  • 食堂打饭——先排队的先打到饭
  • 打印机任务——先提交的先打印
  • 串口数据接收——先到的字节先被处理

队列的两个基本操作:

  • 入队(Enqueue):在队尾添加一个元素
  • 出队(Dequeue):从队头移除一个元素

核心特性:FIFO(First In First Out)

最先进入队列的元素,最先被移出队列。这个特性决定了队列在数据缓冲、任务调度等场景中的广泛应用。

队列的抽象数据类型

作为一个C语言开发者,我们得把队列抽象成数据结构。我个人习惯这样定义:

// 队列的抽象数据类型定义
typedef struct {
    int *data;      // 存储元素的数组
    int front;      // 队头指针
    int rear;       // 队尾指针
    int capacity;   // 队列容量
} Queue;

这里有几个关键点:

  • data:指向一块连续内存,用来存数据
  • front:指向队头元素的位置
  • rear:指向队尾元素的下一个位置(这个约定很重要)
  • capacity:队列最多能存多少个元素

队列的基本操作接口:

// 初始化队列
Queue* queue_create(int capacity);

// 销毁队列
void queue_destroy(Queue *q);

// 判断队列是否为空
int queue_is_empty(Queue *q);

// 判断队列是否已满
int queue_is_full(Queue *q);

// 入队
int queue_enqueue(Queue *q, int value);

// 出队
int queue_dequeue(Queue *q, int *value);

// 获取队头元素
int queue_peek(Queue *q, int *value);

// 获取队列当前元素个数
int queue_size(Queue *q);

我的经验之谈:在嵌入式系统中,我通常会把队列的容量设计成2的幂次方(比如16、32、64)。这样可以用位运算代替取模运算,效率更高。不过这是后话了,今天我们先打好基础。

顺序队列的实现与假溢出问题

顺序队列就是用数组来实现的队列。听起来简单,但有个坑——假溢出

先看一个简单的顺序队列实现:

#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>

#define QUEUE_SIZE 5

typedef struct {
    int data[QUEUE_SIZE];
    int front;
    int rear;
} SeqQueue;

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

// 判断是否为空
bool is_empty(SeqQueue *q) {
    return q->front == q->rear;
}

// 判断是否已满
bool is_full(SeqQueue *q) {
    return q->rear == QUEUE_SIZE;
}

// 入队
bool enqueue(SeqQueue *q, int value) {
    if (is_full(q)) {
        printf("队列已满,无法入队\n");
        return false;
    }
    q->data[q->rear] = value;
    q->rear++;
    return true;
}

// 出队
bool dequeue(SeqQueue *q, int *value) {
    if (is_empty(q)) {
        printf("队列为空,无法出队\n");
        return false;
    }
    *value = q->data[q->front];
    q->front++;
    return true;
}

这段代码看起来没问题,对吧?但实际运行一下,你就会发现问题。

假设队列容量是5,我们做以下操作:

  1. 入队A、B、C、D、E —— 队列满了,rear指向5
  2. 出队A、B、C —— front指向3,rear还是5
  3. 现在想入队F —— 判断队列已满,但实际上前面3个位置是空的!

这就是假溢出问题。

注意:假溢出不是队列真的满了,而是rear指针走到了数组末尾,无法再插入新元素。但数组前面还有空闲位置。这就像排队时,前面的人走了,但新来的人却只能排在最后面——显然不合理。

为什么会这样?因为我们的front和rear指针只增不减。出队时front往后移,但前面的空间就浪费了。随着操作次数增加,rear迟早会撞到数组边界。

我曾经在一个数据采集项目中遇到过这个问题。当时用顺序队列缓存传感器数据,跑了一整天后,队列突然报满,但实际只存了不到一半的数据。排查了半天才发现是假溢出。嗯,从那以后,我对这个问题就特别敏感。

解决假溢出的思路

解决假溢出,常见有两种方案:

方案一:元素搬移

每次出队后,把后面的元素往前移动。但这样时间复杂度是O(n),效率太低。在嵌入式系统中,尤其是实时性要求高的场景,这招基本不能用。

方案二:循环队列

把数组首尾相连,形成一个环。当rear到达数组末尾时,如果数组前面有空位,就绕回到开头。这是最常用的方案,也是下一节的重点内容。

循环队列的核心思想:

  • 用取模运算实现指针的循环移动:rear = (rear + 1) % capacity
  • 判空条件:front == rear
  • 判满条件:(rear + 1) % capacity == front(牺牲一个元素空间来区分空和满)

这里我画了一张图,帮你理解顺序队列和假溢出的关系:

顺序队列的假溢出问题示意图 初始状态(空队列) [0] [1] [2] [3] [4] front=0 rear=0 入队 A,B,C,D,E(队列满) A B C D E front=0 rear=5 出队 A,B,C(假溢出发生) 已出队 已出队 已出队 D E front=3 rear=5 尝试入队 F → 失败! 已出队 已出队 已出队 D E front=3 rear=5(已到末尾) 假溢出问题 • 队列中实际只有2个元素(D和E),但 rear 已到数组末尾 • 判断队列已满的条件 rear == QUEUE_SIZE 成立,无法入队 • 前面3个位置空闲,但无法利用 → 这就是「假溢出」

假溢出的本质

假溢出的本质是什么?说白了,就是线性数组的「一次性」使用问题

数组是线性的,从0到N-1。front和rear只能往后移动,不能回头。这就导致了一个矛盾:

  • 从逻辑上看,队列前面有空位
  • 从物理上看,rear已经到数组边界了

解决这个矛盾,就是把数组「掰弯」,让首尾相连。这就是循环队列的由来。

避坑指南:我曾经在一个项目中,用顺序队列做串口数据缓冲。测试时数据量小,一切正常。量产之后,用户反馈设备运行几小时后会死机。查了三天,发现是假溢出导致数据丢失,进而引发协议解析错误。从那以后,我只要用顺序队列,一定会考虑假溢出的问题。

小结

今天我们聊了队列的基本概念和顺序实现。核心要点:

  • 队列是FIFO结构,入队在队尾,出队在队头
  • 顺序队列用数组实现,front和rear指针管理
  • 假溢出是顺序队列的固有缺陷——rear到边界但前面有空位
  • 解决方案:循环队列(下一节详细讲)

队列在嵌入式系统中太常见了。串口数据缓冲、按键事件处理、任务调度……几乎每个项目都会用到。把队列搞明白,你的嵌入式开发之路会顺畅很多。

下一节,我们来解决假溢出问题——循环队列的实现。到时候我会分享一个我实际项目中用过的循环队列代码,保证实用。


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