摘要:
「队列(Queue)」也是一种受限的线性表数据结构,有先入先出的特点,可以使用数组或链表实现,也有很多的真实应用场景。
队列
队列也是线性表数据结构的一种,不过操作受到一定的限制,只能从一端插入数据,另一端取出数据,这样受限的操作也就使队列有了数据先入先出的特点,插入数据和取出数据的操作分别称为「入队」和「出队」。
队列的实现可以用数组和链表两种方式实现,用数组实现的称为「顺序队列」,使用链表方式实现的称为「链式队列」,接下来实现一下顺序队列。
顺序队列
先上代码
public class ArrayQueue {
private int head = 0;
private int tail = 0;
private int n;
private String[] items;
public ArrayQueue(int size) {
this.n = size;
items = new String[size];
}
public boolean enqueue(String item) {
if(tail - head == n) {
return false;
} else if(tail == n) {
moveData();
}
items[tail] = item;
tail++;
return true;
}
private void moveData() {
for(int i = head; i < tail; i++) {
items[i - head] = items[i];
}
tail = tail - head;
head = 0;
}
public String dequeue() {
if(head == tail) {
return null;
}
String result = items[head];
head++;
return result;
}
}
在顺序队列的实现中,当队满时会有两种情况,一种是整个队列已经没有空闲空间存储数据,另一种情况是队列的队尾指针指向了数组的尾部,但有些数据已经进行了出队操作,此时的队头指针并不是指向数组头部,第二种情况的队列其实未满,但要将队列尾部空闲空间留出,就需要进行数据搬移。
接着来分析一下数据搬移操作下,入队操作的时间复杂度为多少?假设队列大小为 n,当前空余的队列空间为 m,此时队尾指针指向数组尾部,需要进行数据搬移,需要循环 n − m n - m n−m 次,时时间复杂度为 O ( n − m ) O(n-m) O(n−m),数据搬移完成后可以进行 m 次不需要数据搬移的入队操作,所以将数据搬移的 O ( n − m ) O(n - m) O(n−m) 时间复杂度均摊至 m 次不需要数据搬移的入队操作上,入队操作的时间复杂度为 O ( n − m m ) O(\frac{n-m}{m}) O(mn−m)。
看过来这种情况下入队操作的时间复杂度被两个不同的数据规模影响,其实可以通过概率的方式统计下平均情况。对于一个大小为 n 的队列,空闲空间的大小可能有 1 , 2 , 3 , . . . , n 1,2,3,...,n 1,2,3,...,n 总共 n 种可能,假设每种可能出现的机率相同都为 1 n \frac{1}{n} n1,平均情况下空闲空间大小为 n ( n + 1 ) 2 n = n + 1 2 \frac{n(n+1)}{2n}=\frac{n+1}{2} 2nn(n+1)=2n+1。使用同样的方式分析平均情况下需要数据搬移的次数,分别是 n − 1 , n − 2 , n − 3 , . . . , 1 , 0 n-1,n-2,n-3,...,1,0 n−1,n−2,n−3,...,1,0,假设每种情况出现的机率相同都为 1 n \frac{1}{n} n1,平均情况下需要数据搬移 n ( n − 1 ) 2 n = n − 1 2 \frac{n(n-1)}{2n}=\frac{n-1}{2} 2nn(n−1)=2n−1 次。空闲空间的数量就是进行搬移操作后可连续入队不需要搬移数据的次数,可以将搬移次数均摊,入队的时间复杂度就为 n − 1 2 n + 1 2 = n − 1 n + 1 = 1 − 2 n + 1 = O ( 1 ) \frac{\frac{n-1}{2}}{\frac{n+1}{2}}=\frac{n-1}{n+1}=1-\frac{2}{n+1}=O(1) 2n+12n−1=n+1n−1=1−n+12=O(1),所以即使在入队时加入了数据搬移操作,时间复杂度依然是 O ( 1 ) O(1) O(1)。
循环队列
顺序队列会存在数据搬移的操作,为了避免数据搬移操作可以使用循环队列来解决,循环队列就是当队尾指针指向数组尾部时会转向数组头部开始,这样就不需要通过数据搬移的操作将队队列的空闲空间转移出来供入队操作使用。
循环队列实现如下
public class LoopQueue {
private int head = 0;
private int tail = 0;
private int n;
private String[] items;
public LoopQueue(int size) {
this.n = size;
items = new String[size];
}
public boolean enqueue(String item) {
if((tail + 1) % n == head) {
return false;
}
items[tail] = item;
tail = (tail + 1) % n;
return true;
}
public String dequeue() {
if(head == tail) {
return null;
}
String result = items[head];
head++;
return result;
}
}
从代码中可以看出,循环队列的重点在于对队空队满的判断,当 head == tail
时为队空,当 (tail + 1) % 数组长度 == head
时为队满,这样会使队列中有一个空闲空间用来判断队满,导致队列中有一个存储空间被浪费。
实际场景
队列在实际场景中的使用多为阻塞队列、并发队列,主要讲一下阻塞队列。
阻塞队列就是在队空时出队操作会被阻塞,直到队列中有了数据才会返回,当队满时数据入队会也会阻塞,直到队列有空闲空间时才插入数据,然后再返回。
当有新任务请求线程资源而线程池中没有空闲线程时,线程池有两种处理方式,一种是直接拒绝请求,这种方式不会阻塞,而另一种方式是阻塞的方式,当没有空闲线程时就将请求排队,有空闲线程时再将队头的请求出队,进行处理。
排队的队列也可以使用数组或链表的方式实现,但是使用链表时可以形成无限长度的排队队列,这样会造成排队在队列后面的请求等待时间过长,对于对响应时间有要求的系统来说不太理想,如果使用数组来实现这个排队队列,因为在数组初始化时就已经限制了队列大小,所以不会导致有过多排队阻塞的情况,不过队列的大小设置也是有讲究的,要实现资源的合理利用又要发挥最大性能。
文章中如有问题欢迎留言指正
此章节关于顺序栈、链式栈等代码已经上传GitHub,可点击此处跳转查看。
数据结构与算法之美笔记系列将会做为我对王争老师此专栏的学习笔记,如想了解更多王争老师专栏的详情请到极客时间自行搜索。