数据结构——队列

本文介绍了队列的基本概念和操作,包括顺序队列、链式队列、循环队列以及阻塞队列和并发队列。重点讨论了队列在线程池中的应用,分析了不同实现方式对响应时间和资源利用的影响,强调了队列在系统设计中的重要性。

队列也是一种数据结构,今天我们主要学习几种基本的队列,然后学习下队列在线程池中的应用。

1. 什么是队列?

队列具有“先进先出,后进后出”的特点。支持操作的也有限,最基本的操作也只有两种:

  • 入队enqueue():放一个数据到队列尾部

  • 出队dequeue():从队列头部取出一个元素

从上图可以看出,队列和栈一样,都是操作受限的线性表数据结构。

队列作为一种非常基础的数据结构,应用非常广泛,特别是一些具有某些额外特性的队列,比如循环队列、阻塞队列、并发队列。它们在很多底层系统、框架、中间件的开发中,起着非常关键的作用。比如高性能Disruptor、Linux环形缓存等,都用到了循环并发队列;Java concurrent并发包利用ArrayBlockingQueue来实现公平锁等。

2. 顺序队列和链式队列

队列跟栈一样,都可以使用数组和链表来实现。

  • 用数组实现的队列叫作顺序队列

  • 用链表实现的队列叫作链式队列

顺序队列

// 用数组实现的队列
public class ArrayQueue {
  // 数组:items,数组大小:n
  private String[] items;
  private int n = 0;
  // head 表示队头下标,tail 表示队尾下标
  private int head = 0;
  private int tail = 0;

  // 申请一个大小为 capacity 的数组
  public ArrayQueue(int capacity) {
    items = new String[capacity];
    n = capacity;
  }

  // 入队
  public boolean enqueue(String item) {
    // 如果 tail == n 表示队列已经满了
    if (tail == n) return false;
    items[tail] = item;
    ++tail;
    return true;
  }

  // 出队
  public String dequeue() {
    // 如果 head == tail 表示队列为空
    if (head == tail) return null;
    // 为了让其他语言的同学看的更加明确,把 -- 操作放到单独一行来写了
    String ret = items[head];
    ++head;
    return ret;
  }
}

在栈中,我们只需要一个栈顶指针,但在数列中需要两个指针:

  • head指针:指向队头

  • tail指针:指向队尾

为了方便理解,可以结合下面的图来理解:当a、b、c、d依次入队后,队列中的head指针指向下标为0的位置,tail指针指向下标为4的位置。

当调用了两次出队操作后,队列中head指针指向下标为2的位置,tail指针仍然指向下标为4的位置。

从图中我们可以看出,随着不停的进行入队、出队操作,head和tail都会不断的往后移动。当tail移动到最右边时,即使数组中还有空闲的空间,但还是无法继续往队列中添加数据了。为了优化这种情况,我们只需要在出队时,不搬移数据。在入队是,如果没有空闲空间,就集中触发一次数据搬移操作。

所以优化方案就是:出队函数dequeue()保持不变,修改下enqueue()的实现:

// 入队操作,将 item 放入队尾
  public boolean enqueue(String item) {
    // tail == n 表示队列末尾没有空间了
    if (tail == n) {
      // tail ==n && head==0,表示整个队列都占满了
      if (head == 0) return false;
      // 数据搬移
      for (int i = head; i < tail; ++i) {
        items[i-head] = items[i];
      }
      // 搬移完之后重新更新 head 和 tail
      tail -= head;
      head = 0;
    }
    
    items[tail] = item;
    ++tail;
    return true;
  }

从上面的代码中可以看到,当队列的tail指针移动到数组最右边后,如果有新的数据入队,就将head和tail之间的数据,整体搬移到数组中0到tail-head的位置。

这种实现思路,出队操作的时间复杂度是O(1)。

链式队列

基于链表的实现,同样需要两个指针:

  • head指针:指向链表的第一个结点

  • tail指针:指向链表的最后一个结点

代码实现如下:

package queue;

public class QueueBasedOnLinkedList {

  // 队列的队首和队尾
  private Node head = null;
  private Node tail = null;

  // 入队
  public void enqueue(String value) {
    if (tail == null) {
      Node newNode = new Node(value, null);
      head = newNode;
      tail = newNode;
    } else {
      tail.next = new Node(value, null);
      tail = tail.next;
    }
  }

  // 出队
  public String dequeue() {
    if (head == null) return null;

    String value = head.data;
    head = head.next;
    if (head == null) {
      tail = null;
    }
    return value;
  }

  public void printAll() {
    Node p = head;
    while (p != null) {
      System.out.print(p.data + " ");
      p = p.next;
    }
    System.out.println();
  }

  private static class Node {
    private String data;
    private Node next;

    public Node(String data, Node next) {
      this.data = data;
      this.next = next;
    }

    public String getData() {
      return data;
    }
  }
}

为了方便理解,画了如下图:

从图中可以看出:

入队时:tail->next=new_node,tail=tail->next

出队时:head=head->next

3. 循环队列

在前面用数组实现队列的时候,当tail=n时,就会进行数据的搬移操作,这样在入队的时候性能就会受影响。而循环队列就可以有效的避免这种情况下的数据搬移。

循环队列的首尾相连,形成一个环形,如下图所示:

上面队列的大小是8,当前head=4,tail=7。

当有一个新元素a入队时,将其放到下标为7的位置,但此时并不将tail更新为8,而是将其在环中后移一位,到下标为0的位置。

当再有一个新元素b入队时,将其放到下标为0的位置,然后tail加1更新为1。所以,在a、b依次入队后,循环队列就变成了如下所示:

通过上面的方法,就可以避免数据搬移的操作。

在用数组实现的非循环队列中,队满的判断条件是tail == n,队空的判断条件是head==tail。

在循环队列中,队空的条件仍然是head==tail,但队满的条件有点复杂,为了总结出规律,画了一张队满的图,如下所示:

在上图中,head=4,tail=3,n=8,故总结出的规律是(3+1)%8=4,所以在循环队列中,队满的判断条件是:(tail+1)%n=head。

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

循环队列代码实现如下:

public class CircularQueue {
  // 数组:items,数组大小:n
  private String[] items;
  private int n = 0;
  // head 表示队头下标,tail 表示队尾下标
  private int head = 0;
  private int tail = 0;

  // 申请一个大小为 capacity 的数组
  public CircularQueue(int capacity) {
    items = new String[capacity];
    n = capacity;
  }

  // 入队
  public boolean enqueue(String item) {
    // 队列满了
    if ((tail + 1) % n == head) return false;
    items[tail] = item;
    tail = (tail + 1) % n;
    return true;
  }

  // 出队
  public String dequeue() {
    // 如果 head == tail 表示队列为空
    if (head == tail) return null;
    String ret = items[head];
    head = (head + 1) % n;
    return ret;
  }
}
4. 阻塞队列和并发队列

阻塞队列

在队列的基础上加上阻塞操作。

在队列为空的时候,从队头取数据会被阻塞。因为此时没有数据可取,直到队列中有了数据才能返回;在队列满了的时候,从队尾插入数据就会被阻塞,直到队列中有空闲位置时,再插入数据,然后再返回。

从上面的图可以看出,阻塞队列是一种“生产者——消费者模型”,这种模型可以有效地协调生产和消费的速度。当“生产者”的生产速度过快,“消费者”来不及消费时,存储数据的队列很快就会变满,此时,生产者就会阻塞等待,直到“消费者”消费了数据,“生产者”才会被唤醒继续“生产”。

其实,基于阻塞队列,我们可以通过协调“生产者”和“消费者”的个数来提高数据的处理效率,例如我们可以配置多个“消费者”,如下图所示:

并发队列

在多线程情况下,会有多个线程同时操作队列,而线程安全的队列我们叫作“并发队列”。

并发队列最简单的实现方式就是直接在enqueue()、dequeue()的方法上加锁,同一时刻只允许一个存或取的操作。但当锁粒度较大时,并发度会比较低。

5. 队列在线程池中的应用

当线程池中没有空闲线程时,此时线程池一般有两种处理策略:

  1. 非阻塞式的处理方式,直接拒绝任务请求

  2. 阻塞式的处理方式,将请求排队,等到有空闲线程时,取出排队的请求继续处理。

为了公平的处理每个排队的请求,我们可以采用队列这种数据结构去处理。从前面我们可以知道队列有基于链表和数组的两种实现方式,但这两种实现方式对于排队请求又有区别:

  • 基于链表的实现方式,可以实现一个支持无限排队的无解队列,但可能会导致过多的请求排队等待,请求处理响应的时间过长,所以,针对响应时间比较敏感的系统,基于链表实现的无限排队线程池是不合适的。

  • 基于数组实现的有界队列,队列的大小有限,在线程池中排队请求超过队列大小时,后续的请求就会直接被拒绝。这种方式对响应时间敏感的系统来说,就会更加合理。但是,设置一个合理的队列大小,也是非常重要的。队列太大会导致等待的请求太多,队列太小会导致无法充分利用系统资源,不能发挥出最大性能。

实际上,对于大部分资源有限的场景,当没有空闲资源时,基本上都可以通过“队列”这种数据结构来实现请求排队。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值