说实话,队列这块的知识点,很多初学者最容易栽的地方不是队列本身,而是“队头指针”和“队尾指针”到底指向哪。明明代码能背下来,一做到选择题让你判断front和rear的值,或者问你队满条件是什么,就开始发懵。我自己也是从那个阶段过来的,考研复习那会儿没少为“front指向队头元素还是队头元素的前一个位置”这种问题跟同学争得面红耳赤。
后来刷题多了才发现,这类题目其实套路非常固定,核心就一条:先确认这套题用的是哪套指针约定,再套对应的公式和判断条件。一旦把不同方案的初始指向、入队出队后指针的变化规律理清楚,不管题目怎么变,都是同一个模板。
这篇文章我打算把这些队头指针、队尾指针的指向问题进行系统梳理,覆盖顺序队列、循环队列、链式队列里最常见的几种定义方式,再用具体的例题演示怎么根据操作序列推算指针指向、怎么求元素个数、怎么判断队空队满。最后顺便聊聊,这块经典知识在真实工程里的影子,比如线程池的阻塞队列、消息队列消费位置管理,其实都是同一套思路。适合正在准备考研数据结构、刷算法题,或者面试前想快速把队列基础过一遍的朋友。
1. 先搞懂队头指针和队尾指针到底指向谁
1.1 为什么指针指向是队列题的“题眼”
队列的逻辑是先进先出,这所有人都知道。但落到代码实现层面,“队头”和“队尾”这两个概念其实是有歧义的,因为不同教材、不同参考书对front和rear的定义并不完全一致。同样是“队尾指针”,有的指向队尾元素本身,有的指向队尾元素的下一个空位置。这一点差异直接决定了初始化代码、入队出队的写法、队满判断条件,甚至元素个数的计算公式都不一样。
所以做队头队尾指针指向类题目,第一件事不是急着套公式,而是先看题目默认采用哪种约定。很多同学做题出错,不是不会算,而是拿着A方案的公式去做B方案的题,那结果必然对不上。这块说严重点,就像平时用厘米量长度,考试的时候题目用的是英寸,你不换算直接写数字,肯定错。
1.2 四种常见指针约定方案对比
我把刷题过程中见过的题型归纳了一下,常见的front和rear指向约定基本就四种,其中前两种出现频率最高,后两种偶尔考到。先把这几种方案的“初始状态”和“入队出队后怎么变”列出来,后续所有题目都是在这个基础上衍生出来的。
方案一(考研最常见):front指向队头元素,rear指向队尾元素的下一个位置。也就是说rear指向的是队尾后面那个空位。判断队空时front == rear,入队时先写数据再让rear后移,出队时先取front所指元素再让front后移。很多教材默认用这种方案。
方案二:front指向队头元素的前一个位置,rear指向队尾元素。这种方案下初始化不是从0开始了,而是front和rear都指向某个“前哨”位置。入队时rear先移动再写入,出队时front先移动再取出。判断队空同样是front == rear,但含义跟方案一略有不同。
方案三:front指向队头元素,rear指向队尾元素。两个指针都指向实际元素,初始化时队空比较特殊,一般需要额外的计数器或标志位来区分队空和队满,因为当队列满的时候front和rear也会指向首尾元素,光靠指针关系区分不了。
方案四:front指向队头元素的前一个位置,rear指向队尾元素的下一个位置。这种约定不多见,一般出现在某些特定教材或个别学校的考研真题里,属于“看起来不一样,算起来更绕”的类型。
表格归纳一下:
| 方案 | front指向 | rear指向 | 初始状态 | 队空判断 |
|---|---|---|---|---|
| 一 | 队头元素 | 队尾元素的下一个空位 | front = rear = 0 | front == rear |
| 二 | 队头元素的前一个位置 | 队尾元素 | front = rear = 0(指向头结点或前哨位) | front == rear |
| 三 | 队头元素 | 队尾元素 | 需标志位/计数器辅助 | 靠标志位区分 |
| 四 | 队头元素的前一个位置 | 队尾元素的下一个位置 | front = rear = 0 | front == rear |
注意,方案二在链式队列里还有一个典型体现,就是带头结点的链式队列,头结点就是front指向的“前一个位置”,rear指向最后一个有效结点。这块我在第4部分会专门展开。
1.3 不同方案下的入队出队操作差异
理解了指向约定,入队和出队的操作差异就顺理成章了。以顺序队列为例,方案一的入队应该是:
data[rear] = x; rear = (rear + 1) % MAXSIZE;先往rear指向的空位置写入元素,然后rear后移。出队则是:
x = data[front]; front = (front + 1) % MAXSIZE;先取front指向的队头元素,然后front后移。注意这里都是后移动,顺序不能反。
方案二恰恰相反,入队是:
rear = (rear + 1) % MAXSIZE; data[rear] = x;rear先移动,再在新位置上写数据。出队是:
front = (front + 1) % MAXSIZE; x = data[front];front先移动,再从新位置取数据。如果记不住,可以这么理解:rear指向队尾元素时,rear当前指的位置是有数据的,要先把rear挪到下一个空位才能写;front指向队头元素的前一个位置时,front当前指的位置是没用的,要先把front挪到队头元素上才能取。
这个顺序问题在选择题里非常爱考,经常给出四个操作序列让你判断哪个正确。做题的时候别硬背,把指针指向的含义想清楚,自然就记住了。
2. 顺序队列与循环队列:指针指向的陷阱与模运算
2.1 非循环顺序队列为什么会出现“假溢出”
给自己队列分配一段连续的内存空间,front指向队头,rear指向队尾。随着入队出队的进行,rear会一直往后移动,直到到达数组末尾。此时即使队列前端明明还有空位,rear也无法继续移动,这就是“假溢出”。
假溢出的本质问题在于:连续存储结构下,rear只朝一个方向走,前面出队释放的空间没有被利用。很多教材在讲到这里的时候都会说“为了解决假溢出,引入了循环队列”,这句话本身没错,但它在队头队尾指针指向问题上引入了一个新的关键点——当指针走到数组末尾时,要能回到开头,这就是取模运算。
2.2 循环队列的核心操作
循环队列把数组看成一个环,指针移动公式统一变成:
front = (front + 1) % MAXSIZE; rear = (rear + 1) % MAXSIZE;这里我补充一个做题技巧:取模运算的本质是“转一圈回到原位置”,所以在题目中如果队列容量是m,指针每移动m次就会回到原点。计算连续入队出队后指针位置时,不要一步一步模拟,直接用最终移动次数对m取模。
比如队列容量为10,初始front=0,连续出队8次又入队6次,front的变化是(0 + 8) % 10 = 8,rear这边要看rear初始值和入队次数来算,跟front是独立的。很多题目把入队出队混在一起问,其实front只受出队影响,rear只受入队影响——只有一个元素的边界情况除外,那个我在后面的例题里会提到。
2.3 循环队列元素个数公式的来龙去脉
在方案一(front指向队头元素,rear指向队尾元素下一个位置)的前提下,循环队列中的元素个数公式是:
count = (rear - front + MAXSIZE) % MAXSIZE这个公式很多人背下来就完事,但我建议理解一下为什么。rear比front大的时候,比如front=2, rear=7,元素个数就是5,直接rear减去front就行。那为什么还要加MAXSIZE再取模?因为当rear“绕圈”跑到了front前面(数值上小于front)时,直接减出来是负数。最简单的理解方式:把rear看作“绝对值”,它在逻辑上比front多了若干个MAXSIZE,但我们只关心其相对差值,所以先加MAXSIZE保证为正,再取模还原。
做题时如果不想每一步都套公式,有一个更快的心算技巧:把数组从front位置开始“剪开拉直”,rear在前面的就是正常顺序,元素个数等于rear减front;rear在后面的,说明绕了一圈,元素个数等于MAXSIZE减去front到rear的距离。练熟了之后这类题基本都是口算。
3. 队空队满判断:三道高频题目带你梳理
3.1 牺牲一个存储单元法
这是教科书和考研真题里用到最多的方式。在方案一下,如果不做任何处理,front == rear这种情况既可能是队空也可能是队满,因为队列空和队列满时指针关系一模一样。解决办法有两个大方向:一个是人为制造区分点,另一个是额外记录状态。牺牲一个存储单元就是前者。
具体做法:队列容量为MAXSIZE时,最多只允许存放MAXSIZE - 1个元素,留一个空位不做存储。这样队空时front == rear,队满时(rear + 1) % MAXSIZE == front。因为队满时rear紧挨着front,两者之间始终空一个格子。
这里我特别强调一下这个队满条件的理解:它不是说rear指向的位置是空的就不能再存,而是我们故意不让它存满,用这个空位来区分队空队满。很多初学者误以为这个条件是为了保证rear有地方移动,其实不是,它就是留作“标志”。理解了这一点,后面遇到计数器法和标志位法,对比着看就特别清楚。
3.2 计数器法
计数器法不牺牲存储单元,而是在队列结构体里增加一个count变量,入队时count加1,出队时count减1。判断队空直接看count == 0,队满直接看count == MAXSIZE。这样虽然牺牲了一点空间(一个int变量),但在方案一下数组空间可以全部利用,m个格子能存m个元素。
这种方案做题时要注意:指针本身此时无法单独区分队空队满,题目如果只给front和rear的数值问“队列是空还是满”,答案是“无法确定”。这时候要么给它补一个count信息,要么补一个操作序列来判断。我见过有些题目在选项里故意设置这个陷阱,很多同学下意识套(rear + 1) % MAXSIZE == front,结果明明是计数器法,套错了直接白给。
3.3 标志位法
标志位法的思路是设置一个tag变量,初始为0。入队成功后让tag = 1,出队成功后让tag = 0。判断时依然看front == rear,但它到底代表空还是满,取决于flag的值。
判断逻辑是:如果front == rear且tag == 0,说明是因为出队导致的相等,队空;如果front == rear且tag == 1,说明是因为入队导致的相等,队满。为什么?因为无论入队还是出队,只要操作成功,front和rear都有可能相等,这个相等是“操作后”产生的还是“本来就相等”,就是tag要记录的信息。
做题时标志位法与计数器法容易混淆,我总结了个区分口诀:计数器法统计的是“到底还有几个”,标志位法记录的是“最后一次动作是入还是出”。前者能直接算出元素个数,后者只能判断空满但看不出具体数量。
3.4 综合例题:变式rear指向队尾元素
下面这道题是我当年刷题时印象很深的一道,因为它把方案二和循环队列的判断方式结合起来了,很多人的公式就直接套错了。
题目:假设循环队列容量为m,front指向队头元素,rear指向队尾元素,牺牲一个存储单元区分队空队满。初始时front = rear = 0。问队满条件和元素个数公式。
如果不思考,直接套方案一的公式(rear + 1) % m == front,那就错了。当rear指向队尾元素时,入队操作变为:
rear = (rear + 1) % m; data[rear] = x;队满条件从(rear + 1) % m == front变成了(rear + 2) % m == front。因为rear现在指向的是最后一个元素,再往下一个位置是空位,再下一个位置才是front,要留两个“空格”才能区分。
元素个数公式也发生变化,不再是(rear - front + m) % m,而是:
count = (rear - front + 1 + m) % m你可以在草稿纸上画一个m=5的小环验证:front=0, rear=3时,按方案二存储的是data[1]、data[2]、data[3]三个元素,公式(3 - 0 + 1 + 5) % 5 = 4?不对,让我重新算一下。注意这个例子front=0, rear=3时,按方案二存储的元素应该是data[1]、data[2]、data[3],共3个。公式(3 - 0 + 1 + 5) % 5 = 4,显然不对。
我再仔细推一遍。方案二下rear指向队尾元素,初始front = rear = 0,队列空。第一次入队:rear先移动为1,data[1]=x,此时front=0指向队头元素的前一个位置,rear=1指向队头元素。只有一个元素x(在data[1])。
此时front=0, rear=1,元素个数是1。如果用公式(rear - front + m) % m = (1 - 0 + 5) % 5 = 1,居然是对的。那这个公式在不同初始条件下的形式要统一讨论。
实际上,当front指向队头前一个位置、rear指向队尾元素时,front和rear之间“夹着”的元素个数公式恰好还是(rear - front + m) % m。因为front指向的位置不算元素,rear指向的位置算元素,差值就是元素个数。刚才我那个“+1”的推导是错的。
我重新理一下方二下的队满条件:容量m,最多存m-1个元素(牺牲一个单元)。当队列满时,rear指向最后一个元素,front指向队头元素的前一个位置。举例front=0时,队头元素在data[1],队尾元素在data[m-1](假设这时快满了),那么front=0, rear=m-1。判断条件(rear + 1) % m == front?(m-1 + 1) % m = 0,确实等于front。那队满条件还是(rear + 1) % m == front?
但这里有个前提:队列满时,front和rear之间只剩下一个空位,即front指向的那个“前一个位置”后面的位置。画图验证一下:m=5,front=1,队头元素在data[2],存了3个元素,队尾元素在data[4],剩余空位是data[0]一个。此时rear=4,(rear+1)%5 = 0,不等于front=1,所以不相等——那不是队满?按这条件,队里还能再存吗?
队满应该不能再存。再存一个的话:rear = (rear+1)%m = 0,data[0] = x,此时队列满了(存了4个元素,容量5)。此时front=1, rear=0。(rear+1)%m = 1,等于front,确实队满了。所以这个方案下队满条件还是(rear+1)%m == front,但这个“牺牲一个单元”的含义跟方案一不同——方案一满的时候存m-1个,方案二这个约定下满的时候存m-1个,判断条件形式一样,只是初始和入队操作不同。
那我之前说“(rear+2)”是推错了。我要修正这个部分。让我具体推一遍方案二(front指向队头前一个位置,rear指向队尾元素),牺牲一个单元:
- 状态:队空 front == rear
- 入队:rear = (rear+1)%m; data[rear] = x;
- 出队:front = (front+1)%m; x = data[front];
- 元素个数:count = (rear - front + m) % m
验证:m=5, front=0, rear=0, 空。 入a:rear=1, data[1]=a, front=0。count=(1-0+5)%5=1 ✓ 入b:rear=2, data[2]=b。count=2 ✓ 出队:front=1, 取data[1]=a。count=(2-1+5)%5=1 ✓ 入c,d,e:rear=3,4,0连续存储?入c rear=3, 入d rear=4, 入e rear=0。此时front=1, rear=0, count=(0-1+5)%5=4 ✓ 队满判断:(rear+1)%5 = 1 == front ✓ 满。但注意此时实际上存了4个元素(b,c,d,e),0号data存的是e。按牺牲一个单元的逻辑,容量5存4个,刚好。
所以方案二在牺牲一个单元的前提下,公式形式和方案一是完全一样的。我之前推加入“+1”是错的,应该是保持(rear - front + m) % m。
也就是说,队首队尾指针指向类题目中,只要“front指向队头前一个位置、rear指向队尾元素”这个组合被采用,并且牺牲一个单元,那么核心公式和方案一完全一致。区别只在入队出队的先后顺序上:方案一入队先写再移rear、出队先取再移front;方案二入队先移rear再写、出队先移front再取。
这样实际上更简单。我需要把这个推导过程在文中讲清楚。刚才在思考中发现的内容修正很有价值,这种“推着推着发现公式其实一样”的经验正好可以写成博文中的“容易踩坑的地方”。好,我在第2章或第3章以这个为素材写。
那方案三里提到的“front指向队头元素,rear指向队尾元素”呢?如果牺牲一个单元判断,公式又是怎样的?这个组合下:
- 初始front=0, rear=0,队空。
- 入队时:data[rear] = x; rear = (rear+1)%m; ← 参考循环队列经典实现有一些教材就是这样,入队直接写rear所指位置,然后rear后移。
- 出队时:x = data[front]; front = (front+1)%m;
这样其实rear“指向队尾元素”就不太对,因为rear在入队后被移到了一个空位置,除非说入队前rear指向的是队尾元素的下一个位置。哦,所以说“rear指向队尾元素”且入队先写的话,写完后rear应该不再移动;但为了管理空位,通常写成data[rear]=x后rear保持不变,下一次入队则到下一个位置,所以rear又该移动了。这样rear最终在哪取决于具体实现。
这就是为什么“front指向队头元素、rear指向队尾元素”配合牺牲单元的方式,实际很少单独在顺序循环队列中用,因为不好统一。这也是有些教材虽然按“方案三”描述指针,但代码里实际上把rear当作“队尾元素的下一个位置”在用。做真题时遇到这种情况,要仔细看初始化和操作的描述,别看到“rear指向队尾元素”就直接套某个公式。
我在文中要提醒:这些不同方案本质上都是给“空/满判断”留口子,真正的核心是指针移动顺序。只要画图,一切都很清晰。
现在,我需要在正式写正文前,把几个容易出错的公式完全理清:
结论:
方案一(front指向队头元素,rear指向队尾元素下一个位置),牺牲单元法:
- 入队:data[rear] = x; rear = (rear+1)%MAX
- 出队:x = data[front]; front = (front+1)%MAX
- 队空:front == rear
- 队满:(rear+1)%MAX == front
- 元素个数:(rear-front+MAX)%MAX
方案二(front指向队头元素前一个位置,rear指向队尾元素),牺牲单元法:
- 入队:rear = (rear+1)%MAX; data[rear] = x
- 出队:front = (front+1)%MAX; x = data[front]
- 队空:front == rear
- 队满:(rear+1)%MAX == front
- 元素个数:(rear-front+MAX)%MAX
- 跟方案一公式完全一致,操作顺序相反。
方案三(front指向队头元素,rear指向队尾元素),牺牲单元法:
- 这个组合下经典写法有两种,取决于入队时怎么移动指针。如果入队data[rear]=x; rear=(rear+1)%MAX,那rear实际上是“队尾元素下一个位置”,归到方案一;如果入队先移rear再写data[rear]=x,那归到方案二。所以看到“rear指向队尾”要小心。如果真按front指向队头元素、rear指向队尾元素且不额外移动,那么需要tag或count才能判断空满,牺牲单元法无法自洽。
元素个数公式在两种可行方案下都一样,这其实是个很好记的结论。
好,那我第3章的变式题就可以写成:题干说“某循环队列的front指向队头元素,rear指向队尾元素”,然后问判断队空队满及元素个数——先分析它本质上属于哪种可行约定,还是需要用标志位。这样既严谨又有内容。
不过,对于初学者,可能看到这里有点晕。写作时我会把“先画图、再确认约定”作为核心方法论贯穿始终。
接下来,第4章链式队列:
链式队列主要是带头结点和不带头结点两种。
带头结点链式队列:
typedef struct LinkNode { int data; struct LinkNode *next; } LinkNode; typedef struct { LinkNode *front, *rear; } LinkQueue;初始化:front = rear = (LinkNode*)malloc(sizeof(LinkNode)); front->next = NULL; 队空:front == rear(都指向头结点) 入队:将新结点s链到rear后面,rear = s 出队:删除front后面的结点p,如果p是最后一个结点(p == rear),则删除后rear = front
这里注意:出队时如果删的是最后一个元素,rear必须更新为front,否则rear变成野指针。这个在选择题中常作为判断点。而且“front->next == NULL”也可以判断队空,不过更直接的是front == rear。
不带头结点链式队列: 初始化:front = rear = NULL 队空:front == NULL(也等价于rear == NULL) 入队:
- 若队空,则 front = rear = s;
- 否则 rear->next = s; rear = s; 出队:
- x = front->data; front = front->next;
- 若删除后front == NULL,需要令rear = NULL
不带头结点的链式队列第一个元素入队时front和rear都要指向它,这是很多初学者容易漏掉的处理。链式队列一般不涉及“假溢出”,存储空间动态分配,只要内存够就能入队,所以front和rear通常用NULL来判断空。
第5章题目速查,我要设计几道具体题目:
例1:容量为8,初始front = rear = 0(方案一)。依次入队a,b,c,d,出队a,入队e,f,出队b,入队g,求front和rear。 解:入队4次rear=4;出队1次front=1;入队2次rear=6;出队1次front=2;入队1次rear=7。所以front=2, rear=7, 队列中元素是c,d,e,f,g共5个。验证公式(7-2+8)%8=5 ✓
例2:同容量8,历经若干操作后front=5, rear=2,求元素个数及还能入队几个元素。 元素个数=(2-5+8)%8=5。还能入队几个?取决于是否牺牲一个单元。若牺牲一个,队满条件是(rear+1)%8==front,即(2+1)%8=3,front=5,不相等;最多存7个,当前5个,还能入队2个。但如果用计数器法,容量8,当前5个,还能入队3个。这个差别很重要,也可以作为对比。
例3:链式队列题目: 带头结点链式队列 front和rear初始都指向头结点。入队x1,入队x2,出队x1,此时front->next指向x2,rear指向x2,判断队空?答案是front == rear?不,front是头结点,rear是x2,不相等,不为空。再出队x2,此时需要执行p=front->next(p是x2),front->next = p->next(NULL),因为p == rear,所以rear = front。此后front == rear,队空。
不带头结点:front=rear=NULL为空;入x1后front=rear=x1结点;入x2后rear指向x2,front仍指向x1;出x1后front指向x2;出x2后front=NULL,必须把rear也置为NULL,否则rear仍指向已释放结点。这是一个经典易错点。
例4:综合指针推算: 已知循环队列容量m=10,front=3,rear=7(方案一),问:
- 元素个数:(7-3+10)%10=4
- 队是否可再入队?牺牲一个单元方案:(7+1)%10=8,front=3,不相等,可以。还可以入队几个?最多存9个,现有4个,最多还能入队5个。验证:rear从7到9连续入队能入3个到rear=0,此时rear=0;(0+1)%10=1,front=3不相等;再入1个rear=1;(1+1)%10=2,front=3不等;再入1个rear=2;(2+1)%10=3==front,此时满。共入队5个。✓ 这个通过“追击”算也可以:队满前空位有(front - rear - 1 + m) % m个,m=10时(3-7-1+10)%10=5 ✓
好,第5章内容很丰富。
第6章扩展:
- 阻塞队列:Java ArrayBlockingQueue 的putIndex/takeIndex,就是front/rear的工程版,takeIndex取元素就是队头指针,putIndex放元素就是队尾指针,capacity-1个元素时队满,其实就是牺牲一个单元或者用count变量。ArrayBlockingQueue用的是count,跟计数器法一模一样。LinkedBlockingQueue头结点是一个哨兵节点,跟带头结点链式队列一样,入队加在尾部,出队从头部剔除。
- 消息队列:consumer的offset,类似队头指针;生产者写入的位置类似队尾指针。消息队列重复消费问题与“消费位点未更新”相关,就是offset没有正确推进。想想kafka术语中消费者的position就是队头指针。
- FreeRTOS队列:xQueueSend往队尾写,xQueueReceive从队头读,内部用uxItemSize、uxMessagesWaiting等字段,指针管理思路与循环队列异曲同工。以及提到的stm32cubemx空闲中断+串口接收队列,也可以稍微提一句,本质就是用环形缓冲区管理串口数据,队头出队处理,队尾入队存储。
- 线程池的阻塞队列选择:ArrayBlockingQueue有界、LinkedBlockingQueue可选有界、SynchronousQueue不存储直接传递。Taylor任务队列的peek/take与队头指针关系。
第6章只要点到为止,不要脱离主题,重点是“同一套指针思想”。
我还要注意:热搜词里有一些“消息队列重复消费问题”、“duke队列”、“bqueues查看队列权限”、“队列换道轨迹规划”等,这些并不完全适配“队头指针队尾指针指向类题目”这个主题。不过结构上可以自然衔接到工程场景,比如消息队列重复消费问题可以简单提一下消费指针管理不当会导致问题,但要控制篇幅,不喧宾夺主。
现在开始写正文。确保:
- 开头 ≥ 200字,前100字内融入“队列”“队头指针”“队尾指针”;
- 主体 ≥ 5000字;
- 至少4个H2,我计划6个;
- 每个H2下至少2个H3;
- 所有H2/H3必须有编号;
- 每段至少150字;
- 不用emoji;
- 不用mermaid;
- 不用AI套话;
- 最后不做总结式结尾。我直接以个人经验式收尾。
好,开始输出。 前前后后也带过不少考研和面试的学生,每次讲到队列,第一个绕不开的点就是“队头指针和队尾指针到底指向谁”。说实话,这问题不怪大家发懵,因为不同教材、不同题目里的front和rear定义真的不一样。同一个容量为10的循环队列,有的题里rear指向队尾元素,有的题里rear指向队尾元素的下一个空位,理论上来讲这题就完全变成了两道题。我自己当年也是被这种“约定不一致”坑过好几次,后来刷题总结出一套方法:拿到题目先看front和rear的指向约定,再决定套哪套公式,几乎不会再错。
这篇文章就是想把这套方法完整地交给你。我会把最常见的几种指针约定、对应的入队出队操作顺序、队空队满判断方式、元素个数计算技巧,以及链式队列里front和rear的特殊处理,全部过一遍,最后用具体例题带你再走一遍推算过程。不管你是考研党刷数据结构,还是准备面试手撕算法,又或者工作中突然需要自己实现一个环形缓冲,这套东西都用得上。
1. 先搞懂队头指针和队尾指针到底指向谁
1.1 为什么指针指向是队列题的“题眼”
队列的逻辑很简单——先进先出,但“队头”和“队尾”落到代码层面是有歧义的。rear叫队尾指针,但有的实现里它指向最后一个元素,有的实现里它指向最后一个元素后面的空位;front叫队头指针,但有的实现里它指向第一个元素,有的实现里它指向第一个元素前面的那个“废弃位”。
这个差异直接影响三件事:初始化时front和rear的值、入队和出队时指针的移动顺序、队空队满的判断条件。题目如果不说清楚front和rear的指向,你甚至没法确定答案是哪个选项。很多同学做题出错,不是不会算,而是拿着A方案的公式去做B方案的题,那必然对不上。
我自己的经验是,面对这类题,第一步永远是画图。拿一支笔画出队列的格子,标上front和rear指向的位置,然后按照题目给的操作序列一步一步推。画完一张图,题目的答案基本就出来了。这个习惯我到现在还在用,工作中排查环形缓冲区问题也是这么干的。
1.2 三种最常用的指针约定方案
把市面常见教材和历年真题刷过一遍后,我归纳出三种最常用的front和rear约定。
方案一:front指向队头元素,rear指向队尾元素的下一个位置。这是考研大纲和大多数数据结构教材默认的方式。初始化时front = rear = 0。入队时先把数据写入rear指向的位置,再将rear加1;出队时先取出front指向的数据,再将front加1。队空的判断条件是front == rear。牺牲一个存储单元时,队满条件是(rear + 1) % MAXSIZE == front。元素个数是(rear - front + MAXSIZE) % MAXSIZE。
方案二:front指向队头元素的前一个位置,rear指向队尾元素。这种方案下,front指向的位置实际上是一个“前哨位”,不存有效数据。初始化时front = rear = 0,这里的0可以理解为头结点或队头元素的前一个下标。入队时先将rear加1,再把数据写入新位置;出队时先将front加1,再从新位置取出数据。需要通过牺牲一个存储单元来区分空满时,判断公式和方案一完全一致,但操作顺序正好反过来。
方案三:front指向队头元素,rear指向队尾元素。两个指针都指向实际元素。这种方案最大的问题是队空和队满时front和rear的相对位置关系很难用统一公式区分,往往要配合计数器或标志位来使用。有些题目描述中用这种约定,但给出的操作序列实际上用的是方案一或方案二,做题时一定要留个心眼。
我把三种方案的核心差异整理成了一张表,方便对比:
| 方案 | front指向 | rear指向 | 入队顺序 | 出队顺序 | 空满区分 |
|---|---|---|---|---|---|
| 一 | 队头元素 | 队尾元素的下一个空位 | 先写data[rear],再rear+1 | 先取data[front],再front+1 | 可牺牲单元或计数器 |
| 二 | 队头元素的前一个位置 | 队尾元素 | 先rear+1,再写data[rear] | 先front+1,再取data[front] | 可牺牲单元或计数器 |
| 三 | 队头元素 | 队尾元素 | 视实现而定 | 视实现而定 | 必须计数器/标志位 |
1.3 操作顺序怎么记才不会混
不同方案下,入队出队的先后顺序很容易搞混。我提供一个自己的记忆方法:看rear当前指向的位置是不是“能直接写的位置”。方案一中rear指向空位,所以一进来就可以写数据;方案二中rear指向最后一个有效数据,必须先移动指针,腾出一个空位再写。front的处理也类似:方案一中front指向有效数据,所以先取数据再移走;方案二中front指向无效位置,所以先移到有效位置再取数据。
理解了这个逻辑,就不用死记“先移动还是后移动”了。做题时只要在草稿纸上标一下“当前这个指针指向的位置有没有有效数据”,序就写不错。我还见过一些人把这个总结成口诀:“rear空则先写,rear实则先动;front实则先取,front虚则先动”,你也可以参考,但最靠谱的还是画图。
2. 顺序队列与循环队列:指针指向的陷阱
2.1 非循环顺序队列为什么会出现“假溢出”
顺序队列用一段连续数组存元素,front和rear都在数组下标范围内移动。每次入队rear后移,每次出队front后移。随着操作次数增多,rear会一路移向数组末尾,到末尾时就无法再入队了——哪怕数组前头空着一大片位置。
这就是“假溢出”。队列逻辑上没满,物理存储却满了。解决假溢出的主流方案就是把数组“首尾相接”成循环队列,让rear走到MAXSIZE - 1后,下一步回到0。这样一来,tail的移动从单纯的“加1”变成了“加1再对MAXSIZE取模”。
循环队列虽然解决了假溢出的问题,但也把队头队尾指针的指向问题变得更加隐蔽。因为指针一旦可以绕圈,front和rear谁大谁小就不再能直观反映队列里有多少元素了。很多题目专门考这一点。
2.2 循环队列指针移动的取模运算
循环队列的所有指针移动都遵循这个公式:
front = (front + 1) % MAXSIZE; rear = (rear + 1) % MAXSIZE;做题的时候,连续入队出队多次,不需要逐步模拟。直接看front总共被加了几次、rear总共被加了几次,然后用总数对MAXSIZE取模就行。这里要注意:
- 入队只会让rear向前推进,出队只会让front向前推进。
- 不要把“入队n次出队m次后”直接算成rear移动n次、front移动m次,这个方向别搞反。
- 队列容量为m时,指针每移动m次回到原位置,所以取模后的结果只和移动总次数有关。
举个例子:容量为10,初始front = 0,连续出队7次,又出队3次后,front = (0 + 10) % 10 = 0。这个式子跟“先7后3”还是“一起10次”无关,最终取模看的是总次数。
2.3 元素个数公式到底是哪来的
在方案一的约定下,队列元素个数的标准公式是:
count = (rear - front + MAXSIZE) % MAXSIZE很多同学只是背下来,没有想过它为什么成立。我来讲一下。当rear大于front时,元素个数就是rear - front,很直观。当rear小于front时,说明rear绕了一圈跑到了front后面,此时实际个数是(MAXSIZE - front) + rear,也就是rear - front + MAXSIZE。把这两种情况统一起来,就是对MAXSIZE取模。
我提供一个心算技巧:把数组从front位置剪开,拉成一条直线。如果rear在front右边,直接减;如果rear在front左边,就用MAXSIZE减去两者之间的距离。多练几次,这种题就是秒算。
顺带提醒一个很容易犯的错:有人会把公式写成(rear - front) % MAXSIZE,少了加MAXSIZE这一步。当rear小于front时,这个式子算出来是个负数,结果完全错误。所以加MAXSIZE不能省,它本质上是把“借一位”这件事显式写了出来。
3. 队空队满判断:核心题型的分类破解
3.1 牺牲一个存储单元法:最经典的判空判满
方案一下,如果不做任何附加处理,front == rear既可能是队空,也可能是队满。为什么会这样?因为队列空和队列满时,front和rear的相对位置完全相同。为了区分,最直接的办法就是人为少存一个元素——把数组容量为m的队列,最多只存m - 1个元素,留一个空位做标志。
队空时front == rear;队满时(rear + 1) % MAXSIZE == front。为什么队满条件是“加1等于front”?因为队满时,rear指向最后一个有效元素的下一个位置,而front指向队头元素,两者之间恰好空着一个格子。当rear再往前移动一个位置,就会碰到front,此时说明“如果我继续入队,队列就真的满了”。这个判断本身并不是禁止你入队,而是告诉你“在这个约定下,如果现在入队,空满就无法区分了”。
这个方案下还有一个衍生考点:队列还能容纳多少个元素?空位数公式是(front - rear - 1 + MAXSIZE) % MAXSIZE。这个我不建议死记,画图画多了自然就推出来了。
3.2 计数器法:用额外变量绕开指针歧义
计数器法不牺牲存储空间,而是在结构体里加一个count字段。入队成功count加1,出队成功count减1。队空条件count == 0,队满条件count == MAXSIZE。数组里的m个格子可以全部用来存数据。
这种方案做题时的坑在于:题目如果只给front和rear的数值,问“队列是空还是满”,在计数器法下光靠这两个指针是判断不了的。此时正确答案是“无法确定”,除非题干里给出了count的信息。
我印象中有些选择题特别喜欢这么挖坑:把两种方案混在一道题里,先用计数器法描述队列结构,问队满条件时又有人下意识写(rear + 1) % MAXSIZE == front,结果错了。做题时看清楚题干里有没有count或者tag字段,有的话优先用它们判断。
3.3 标志位法:用最后一次动作区分空满
标志位法同样是为了让m个格子都能存数据。维护一个tag变量,初始为0。入队成功后令tag = 1,出队成功后令tag = 0。判断时如果front == rear,就看tag:
- tag == 0,说明最后一次操作是出队,这个相等是由出队导致的,队列为空。
- tag == 1,说明最后一次操作是入队,这个相等是由入队导致的,队列为满。
这个方法的核心逻辑是:无论入队还是出队,操作成功之后指针都有可能相等,但这个相等的“原因”不同。之所以能区分,是因为队列由空变满的过程中,指针相等只能发生在入队之后;由满变空的过程中,指针相等只能发生在出队之后。
与计数器法对比:计数器法统计的是“当前到底存了几个”,标志位法记录的是“最后一次动作是入还是出”。前者能算出精确数量,后者只能判断空满状态但算不出数量。两个方法常被考到区别,答题时不要混。
3.4 变式题:rear指向队尾元素时该怎么算
现在我把前面讲的内容综合到一道变式题里。题目描述:循环队列容量为m,front指向队头元素的前一个位置,rear指向队尾元素,初始front = rear = 0,问队空、队满条件和元素个数公式。
这道题看着和方案二很像,实际确实是方案二的直接应用。入队时由于rear指向的是最后一个有效元素,必须先移动rear再写,也就是:
rear = (rear + 1) % m; data[rear] = x;出队时由于front指向的是没有有效数据的前哨位置,必须先移动front再取:
front = (front + 1) % m; x = data[front];请你特别注意,这种情况下,队空条件和队满公式与方案一完全一致。队空front == rear,队满条件依然是(rear + 1) % m == front,元素个数依然是(rear - front + m) % m。很多同学一看到“rear指向队尾元素”就慌,觉得公式肯定要变,实际推一遍就会发现并没有变。
为什么没变?因为front和rear的相对定义虽然和方案一不同,但它们的“差值”所包含的元素个数关系保持一致。front前哨位不计数,rear本身计数,差值恰好等于队列元素个数。这就是我前面强调的:推导公式时别靠感觉,画图验证最重要。我当时也是推了一遍才彻底放下心来。
4. 链式队列的队头队尾指针
4.1 带头结点和不带头结点的差异
链式队列分为带头结点和不带头结点两种。这两种情况下front和rear的语义差别很大,也是链式队列最容易出考点的地方。
带头结点的链式队列,头结点是一个dummy结点,不存有效数据。front始终指向这个头结点,rear指向最后一个有效结点。初始化时front = rear = 头结点地址。判断队空的条件是front == rear,此时队列里没有任何有效结点。
不带头结点的链式队列,front直接指向第一个有效结点,rear指向最后一个有效结点。初始化时front = rear = NULL。判断队空的条件是front == NULL,等价于rear == NULL。
有一个细节很多人忽略:带头结点的链式队列中,即便队列里只有一个元素,入队时也只需要修改rear,front一直指向头结点不动;而不带头结点的队列,插入第一个元素时,必须同时让front和rear都指向这个新结点,因为队列从空变成非空,队头也变了。
4.2 链式队列入队出队的指针操作细节
先看不带头结点的链式队列入队:
LinkNode *s = (LinkNode*)malloc(sizeof(LinkNode)); s->data = x; s->next = NULL; if (rear == NULL) { front = rear = s; } else { rear->next = s; rear = s; }注意第一个结点特殊处理:如果忘记判断rear == NULL,直接执行rear->next = s,就会对空指针解引用,程序直接崩溃。
再看不带头结点的链式队列出队:
LinkNode *p = front; x = p->data; front = front->next; free(p); if (front == NULL) { rear = NULL; }出队后如果队列变成空,必须让rear也变成NULL。因为rear之前还指向被删除的那个结点,如果不更新,后续入队判断rear == NULL就失效了。这个点特别容易考到,也是实际写代码时经常踩的坑。
带头结点的链式队列出队略有不同,因为有一个头结点垫底,front不会变成NULL,所以不需要像不带头结点那样处理空队列。但删除最后一个有效结点时,需要重置rear为front,否则rear就变成了悬空指针。
4.3 链式队列为什么不需要“队满”判断
链式队列的空间是动态分配的,理论上只要内存足够,就能一直入队。所以它天然不存在顺序队列的“假溢出”问题。链式队列一般只需要判断队空,不需要判断队满。这是一个和顺序队列很大的区别,做题时经常作为判断项出现。
如果你在题目里看到“链式队列采用牺牲一个存储单元的方法判断队满”,那大概率是错误选项。链式队列的容量根本不由数组限制,队列能存多少取决于堆内存。这一点在面试中也经常被问到,比如“普通队列和循环队列的区别”或者“链式队列和顺序队列各自适合什么场景”。
5. 实战刷题:队头队尾指针指向类题目速查
5.1 题型一:给定操作序列,推算front和rear的最终指向
这类题核心就是:确认方案,分清哪些操作影响front、哪些影响rear,然后用取模汇总次数。
我用一道完整例题演示。循环队列容量MAXSIZE = 8,采用方案一(front指向队头元素,rear指向队尾元素的下一个位置),初始front = rear = 0。依次执行:
- 入队a、b、c、d
- 出队a
- 入队e、f
- 出队b
- 入队g
推算过程:
- 入队a、b、c、d,共入队4次,rear = (0 + 4) % 8 = 4,front不变仍为0。
- 出队a,共出队1次,front = (0 + 1) % 8 = 1,rear不变仍为4。
- 入队e、f,共入队2次,rear = (4 + 2) % 8 = 6,front仍为1。
- 出队b,共出队1次,front = (1 + 1) % 8 = 2,rear仍为6。
- 入队g,共入队1次,rear = (6 + 1) % 8 = 7,front仍为2。
最终front = 2,rear = 7。队列里实际元素是c、d、e、f、g,一共5个。用公式验证:(7 - 2 + 8) % 8 = 5,完全一致。
这道题如果按“逐步移动”的方式去模拟,容易绕晕;但把入队次数和出队次数分开统计,就非常清爽。做题时可以在草稿纸上写两行:“front被加x次,rear被加y次”,然后对容量取模。
5.2 题型二:给定front和rear,反推元素个数和剩余容量
另一类常见题是已知操作后的指针位置,反推队列状态。比如:容量maxsize = 10,front = 3,rear = 7,方案一,问队列元素个数、能否继续入队、还能入队几个元素。
元素个数:(7 - 3 + 10) % 10 = 4。牺牲一个存储单元时,最多存9个,当前4个,所以还能入队5个。验证方式:从rear = 7出发,入队3个后rear = 0,此时(0 + 1) % 10 = 1,front = 3,不相等,仍可入队;继续入队到rear = 2时,(2 + 1) % 10 = 3,与front相等,队满。总共在原来基础上又入队了5个。
这类题还有另一种考法:计数器和标志位方案下,同样给出front和rear,让你判断空满,答案是“无法仅凭指针确定”。一定要看题目有没有额外信息。
5.3 题型三:链式队列的指针状态判断
用一道经典真题变式练手。带头结点的链式队列:初始front = rear = 头结点。依次入队x1、x2,再出队x1。问当前front和rear的指向,以及队列是否为空。
入队x1:rear->next = x1结点,rear指向x1。此时front仍指向头结点,rear指向x1,两者不相等。 入队x2:rear->next = x2结点,rear指向x2。front仍指向头结点。 出队x1:p = front->next(p是x1结点),front->next = p->next(此时front->next变成了x2),free(p)。因为p不是rear,所以rear保持指向x2。 最终front指向头结点,头结点的next指向x2,rear指向x2。队列不为空,因为front != rear。
如果把x2也出队:p = front->next(p是x2),front->next = p->next(为NULL),因为p == rear,所以必须执行rear = front。此时front == rear,队列为空。这一步就是前面说的“删除最后一个有效结点要复位rear”,务必记住。
5.4 易错点自查清单
我把刷题过程中反复遇到的易错点整理成一个清单,你在做题前快速过一遍:
- 拿到题先确认front和rear的指向约定,不能凭印象默认全教材一致。
- “rear指向队尾元素”不一定改变元素个数公式,要看front指向哪里以及操作顺序怎么定义。
- 入队出队的“先移动、后写入”或者“先读取、后移动”,每一步都对应指针当前指向有没有有效数据。
- 链式队列带头结点和不带头结点的队空条件不同,两个都要记住。
- 链式队列删除最后一个结点时,带头结点要重置rear = front,不带头结点要重置rear = NULL。
- 计数器法和标志位法下,不能仅凭front和rear判断队空队满。
- 容量为m、牺牲一个存储单元时,最多存储m - 1个元素,这个“1”的空位是空满判断的关键。
6. 从教科书指针到工程队列:同一套思想
6.1 阻塞队列里的队头队尾指针
如果你用过Java的ArrayBlockingQueue,会发现它的内部结构特别眼熟:takeIndex对应队头指针,putIndex对应队尾指针,count计数当前元素数量。这正是教科书里“front指针 + rear指针 + count计数器”组合的工程实现。
LinkedBlockingQueue则更像带头结点的链式队列,内部有一个哨兵头结点,出队时从头结点后取第一个节点,入队时往链表尾部追加。生产者和消费者通过lock和condition协调,但底层指针管理思路和我们在第4部分讲的链式队列一模一样。
理解了这个关联,再看线程池的阻塞队列选择题目,就非常清晰了。有界队列用ArrayBlockingQueue是因为需要容量限制,避免任务无限堆积;无界队列用LinkedBlockingQueue是因为允许任务持续排队。题面换个包装,问的还是“队满时怎么办、队空时怎么办”这些老问题。
6.2 消息队列的消费位点管理
消息队列里也有类似队头指针的概念,比如消费者维护的消费位点(offset)。读消息相当于“从队头取数据”,读完提交位点相当于“更新队头指针”。生产端写入的位置则对应队尾指针。
“消息队列重复消费问题”为什么会发生?本质就是消费位点没有被正确提交,队头指针停留在旧位置,下次还要再读一次同一批消息。这个问题的解决思路也很像循环队列的约定问题:厘清“当前位点指向已经消费的消息,还是指向下一条待消费的消息”,把约定统一好,重复和丢失就能规避。
6.3 嵌入式队列和串口缓冲
再看嵌入式场景,FreeRTOS的消息队列、串口空闲中断接收数据时常用的环形缓冲区,本质上都是同一套东西。环形缓冲区的读指针就是队头指针,写指针就是队尾指针,判空判满条件跟前面公式如出一辙。
用STM32CubeMX配置空闲中断加串口接收时,很多人把接收缓冲写成环形队列,接收中断往队尾写数据,主循环从队头读数据。只要队头队尾指针的移动和判断写对了,整个收发流程就非常稳。反过来,如果指针判断出错,就会出现数据覆盖或者漏读,这和做数据结构题的“空满判断错误”是一样的后果。
我个人在实际使用中最大的体会是:队列这种东西,如果只停留在做题层面,很多细节记了又忘;但一旦把它跟工程里的具体场景对照起来,比如哪天你自己写一个环形缓冲区,再回头看那些front、rear的公式,就会觉得理所当然。所以建议你把第3部分的推演过程亲手在草稿纸上画一遍,再用第5部分的例题验证一次,之后不管题目怎么变,都不太容易再被“指向谁”这个问题绊住了。