目录
基本概念
队列(Queue)是只允许在一端进行插入操作,而在另一端进行删除操作的线性表。队列是一种先进先出(First In First Out)的线性表,简称FIFO。允许插入的一端称为队尾,允许删除的一端称为队头。同样是线性表,队列也有类似线性表的各种操作,不同的是插入数据之只能在队尾进行,删除数据只能在队头进行。
从队列的定义,我们发现队列实现起来并不难。比如:下图中,是一个实现声明了10个长度的队列。加入现在已经添加了10条数据。然后从队头删除两个元素,接下来在添加数据,应该怎么添加呢?
这个时候有两个办法:
方法一:每删除一个元素,后边的数据都往前移动一位。这样很明显会造成性能的浪费。
方法二:从图中可以得知,下标0,1的位置已经空了,这种现象被成为“假溢出”,可以把k 放到下标为0的地方,我们只需要知道当前队头在哪个位置就可以了。所以此时需要使用head和rear两个指针(游标)分别指向队列的前端和末尾。
在C#的源码中,使用的就是方法二。我们一起来看一下C#中对于队列的实现思路。
源码:
https://referencesource.microsoft.com/#System/compmod/system/collections/generic/queue.cs
API:
https://learn.microsoft.com/zh-cn/dotnet/api/system.collections.generic.queue-1?view=net-7.0
队列的顺序存储(C#源码分析)
重要字段
private T[] _array; //储存数据的数组
private int _head; // 前端指针,队列中的第一个有效元素
private int _tail; // 末尾指针,队列中的最后一个有效元素
private int _size; // 元素个数
//默认队列初始化时的默认长度是4,当长度不够时,以当前长度的2倍扩容。
private const int _DefaultCapacity = 4;
构造函数
- Queue<T>():初始化 Queue<T> 类的新实例,该实例为空并且具有默认初始容量。
- Queue<T>(IEnumerable<T>):初始化 Queue<T> 类的新实例,该实例包含从指定集合复制的元素并且具有足够的容量来容纳所复制的元素。
- Queue<T>(Int32):初始化 Queue<T> 类的新实例,该实例为空并且具有指定的初始容量。
// Creates a queue with room for capacity objects. The default initial
// capacity and grow factor are used.
// 为容量对象创建具有空间的队列。使用默认的初始容量和增长因子。
public Queue() {
_array = _emptyArray;
}
// Creates a queue with room for capacity objects. The default grow factor
// is used.
// 根据传入的容量数创建队列。使用默认的生长因子。
public Queue(int capacity) {
if (capacity < 0)
ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.capacity, ExceptionResource.ArgumentOutOfRange_NeedNonNegNumRequired);
_array = new T[capacity];
_head = 0;
_tail = 0;
_size = 0;
}
// Fills a Queue with the elements of an ICollection. Uses the enumerator
// to get each of the elements.
// 用IEnumerable的元素填充Queue。使用枚举数来获取每个元素。
public Queue(IEnumerable<T> collection)
{
if (collection == null)
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.collection);
_array = new T[_DefaultCapacity];
_size = 0;
_version = 0;
using(IEnumerator<T> en = collection.GetEnumerator()) {
while(en.MoveNext()) {
Enqueue(en.Current);
}
}
}
属性 Count
//只有get方法, 返回_szie
public int Count {
get { return _size; }
}
Clear()方法
从 Queue<T> 中移除所有对象。
public void Clear() {
//因为是循环数组,所以需要判断前端指针和末尾指针的位置,不然可能会有遗漏
if (_head < _tail)
Array.Clear(_array, _head, _size);
else {
Array.Clear(_array, _head, _array.Length - _head);
Array.Clear(_array, 0, _tail);
}
//把字段设置为默认值
_head = 0;
_tail = 0;
_size = 0;
_version++;
}
Enqueue()方法
首先判断容量是否够用,不够用的话进行扩容。然后把数据存到游标的位置,并且通过取余的方法,计算下一次添加数据时,末尾游标应该在的位置。
// Adds item to the tail of the queue.
// 将对象添加到 Queue<T> 的结尾处。
public void Enqueue(T item) {
//判断容量是否够用,不够用扩容。
if (_size == _array.Length) {
int newcapacity = (int)((long)_array.Length * (long)_GrowFactor / 100);
if (newcapacity < _array.Length + _MinimumGrow) {
newcapacity = _array.Length + _MinimumGrow;
}
SetCapacity(newcapacity);
}
//添加到末尾
_array[_tail] = item;
//通过取余的方法,计算下一次添加数据时,末尾游标应该在的位置。
_tail = (_tail + 1) % _array.Length;
_size++;
_version++;
}
// PRIVATE Grows or shrinks the buffer to hold capacity objects. Capacity
// must be >= _size.
// 私有。增大或缩小缓冲区以容纳容量对象。必须容量 >= _size
private void SetCapacity(int capacity) {
T[] newarray = new T[capacity];
if (_size > 0) {
if (_head < _tail) {
Array.Copy(_array, _head, newarray, 0, _size);
} else {
Array.Copy(_array, _head, newarray, 0, _array.Length - _head);
Array.Copy(_array, 0, newarray, _array.Length - _head, _tail);
}
}
_array = newarray;
_head = 0;
_tail = (_size == capacity) ? 0 : _size;
_version++;
}
//如果元素数小于当前容量的 90%,将容量设置为 Queue<T> 中的实际元素数
public void TrimExcess() {
int threshold = (int)(((double)_array.Length) * 0.9);
if( _size < threshold ) {
SetCapacity(_size);
}
}
当扩容的时候,会使用到创建的数据,把原来的数据Copy到新的数组。这个时候会消耗性能。
Dequeue()方法
移除队列头部的对象并返回它,并且通过取余的方法,把前端指针往后移动一位,并且计算它应该在的位置。
// Removes the object at the head of the queue and returns it. If the queue
// is empty, this method simply returns null.
// 移除队列头部的对象并返回它。如果队列为空,则此方法返回null
public T Dequeue() {
if (_size == 0)
ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_EmptyQueue);
T removed = _array[_head];
_array[_head] = default(T);
//通过取余的方法,把前端指针往后移动一位,并且计算它应该在的位置。
_head = (_head + 1) % _array.Length;
_size--;
_version++;
return removed;
}
Peek()方法
返回位于 Queue<T> 开始处的对象但不将其移除
// Returns the object at the head of the queue. The object remains in the
// queue. If the queue is empty, this method throws an
// InvalidOperationException.
public T Peek() {
if (_size == 0)
ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_EmptyQueue);
return _array[_head];
}
Contains()方法
通过循环的方法确定某元素是否在 Queue<T> 中,这个方法还是会消耗一定的性能,尤其是在数量比较大的时候。
// Returns true if the queue contains at least one object equal to item.
// Equality is determined using item.Equals().
// 如果队列中至少包含一个等于item的对象,则返回true。相等性由item.Equals()确定。
ublic bool Contains(T item) {
int index = _head;
int count = _size;
EqualityComparer<T> c = EqualityComparer<T>.Default;
while (count-- > 0) {
if (((Object) item) == null) {
if (((Object) _array[index]) == null)
return true;
}
else if (_array[index] != null && c.Equals(_array[index], item)) {
return true;
}
index = (index + 1) % _array.Length;
}
return false;
}
队列的链式存储
首先,创建一个Node类,用于记录数据和指向下一个结点的指针。
public class Node<T>
{
public T Data; // 数据
public Node<T> Next; // 指向下一个节点的指针
public Node(T data)
{
Data = data;
Next = null;
}
}
然后创建一个LinkedQueue<T>类,包含Head(头节点)和Tail(尾节点)两个字段,这两个更像是游标,可以快速的定位到队头和队尾。
下面是完整代码,并且在每个方法上都标注了内部逻辑。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace LinkedQueue
{
internal class LinkedQueue<T>
{
public class Node<T>
{
public T Data; // 数据
public Node<T> Next; // 指向下一个节点的指针
public Node(T data)
{
Data = data;
Next = null;
}
}
private Node<T> Head; // 头节点
private Node<T> Tail; // 尾节点
public LinkedQueue()
{
Head = null;
Tail = null;
}
/// <summary>
/// 检查队列是否为空
/// 如果头节点为null,说明队列为空。
/// </summary>
/// <returns>队列为空返回true,否则返回false</returns>
public bool IsEmpty()
{
return Head == null;
}
/// <summary>
/// 入队
/// 创建一个新节点,并将其添加到队列尾部。
/// 如果队列为空,则新节点同时作为头节点和尾节点。
/// 否则,将新节点添加到尾节点后面,并更新尾节点。
/// </summary>
/// <param name="data">要入队的元素</param>
public void Enqueue(T data)
{
Node<T> newNode = new Node<T>(data);
if (IsEmpty())
{
Head = Tail = newNode; // 队列为空时,新节点既是头节点也是尾节点
}
else
{
Tail.Next = newNode; // 将新节点添加到尾节点后面
Tail = newNode; // 更新尾节点
}
}
/// <summary>
/// 出队
/// 从队列头部移除并返回元素。
/// 如果队列为空,抛出异常。
/// 否则,获取头节点的数据,更新头节点为下一个节点。
/// 如果新的头节点为空,说明队列已经为空,将尾节点也设置为null。
/// </summary>
/// <returns>返回队列头部的元素</returns>
public T Dequeue()
{
if (IsEmpty())
{
throw new Exception("Queue is empty");
}
else
{
T data = Head.Data; // 获取头节点的数据
Head = Head.Next; // 更新头节点
if (Head == null)
{
Tail = null; // 如果头节点为空,尾节点也应为空
}
return data;
}
}
/// <summary>
/// 查看队列头部的元素,但不移除它
/// 如果队列为空,抛出异常。
/// 否则,返回头节点的数据。
/// </summary>
/// <returns>返回队列头部的元素</returns>
public T Peek()
{
if (IsEmpty())
{
throw new Exception("Queue is empty");
}
else
{
return Head.Data;
}
}
/// <summary>
/// 判断队列中是否包含某个元素
/// 遍历链表,检查每个节点的数据是否与给定元素相等。
/// 如果找到相等的数据,返回true。
/// 否则,返回false。
/// </summary>
/// <param name="data">要查找的元素</param>
/// <returns>包含返回true,否则返回false</returns>
public bool Contains(T data)
{
Node<T> currentNode = Head;
while (currentNode != null)
{
if (currentNode.Data.Equals(data))
{
return true;
}
currentNode = currentNode.Next; // 遍历链表
}
return false;
}
/// 将队列转换为数组
/// 首先遍历链表,计算队列中的元素个数。
/// 然后创建一个相应大小的数组。
/// 再次遍历链表,将每个节点的数据添加到数组中。
/// 最后返回数组。
/// </summary>
/// <returns>返回包含队列所有元素的数组</returns>
public T[] ToArray()
{
int count = 0;
Node<T> currentNode = Head;
while (currentNode != null)
{
count++;
currentNode = currentNode.Next; // 计算队列元素个数
}
T[] array = new T[count];
currentNode = Head;
for (int i = 0; i < count; i++)
{
array[i] = currentNode.Data;
currentNode = currentNode.Next; // 将队列元素添加到数组中
}
return array;
}
}
}
顺序存储和链式存储的对比
优点:
动态大小:链表实现的队列可以在运行时动态调整大小,因此无需预先分配大量内存。这使得链表实现的队列在处理不确定数量的数据时非常有用。
入队和出队操作的时间复杂度为 O(1):链表实现的队列在入队和出队操作时只需要修改指针,因此操作非常快速。
不会发生内存浪费:链表实现的队列只需要为实际使用的元素分配内存,因此不存在内存浪费的问题。
缺点:
额外的内存开销:链表实现的队列需要额外的内存来存储指向下一个元素的指针。这会导致链表实现的队列比数组实现的队列占用更多的内存。
随机访问效率较低:链表实现的队列不支持随机访问,如果需要访问特定位置的元素,需要从头开始遍历链表,时间复杂度为 O(n)。
用数组实现队列:
优点:
随机访问效率高:数组实现的队列支持随机访问,可以在 O(1) 时间内访问任意位置的元素。
内存连续:数组实现的队列中的元素在内存中是连续的,这有助于提高缓存命中率,从而提高程序的性能。
缺点:
静态大小:数组实现的队列在创建时需要预先分配内存,大小固定。如果队列的大小超过了预先分配的内存,需要重新分配内存并将元素复制到新的内存空间。这会导致额外的时间和空间开销。
入队和出队操作的时间复杂度可能为 O(n):数组实现的队列在进行入队和出队操作时可能需要移动元素,这可能导致 O(n) 的时间复杂度。不过,可以使用循环数组或者双端队列的方式优化这个问题,使得入队和出队操作的时间复杂度降低为 O(1)。
可能存在内存浪费:如果队列的实际使用大小远小于预先分配的内存,会导致内存浪费。