数据结构与算法学习之路(java语言)二

  本文是本人在学习过程中的一些笔记,如有错误请见谅。

  上一篇介绍了抽象数据类型中的表的实现,本文将介绍其他两种数据类型栈和队列。

一、栈ADT

  1.栈模型

  栈(stack)是限制插入和删除只能在一个位置上进行的表,该位置是表的末端,叫作栈的顶(top)只有栈顶元素可以被访问。对战的基本操作有push(进栈)和pop(出栈),前者相当于插入,后者则是删除最后插入的元素。最后插入的元素可以通过使用top例程在执行pop前进行考察。对空栈进行的pop或top操作一般被认为是栈的一个错误,另外当运行push时空间用尽是一个实现限制但不是栈错误。

  栈又叫做LIFO(Last In First Out,后进先出)表。其中push是输入操作,pop和top是输出操作。普通的清空栈操作和判断是否空栈的测试都是栈的操作指令的一部分,但是我们对栈能做的操作基本也就是push和pop。

  2.栈的实现

  由于栈是一个表,因此任何实现表的方法都能实现栈。显然ArrayList和LInkedList都支持栈操作它们在绝大多数情况下都是最合理的选择。偶尔设计一种特殊目的的实现可能会更快(比如被放到栈的元素属于基本类型)。因为栈操作是常数操作,所以除非在非常独特的环境下,不然是很难有明显的改进的。这里会给出两个实现方式:链表实现和数组实现,二者均简化了在ArrayList和LInkedList中的逻辑,实现非常简单因此就不贴代码了。

  链表实现:通过在表的顶端插入来实现push,通过删除表顶端的元素实现pop。top操作知识考查表顶端元素并返回它的值,有时会将pop和top两个操作合为一个操作。

  数组实现:这种方法避免了链且为更多情况下的解决方案。这里会有一个theArray方法和一个topOfStack数值,对于空栈topOfStack=-1(这里也是初始化的做法),为将某个元素x推入栈中,我们将topOfStack增加1,然后调用theArray(topOutStack)=x。为了弹出栈元素,我们置返回值为theArray(topOutStack),然后使topOfStack-1。

  这些操作以非常快的常数时间运行,而且现在大多数计算机将栈操作作为它指令系统的一部分,即栈会作为继数组之后计算机科学中最基本的数据结构。(就是说栈的操作对于计算机来说不仅简单而且速度很快)

  3.应用

  毫无疑问,计算机对栈的操作执行效率很高而且这些少量的操作强大且重要。下面会例出三个例子,第三个例子会说明程序是如何组织的。

  1.平衡符号

  编译器需要检查代码的语法错误,但是常常由于缺少一个符号(如一楼一个花括号或者注释起始符)引起编译器上百行的诊断,而真正的错误并没有找出(在这一点上java编译器还是比较可靠的,但不是所有编译器)。在这种情况下一个有用的工具就是检查符号是否有效。于是每一个左花括号,左方括号以及左圆括号必然有右括号与其对应(比如{[]}是合法的,但是{[}]是错误的)。显然为此编写一个大型程序去校验是不值得的,事实上检验这些事情是很容易的,我们就圆括号、方括号和花括号进行一次校验,我们这个简单的算法用到一个栈:

 

 1 class Check {
 2 
 3         // 用一个Map来装需要成对的符号
 4         private HashMap<Character, Character> mappings;
 5 
 6         // 初始化映射,方便之后读取使用
 7         public Check() {
 8             this.mappings = new HashMap<Character, Character>();
 9             this.mappings.put(')', '(');
10             this.mappings.put('}', '{');
11             this.mappings.put(']', '[');
12         }
13 
14         public boolean isValid(String s) {
15 
16             // 初始化一个栈
17             Stack<Character> stack = new Stack<Character>();
18 
19             for (int i = 0; i < s.length(); i++) {
20                 char c = s.charAt(i);
21 
22                 // 判断当前字符是否为一个右括号
23                 if (this.mappings.containsKey(c)) {
24 
25                     // 元素出栈,若为空则设置为"#"
26                     char topElement = stack.empty() ? '#' : stack.pop();
27 
28                     //如果括号不能组成对则返回false
29                     if (topElement != this.mappings.get(c)) {
30                         return false;
31                     }
32                 } else {
33                     // 如果为一个做括号则元素入栈
34                     stack.push(c);
35                 }
36             }
37 
38             // 如果元素为空,则校验为true
39             return stack.isEmpty();
40         }
41     }

  2.后缀表达式

  假设这么一个场景:我们在外购物,一些货物会有折扣,我们在使用计算器计算各个物品价格为4.99,5.99,6.99的花费,商品会有9折的折扣。那么输入这些数据的自然方式将是:

4.99+5.99+6.99*0.9=

  随着计算器的不同,这个结果或者是所要的答案16.173(顺序执行),或者是科学答案17.271。最简单的计算器都会给出第一个答案,但是许多先进的计算器是知道乘法的优先级高于加法的。

  另外一种情况, 如果我们的折扣只适用于4.99和6.99的商品,那么计算顺序为:

4.99*0.9+5.99+6.99*0.9=

  将在科学计算器中给出正确答案16.772,而在简单计算器上给出错误答案15.724。科学计算器一般包含括号,因此我们总能通过加括号的方式得到正确的答案,但是使用简单计算器需要我们记住中间的结果。

  这例典型的计算顺序可以是将4.99和0.9相乘记为A1,然后将5.99与A1相加,再将结果存入A1;我们再将6.99与0.9相乘记为A2,最后将A1和A2相加并把结果存入A1。这样我们可以将这种操作顺序书写如下:

4.99 0.9 * 5.99 + 6.99 0.9 * +

  这个记法叫做后缀(postfix)或是逆波兰(reverse Polish)记法,其求值过程就是上述过程,计算这个问题最简单的方法是使用一个栈当见到数时就把它推入栈,在遇到运算符时就将该符号作用于从栈中弹出的两个数上,再将结果推入栈中。下面模拟一下计算6 5 2 3 + 8 * + 3 + *的过程。

  计算一个后缀表达式花费的时间是O(N),因为对输入中的每个元素处理都由栈操作组成,花费时间为常数,这样计算的优点是没有必要知道任何优先的规则。这里介绍了后缀表达式顺便再简介一下前缀表达式和中缀表达式,前缀表达式使用非常少,指的是将符号写在适用数字之前比如1-(2+3)的前缀表达式为- 1 + 2 3,将表达式从右往左直接入栈出栈即可。中缀表达式就是我们正规的运算表达式,需要了解的是中缀和后缀的转换。

我们可以用栈将一个标准形式的表达式(中缀表达式)转换成后缀表达式。假设表达式合法为:

a + b * c ( d * e + f ) * g

先说一下思路,计算从一个空的栈开始,当读到一个操作数时,立即把它放到输出中,已经见到过但尚未放到输出的操作符推入栈中。当遇到左括号时也推入栈,如果遇到一个右括号,那么就将栈元素弹出并写在输出中直到遇到一个对应的左括号,但是这个左括号只被弹出并不输出。如果见到其他符号(+,*,(),那么我们从栈中弹出元素直到发现优先级更低的元素为止。这里需要注意:如果处理一个左括号(时,+的优先级最低,而(的优先级最高。当弹栈的工作结束后我们再将操作符压入栈中。最后,如果读到输入的末尾,我们将栈元素弹出直到变为空栈,将符号写在输出中。

  与之前相同,这种转换只需要经过O(N)时间并经过一趟输入后即可完成,可以指定加法和减法具有相同优先级,乘法和除法具有相同优先级而将减法和乘法加入到指令集中去。值得一说的是,a - b - c应转换成a b - c -而不是a b c - -,图中算法进行了正确的操作是因为这些操作符是从左向右结合的,一些情况下未必如此。

  3.方法调用

  检测平衡符号的算法提出了一种在编译过程语言和面向对象语言中实现方法间互相调用的方式。在调用时会有这样一个问题:在调用一个新方法时,主调例程的所有局部变量需要由系统存储起来,否则被调用的新方法会重写主调例程的变量所使用的的内存,与此同时该主调例程的当前位置也需要储存,以便在新方法运行完后能够将返回数据进行转移。这些变量一般由编译器指派给机器的寄存器,但存在某些冲突(通常所有的方法都是获取指定给1号寄存器的某些变量),特别是涉及递归的时候。该问题就类似于平衡符号,方法调用和方法返回基本上类似于开括号和闭括号。

  方法调用时,需要存储所有重要的信息就像存储器的值(对应变量的名字)和返回地址(它可以从程序的计数器得到,一般情况是在一个寄存器中)等,都要以抽象的方式存储并被置于一个堆(pile)的顶部。然后再控制转移到新方法,该方法能用它的值来代替这些寄存器,如果它又被其他方法调用也是遵循同样的过程。当需要返回时会查看并复原所有的寄存器然后进行返回转移。

  显然所有的工作都可以由一个栈来完成,而这正是每一种程序设计语言中实现递归的方法。所有的信息称为活动记录(activtion record),或者叫做栈帧(stack frame)。在实际计算机中的栈常常是从内存分区的高端的高端向下增长,而且不会有溢出检测(java系统中会有检测)。由于系统总会运行着太多个方法,因此栈空间用尽的情况总是有可能发生的。在没有溢出检测的语言和系统中,程序将崩溃且没有明显的说明;而在java中会出现一个异常(StackOverFlow)。

  正常情况下我们不应该越出栈空间,发生这种情况通常是由时空递归的指向引起。举一个尾递归的例子:

1     public static <E> void printList(Iterator<E> itr){
2         if (!itr.hasNext())
3             return;
4         System.out.println(itr.next());
5         printList(itr);
6     }

  该例程完全合法且执行该例程会递归打印出一个集合,它甚至能处理空集合。但是如果这个集合有100 000个元素需要打印,那么表示第5行嵌套调用了100 000个活动记录的栈。一般这些活动记录包含了全部信息而特别庞大,因此这个程序很可能会越出栈空间。所以这个尾递归使用得非常不合适,在递归调用结束之后我们实际上也不需要知道那些存储的值。因此我们可以带着在一次递归调用中已经用过的那些值转移到本方法顶部。因此可以通过将代码放到一个while循环中来代替递归调用。它可以模拟递归调用因为它什么都不需要储存(实际上一些编译器能够自动去除尾递归)。

1     public static <E> void printList(Iterator<E> itr) {
2         while (true) {
3             if (itr.hasNext())
4                 return;5             System.out.println(itr.next());
6         }
7     }

  递归总是能被彻底去除(编译器是在转变成汇编语言时完成递归去除的),这个操作相当冗长乏味。在操作时一般会使用一个栈,而且仅当你能把最低限度的最小值放到栈上时,这个方法才值得一用。需要指出的是,虽然去除递归后程序执行会变快,但是阅读性会变差。

二、队列ADT

  1.队列模型

  队列跟栈一样也是表,只是在使用队列时插入在一段进行而删除在另一端进行(FIFO)。队列的基本操作是enqueue(入队),它是在表的末端(rear,队尾)插入一个元素,和另一个基本操作dequeue(出队),它是删除并返回在表的开头(front,队头)的元素。

  2.队列的数组实现

  与栈相似,对于队列来说任何表的实现都是合法的。每一个队列数据结构,我们保留一个数组theArray以及位置front和back,还需要记录实际存在于队列中的元素个数currentSize。

  这里拿一个容量为10的数组来模拟处于某个中间状态的队列。为了使一个元素x入队(即执行enqueue),需要先让currentSize和back增加1,然后置theArray[back]=x,若使元素出队(dequeue)将返回值为theArray[front],且currentSize减1,然后使front增加1。在这实现过程中会存在一个问题,经过多次enqueue后队列似乎会满,因为back新在是数组的最后一个下标,而下一次再enqueue就会是一个不存在的位置。简单的解决方法是,只要front或者back到达数组的尾端,它就又绕回开头,做成循环数组。循环数组的代码也只想要在front或者back增加1超越数组时重置到第一个位置即可,但是可能会使得运行时间加倍。队列的大小甚至也可以由back和front算出。在保证enqueue的次数不会大于队列容量的应用中,使用回绕是没有必要的,像栈一样除非主调例程肯定队列非空,否则dequeue会很少执行。因此这种操作常常也不会有错误检测。并且附加错误检测的话花费大量时间。

  

posted @ 2019-12-26 14:49  西伯利亚大尾巴熊  阅读(238)  评论(0)    收藏  举报