为了能够学好数据结构和算法,换份好工作,下面记录学习栈和队列知识的过程,以备以后工作和笔试中能够用到,欢迎大家参考,有问题指正。
本篇博客会讲到三种数据存储类型:栈、队列和优先级队列。这三种数据结构与数组、链表、树等等不同:
1、栈、队列和优先级队列更多的是作为程序员的工具,主要作为构思算法的辅助工具,而不是完全的数据存储工具,这些数据结构的生命周期比那些数据库类型的结构要短的多。
2、这三种数据结构访问是受限制的,即在特定时刻只有一个数据项可以被读取或删除。不像数组,只要知道数据项的下标就可以访问。
3、这三种数据结构比数组和其他数据存储结构更抽象。主要通过接口对栈、队列和优先级队列进行定义,这些接口表明通过他们可以完成的操作,而它们的主要实现机制对用户来说是不可见的。例如,栈的主要机制可以用数组实现,也可以用链表来实现;优先级队列内部可以用数组或一种特别的数——堆来实现。
一、栈
栈只允许访问一个数据项:即最后插入的数据项。移除这个数据项后才能访问倒数第二个插入的数据项,依次类推。应用:利用栈来检验源程序中的小括号、中括号和大括号是否匹配的问题;在二叉树中,用栈来辅助遍历书的节点,利用栈来辅助查找图的顶点。栈的特点是后进先出(LIFO)。
栈的概念和实现它的内部数据结构应该是完全分开的,栈除了可以用数组实现,还可以用链表来实现。本文用数组来演示,因为栈的访问是受限访问的,所以不能利用下标访问各数据项。
Push(入栈):入栈过程分两步:把栈顶的指示加一(Top+1),然后才是真正将数据项插入到数组存储单元中。如果颠倒顺序,就会将原存在栈顶的数据项覆盖。当编写代码实现栈时,记住这两个步骤的执行顺序是非常重要的。
理论上,ADT定义的栈是不会满的,但是数组实现的栈会满。
Pop(出栈):出栈同样有两步动作:移除由栈顶Top所指向的数据项,然后栈顶指示减一(Top-1),指向新的栈顶元素。在这里的操作顺序与入栈的操作顺序相反。
注意:实际上,被删除的数据还继续留在数组里,直到它被新的数据项覆盖为止。但是由于Top指示移到那些数据的位置下面,那么就不能再访问它们了。因此,在概念上它们已经不存在啦。
Peek(查看):入栈和出栈是栈的两个最主要的操作。但是,有时只需要读取栈顶元素的值,而不移除它。
注意:只能查看栈顶元素。通过设计,用户看不到任何其他的数据项。
1.1、栈的容量
栈通常很小,是临时的数据结构。如果栈是由数组实现的,需要先规定栈的大小;如果是用链表来实现,就不需要先规定栈的容量。
注意:用数组实现的栈在push()或pop()的时候,要做栈满或栈空的检查,当然最好的方式是在两个方法内部去检查这些错误,在java中,发现这些错误一个好的方法就是抛出异常,异常可以被用户捕获并处理。
下面给出一个用数组实现栈的实例代码:
public class StackX { /** * 栈数组的大小 */ private int maxSize; /** * 栈数组 */ private long[] stackArray; /** * 栈顶 */ private int top; /** * 构造方法 * @param max 栈的容量 */ public StackX(int max) { //由于栈是由数组实现的,需要先规定栈的大小; //但是如果使用链表来实现栈,就不需要先规定栈的容量. maxSize = max; stackArray = new long[maxSize]; top = -1; } /** * 将数据data入栈 许多栈的类在push()的时候内部检查栈是否满,是比较好的方式。 * @param data 要入栈的数据 */ public void push(long data) { stackArray[++top] = data; } /** * 数据出栈 许多栈的类在pop()的时候内部检查栈是否空,是比较好的方式。 * @return 栈顶数据 */ public long pop() { return stackArray[top--]; } /** * 查看栈的数据 * @return 栈顶数据 */ public long peek() { return stackArray[top]; } /** * 判断栈是否已满 * @return true:满栈 false:未满 */ public boolean isFull() { return (top == maxSize-1); } /** * 判断栈是否是空的 * @return true:满空 false:非空 */ public boolean isEmpty() { return (top == -1); } }
1.2、栈的效率和应用
StackX类中实现的栈,数据项入栈和出栈的时间复杂度都为常数O(1),也就是说,栈操作所耗的时间不依赖于栈中数据项的个数,因此操作时间很短。栈不需要比较和移动。
栈通常用于解析某种类型的文本串。本博文给出栈的两个应用:单词逆序和分隔符匹配。
1、单词逆序
运行程序,提示输入一个单词,回车后,屏幕上显示字母顺序倒置后的词。这个时候我们可以使用上面的数组实现的栈作为辅助来实现单词逆序的目的,上面的程序中操作的是long型,在这要改为char数据类型。
Reverse.java:
package com.hairui.stack; public class Reverse { /** * 逆序前的字符串 */ private String input; /** * 逆序后的字符串 */ private String output; /** * 构造方法 * @param in 待逆序的字符串 */ public Reverse(String in) { input = in; } /** * 对字符串进行逆序 * @return 逆序后的字符串 */ public String doRev() { StackX theStack = new StackX(input.length()); for (int i = 0; i < input.length(); i++) { theStack.push(input.charAt(i)); } output = ""; for (int i = 0; i < input.length(); i++) { char ch = theStack.pop(); output += ch; } return output; } }
ReverseApp.java:
package com.hairui.stack; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; public class ReverseApp { public static void main(String[] args) { String input, output; while(true) { System.out.println("Please input a string:"); System.out.flush(); input = getString(); //如果输入字符串为空,则跳出循环重新等待输入 if(input.equals("")) break; Reverse reverse = new Reverse(input); output = reverse.doRev(); System.out.println("Reverse string:" + output); } } /** * 读取输入的字符串 * @return 输入字符串 */ public static String getString() { InputStreamReader isr = new InputStreamReader(System.in); BufferedReader br = new BufferedReader(isr); String str = null; try { str = br.readLine(); } catch (IOException e) { e.printStackTrace(); } return str; } }
2、分隔符匹配
在实现这个程序的时候,可以重用实例一字符串逆序中的StackX类,这是面向对象编程的优点之一。
BracketChecker.java:
public class BracketChecker { /** * 待检查的字符串 */ private String input; /** * 构造方法 * @param s 待检查的字符串 */ public BracketChecker(String s) { input = s; } /** * 检查分隔符匹配 * @return true:匹配;false:不匹配 */ public boolean check() { int size = input.length(); StackX theStack = new StackX(size); for(int i = 0; i< size; i++) { char ch = input.charAt(i); switch (ch) { case '(': case '[': case '{': theStack.push(ch); break; case ')': case ']': case '}': if(!theStack.isEmpty()) { char left = theStack.pop(); if((left == '(') && (ch == ')') || (left == '[') && (ch == ']') || (left == '{') && (ch == '}')) { break; } else return false; } else { return false; } } } if (theStack.isEmpty()) { return true; } else return false; } }
BracketApp.java:
public class BracketApp { public static void main (String[] args) { while (true) { System.out.println("Please input a string:"); System.out.flush(); String str = getString(); if (str.equals("")) { break; } BracketChecker bracketChecker = new BracketChecker(str); boolean result = bracketChecker.check(); if(result) { System.out.println("match"); } else { System.out.println("no match"); } } } /** * 读取输入的字符串 * @return 输入字符串 */ public static String getString() { InputStreamReader isr = new InputStreamReader(System.in); BufferedReader br = new BufferedReader(isr); String str = null; try { str = br.readLine(); } catch (IOException e) { e.printStackTrace(); } return str; } }
以上两个实例同样可以利用普通数组来完成栈的操作,但是那样就不得不自己老惦记着最后添加的字符的下标值。栈在抽象概念上更便于使用。栈通过提供限定性的访问方法push()和pop(),使程序易读且不易出错。
二、队列
队列是一种数据结构,有点类似栈,只是队列中第一个插入的数据项也会最先被移除(先进先出,FIFO);而在栈中,最后插入的数据项最先移除(LIFO)。队列和栈一样也被用作程序员的工具。
它可以用于模拟真实世界的环境,例如模拟人们在银行里排队等等,飞机等待起飞,或者因特网络上数据包等待发送。在计算机(或网络)操作系统里,有各种队列在安静的工作着,如打印作业在打印队列中等待打印。
队列可以用数组来实现,同时链表也常用来实现队列。队列两个基本操作是inserting(插入)一个数据项,即把一个数据项放入队尾,另一个是removing(移除)一个数据项,即移除队头的数据项。
栈中的插入和移除数据项的方法命名是很标准,称为push和pop。然而队列的方法至今没有标准化的命名。
插入:put、add、enque等等;
删除:delete、get、deque等等;
本文采用insert、remove、front、rear。
通常实现队列时,删除的数据项还会保存在内存中,只是它不能被访问了,因为队头指针(Front)已经移到它的下一个位置了。
和栈中的情况不同,队列中的数据项不总是从数组的0下标处开始。移除了一些数据项后,队头指针会指向一个较高的下标位置。
Peek:查看操作返回队头数据项的值,然而并不从队列中删除这个数据项。一些队列的实现中虽然也有rearPeek()方法和frontPeek()方法,但通常只希望获得要移除的数据项的值,而不是刚插入数据项的值。
循环队列
计算机中在队列里删除一个数据项后,也可以将其他数据项都向前移动,但这样做的效率很差。相反,我们通过队列中队头和队尾指针的移动保持所有数据项的位置不变。
为了避免队列不满却不能插入新数据项的问题,可以让队头队尾指针绕回到数组开始的位置,这就是循环队列(有时也称为“缓冲环”)。
队尾指针回绕之后,它现在处在队头指针的下面,这就颠倒了初始的位置。这可以称为“折断的序列”:队列中的数据项存在数组两个不同的序列中。删除足够多的数据项后,队头指针也回绕,这时队列的指针回到了初始运行时的位置状态,队头指针在队尾指针的下面,数据项也恢复为单一的连续序列。 下面用数组来实现队列:
Queue.java:
/** * 循环队列 * @author hairui * */ public class Queue { /** * 队列的大小 */ private int maxSize; /** * 队列数组,存放数据 */ private long[] queueArray; /** * 队列头指针 */ private int front; /** * 队列尾指针 */ private int rear; /** * 队列中元素的个数 */ private int nItems; /** * 构造方法 * @param size 创建队列的大小 */ public Queue(int size) { maxSize = size; queueArray = new long[maxSize]; front = 0; rear = -1; nItems = 0; } /** * 向队尾插入一个数据 * @param data */ public void insert(long data) { //环绕式处理 if(rear == maxSize - 1) rear = -1; queueArray[++rear] = data; nItems++; } /** * 从队列头取出一个数据 * @return 队列头的数据 */ public long remove() { long temp = queueArray[front++]; if(front == maxSize) front = 0; nItems--; return temp; } /** * 查看队列头的数据 * @return 队列头数据 */ public long peekFront() { return queueArray[front]; } /** * 队列数据的个数 * @return */ public int size() { return nItems; } /** * 判断队列是否是空队列 * @return true:空队列 false:非空队列 */ public boolean isEmpty() { return (nItems == 0); } /** * 判断队列是否是满队列 * @return true:队列满 false:非满队列 */ public boolean isFull() { return (nItems == maxSize); } }
程序实现的Queue类中不但有front(队头)和rear(队尾)字段,还有队列中当前数据项的个数:nItems。
insert()方法运行的前提条件是队列不满,更通用的做法是在insert()方法中加入检查队列是否满的判定,如果出现向已满队列里插入数据项的情况就抛出异常。一般情况下,插入操作是rear(队尾指针)加一后,在队尾指针所指的位置处插入新的数据,此时要注意rear指针指向数组顶端的时候。
remove()方法运行的前提条件是队列不空,在调用这个方法之前应该调用isEmpty()方法确保队列不空,或者在remove()方法里加入这种出错检查的机制。移除操作总是由front指针得到队头数据项的值,然后将front加一。
peek()方法简单易懂,它返回front指针所指数据项的值。
队列的效率
和栈一样,队列中插入数据项和移除数据项的时间复杂度均为O(1)。
双端队列
双端队列就是一个两端都是结尾的队列。队列的每一端都可以插入数据项和移除数据项。双端队列与栈和队列相比,是一种多用途的数据结构,在容器类库中有时会用双端队列来提供栈和队列的两种功能。
三、优先级队列
优先级队列是比栈和队列更专用的数据结构,在很多情况下都很有用。像普通队列一样,优先级队列有一个队头和一个队尾,并且也是从队头移除数据项。不过在优先级队列中,数据项按关键字的值有序,这样关键字最小的数据项(或在某些实现中是关键字最大的数据项)总是在队头。数据项插入的时候会按照顺序插入到合适的位置以确保队列的顺序。
像栈和队列一样,优先级队列也经常用作程序员编程的工具。可以看到在图的最小生成树算法中应用优先级队列。在抢先式多任务操作系统中,程序排序在优先级队列中,这样优先级最高的程序就会先得到时间片并得以运行。
除了可以快速访问最小关键字的数据项,优先级队列还应该可以实现相当快的插入数据项。因此,正如前面提到过的,优先级队列通常使用一种称为堆的数据结构来实现。本章使用简单的数组实现优先级队列。这种实现方法插入比较慢,但是它比较简单,适用于数据量比较小并且不是特别注重插入速度的情况。
待出队的数据项总是在数组的高端,所以删除操作又快又容易。删除数据项后,队头指针下移指向队列新的高端,不需要移动和比较。从算法中就可以知道队列的头总是数组顶端下标为nItems-1的位置,数据项按序插入,而不是插入到队尾处。
在数据项个数比较少,或不太关心速度的情况下,用数组实现优先级队列还可以满足要求。如果数据项很多,或速度很重要时,采用堆是更好的选择。
/** * 数组实现的优先级队列 * @author hairui * */ public class PriorityQ { /** * 数组的最大容量 */ private int maxSize; /** * 有序数组,最大值在下标0位置,最小值在nItems-1位置 */ private long[] queArray; /** * 元素的个数 */ private int nItems; /** * 优先级队列构造方法 * @param s 队列容量 */ public PriorityQ(int s) { maxSize = s; queArray = new long[maxSize]; nItems = 0; } /** * 按顺序插入数据项 * @param item 要插入的数据项 */ public void insert(long item) { int j; if(nItems == 0) queArray[nItems++] = item; else { for(j = nItems-1; j >= 0; j--) { if(item > queArray[j]) queArray[j+1] = queArray[j]; else break; } queArray[j+1] = item; nItems++; } } /** * 移除数据项 * @return 移除的数据项 */ public long remove() { return queArray[--nItems]; } /** * 查看数据项 * @return 数组顶部的数据项 */ public long peekMin() { return queArray[nItems-1]; } /** * 判断队列是否为空 * @return true:空; false:非空 */ public boolean isEmpty() { return (nItems == 0); } /** * 判断队列是否满 * @return true:满; false:非满 */ public boolean isFull() { return (nItems == maxSize); } }
优先级队列的效率
本文实现的优先级队列中,插入操作需要O(N)的时间,而删除操作则需要O(1)的时间。