前言
上一篇:算法分析
下一篇:基本排序
本篇内容主要是栈,队列 (和包)的基本数据类型和数据结构
文章里头所有的对数函数都是以 2 为底
关于性能分析,可能还是需要一些数学知识,有时间可以回一下
在很多应用中,我们需要维护多个对象的集合,而对这个集合的操作也很简单
基本数据类型
- 对象的集合
-
操作:
- insert -- 向集合中添加新的对象
- remove -- 去掉集合中的某个元素
- iterate -- 遍历集合中的元素并对他们执行某种操作
- test if empty -- 检查集合是否为空
- 做插入和删除操作时我们要明确以什么样的形式去添加元素,或我们要删除集合中的哪个元素。
处理这类问题有两个经典的基础数据结构:栈(stack) 和队列(queue)
两者的区别在于去除元素的方式:
-
栈:去除最近加入的元素,遵循后进先出原则(LIFO: last in first out)。
- 插入元素对应的术语是入栈 -- push;去掉最近加入的元素叫出栈 -- pop
-
队列:去除最开始加入的元素,遵循先进先出原则(FIFO: first in first out)。
- 关注最开始加入队列的元素,为了和栈的操作区分,队列加入元素的操作叫做入队 -- enqueue;去除元素的操作叫出队 -- dequeue
此篇隐含的主题是模块式编程,也是平时开发需要遵守的原则
模块化编程
这一原则的思想是将接口与实现完全分离。比如我们精确定义了一些数据类型和数据结构(如栈,队列等),我们想要的是把实现这些数据结构的细节完全与客户端分离。客户端可以选择数据结构不同的实现方式,但是客户端代码只能执行基本操作。
实现的部分无法知道客户端需求的细节,它所要做的只是实现这些操作,这样,很多不同的客户端都可以使用同一个实现,这使得我们能够用模块式可复用的算法与数据结构库来构建更复杂的算法和数据结构,并在必要的时候更关注算法的效率。
Separate client and implementation via API.
API:描述数据类型特征的操作
Client:使用API操作的客户端程序。
Implementation:实现API操作的代码。
下面具体看下这两种数据结构的实现
栈 Stack
栈 API
假设我们有一个字符串集合,我们想要实现字符串集合的储存,定期取出并且返回最后加入的字符串,并检查集合是否为空。我们需要先写一个客户端然后再看它的实现。
字符串数据类型的栈
性能要求:所有操作都花费常数时间
客户端:从标准输入读取逆序的字符串序列
测试客户端
import edu.princeton.cs.algs4.StdIn;
import edu.princeton.cs.algs4.StdOut;
public static void main(String[] args)
{
StackOfStrings stack = new StackOfStrings();
while (!StdIn.isEmpty())
{
//从标准输入获取一些字符串
String s = StdIn.readString();
//如果字符串为"-",则客户端将栈顶的字符串出栈,并打印出栈的字符串
if (s.equals("-")) StdOut.print(stack.pop());
//否则将字符串入栈到栈顶
else stack.push(s);
}
}
客户端输入输出:
栈的实现:链表
链表(linked-list)连接待添加...
我们想保存一个有节点组成的,用来储存字符串的链表。节点包含指向链表中下一个元素的引用(first).
维持指针 first 指向链表中的第一个节点
- Push:入栈,在链表头插入一个新的节点
- Pop:出栈,去掉链表头处第一个节点
Java 实现
public class LinkedStackOfStrings
{
//栈中唯一的实例变量是链表中的第一个节点的引用
private Node first = null;
//内部类,节点对象,构成链表中的元素,由一个字符串和指向另一个节点的引用组成
private class Node
{
private String item;
private Node next;
}
public boolean isEmpty()
{ return first == null; }
//
public void push(String item)
{
//将指向链表头的指针先保存
Node oldfirst = first;
//创建新节点:我们将要插入表头的节点
first = new Node();
first.item = item;
//实例变量的next指针指向链表oldfirst元素,现在变成链表的第二个元素
first.next = oldfirst;
}
//出栈
public String pop()
{
//将链表中的第一个元素储存在标量 item 中
String item = first.item;
//去掉第一个节点:将原先指向第一个元素的指针指向下一个元素,然后第一个节点就等着被垃圾回收处理
first = first.next;
//返回链表中原先保存的元素
return item;
}
}
图示:
出栈:
入栈:
性能分析
通过分析提供给客户算法和数据结构的性能信息,评估这个实现对以不同客户端程序的资源使用量
Proposition 在最坏的情况下,每个操作只需要消耗常数时间(没有循环)。
Proposition 具有n个元素的栈使用 ~40n 个字节内存
(没有考虑字符串本身的内存,因为这些空间的开销在客户端上)
栈的实现:数组
栈用链表是实现花费常数的时间,但是栈还有更快的实现
另一种实现栈的 natural way 是使用数组储存栈上的元素
将栈中的N个元素保存在数组中,索引为 n,n 对应的数组位置即为栈顶的位置,即下一个元素加入的地方
- 使用数组 s[] 在栈上存储n个元素。
- push():在 s[n] 处添加新元素。
- pop():从 s[n-1] 中删除元素。
在改进前使用数组的一个缺点是必须声明数组的大小,所以栈有确定的容量。如果栈上的元素个数比栈的容量多,我们就必须处理这个问题(调整数组)
Java 实现
public class FixedCapacityStackOfStrings
{
private String[] s;
//n 为栈的大小,栈中下一个开放位置,也为下一个元素的索引
private int n = 0;
//int capacity:看以下说明
public FixedCapacityStackOfStrings(int capacity)
{ s = new String[capacity]; }
public boolean isEmpty()
{ return n == 0; }
public void push(String item)
{
//将元素放在 n 索引的位置,然后 n+1
s[n++] = item;
}
public String pop()
{
//然后返回数组n-1的元素
return s[--n];
}
}
int capacity: 在构造函数中加入了容量的参数,破坏了API,需要客户端提供栈的容量。不过实际上我们不会这么做,因为大多数情况下,客户端也无法确定需要多大栈,而且客户端也可能需要同时维护很多栈,这些栈又不同时间到达最大容量,同时还有其他因素的影响。这里只是为了简化。在调整数组中会处理可变容量的问题,避免溢出
对于两种实现的思考
上述的实现中我们暂时没有处理的问题:
Overflow and underflow
- Underflow :客户端从空栈中出栈我们没有抛出异常
- Overflow :使用数组实现,当客户端入栈超过容量发生栈溢出的问题
Null item:客户端是否能向数据结构中插入空元素,上边我们是允许的
Duplicate items: 客户端是否能向数据结构中重复入栈同一个元素,上边我们是允许的
Loitering 对象游离:即在栈的数组中,我们有一个对象的引用,可是我们已经不再使用这个引用了
数组中当我们减小 n 时,在数组中仍然有我们已经出栈的对象的指针,尽管我们不再使用它,但是Java系统并不知道。所以为了避免这个问题,有效地利用内存,最好将去除元素对应的项设为 null,这样就不会剩下旧元素的引用指针,接下来就等着垃圾回收机制去回收这些内存。这个问题比较细节化,但是却很重要。
public String pop()
{
String item = s[--n];
s[n] = null;
return item;
}
调整数组
之前栈的基本数组实现需要客户端提供栈的最大容量,现在我们来看解决这个问题的技术。
待解决的问题:建立一个能够增长或者缩短到任意大小的栈。
调整大小是一个挑战,而且要通过某种方式确保它不会频繁地发生。
怎样加长数组
反复增倍法 (repeated doubling):当数组被填满时,建立一个大小翻倍的新数组,然后将所有的元素复制过去。这样我们就不会那么频繁地创建新数组。
Java 实现
public class ResizingArrayStackOfStrings {
private String[] s;
//n 为栈的大小,栈中下一个开放位置,也为下一个元素的索引
private int n = 0;
public ResizingArrayStackOfStrings(){
s = new String[2];
}
public boolean isEmpty() {
return n == 0;
}
/**
* 从大小为1的数组开始,如果发现数组被填满,那么就在插入元素之前,将数组长度调整为原来的2倍
* @param item
*/
public void push(String item) {
if (n == s.length) resize(2 * s.length);
s[n++] = item;
}
/**
* 调整数组方法
* 创建具有目标容量的新数组,然后把当前栈复制到新栈的前一半
* 然后重新设置和返回实例标量
* @param capacity
*/
private void resize(int capacity) {
System.out.println("resize when insert item "+ (n+1));
String[] copy = new String[capacity];
for (int i = 0; i < n; i++)
copy[i] = s[i];
s = copy;
}
public String pop() {
return s[--n];
}
}
性能分析
往栈中插入 n 个元素的时间复杂度是线性相近的,即与 n 成正比 ~n
Q. 假设我们从一个空的栈开始,我们执行 n 次入栈, 那么我们的 **resize()** 方法被调用了几次?
A. 是以 2 为底的对数次。因为我们只有在栈的大小等于 2 的幂函数的时候,即 2^1,2^2,2^3 ... 2^i 的时候才会调用 resize().
在 1 到 n 之间,符合 2 的幂的数字(如 2,4,8,16...) 一共有 logn 个,其中 log 以为 2 为底.
我们在插入 2^i 个元素时,需要复制数组 logn 次,需要花费访问数组 n + (2 + 4 + 8 + ... + m) ~3n 的时间,其中 m = 2^logn = n
- n : 无论数组翻不翻倍,对于每个新元素,入栈需要 Θ(1) 时间。因此,对于 n 个元素,它需要 Θ(n) 时间。即忽略常数项,插入 n 个 就有 n 次入栈的操作,就访问 n 次数组
-
(2 + 4 + 8 + ... + n):
- 如果 n = 2^i 个元素入栈,需要数组翻倍 lgn 次。
从技术上讲,总和(2 + 4 + 8 + .. + m)是具有 logN 个元素的几何级数
然后:(2 + 4 + 8 + .. + m)= 2 *(2 ^ log N - 1) = 2(N - 1) = 2N - 2 ~2N
- 如果 n = 2^i 个元素入栈,需要数组翻倍 lgn 次。
- => N +(2 + 4 + 8 + .. + N)= N + 2N - 2 = 3N - 2 ~3N
举个栗子~,如果我们往栈中插入 8 (2^3) 个元素,那么我们必须将数组翻倍 lg8 次,即3次。因此,8个元素入栈的开销为 8 +(2 + 4 + 8)= 22 次 ≈ 24 次
再举个栗子~,如果插入 16 (2^4) 个元素,那么我们必须将数组翻倍 lg16 次,即4次。因此,16个元素入栈的开销为 16 +(2 + 4 + 8 + 16)= 46 次 ≈ 48 次
或者粗略想象一下,如果我们计算一下开销,插入前 n (n = 2^i) 个元素,是对 2 的幂从1到N求和。 这样,总的开销大约是3N。先要访问数组一次,对于复制要访问两次。所以,要插入元素,大约需要访问数组三次。
下边的图是观察时间开销的另一种方式,表示了入栈操作需要访问数组的次数。
每次遇到2的幂,需要进行斜线上的数组访问时间,但是从宏观上来看,是将那些元素入栈上花去了红色直线那些时间 这叫做平摊分析。考虑开销时将总的开销平均给所有的操作。关于平摊分析就不再解释了,有兴趣可以自行了解...
怎样缩小数组
如果数组翻倍多次后,又有多次出栈,那么如果不调整数组大小,数组可能会变得太空。那数组在什么情况下去缩小,缩小多少才合适?
我们也许这么考虑,当数组满了的时候将容量翻倍,那么当它只有一半满的时候,将容量缩减一半。但是这样并不合理,因为有一种现象叫做thrashing:即客户端刚好反复交替入栈出栈入栈出栈...
如果数组满了就会反复翻倍减半翻倍减半,并且每个操作都会新建数组,都要花掉正比与N的时间,这样就会导致thrashing频繁发生要花费平方时间,这不是我们想要的。
有效的解决方案是直到数组变为 1/4 满的时候才将容量减半
我们只要测试数组是否为 1/4 满,如果是,则调整大小使其为 1/2 满。
不变式:数组总是介于25% 满与全满之间
public String pop()
{
String item = s[--n];
//解决之前说的对象引用游离问题
s[n] = null;
if (n > 0 && n == s.length/4) resize(s.length/2);
return item;
}
这样的好处:
- 因为是半满的,既可以插入向栈插入元素,又可以从栈删除元素,而不需要再次进行调整数组大小的操作直到数组全满,或者再次1/4满。
- 每次调整大小时, 开销已经在平摊给了每次入栈和出栈
下图展示了上边测试写的客户端例子中数组上的操作
可以看到在开始时,数组大小从1倍增到2又到4,但一旦到8,数组的大小则维持一段时间不变,直到数组中只有2个元素时才缩小到4,等等。
算法分析
运行时间
数组调整大小并不经常发生,但这是实现栈API的一种很有效的方式,客户端不需要提供栈的最大容量,但依然保证了我们使用的内存大小总是栈中实际元素个数的常数倍,所以分析说明对于任意的操作序列,每个操作的平均运行时间与常数成正比。
这里,存在最坏情况(worst case)。当栈容量翻倍时,需要正比于N的时间,所以性能不如我们想要的那么好,但是优势在于进行入栈出栈操作时非常快,入栈只需要访问数组并移动栈顶索引。对于大多数操作都很高效的。对于众多的客户端这是个很有效的权衡。
内存使用
栈的内存用量实际上比链表使用更少的内存。
给出的命题. 一个 ResizingArrayStackOfStrings 内存用量在 ~8n bytes 到 ~32n bytes 之间,取决于数组有多满。
只看 “private String[] s;”
・~ 8n 当数组满时. -- 栈的实际长度 = n,所以内存占用是 8 乘以栈不为空的元素个数
・~ 32n 当数组 1/4 满时. -- 栈的实际长度 = 4n,所以内存占用是 8 乘以(4 乘以栈的有效元素个数)
这里只是计算了 Java中数组占用的空间。同样地,这个分析只针对栈本身 而不包括客户端上的字符串。
调整数组实现VS链表实现
那么使用可调整大小的数组与链表之间如何取舍呢?
- 这是两种 API相同的不同的实现,客户端可以互换使用。
哪个更好呢?
- 很多情形中,我们会有同一API的多种实现。你需要根据客户端的性质选择合适的实现。
Linked-list implementation.
- 对于链表,每个操作最坏情况下需要常数时间,这是有保障的
- 但是为了处理链接,我们需要一些额外的时间和空间。所以链表实现会慢一些
Resizing-array implementation.
- 可调大小的数组实现有很好的分摊时间,所以整个过程总的平均效率不错
- 浪费更少的空间,对于每个操作也许有更快的实现
所以对于一些客户端,也许会有区别。以下这样的情形你不会想用可调大小数组实现:你有一架飞机进场等待降落,你不想系统突然间不能高效运转;或者互联网上的一个路由器,数据包以很高的速度涌进来,你不想因为某个操作突然变得很慢而丢失一些数据。
客户端就可以权衡,如果想要获得保证每个操作能够很快完成,就使用链表实现;
如果不需要保证每个操作,只是关心总的时间,可能就是用可调大小数组实现。因为总的时间会小得多,如果不是最坏情况下单个操作非常快。
尽管只有这些简单的数据结构,我们都需要做很重要的权衡,在很多实际情形中真的会产生影响。
队列 Queue
接下来我们简要考虑一下使用相同基本底层数据结构的队列的实现。
队列的API
这是字符串队列对应的API,实际上和栈的API是相同的,只是名字不一样而已...
入栈换成了入队(enqueue),出栈换成了出队(dequeue)。语义是不同的。
入队操作向队尾添加元素,而出队操作从队首移除元素。
就像你排队买票一样,入队时你排在队列的最后,在队列里待的最久的人是下一个离开队列的人。
数据结构的性能要求:所有操作都花费常数时间。
链表实现
队列的链表表示中,我们需要维护两个指针引用。一个是链表中的第一个元素,另一个是链表最后一个元素。
插入的时候我们在链表末端添加元素;移除元素的时候不变,依然从链表头取出元素。
- 出队 dequeue
那么这就是出队操作的实现,和栈的出栈操作是一样的。保存元素,前进指针指向下一个节点,这样就删除了第一个节点,然后返回该元素。
- 入队 enqueue
入队操作时,向链表添加新节点。我们要把它放在链表末端,这样它就是最后一个出队的元素。
首先要做的是保存指向最后一个节点的指针,因为我们需要将它指向下一个节点的指针从null变为新的节点。
然后给链表末端创建新的节点并对其属性赋值,将旧的指针从null变为指向新节点。
实际上现在指针操作仅限于如栈和队列这样的少数几个实现以及一些其他的基本数据结构了。现在很多操作链表的通用程序都封装在了这样的基本数据类型里。
Java 实现
这里处理了当队列为空时的特殊情况。为了保证去除最后一个元素后队列是空的,我们将last设为null,还保证first和last始终都是符合我们预想。
完整代码: Queue.java 这里用到了泛型和迭代器的实现
数组实现
用可调大小的数组实现并不难,但绝对是一个棘手的编程练习。
- 我们维护两个指针,分别指向队列中的第一个元素和队尾,即下一个元素要加入的地方。
- 对于入队操作在 tail 指向的地方加入新元素
- 出队操作移除 head 指向的元素
棘手的地方是一旦指针的位置超过了数组的容量,必须重置指针回到0,这里需要多写一些代码,而且和栈一样实现数据结构的时候你需要加上调整容量的方法。
完整代码:ResizingArrayQueue.java
额外补充
两个栈实现队列的数据结构
实现具有两个栈的队列,以便每个队列操作都是栈操作的常量次数(平摊次数)。
提示:
1.如果将元素推入栈然后全部出栈,它们将以相反的顺序出现。如果你重复这个过程,它们现在又恢复了正常的队列顺序。
2.为了避免不停的出栈入栈,可以加某个判定条件,比如当 dequeue 栈为空时,将 enqueue 栈的元素出栈到 dequeue 栈,然后最后从dequeue 栈出栈,也就实现了出队的操作。直到 dequeue 栈的元素都出栈了,再次触发出队操作时,再从 enqueue 栈导入数据重复上边的过程
实现参考:QueueWithTwoStacks.java
泛型 -- Generic
接下来我们要处理的是前面实现里另一个根本性的缺陷。前面的实现只适用于字符串,如果想要实现其他类型数据的队列和栈怎么 StackOfURLs, StackOfInts... 嗯。。。
这个问题就涉及泛型的话题了。
泛型的引出
实际上很多编程环境中这一点都是不得不考虑的。
- 第一种方法:我们对每一个数据类型都实现一个单独的栈
这太鸡肋了,我们要把代码复制到需要实现栈的地方,然后把数据类型改成这个型那个型,那么如果我们要处理上百个不同的数据类型,我们就得有上百个不同的实现,想想就很心累。
不幸的是Java 推出 1.5 版本前就是陷在这种模式里,并且非常多的编程语言都无法摆脱这样的模式。所以我们需要采用一种现代模式,不用给每个类型的数据都分别搞实现。
- 第二种方法:我们对 Object 类实现数据结构
有一个广泛采用的捷径是使用强制类型转换对不同的数据类型重用代码。Java中所有的类都是 Object 的子类,当客户端使用时,就将结果转换为对应的类型。但是这种解决方案并不令人满意。
这个例子中我们有两个栈:苹果的栈和桔子的栈。接下来,当从苹果栈出栈的时候需要客户端将出栈元素强制转换为苹果类型,这样类型检查系统才不会报错。
这样做的问题在于:
- 必须客户端完成强制类型转换,通过编译检查。
- 存在一个隐患,如果类型不匹配,会发生运行时错误。
第三种方法:使用泛型
这种方法中客户端程序不需要强制类型转换。在编译时就能发现类型不匹配的错误,而不是在运行时。
这个使用泛型的例子中栈的类型有一个类型参数(Apple),在代码里这个尖括号中。
如果我们有一个苹果栈,并且试图入栈一个桔子,我们在编译时就会提示错误,因为声明中那个栈只包含苹果,桔子禁止入内。
优秀的模块化编程的指导原则就是我们应当欢迎编译时错误,避免运行时错误。
因为如果我们能在编译时检测到错误,我们给客户交付产品或者部署对一个API的实现时,就有把握对于任何客户端都是没问题的。
有些运行时才会出现的错误可能在某些客户端的开发中几年之后才出现,如果这样,就必须部署我们的软件,这对每个人都是很困难的。
实际上优秀的泛型实现并不难。只需要把每处使用的字符串替换为泛型类型名称。
链表栈的泛型实现
如这里的代码所示,左边是我们使用链表实现的字符串栈,右边是泛型实现。
左边每处用到字符串类型的地方我们换成了item。在最上面类声明的地方我们用尖括号声明 item 是我们要用的泛型类型,这样的实现非常直截了当,并且出色地解决了不同的数据类型单独实现的问题。
数组栈的泛型实现
基于数组的实现,这种方法不管用。目前很多编程语言这方面都有问题,而对Java尤其是个难题。我们想做的是用泛型名称 item 直接声明一个新的数组。比如这样:
public class FixedCapacityStack-
{
private Item[] s;
private int n = 0;
public FixedCapacityStack(int capacity)
//看这里看这里像这样,但是实际我们在java当中我却不能这样方便的实现
{ s = new Item[capacity]; }
public boolean isEmpty()
{ return n == 0; }
public void push(Item item)
{ s[n++] = item; }
public Item pop()
{ return s[--n]; }
}
如有备注的这行所示。其他部分都和之前的方法没区别。不幸的是,Java不允许创建泛型数组。对于这个问题有各种技术方面的原因,在网上关于这个问题你能看到大量的争论,这个不在我们讨论的范围之内。关于协变的内容,可以自行了解,嗯。。。我一会也去了解了解...
这里,要行得通我们需要加入强制类型转换。
我们创建 Object 数组,然后将类型转换为 item 数组。教授的观点是优秀的代码应该不用强制类型转换, 要尽量避免强制类型转换,因为它确实在我们的实现中留下了隐患。但这个情况中我们必须加入这个强制类型转换。
当我们编译这个程序的时候,Java会发出警告信息说我们在使用未经检查或者不安全的操作,详细信息需要使用-Xlint=unchecked 参数重新编译。
我们加上这个参数重新编译之后显示你在代码中加入了一个未经检查的强制类型转换,然后 java 就警告你不应该加入未经检查的强制类型转换。但是这么做并不是我们的错,因为你不允许我们声明泛型数组,我们才不得不这么做。收到这个警告信息请不要认为是你的代码中有什么问题。
自动装箱 (Autoboxing) 与拆箱 (Unboxing)
接下来,是个跟Java有关的细节问题,
Q. 对基本数据类型,我们怎样使用泛型?
我们用的泛型类型是针对 Object 及其子类的。前面讲过,是从 Object 数组强制类型转换来的。为了处理基本类型,我们需要使用Java的包装对象类型。
如大写的 Integer 是整型的包装类型等等。另外,有个过程叫自动打包,自动转换基本类型与包装类型,所以处理基本类型这个问题,基本上都是在后台完成的.
Autoboxing:基本数据类型到包装类型的自动转换。
unboxing:包装器类型到基本数据类型的自动转换。
综上所述,我们能定义适用于任何数据类型的泛型栈的API,而且我们有基于链表和数组两种实现。我们讲过的使用可变大小数组或者链表,对于任何数据类型都有非常好的性能。
额外补充
在 Java 6, 必须在变量声明(左侧)和构造函数调用(右侧)中指定具体类型。从Java 7 开始,可以使用菱形运算符:Stack
Q. 为什么Java需要强制转换(或反射)?
简短的回答: 向后兼容性。
详细地回答:需要了解类型擦除和协变数组
Q. 当我尝试创建泛型数组时,为什么会出现“无法创建泛型数组”错误?public class ResizingArrayStack
A. 根本原因是Java中的数组是协变的,但泛型不是。换句话说,String [] 是 Object [] 的子类型,但 Stack
Q. 那么,为什么数组是协变的呢?
A. 许多程序员(和编程语言理论家)认为协变数组是 Java 类型系统中的一个严重缺陷:它们会产生不必要的运行时性能开销(例如,参见ArrayStoreException)并且可能导致细微的 BUG。在Java中引入了协变数组,以避免Java在其设计中最初没有包含泛型的问题,例如,实现Arrays.sort(Comparable [])并使用 String [] 类型的输入数组进行调用。
Q. 我可以创建并返回参数化类型的新数组,例如,为泛型队列实现 toArray() 方法吗?
A. 不容易。如果客户端将所需具体类型的对象传递给 toArray(),则可以使用反射来执行此操作。这是 Java 的 Collection Framework 采用的(笨拙)方法。
迭代器 Iterators
Java还提供了另一种能够使客户端代码保持优雅紧凑,绝对值得添加到我们的基本数据类型的特性 -- 迭代器。
遍历
对于遍历功能,大多数客户端想做的只是遍历集合中的元素,我们考虑的任何内部表示,这对于客户端是不相关的,他们并不关心集合的内部实现。也就是说我们允许客户端遍历集合中的元素,但不必让客户端知道我们是用数组还是链表。
Java提供了一个解决方式,就是实现遍历机制,然后使用 foreach.
Foreach loop
我们自找麻烦地要让我们的数据类型添加迭代器是因为,如果我们的数据结构是可遍历的,在Java中我们就可以使用非常紧凑优雅的客户端代码,即所谓的for-each语句来进行集合的遍历。
使用了迭代器后,以下两种写法都可以不考虑底层的内部实现而遍历某个集合,两种方法是等价的:
Stack stack;
...
// "foreach" loop
for (String s : stack)
StdOut.println(s);
...
// 与上边的方法等价
Iterator i = stack.iterator();
while (i.hasNext())
{
String s = i.next();
StdOut.println(s);
}
所以如果我们有一个栈 stack 可以写(for String s: stack) 表示对栈中每个字符串,执行打印输出。
我们也可以写成下边这种完整形式的代码,但不会有人这么做,因为它和上边的简写形式是等价的。
不使用迭代器的话要实现遍历客户端代码中就要执行非常多不必要的入栈出栈操作。所以这是能够让遍历数据结构中的元素的客户端代码变得这么紧凑的关键所在。
要使用户定义的集合支持 foreach 循环:
- 数据类型必须具有名为 iterator() 的方法
-
iterator() 方法返回一个对象,这个对象具有两个核心方法:
- hasNext() 方法, 当不再遍历到任何元素时,返回false
- next() 方法, 返回集合中的下一个元素
迭代器
为了支持 foreach 循环,Java 提供了两个接口。
- Iterator 接口:有 next() 和 hasNext() 方法。
- Iterable 接口:iterator() 方法返回 一个迭代器 Iterator
- 两者都应该与泛型一起使用
Q. 什么是 Iterable ?
A. 在 Java 语言中 Iterable 是具有返回迭代器的方法的一种类。
来源:jdk 8 java.lang.Iterable 接口
//此处T可以随便写为任意标识,常见的如T、E、K、V等形式的参数常用于表示泛型
//在实例化泛型类时,必须指定T的具体类型
public interface Iterable {
/**
* Returns an iterator over elements of type {@code T}.
*
* @return an Iterator.
*/
Iterator iterator();
...
}
Q. 那么什么是迭代器 iterator ?
A. 迭代器是具有 hasNext() 和 next() 方法的类。
来源:jdk 8 java.util.Iterator 接口
public interface Iterator {
boolean hasNext();
E next();
default void remove()
default void forEachRemaining(Consumer super E> action)
}
Java还允许 remove() 方法,但我们认为这不是一个很好的特性,它有可能成为调试隐患,一般不用。
那么,只要有 hasNext() 和 next() 方法就使得数据结构是可遍历的,所以我们要实现这两个方法。
下面我们要做的是看看如何使我们的栈、队列和后面要讲到的其他数据结构实现所谓的 Iterable(可遍历类)接口。
实例
我们要给我们所有的基本数据结构提供遍历机制。实现这个功能并不特别难,而且绝对值得投入精力。这是基于栈的代码。
栈实现迭代器: 链表实现
接下来我们要实现Iterable接口。
实现Iterable接口意味着什么呢?这个类需要有iterator()方法返回迭代器。
什么是迭代器呢?我们要用一个内部类。这个例子中,命名为 ListIterator 的内部类实现 Iterator 接口,并且是泛化(generic)的。
ListIterator 这个类主要完成的是实现 hasNext() 和 next() 方法。从名字就能清楚知道它们的语义。
- hasNext() 在完成遍历之后会返回 false;如果还没有完成,应该返回 true
- next() 方法提供要遍历的下一个元素
所以如果是基于链表的数据结构,我们要从表头 first 元素开始,这是处于表头的元素.
我们要维护迭代器中的实例变量 current 存储当前正在遍历的元素。我们取出current元素,然后将 current 引用指向下一个元素,并返回之前储存的item,也就将current 移动到了下一个元素上。
客户端会一直测试 hasNext(),所以当current变成空指针,hasNext 返回 false 终止遍历。
在我们的遍历中,我们只需要关注实现 next() 和 hasNext()方法,使用一个局部实例变量 current 就能完成。
如果遍历已经终止,客户端还试图调用 next() 或者试图调用 remove() 时抛出异常,我们不提供remove()方法。
完整代码:StackImpIterator
栈实现迭代器: 数组实现
对于基于数组的实现,就更简单了。使用迭代器我们能控制遍历顺序,使其符合语义和数据结构。
遍历栈时你要让元素以出栈的顺序返回,即对于数组是逆序的,那么这种情况下next() 就将索引减 1,返回下一个元素。
而我们的实例变量是数组的索引。
只要该变量为正,hasNext() 返回 true。要实现这个遍历机制只需要写几行Java代码,以后遇到涉及对象集合的基本数据类型中我们都会用这种编程范式。
完整代码:ResizingArrayStack
实际上很多客户端并不关心我们返回元素的顺序。我们经常做的是直接向集合中插入元素,接下来遍历已有的元素。这样的数据结构叫做背包。
我们来看看它的API。
顺序并不重要,所以我们想要能直接添加元素,也许还想知道集合大小。
我们想遍历背包中所有的元素,这个API更简单,功能更少,但依然提供了几个重要的操作。
使用这个API,我们已经看过实现了,只需要将栈的出栈操作或者队列的出队操作去掉,就能获得这个有用的数据结构的良好的实现
完整代码:Bag--ListIterator ;Bag--ArrayIterator
栈与队列的应用
栈的应用:
栈确实非常基础,很多计算基于它运行 因为它能实现递归
·Java虚拟机
·解析编译器 (处理编译一种编程语言或者解释为实际的计算)
·文字处理器中的撤消
·Web浏览器中的后退按钮(当你使用网页浏览器上的后退按钮时,你去过的网页就存储在栈上)
·打印机的PostScript语言
·在编译器中实现函数的方式 (当有函数被调用时,整个局部环境和返回地址入栈,之后函数返回时, 返回地址和环境变量出栈. 有个栈包含全部信息,无论函数调用的是否是它本身。栈就包含了递归。实际上,你总能显式地使用栈将递归程序非递归化。)
队列的应用:
应用程序
·数据缓冲区(iPod,TiVo,声卡...)
·异步数据传输(文件IO,sockets...)
·共享资源上分配请求(打印机,处理器...)
...
模拟现实世界
·交通的流量分析
·呼叫中心客户的等待时间
·确定在超市收银员的数量...
前面的一些基本数据结构和实现看起来相当基础和简单,但马上我们就要涉及这些基本概念的一些非常复杂的应用。
首先要提到的是我们实现的数据类型和数据结构往往能在 Java 库中找到,很多编程环境都是如此。比如在 Java 库中就能找到栈和队列。
Java 对于集合有一个通用 API,就是所谓的List接口。具体请查看对应版本 jdk 的源码。
List接口包含从表尾添加,从表头移除之类的方法,而且它的实现使用的是可变大小数组。
在 Java 库中
- java.util.ArrayList 使用调整大小数组实现
- java.util.LinkedList 使用双向链表实现
我们考虑的很多原则其实 Java 库中 LinkedList 接口一样考虑了。 但是我们目前还是要用我们自己的实现。
问题在于这样的库一般是开发组(committee phenomenon)设计的,里头加入了越来越多的操作,API变得过宽和臃肿。
在API中拥有非常多的操作并不好,等成为有经验的程序员以后知道自己在做什么了,就可以高效地使用一些集合库。但是经验不足的程序员使用库经常会遇到问题。用这样包含了那么多操作的,像瑞士军刀一样的实现,很难知道你的客户端需要的那组操作是否是高效实现的。所以这门算法课上坚持的原则是我们在课上实现之前,能表明你理解性能指标前,先不要使用 Java 库.
Q. 在不运行程序的情况下观察下面一段将会打印什么?
int n = 50;
Stack stack = new Stack();
while (n > 0) {
stack.push(n % 2);
n = n / 2;
}
for (int digit : stack) {
StdOut.print(digit);
}
StdOut.println();
A. 110010
值得注意的是,如果使用的是上边我们自己定义的 iterator 去遍历,那么得到的就是符合栈后进先出特点的答案,但是如果直接使用java.util.Stack 中的Stack,在重写遍历方式前,他得到的就是先进先出的答案,这不符合栈的数据类型特点。
这是因为 JDK (以下以 jdk8 为例) 中的 Stack 继承了 Vector 类
package java.util;
public class Stack extends Vector {
...
}
而 Vector 这个类中 Stack 实现的迭代器的默认的遍历方式是FIFO的,并不是栈特点的LIFO
状态(已关闭,短期不会修复):让 JDK 中的栈去继承 Vector 类并不是一个好的设计,但是因为兼容性的问题所以不会去修复。
所以更印证了前面的提议,如果在没有对 JDK 底层数据结构有熟悉的了解前,提交的作业不推荐使用 JDK 封装的数据结构!
编程练习
Programming Assignment 2: Deques and Randomized Queues
原题地址:里头有具体的 API 要求和数据结构实现的性能要求。
使用泛型实现双端队列和随机队列。此作业的目标是使用数组和链表实现基本数据结构,并加深泛型和迭代器的理解。
Dequeue: double-ended queue or deque (发音为“deck”) 是栈和队列的概括,支持从数据结构的前面或后面添加和删除元素
性能要求:deque 的实现必须在最坏的情况下支持每个操作(包括构造函数)在最坏情况下花费常量时间。一个包含 n 个元素的双端队列最多使用 48n + 192 个字节的内存,并使用与双端队列当前元素数量成比例的空间。此外,迭代器实现必须支持在最坏的情况下每个操作(包括构造函数)都是用常数时间。
Randomized queue: 随机队列类似于栈或队列,除了从数据结构中移除的元素是随机均匀选择的。
性能要求:随机队列的实现必须支持每个操作( 包括生成迭代器 )都花费常量的平摊时间。
也就是说,对于某些常数c,任意 m 个随机队列操作序列(从空队列开始)在最坏情况下最多 c 乘以 m 步。包含n个元素的随机队列使用最多48n + 192 个字节的内存。
此外,迭代器实现必须支持在最坏情况下 next()和 hasNext()操作花费常量时间; 迭代器中的构造函数花费线性时间; 可能(并且将需要)为每个迭代器使用线性数量的额外内存。
Permutation: 写一个叫 Permutation.java 的客户端,将整数 k 作为命令行参数; 使用StdIn.readString() 读取标准输入的字符串序列; 并且随机均匀地打印它们中的 k 个。每个元素从序列中最多打印一次。
比如在输入端的序列是:
% more distinct.txt
A B C D E F G H I
那么打印的时候:
% java-algs4 Permutation 3 < distinct.txt
C
G
A
而绝对不出现:
C
G
G
同个元素被多次打印的情况
命令行输入:可以假设 0≤k≤n,其中 n 是标准输入上的字符串的个数。
性能要求:客户端的运行时间必须与输入的数据大小成线性关系。可以仅使用 恒定数量的内存 加上 一个最大大小为 n 的 Deque 或RandomizedQueue 对象的大小。(对于额外的挑战,最多只使用一个最大大小为 k 的 Deque 或 RandomizedQueue 对象)
每一次作业都会有一个 bonus 的分数,就是类似奖励的分数,本次的作业的额外加分是上分的括号内容,同时还有内存使用之类
Test 3 (bonus): check that maximum size of any or Deque or RandomizedQueue object
created is equal to k
- filename = tale.txt, n = 138653, k = 5
- filename = tale.txt, n = 138653, k = 50
- filename = tale.txt, n = 138653, k = 500
- filename = tale.txt, n = 138653, k = 5000
- filename = tale.txt, n = 138653, k = 50000
==> passed
Test 8 (bonus): Uses at most 40n + 40 bytes of memory
==> passed
Total: 3/2 tests passed!
附录
git 地址 100/100:在此