【LeetCode 622】设计循环队列:警惕 Rear() 方法中的"幽灵数据"陷阱
在数据结构的实现中,循环队列(Circular Queue) 是一个非常经典的题目。它通过复用数组空间,避免了普通队列在元素出队后空间浪费的问题。
然而,在使用数组模拟循环队列时,有一个非常容易被忽视的细节:当队列"逻辑上"为空时,数组中可能残留着"物理上"存在的旧数据。 如果处理不当,这些旧数据就会像幽灵一样被错误的读取,导致判定失败。
本文将以 LeetCode 622 题为例,分析一种常见的实现漏洞。
1. 核心设计策略:多开辟一个空间
在设计循环队列时,最关键的问题是如何区分 "队列空" 和 "队列满" 。
如果不采取特殊策略,当 front == rear 时,既可能表示空,也可能表示满。
一种通用的、无须维护额外 size 变量的解决方案是:数组长度设为 k + 1。
- 队列容量 :
k - 数组实际长度 :
k + 1 - 判空条件 :
front == rear - 判满条件 :
(rear + 1) % length == front
在这种设计下,rear 指针总是指向下一个存放数据的位置,始终保留一个空位不存数据,用来作为满队列的标志位。
2. 案发现场:测试用例为何失败?
在实现 Rear()(获取队尾元素)方法时,初学者容易写出如下代码:
java
// ❌ 存在隐患的实现
public int Rear() {
// 计算 rear 的前一个位置(因为 rear 指向的是下一个空位)
int index = (rear == 0) ? queue.length - 1 : rear - 1;
return queue[index];
}
乍一看逻辑是正确的:因为 rear 指向的是新元素将要插入的位置,所以当前的队尾元素自然在 rear - 1 的位置(考虑数组越界需取模处理)。
然而,在以下测试场景中,该代码会报错:
- 入队 :
enQueue(7)。此时数组中queue[0] = 7,rear移动到 1。 - 出队 :
deQueue()。此时front移动到 1。- 关键点 :此时
front == rear,队列逻辑上已经为空。 - 但在物理内存中 :
queue[0]的位置依然存储着7,并没有被清零。
- 关键点 :此时
- 获取队尾 :调用
Rear()。- 上述代码计算
index为 0。 - 程序直接返回了
queue[0],也就是 7。
- 上述代码计算
- 预期结果 :队列为空,根据题目要求,应该返回 -1。
这就是**"逻辑删除"**带来的副作用。deQueue 只是移动了指针,数据依然残留在数组里。如果不先检查队列是否为空,直接通过坐标回溯去取值,就会读到本该"不存在"的脏数据。
3. 修复方案与正确代码
问题的根源在于:在读取数据前,缺少了对队列状态的校验。
与 Front() 方法通常会检查 isEmpty() 一样,Rear() 方法也必须严格执行判空逻辑。
修正后的逻辑:
java
public int Rear() {
// ✅ 必须先检查队列是否为空
if (isEmpty()) {
return -1;
}
// 确认非空后,再安全地回溯位置
int index = (rear == 0) ? queue.length - 1 : rear - 1;
return queue[index];
}
完整且健壮的代码实现:
java
class MyCircularQueue {
public int front = 0;
public int rear = 0;
public int[] queue;
public MyCircularQueue(int k) {
// 策略:多开一个空间,用于利用 (rear+1)%len == front 来判满
queue = new int[k + 1];
}
public boolean enQueue(int value) {
if (isFull()) return false;
queue[rear] = value;
rear = (rear + 1) % queue.length;
return true;
}
public boolean deQueue() {
if (isEmpty()) return false;
// 逻辑删除:只移动指针,不需要物理擦除数据
front = (front + 1) % queue.length;
return true;
}
public int Front() {
if (isEmpty()) return -1;
return queue[front];
}
public int Rear() {
// 【关键】防止读取到已出队的残留数据
if (isEmpty()) return -1;
int index = (rear == 0) ? queue.length - 1 : rear - 1;
return queue[index];
}
public boolean isEmpty() {
return front == rear;
}
public boolean isFull() {
return (rear + 1) % queue.length == front;
}
}
4. 技术总结
-
逻辑删除 vs 物理删除 :
在基于数组的缓冲区设计中,删除操作通常是 O(1) 的指针移动。这意味着内存中的旧数据会一直存在,直到被新数据覆盖。因此,所有读取操作(Get/Peek)必须建立在"数据有效性"的校验之上。
-
方法的一致性 :
在编写
Front()和Rear()这种成对的方法时,逻辑结构应当保持对称。如果Front()进行了判空处理,Rear()也绝不能省略,否则极易产生边缘 Case 的 Bug。 -
循环队列的判满策略 :
使用
k+1的数组长度是实现循环队列最优雅的方式之一,它避免了引入复杂的标志位,使得代码逻辑更加清晰简洁。