【数据结构】 深度剖析循环队列

写在前面
在平常工作或面试中,都会涉及到数据结构。在某些情况下,系统提供的数据结构无法满足特定的需求,此时,扩展或重写适合自己需求的数据结构就显得相当重要。而如何设计高效、简洁的数据结构,就成了考察程序员功底的一个重要依据。

文章目录

    • 一、问题引入
    • 二、问题初步分析
    • 三、循环队列实现【第一版】
    • 四、第一版本分析优化
    • 五、循环队列实现【第二版】
    • 六、总结

一、问题引入

某厂笔试题目:请基于学习过的队列知识,重新设计一个“循环队列”。要求:

  • 线性结构。
  • 操作表现基于FIFO(先进先出)原则,且队尾和队头之间需要连在一起,形成一个环。
  • 满足时间复杂度O(1),空间复杂度O(N)。
  • 实现语言任意。

二、问题初步分析

    各位大佬,拿到这个面试题或者需求该怎么分析呢?

    首先,线性结构,首选的肯定是数组,在不同语言中也可以选择不同的线性存储结构,比如C++的 vector容器。

    接着,FIFO,如何实现先进先出?以及队尾和队头之间连在一起形成环?在分析到这里的时候,我当时已经是一脸懵,但是仔细一想,如果设置两个标志指针,一个指向头,一个指向尾,不就行了。

     再接着分析,满足时间复杂度O(1),即满足线性时间,一般是入队和出队不能有遍历的情况。空间复杂度O(N),用数组就可以直接实现。
     以上分析后,拿起键盘,噼里啪啦一顿乱敲,初版形成了,请看下个章节。
    

三、循环队列实现【第一版】

#include
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 都指向同一位置 ,如下图
【数据结构】 深度剖析循环队列_第1张图片
【数据结构】 深度剖析循环队列_第2张图片
由上两图可以得出循环队列空队的判断条件。

    开始入队,头结点 front保持在0号位置 ,尾结点随着入队元素变动,如图尾结点 rear 在2号位置。形成一个连续的队列。
【数据结构】 深度剖析循环队列_第3张图片
    接着继续入队,队满时出队,头结点 front 开始移动,如图头结点在2号位置,尾结点 rear在4号位置,队列状态如下:
【数据结构】 深度剖析循环队列_第4张图片
    上述情况下,如果继续入队,一般普通的队列会提示队满,因为尾结点已经在队列末尾了。但是实际情况是队头还有两个空位置,这就浪费了空间。作为循环队列,是完全可以避免空间浪费的,如下图:
【数据结构】 深度剖析循环队列_第5张图片
    那么,什么情况下才无法继续入队呢?队满的状态是什么呢?下图所示:
【数据结构】 深度剖析循环队列_第6张图片
    由此可见当 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+count1)%capacity

其中 capacity 是数组长度,count 是队列长度,front rear 分别是队首和队尾的索引。
    按照以上思路,重新优化算法,实现请参考下个章节。

五、循环队列实现【第二版】

奉上优化后的代码:

#include 
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;
	}
};

六、总结

    有位前辈曾说过:“设计数据结构的关键是如何设计属性,好的设计属性数量更少“。那么为什么会越少越好呢?原因如下:

  1. 属性数量少说明属性之间冗余更低,依赖少。
  2. 属性冗余度越低,操作逻辑越简单,发生错误的可能性更低,出错率低。
  3. 属性数量少,使用的空间也少,操作性能更高,空间复杂度更低。

    但是,凡事不可能是绝对的,一定的冗余可以降低操作的时间复杂度,达到时间复杂度和空间复杂度的相对平衡。根据以上原则,设计循环队列数据结构时,使用了4个属性,下面列举每个属性,并解释其含义。

  • arr:一个固定大小的数组,用于保存循环队列的元素。
  • front:一个整数,保存队首的索引。
  • rear: 保存队尾的索引。
  • capacity:循环队列的容量,即队列中最多可以容纳的元素数量

    另外,涉及到的计算总结如下:

  • 入队:rear = (rear + 1) % capacity;
  • 出队:front = (front + 1) % capacity
  • 队满:front == (rear + 1) % capacity
  • 队空:front == rear

    通过整个环节的不停折腾,终于将循环队列的问题敲定。鉴于水平有限,分析后的最优版本肯定还存在优化的空间,希望各位大神批评指正,一起进步。

你可能感兴趣的:(数据结构,队列,数据结构,算法,c++)