写在前面
在平常工作或面试中,都会涉及到数据结构。在某些情况下,系统提供的数据结构无法满足特定的需求,此时,扩展或重写适合自己需求的数据结构就显得相当重要。而如何设计高效、简洁的数据结构,就成了考察程序员功底的一个重要依据。
一、问题引入
某厂笔试题目:请基于学习过的队列知识,重新设计一个“循环队列”。要求:
- 线性结构。
- 操作表现基于FIFO(先进先出)原则,且队尾和队头之间需要连在一起,形成一个环。
- 满足时间复杂度O(1),空间复杂度O(N)。
- 实现语言任意。
二、问题初步分析
各位大佬,拿到这个面试题或者需求该怎么分析呢?
首先,线性结构,首选的肯定是数组,在不同语言中也可以选择不同的线性存储结构,比如C++的 vector容器。
接着,FIFO,如何实现先进先出?以及队尾和队头之间连在一起形成环?在分析到这里的时候,我当时已经是一脸懵,但是仔细一想,如果设置两个标志指针,一个指向头,一个指向尾,不就行了。
再接着分析,满足时间复杂度O(1),即满足线性时间,一般是入队和出队不能有遍历的情况。空间复杂度O(N),用数组就可以直接实现。
以上分析后,拿起键盘,噼里啪啦一顿乱敲,初版形成了,请看下个章节。
三、循环队列实现【第一版】
#include<iostream>
using namespace std;
class CircularQueue {
public:
// 构造函数,初始化队列大小,有k个长度
CircularQueue(int k) {
this->length = k+1;
this->front = 0;
this->rear = 0;
this->capacity = 0;
this->p = new int[length];
}
~CircularQueue()
{
if(NULL != this->p)
{
delete[] this->p;
this->p = NULL;
}
}
// 入队
bool enQueue(int value) {
if (isFull())
{
return false;
}
if (capacity == 0)
{
p[rear] = value;
front = 0;
rear ++;
capacity++;
return true;
}
if ((rear >= length || capacity >= length) && front == 0)
{
// 满了,插入失败
return false;
}
if (rear >= length && capacity<length && front != 0)
{
rear = 0;
p[rear] = value;
capacity++;
rear++;
return true;
}
p[rear] = value;
capacity++;
rear++;
return true;
}
// 出队
bool deQueue() {
if (isEmpty())
{
return false;
}
front++;
capacity--;
if (front >= rear)
{
front = 0;
rear = 0;
capacity = 0;
}
return true;
}
// 获取队头元素
int Front() {
if (isFull())
{
return -1;
}
return p[front];
}
// 获取队尾元素
int Rear() {
if (isEmpty())
{
return -1;
}
return p[rear-1];
}
// 判断队列是否为空
bool isEmpty() {
if (capacity <= 0)
{
return true;
}
return false;
}
// 判断队列是否已满
bool isFull() {
if (capacity >= length)
{
return true;
}
return false;
}
private:
// 总长度
int length;
// 存储数组对象
int* p = NULL;
// 队头索引
int front = 0;
// 队尾索引
int rear = 0;
// 数组容量
int capacity = 0;
};
以上是第一个版本的完整代码,基本实现了时间复杂度O(1)和空间复杂度O(n)。测试代码如下。
int main()
{
CircularQueue *cir = new CircularQueue(6);
int intRe = 0;
cir->enQueue(6);
intRe = cir->Rear();
intRe = cir->Rear();
cir->deQueue();
cir->enQueue(5);
cir->Rear();
cir->deQueue();
intRe = cir->Front();
cir->deQueue();
cir->deQueue();
cir->deQueue();
return 0;
}
通过测试发现,如果遇到队满,出队后,队头前面有空余位置,继续入队时,入队失败。即无法完成 循环队列的需求。接着优化分析,请看以下章节。
四、第一版本分析优化
通过第一版本的实现,效果并不理想,下面通过示例图分析整个过程。
当队列为空时,头结点 front和 尾结点 rear 都指向同一位置 ,如下图
由上两图可以得出循环队列空队的判断条件。
开始入队,头结点 front保持在0号位置 ,尾结点随着入队元素变动,如图尾结点 rear 在2号位置。形成一个连续的队列。
接着继续入队,队满时出队,头结点 front 开始移动,如图头结点在2号位置,尾结点 rear在4号位置,队列状态如下:
上述情况下,如果继续入队,一般普通的队列会提示队满,因为尾结点已经在队列末尾了。但是实际情况是队头还有两个空位置,这就浪费了空间。作为循环队列,是完全可以避免空间浪费的,如下图:
那么,什么情况下才无法继续入队呢?队满的状态是什么呢?下图所示:
由此可见当 front == rear + 1时,队满了,那如 B所示的情况应该怎么表示呢?请大家思考。
但是,如何控制队头和队尾标志,灵活的实现入队出队的移动呢?
通过观察,对于一个固定大小的数组,任何位置都可以是队首,如果知道队列长度,就可以根据下面公式计算出队尾位置:
r
e
a
r
=
(
f
r
o
n
t
+
c
o
u
n
t
−
1
)
%
c
a
p
a
c
i
t
y
rear=(front+count−1)\%capacity
rear=(front+count−1)%capacity
其中 capacity 是数组长度,count 是队列长度,front 和 rear 分别是队首和队尾的索引。
按照以上思路,重新优化算法,实现请参考下个章节。
五、循环队列实现【第二版】
奉上优化后的代码:
#include<iostream>
using namespace std;
class MyCircularQueue {
private:
int* arr;
int front;
int rear;
int capacity;
public:
// 构造函数
MyCircularQueue(int k)
{
capacity = k + 1;
arr = new int[capacity];
front = 0;
rear = 0;
}
~MyCircularQueue()
{
delete[] arr;
arr=NULL;
}
// 入队
bool enQueue(int value)
{
if (isFull()) {
return false;
}
arr[rear] = value;
rear = (rear + 1) % capacity;
return true;
}
// 出队
bool deQueue()
{
if (isEmpty())
{
return false;
}
front = (front + 1) % capacity;
return true;
}
// 获取队头元素
int Front()
{
if (isEmpty())
{
return -1;
}
return arr[front];
}
// 获取队尾元素
int Rear()
{
if (isEmpty())
{
return -1;
}
return arr[(rear - 1 + capacity) % capacity];
}
// 判断是否为空
bool isEmpty()
{
return front == rear;
}
// 判断是否已满
bool isFull()
{
return front == (rear + 1) % capacity;
}
};
六、总结
有位前辈曾说过:“设计数据结构的关键是如何设计属性,好的设计属性数量更少“。那么为什么会越少越好呢?原因如下:
- 属性数量少说明属性之间冗余更低,依赖少。
- 属性冗余度越低,操作逻辑越简单,发生错误的可能性更低,出错率低。
- 属性数量少,使用的空间也少,操作性能更高,空间复杂度更低。
但是,凡事不可能是绝对的,一定的冗余可以降低操作的时间复杂度,达到时间复杂度和空间复杂度的相对平衡。根据以上原则,设计循环队列数据结构时,使用了4个属性,下面列举每个属性,并解释其含义。
- arr:一个固定大小的数组,用于保存循环队列的元素。
- front:一个整数,保存队首的索引。
- rear: 保存队尾的索引。
- capacity:循环队列的容量,即队列中最多可以容纳的元素数量
另外,涉及到的计算总结如下:
- 入队:rear = (rear + 1) % capacity;
- 出队:front = (front + 1) % capacity
- 队满:front == (rear + 1) % capacity
- 队空:front == rear
通过整个环节的不停折腾,终于将循环队列的问题敲定。鉴于水平有限,分析后的最优版本肯定还存在优化的空间,希望各位大神批评指正,一起进步。