美文网首页
数据结构-队列结构

数据结构-队列结构

作者: acc8226 | 来源:发表于2021-08-02 20:14 被阅读0次

    如何理解“队列”?

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

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

    顺序队列和链式队列

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

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

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

    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 进行数据迁移。

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

    基于链表的实现,我们同样需要两个指针: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 时,会有数据搬移操作,这样入队操作性能就会受到影响。那有没有办法能够避免数据搬移呢?我们来看看循环队列的解决思路。

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

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

    通过这样的方法,我们成功避免了数据搬移操作。看起来不难理解,但是循环队列的代码实现难度要比前面讲的非循环队列难多了。要想写出没有 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

    相关文章

      网友评论

          本文标题:数据结构-队列结构

          本文链接:https://www.haomeiwen.com/subject/lgzceltx.html