数据结构-队列结构

简介: 如何理解“队列”?队列这个概念非常好理解。你可以把它想象成排队买票,先来的先买,后来的人只能站末尾,不允许插队。先进者先出,这就是典型的“队列”。我们知道,栈只支持两个基本操作:入栈 push()和出栈 pop()。队列跟栈非常相似,支持的操作也很有限,最基本的操作也是两个:入队 enqueue(),放一个数据到队列尾部;出队 dequeue(),从队列头部取一个元素。

如何理解“队列”?



队列这个概念非常好理解。你可以把它想象成排队买票,先来的先买,后来的人只能站末尾,不允许插队。先进者先出,这就是典型的“队列”。


我们知道,栈只支持两个基本操作:入栈 push()和出栈 pop()。队列跟栈非常相似,支持的操作也很有限,最基本的操作也是两个:入队 enqueue(),放一个数据到队列尾部;出队 dequeue(),从队列头部取一个元素。


image.png


顺序队列和链式队列



我们知道了,队列跟栈一样,也是一种抽象的数据结构。它具有先进先出的特性,支持在队尾插入元素,在队头删除元素,那究竟该如何实现一个队列呢?


跟栈一样,队列可以用数组来实现,也可以用链表来实现。用数组实现的栈叫作顺序栈,用链表实现的栈叫作链式栈。同样,用数组实现的队列叫作顺序队列,用链表实现的队列叫作链式队列。


我们先来看下基于数组的实现方法。


IQueue 接口定义

package com.s4.queue;
public interface IQueue {
    // 队列的已用长度
    public abstract int getLength();
    // 队列的预设长度
    public abstract int getPresetLength();
    // 入队操作
    public abstract boolean enqueue(Object value);
    public abstract boolean isEmpty();
    public abstract boolean isFull();
    // 出队操作,复杂度是 O(1)
    public abstract Object dequeue();
}


ArrayQueue 关键代码

/*
     * 入队操作: 这个是优化后的方案。入队操作的时间复杂度还是 O(1)
     */
    @Override
    public boolean enqueue(Object value) {
        if (this.isFull()) {
            if (this.head == 0) {
                return false;
            }
            // 进行迁移[head, tail) 左移 head 位
            for (int i = this.head; i < this.tail; i++) {
                this.datas[i - this.head] = this.datas[i];
            }
            // 搬移完之后更新 head 和 tail
            this.tail -= this.head;
            this.head = 0;
        }
        this.datas[this.tail] = value;
        this.tail++;
        return true;
    }
    // 入队操作: 这是一个错误示例,一个较差的方法
    public boolean enqueueBad(Object value) {
        if (this.isFull()) {
            return false;
        }
        this.datas[this.tail] = value;
        this.tail++;
        return true;
    }
    /*
     * 出队操作,复杂度是 O(1)
     */
    @Override
    public Object dequeue() {
        if (this.isEmpty()) {
            return null;
        }
        Object ret = this.datas[this.head];
        this.head++;
        return ret;
    }
    // 每次出队都会迁移,会增加时间复杂度为O(n),因此不推荐这么做
    @Deprecated
    public Object dequeue2() {
        if (this.isEmpty()) {
            return null;
        }
        // 每次进行出队操作都相当于删除数组下标为 0 的数据
        Object ret = this.datas[0];
        // [1, tail -1] 整体左移一位 [1, tail) | [0, tail - 1)
        for (int i = 0; i < tail - 1; i++) {
            this.datas[i] = this.datas[i + 1];
        }
        this.tail--;
        this.datas[this.tail] = null;
        return ret;
    }
}


为了解决随着不停地进行入队、出队操作,head 和 tail 都会持续往后移动。当 tail 移动到最右边,即使数组中还有空闲空间,也无法继续往队列中添加数据了的问题?


除了 dequeue2  这种思路外,可以在依旧保留出队代码不变的基础上,在入队的时候使用 enqueue 进行数据迁移。


image.png

image.png


接下来,我们再来看下基于链表的队列实现方法。


基于链表的实现,我们同样需要两个指针:head 指针和 tail 指针。它们分别指向链表的第一个结点和最后一个结点。如图所示,入队时,tail->next= new_node, tail = tail->next;出队时,head = head->next。我将具体的代码放到 GitHub 上,你可以自己试着实现一下,然后再去 GitHub 上跟我实现的代码对比下,看写得对不对。


网络异常,图片无法展示
|


LinkedQueue 关键代码

// 入队操作
    public boolean enqueue(Object value) {
        if (this.isFull()) {
            return false;
        }
        Node node = new Node();
        node.data = value;
        node.next = null;
        if (this.isEmpty()) {
            this.head = node;
        } else {
            this.tail.next = node;
        }
        this.tail = node;
        this.length++;
        return true;
    }
    // 出队操作,复杂度是 O(1)
    public Object dequeue() {
        if (this.isEmpty()) {
            return null;
        }
        Node nextNode = this.head.next;
        Object ret = this.head.data;
        this.head.data = null;
        this.head.next = null;
        this.head = nextNode;
        if (nextNode == null) {
            this.tail = null;
        }
        this.length--;
        return ret;
    }


循环队列



我们刚才用数组来实现队列的时候,在 tail==n 时,会有数据搬移操作,这样入队操作性能就会受到影响。那有没有办法能够避免数据搬移呢?我们来看看循环队列的解决思路。


循环队列,顾名思义,它长得像一个环。原本数组是有头有尾的,是一条直线。现在我们把首尾相连,扳成了一个环。我画了一张图,你可以直观地感受一下。


image.png


我们可以发现,图中这个队列的大小为 8,当前 head=4,tail=7。当有一个新的元素 a 入队时,我们放入下标为 7 的位置。但这个时候,我们并不把 tail 更新为 8,而是将其在环中后移一位,到下标为 0 的位置。当再有一个元素 b 入队时,我们将 b 放入下标为 0 的位置,然后 tail 加 1 更新为 1。所以,在 a,b 依次入队之后,循环队列中的元素就变成了下面的样子:


image.png

通过这样的方法,我们成功避免了数据搬移操作。看起来不难理解,但是循环队列的代码实现难度要比前面讲的非循环队列难多了。要想写出没有 bug 的循环队列的实现代码,我个人觉得,最关键的是,确定好队空和队满的判定条件。


  • 队列为空的判断条件仍然是 head == tail


  • 当队满时,(tail+1)%n=head,


  • 当队满时,图中的 tail 指向的位置实际上是没有存储数据的。所以,循环队列会浪费一个数组的存储空间。


CircleQueue 关键代码

/*
     * 入队
     */
    @Override
    public boolean enqueue(Object value) {
        if (this.isFull()) {
            return false;
        }
        this.datas[this.tail] = value;
        this.tail = indexPlus(this.tail);
        return true;
    }
    /*
     * 出队,复杂度是 O(1)
     */
    @Override
    public Object dequeue() {
        if (this.isEmpty()) {
            return null;
        }
        Object ret = this.datas[this.head];
        this.head = this.indexPlus(this.head);
        return ret;
    }


阻塞队列和并发队列


前面讲的内容理论比较多,看起来很难跟实际的项目开发扯上关系。确实,队列这种数据结构很基础,平时的业务开发不大可能从零实现一个队列,甚至都不会直接用到。而一些具有特殊特性的队列应用却比较广泛,比如阻塞队列和并发队列。


阻塞队列其实就是在队列基础上增加了阻塞操作。简单来说,就是在队列为空的时候,从队头取数据会被阻塞。因为此时还没有数据可取,直到队列中有了数据才能返回;如果队列已经满了,那么插入数据的操作就会被阻塞,直到队列中有空闲位置后再插入数据,然后再返回。


你应该已经发现了,上述的定义就是一个“生产者 - 消费者模型”!是的,我们可以使用阻塞队列,轻松实现一个“生产者 - 消费者模型”!


这种基于阻塞队列实现的“生产者 - 消费者模型”,可以有效地协调生产和消费的速度。当“生产者”生产数据的速度过快,“消费者”来不及消费时,存储数据的队列很快就会满了。这个时候,生产者就阻塞等待,直到“消费者”消费了数据,“生产者”才会被唤醒继续“生产”。


而且不仅如此,基于阻塞队列,我们还可以通过协调“生产者”和“消费者”的个数,来提高数据的处理效率。比如前面的例子,我们可以多配置几个“消费者”,来应对一个“生产者”。


前面我们讲了阻塞队列,在多线程情况下,会有多个线程同时操作队列,这个时候就会存在线程安全问题,那如何实现一个线程安全的队列呢?


线程安全的队列我们叫作并发队列。最简单直接的实现方式是直接在 enqueue()、dequeue() 方法上加锁,但是锁粒度大并发度会比较低,同一时刻仅允许一个存或者取操作。实际上,基于数组的循环队列,利用 CAS 原子操作,可以实现非常高效的并发队列。这也是循环队列比链式队列应用更加广泛的原因。在实战篇讲 Disruptor 的时候,我会再详细讲并发队列的应用。


内容小结



我的代码实现


https://gitee.com/kaiLee/struct/tree/master/src/main/java/com/s4


今天我们讲了一种跟栈很相似的数据结构,队列。关于队列,你能掌握下面的内容,这节就没问题了。


队列最大的特点就是先进先出,主要的两个操作是入队和出队。跟栈一样,它既可以用数组来实现,也可以用链表来实现。用数组实现的叫顺序队列,用链表实现的叫链式队列。特别是长得像一个环的循环队列。


在数组实现队列的时候,会有数据搬移操作,要想解决数据搬移的问题,我们就需要像环一样的循环队列。循环队列是我们这节的重点。要想写出没有 bug 的循环队列实现代码,关键要确定好队空和队满的判定条件,具体的代码你要能写出来。


除此之外,我们还讲了几种高级的队列结构,阻塞队列、并发队列,底层都还是队列这种数据结构,只不过在之上附加了很多其他功能。阻塞队列就是入队、出队操作可以阻塞,并发队列就是队列的操作多线程安全。


参考



09 | 队列:队列在线程池等有限资源池中的应用


https://time.geekbang.org/column/article/41330


java/09_queue · 编程语言算法集/algo - 码云 - 开源中国


https://gitee.com/TheAlgorithms/algo/tree/master/java/09_queue



目录
相关文章
|
16天前
|
存储 Java
【数据结构】优先级队列(堆)从实现到应用详解
本文介绍了优先级队列的概念及其底层数据结构——堆。优先级队列根据元素的优先级而非插入顺序进行出队操作。JDK1.8中的`PriorityQueue`使用堆实现,堆分为大根堆和小根堆。大根堆中每个节点的值都不小于其子节点的值,小根堆则相反。文章详细讲解了如何通过数组模拟实现堆,并提供了创建、插入、删除以及获取堆顶元素的具体步骤。此外,还介绍了堆排序及解决Top K问题的应用,并展示了Java中`PriorityQueue`的基本用法和注意事项。
22 5
【数据结构】优先级队列(堆)从实现到应用详解
|
4天前
|
前端开发
07_用队列实现栈
07_用队列实现栈
|
4天前
|
测试技术
02_由两个栈组成的队列
02_由两个栈组成的队列
|
7天前
|
存储
|
24天前
|
存储 算法 C语言
数据结构基础详解(C语言): 二叉树的遍历_线索二叉树_树的存储结构_树与森林详解
本文从二叉树遍历入手,详细介绍了先序、中序和后序遍历方法,并探讨了如何构建二叉树及线索二叉树的概念。接着,文章讲解了树和森林的存储结构,特别是如何将树与森林转换为二叉树形式,以便利用二叉树的遍历方法。最后,讨论了树和森林的遍历算法,包括先根、后根和层次遍历。通过这些内容,读者可以全面了解二叉树及其相关概念。
|
24天前
|
存储 机器学习/深度学习 C语言
数据结构基础详解(C语言): 树与二叉树的基本类型与存储结构详解
本文介绍了树和二叉树的基本概念及性质。树是由节点组成的层次结构,其中节点的度为其分支数量,树的度为树中最大节点度数。二叉树是一种特殊的树,其节点最多有两个子节点,具有多种性质,如叶子节点数与度为2的节点数之间的关系。此外,还介绍了二叉树的不同形态,包括满二叉树、完全二叉树、二叉排序树和平衡二叉树,并探讨了二叉树的顺序存储和链式存储结构。
|
24天前
|
存储 C语言
数据结构基础详解(C语言): 栈与队列的详解附完整代码
栈是一种仅允许在一端进行插入和删除操作的线性表,常用于解决括号匹配、函数调用等问题。栈分为顺序栈和链栈,顺序栈使用数组存储,链栈基于单链表实现。栈的主要操作包括初始化、销毁、入栈、出栈等。栈的应用广泛,如表达式求值、递归等场景。栈的顺序存储结构由数组和栈顶指针构成,链栈则基于单链表的头插法实现。
147 3
|
25天前
|
Java
【数据结构】栈和队列的深度探索,从实现到应用详解
本文介绍了栈和队列这两种数据结构。栈是一种后进先出(LIFO)的数据结构,元素只能从栈顶进行插入和删除。栈的基本操作包括压栈、出栈、获取栈顶元素、判断是否为空及获取栈的大小。栈可以通过数组或链表实现,并可用于将递归转化为循环。队列则是一种先进先出(FIFO)的数据结构,元素只能从队尾插入,从队首移除。队列的基本操作包括入队、出队、获取队首元素、判断是否为空及获取队列大小。队列可通过双向链表或数组实现。此外,双端队列(Deque)支持两端插入和删除元素,提供了更丰富的操作。
26 0
【数据结构】栈和队列的深度探索,从实现到应用详解
|
2月前
|
机器学习/深度学习 消息中间件 缓存
栈与队列的实现
栈与队列的实现
40 0
|
2月前
|
存储 算法 测试技术
【初阶数据结构篇】实现顺序结构二叉树(堆的实现方法)
注意传过去的参数是插入的位置,即插入前的size,在调整完后再将size++