队列的链式存储结构及其基本运算算法的实现
前面有文章已经提及过了如何用顺序存储结构实现队列,如果感兴趣请点击此连接:
队列的定义以及队列的顺序存储结构实现,一文读懂队列的顺序表实现方式
队列的链式存储结构是通过由节点构成的单链表实现的,此时只允许在单链表的表首进行删除操作以及在单链表的表尾进行插入操作,这里的单链表是不带头节点的,需要使用两个指针(front队首指针和rear队尾指针)来标识。用于存储队列的单链表简称为链队。
同样的链队也分为:
- 非循环链队
- 循环链队
1.非循环链队
下图说明了一个链队的动态变化过程。
(a)是链队的初始状态,(b)是在链队中进队3个元素后的状态,(c)是从链队中出队两个元素后的状态,从下图不难看出链队也可以概括出四要素:
(1)队空条件为front = rear == null,不妨就以front == null作为队空条件
(2)由于只有在内存溢出时才会出现队满,所以通常不考虑这样的情况
(3)元素e进队的操作是在单链表尾部插入存放e的s新结点,并让队尾指针指向它
(4)出队操作是取出队首结点的data值,并将其从链队中删除。
链队包含两个主要的类:一个是节点类、另一个是链队泛型类。
他们的定义以及基本算法如下:
class LinkNode<E>{ //链队结点泛型类
E data;
LinkNode<E> next;
public LinkNode(){ //构造方法
next = null;
}
public LinkNode(E d){ //重载构造方法
data = d;
next = null;
}
}
class LinkQueueClass<E>{ //链队泛型类
LinkNode<E> front; //首结点指针
LinkNode<E> rear; //尾结点指针
public LinkQueueClass(){//构造方法
front = null;
rear = null;
}
//队列的基本运算算法,下列算法的时间复杂度均为O(1)。
//判断队是否为空
public boolean empty(){
return front == null;
}
//元素进队
public void push(E e){
LinkNode<E> s = new LinkNode<E>(e);//新建结点s
if (empty()) //原队列为空时
front = rear = s;
else{ //原队列不为空时
rear.next = s; //将s结点链接到rear结点的后面
rear = s;
}
}
//元素出队
public E pop(){
E e;
if (empty())
throw new IllegalArgumentException("队列为空");
if (front == rear){ //原链队只有一个结点
e = (E)front.data; //取首结点的值
front = rear = null; //置为空
}
else{ //原队列有多个节点
e = (E)front.data; //取首结点值
front = front.next; //front指向下一个结点
}
return e;
}
//取队头元素值
public E peek(){
if (empty())
throw new IllegalArgumentException("队列为空");
E e = (E)front.data;
return e;
}
}
链队的应用算法设计示例
【例1】采用一个不带头节点只有一个尾结点指针rear的循环单链表存储队列,设计出这种链队的进队、出队、判断队空和求队中元素个数的算法。
解:如下图所示,用只有尾结点指针rear的循环单链表作为队列存储结构,其中每个结点类型为上面出现过的LinkNode。
class LinkQueueClass1<E>{ //本例链队泛型类
LinkNode<E> rear; //尾结点指针
public LinkQueueClass1(){ //构造方法
rear = null;
}
//队列的基本算法
public boolean empty(){ //判断队列是否为空
return rear == null;
}
//元素e进队
public void push(E e){
LinkNode<E> s = new LinkNode<E>(e); //创建新结点s
if (rear == null){ //原队列为空时的操作
s.next = s;
rear = s;
}else { //将s结点插入rear结点之后
s.next = rear.next;
rear.next = s; //让rear指向s结点
rear = s;
}
}
//出队操作
public E pop(){
E e;
if (empty()) //判断原队列是否为空
throw new IllegalArgumentException("队列为空");
if (rear.next == rear){ //原队列只有一个结点时的做法
e = (E)rear.data;
rear = null;
}else{ //原队列有多个结点的做法
e = (E)rear.next.data;
rear.next = rear.next.next; //删除队头结点
}
return e;
}
//取队头元素操作
public E peek(){
E e;
if (empty()) //原队列不为空
throw new IllegalArgumentException("队列为空");
if (rear.next == rear) //原队列只有一个结点
e = (E)rear.data;
else //原队列有多个结点
e = (E)rear.next.data;
return e;
}
}
Java中的队列接口——Queue
在Java语言中提供了队列接口Queue,提供了队列的修改运算,但不同于Stack,由于它是接口,所以在创建时需要指定元素类型,例如LinkedList。Queue接口的主要方法如下:
(1)boolean isEmpty():返回队列是否为空
(2)int size():队列中元素个数
(3)boolean add(E e):将元素e进队(如果立即可行且不会违反容量限制),在成功时返回true,如果当前没有可用空间,则抛出异常
(4)boolean offer(E e):将元素e进队(如果立即可行且不会违反容量限制),当使用有容量限制的队列时,此方法通常要优于add(E e),后者可能无法插入元素,而只是抛出一个异常。
(5)E peek():取队头元素,如果队列为空,则返回null。
(6)E element():取队头元素,它对peek()方法进行了简单的封装,如果队头元素存在,则取出但并不删除,如果不存在则抛出异常。
(7)E poll():出队,如果队列为空,则返回null。
(8)E remove():出队,直接删除队头元素。
在Queue的使用中,主要操作是进队方法offer()、出队方法poll()、取队头元素方法peek(),它们的时间复杂度均为O(1)。
队列的综合应用
【例1】用队列求解迷宫问题
1)问题描述:给定一个M × N的迷宫图,求一条从指定入口到出口的路径。假设迷宫图如下图所示(其中M=6, N=6,含外围加上的一圈不可走的方块,这样做的目的是为了避免在查找时出界),迷宫由多个方块构成,空白方块表示可用走的通道,带阴影方块表示不可走的障碍物。要求所求路径必须是简单路径,即在求得的路径上不能重复出现同一空白方块,而且从每个方块出发只能走上、下、左、右4个相邻的空白方块。
2)数据组织:用队列解决求迷宫路径问题,使用Queue接口对象最为队列qu,qu队列中存放搜索的方块,其Box类型如下:
class Box{ //队列中的方块类
int i; //方块的行号
int j; //方块的列号
Box pre; //前驱方块
public Box(int i1, int j1){ //构造方法
i = i1;
j = j1;
pre = null;
}
}
在设计相关算法时用到一个队列qu,其定义如下:
Queue qu;
3)设计运算算法
在求迷宫路径中,从入口处开始试探,将所有试探的方块进队,如下图所示。
假设当前方块为
(
i
,
j
)
(i,j)
(i,j),对应的队列元素(Box对象)为
b
b
b,然后找到它所有的相邻方块,假设有4个相邻方块可走(实际上最多3个),则这4个相邻可走的方块均进队,它们对应的队列元素分别为
b
1
b_1
b1~
b
4
b_4
b4,将方块
(
i
,
j
)
(i,j)
(i,j)称为它们的前驱方块,它们称为方块
(
i
,
j
)
(i,j)
(i,j)的后继方块,需要设置这样的关系,也就是置每个
b
i
b_i
bi的pre成员(即前驱方块)为
b
b
b,对应
b
i
.
p
r
e
=
b
b_i.pre = b
bi.pre=b。
当找到出口后,此时队列qu中的方块可能有很多,不像用栈求解迷宫问题,此时栈中恰好保存一条迷宫路径上的所有方块。假设出口方块对应的队列元素为 b b b,通过 b . p r e b.pre b.pre向前查找前驱方块,直到入口方块为止,这些方块构成一条迷宫逆路径。
查找从(xi,yi)到(xe,ye)的路径的过程是首先建立(xi,yi)的Box对象b,将b进队,在队列qu不为空时循环:出队一次,称该出队的方块b为当前方块。
(1)如果b是出口,则从b出发查找一条迷宫逆路径,输出该路径后结束
(2)否则按顺时针方向一次性查找方块b的4个方位中的相邻可走方块,每个相邻可走方块均建立一个Box对象 b 1 b_1 b1,置 b 1 . p r e = b b_1.pre=b b1.pre=b,将 b 1 b_1 b1进qu队列。与用栈求解一样,一个方块进队后,其迷宫值置为-1,以避免回过来重复搜索。
如此操作,如果队列为空都没有找到出口,则表示不存在迷宫路径,返回false。
在上面的图3.13所示的迷宫图中求入口(1,1)到出口(4,4)迷宫路径的搜索过程如图3.28所示,图3.28带“×”的方块表示没有相邻可走方块,每个方块旁的数字表示搜索顺序,找到出口后,通过虚线(即pre)找到一条迷宫的逆路径。
用队列求解迷宫问题的MazeClass类定义如下:
class MazeClass{ //用队列求解一条迷宫路径类
final int MaxSize = 20;
int[][] mg; //迷宫数组
int m,n; //迷宫的行/列数
Queue<Box> qu; //队列
public MazeClass(int m1, int n1){
m = m1;
n = n1;
mg = new int[MaxSize][MaxSize];
qu = new LinkedList<Box>(); //创建一个空队qu
}
public void Seting(int[][] a){//设置迷宫数组
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
mg[i][j] = a[i][j];
}
}
}
public void disppath(Box p){ //从p出发找一条迷宫路径
int cnt = 1;
while(p!=null){ //找到入口为止
System.out.print("[" + p.i + "," + p.j + "] ");
if (cnt%5==0)
System.out.println();
cnt++;
p = p.pre;
}
System.out.println();
}
boolean mgpath(int xi, int yi, int xe, int ye){
int i, j, i1 = 0, j1 = 0;
Box b, b1;
b = new Box(xi,yi); //建立入口结点b
qu.offer(b); //结点b进队
mg[xi][yi] = -1; //进队方块的mg值置为-1
while(!qu.isEmpty()){ //队不空时循环
b = qu.poll(); //出队一个方块b
if (b.i == xe && b.j == ye){ //找到了出口,输出路径
disppath(b); //从b出发回推导出迷宫路径并输出
return true; //找到一条路径时返回true
}
i = b.i; j = b.j;
for (int di = 0; di < 4; di++) { //循环扫描每个方位,把可走的方块进队
switch (di){
case 0:i1 = i - 1; j1 = j; break;
case 1:i1 = i; j1 = j+1; break;
case 2:i1 = i + 1; j1 = j; break;
case 3:i1 = i; j1 = j - 1; break;
}
if (mg[i1][j1] == 0){ //找到相邻可走方块
b1 = new Box(i1,j1); //建立后继方块结点b1
b1.pre = b; //设置其前驱方块为b
qu.offer(b1); //b1进队
mg[i1][j1] = -1; //将进队的方块设置为-1
}
}
}
return false; //未找到任何路径时返回false
}
//设计主函数
public static void main(String[] args) {
int[][] a = {{1,1,1,1,1,1},
{1,0,1,0,0,1},
{1,0,0,1,1,1},
{1,0,1,0,0,1},
{1,0,0,0,0,1},
{1,1,1,1,1,1}};
MazeClass mz = new MazeClass(6,6);
mz.Seting(a);
if(!mz.mgpath(1,1,4,4))
System.out.println("不存在迷宫路径");
}
}
本程序的执行结果如下:
该路径如图3.29所示
迷宫路径上方块的箭头表示其前去方块的方位。显然这个解是最优解,也就是最短路径。至于为什么用栈求出的迷宫路径不一定是最短路径,而用队列求出的迷宫路径一定是最短路径,这个问题后续会详细说明。