文章目录
概述
许多基础数据类型(ArrayList)都和对象的集合有关,具体来说,数据类型的值就是一组对象的集合,所有的操作都是关于增删改查集合中的对象,本章主要学习背包,队列和栈,区别在于访问或删除对象的顺序不同。本节关注以下几点:
- 集合中对象的表示方式直接影响操作的效率,所以对于集合来说,要设计适合于表示一组对象的数据结构并高效实现所需方法
- 泛型和迭代:简化代码
- 链式数据结构的实现,通过链表来高效实现背队栈
- 学习背队栈的API和用例,讨论数据类型的值所有可能的表示方法和各种操作的实现(以后会反复出现的学习模式)
集合型抽象数据类型
泛型
- 泛型(参数化类型),如Bag:Item为一个类型参数,是象征性的占位符,使用时必须实例化为一具体的引用数据类型(不能用primitive数据类型)
- 为了在使用泛型时能处理primitive数据类型,利用封装类型来自动装箱和拆箱
可迭代的集合类型
- 用例要求迭代访问集合中的元素(该模式很重要),且不依赖于集合类型的具体实现(不需要知道集合的表示或实现的任何细节)
- 若集合可迭代(集合要添加实现代码),则用增强for循环即可以(该语法称为foreach语句)
- stack和queue的API的唯一不同在于它们名称和方法名(说明无法简单地通过一列方法的签名来说明一个数据类型的所有特点)。在这里,只有自然语言描述才能说明选择被删除的元素(或是foreach语句中下一个被处理的元素)的规则。这些规则的差异是API的重要组成部分,对用例开发至关重要。(?)
背包
- 不支持删除元素的集合数据类型
- 目的是帮助用例收集元素并迭代遍历所有集合中元素(迭代顺序不确定且与用例无关)
- 实例(P77):简单地计算标准输入中的所有double值的平均值和样本标准差(数的计算顺序和结果无关)
先进先出队列(queue)
- 基于FIFO策略的集合类型(按照任务产生的顺序来完成它们的策略,任何服务型策略的基本原则都是公平)
- 日常现象的自然模型
- 迭代访问时,元素处理顺序即添加的顺序(保存元素同时保存了它们的相对顺序)
- 实例(P77):将文件中的所有整数读入一个数组中
下压栈
- 基于后进先出策略的集合类型(一摞文件的组织原则)
- 联系安卓开发的activity
- 迭代访问顺序与压入顺序相反(保存元素同时颠倒了它们的相对顺序)
栈实例——算术表达式求值
Dijkstra的双栈算术表达式求值算法
- 描述为接收一个输入字符串(表达式)并输出表达式的值(进一步地理解为解释器,解释给定字符串所表达的运算并得到结果,且展示一种计算模型,将字符串解释为一段程序,将执行该程序得到结果)
- 明确的递归定义来简化问题:未省略括号,即明确说明所有运算符的操作数的算术表达式
- 两个栈:一个保存运算符,一个保存操作数
- 具体的:根据四种情况从左到右逐个将实体送入栈处理,见书P79(待细看)
- 理解为:从左到右逐个等效替代(子表达式的计算值替代子表达式)
集合类数据类型的实现
定容字符串栈
- 先讨论一简单而经典的实现:一种表示定容字符串栈的抽象数据类型的实现
- 实现API首先要选择数据的表示方式——选择string数组
- 实现API后,用恒等式的方式思考这些条件(定容字符串栈满足的性质)是检验实现正常工作的最简单方式
- 该实现的性能特点在于pop操作和push操作与栈长度独立,具有简洁性
- 需要改进为一种适用性更加广泛的实现来作为通用工具(更强大数据类型的模板)
定容字符串栈的改进
泛型
- 采用类型参数来实现一个泛型的栈
- Java使用类型参数来检测类型不匹配错误,即赋给Item类型变量的值必须是Item类型
- 创建泛型数组是不允许的,需要使用类型转换
a = (Item[])new Object[num]; %item为类型参数,Java系统库中类似抽象数据类型的实现也使用相同方式
动态调整数组的大小
- 用数组表示栈的内容意味着用例要预先估计栈的最大容量,从而为数组预先设置大小,但会存在浪费内存问题,如一个交易系统涉及上亿笔交易和数千个交易的集合,即使该系统限制每笔交易只能出现在一个集合中,但用例必须保证所有集合都有能力保存所有的交易,此外会出现溢出问题
- 考虑在push前检查栈是否full,所以API应该含有isFull方法,但在此省略其实现代码(将用例从处理isFull问题中解脱出来,如原始stack的API所示)——》修改数组的实现,实现动态调整数组大小
- 逐个复制数组元素,但创建更大空间数组的resize方法
- 关注pop的检测条件:判别栈大小是否小于数组的1/4,若成立则数组长度减半,达到状态半满(0.25-1——》0.25-0.5)
在下次需要改变数组大小前还能进行多次的push和pop操作
对象的游离
- 保存一个不需要对象的引用称为游离
- Java垃圾回收策略是回收所有无法被访问的对象的内存
- 在pop实现中,被弹出的元素的引用仍然存在于数组中(名义上该元素是孤儿了,不再需要被访问,但垃圾回收器不知道),所以该元素继续存在,称为对象游离
- 避免对象游离:将被弹出的数组元素的值设为null,从而覆盖无用引用,使得系统在用例用完被弹出的元素后回收它的内存
迭代的实现(Iterator和Iterable)
- 集合类数据类型的基本操作之一:使用foreach迭代遍历和处理集合中元素,好处清晰简洁,不依赖于集合类数据类型的具体实现
- 对于可迭代的集合类数据类型,Java已定义了所需的接口。
- 一个要想可迭代,需要实现一个 iterator()方法并返回一个迭代器Iterator对象(多态性),即实现Iterable接口
- 对于一直使用的数组表示法,需要逆序迭代遍历该数组(栈),所以将迭代器命名为ReverseArrayIterator
- Iterator和Iterable(❤)
- Iterable和Iterator都是接口,集合类要实现Iterable接口才能迭代
- Iterable接口包含一个 iterator()方法,该方法返回一个迭代器对象
- 迭代器对象是一个实现了Iterator接口的类的对象
- Iterator接口包含hasNext方法,remove方法和next方法
- 本书中remove方法总为空,因为希望避免在迭代中穿插能修改数据结构的操作
- 内部类可以访问外部类的实例变量
- 从技术角度看,为了和Iterator的结构保持一致,应该在两种情况下抛出异常(调用remove和数组索引为负),但我们只在foreach中使用迭代器遍历集合元素,所以不存在这些情况
- foreach效果同for循环,但用例无需知道实现细节,即数据的表示方法是数组(数组实现了栈内容表示),对于所有类似于集合的数据类型的实现,该特性至关重要。(改变数据表示方法而无需改变用例代码)
- 关注算法1.1(P89),其几乎达到集合类数据类型实现的最佳性能,即每项操作的用时和集合大小无关(但某些pop会调整数组大小,该项操作耗时和栈大小成正比),空间需求不超过集合大小乘以一个常数
- 算法1.1(泛型的可迭代的stack的API的实现)是集合类数据类型实现的模板
链表
- 一种基础数据结构,一种在集合类数据类型实现中表示数据的方式,构造非Java直接支持的数据结构的例子,其实现作为其他复杂数据结构的构造代码的模板
- 链表是一种递归的数据结构,或者为空,或者指向一个node的引用,该节点含有一个泛型的元素和一个指向另一个链表的引用
- node是一个抽象实体(可能含有任意类型数据,利用泛型机制),所包含的引用显示其在构造链表中的作用(Node类型实例变量显示链表的链式本质),简洁性赋予其极大价值
节点记录
private class Node{
Item item;
Node next;
}
- 用嵌套类定义结点这一数据类型,在需要使用Node类的类中指明参数类型tem,并将Node类标记为private,因为其不是为用例准备的
- 为了强调在组织数据时只使用了Node类,在Node类中未定义任何方法且在代码中直接引用了实例变量(该类型的类称为记录)
- 因为直接引用实例变量,所以不是抽象数据类型,可是由于在我们的实现中node和其用例代码会被封装在相同的类中且无法被该类的用例访问,in this sense,仍然属于数据抽象
构造链表
- 根据递归定义,只需要一个node类型的变量就能表示链表,只要保证对象的值为null或指向另一个node对象
- 例子见图1.3.4(P90):用链接构造一条链表
- 链表表示的是一列元素(序列),数组也能表示一列元素,但链表更方便
- 指向被引用对象的箭头来表示引用关系,链接表示对结点的引用,元素值写在长方形中,而非1.2节中更准确的方式来表示字符串对象和字符数组
- 通过first链接访问首节点,通过last链接访问尾节点
表头插入结点
- 思想:将first保存为oldfirst,然后加入新的first,赋值并创建指向oldfirst的链接
- 运行时间与链表长度无关
Node oldfirst = first
first = new Node()
first.item=?
first.next = oldfirst
表头删除结点
- Node first = first.next() %和之前一样,该操作只含有一条赋值语句
- 一般希望在赋值之前得到该元素的值,因为一旦改变first,则之前其所指向的结点对象称为孤儿对象,对象所占内存则被垃圾回收
- 运行时间与链表长度无关
表尾插入结点
- 需要一个指向链表最后一个节点的链接,修改为指向一个新结点、
- 不能在链表中草率地决定维护一个额外的链接,因为每个链表操作都要检查是否要修改该变量(以及作出相应修改)的代码,如删除首节点的代码可能会改变指向尾节点的引用,如链表只有一个节点(既是首节点又是尾节点)或者空链表(使用空链接)
Node oldlast = last
last = new Node();
last.item = ?
oldlast.next = last;
其他位置的插入和删除
若要删除尾节点,则要寻找前一个节点,将该节点的链接改为null,若为单向链表,则要遍历整条链表来找到next域为last的节点,但所需时间与链表长度成正比,考虑使用双向链表(具体见练习)
遍历
访问链表中所有元素的方式:
for(Node x = first ; x !=null;x = x.next){ //first指向链表首节点
//deal with x.item
}
该方式同迭代遍历数组中的所有元素的标准方式一样自然,在我们的实现中,它是迭代器使用的基本方式
栈的实现
重新回顾stack的API,定义链表数据结构,用链表实现,push和pop类比添加在表头和从表头删除,同样达到最优设计目标:
- 处理任何类型数据
- 所需空间与集合大小成正比
- 操作与集合大小无关
- 具体实现见P94
- 算法的实现代码很简单,但数据结构的性质并不简单(数据结构定义和算法实现的相互作用)
队列的实现
- 基于链表数据结构设计队列(表示为一条从最早插入元素到最近插入元素的链表)
- queue同stack使用相同数据结构链表,但实现不同添加和删除元素的算法(FIFO和FILO的区别所在)
- 三件套(API及实现和用例)见P96
- 结构化存储数据集时,链表play an important role(组织程序和数据的结构)
- 通过抽象数据类型的使用,将链表处理的代码封装在类中
背包的实现
- 简单修改stackAPI实现即可,即也通过维护一条链表来实现bag的API
- 三件套见P98
- 加粗部分代码(P98)使得可以通过遍历链表使stack,queue和bag成为可迭代的
- 嵌套类维护一个实例变量current来记录链表的当前节点
- 迭代器会遍历链表并将当前节点保存在current变量中,背包同stack和queue区别在于链表访问顺序不同
总结
- 本章所学习的SQB实现所提供的抽象使我们能编写简洁用例来操作对象的集合(要深入理解这些抽象数据类型)
- 研究算法和数据结构的开始:1)基础数据结构的定位 2)展示数据结构和算法的关系 3)未来算法实现中的抽象数据类型需要能够支持对对象集合的强大操作,本章实现是起点
- 两种表示集合对象的方式:数组(Java内置)和链表(自己用Java标准方法实现),以后所学的数据结构将以多种方式归并并拓展基础数据结构
- 二叉树为含有多个链接的数据结构,每个节点含有两个链接
- 复合型数据结构,如用背包存储栈,队列存储数组,如“图”用数组的背包表示
- 用上述方式很容易定义任意复杂的数据结构——》研究数据结构以控制复杂度
- 本章所描述数据结构和算法的方式是全书的原型,在研究一个新应用领域时,按如下步骤使用数据抽象解决问题:
- 定义API
- 开发用例代码
- 描述数据结构(一组值的表示),并在API所对应的抽象数据类型的实现中定义实例变量
- 描述算法(实现一组操作的方式),根据算法实现实例方法
- 分析性能特点
答疑
- 泛型的替代方案:为每种类型的数据都实现一个不同的集合数据类型或者构造一个object对象的栈并在pop中将得到的对象转化为所需要的数据类型(但类型不匹配错误只能在运行时发现,使用泛型则能在编译时发现)
- 不需要泛型数组原因:关注类型擦除和公变数组问题
- 创建一个字符串栈的数组的方法:使用类型转换,如
Stack[] a = (Stack[]) new Stack[N]
- 这段类型转换的用例代码不同于1.3.2.2节所示的代码(1.3.2.2使用的是Object而非Stack),因为在使用泛型时,Java在编译时检查类型的安全性,但运行时抛弃所有这些信息,则运行时语句右侧等价于Stack或者只剩下Stack[],所以要将它们转化为 Stack[]
- 栈为空调用pop方法应该抛出运行时异常来定位错误信息(null check)
- ResizingArrayStack是控制一些抽象数据类型内存使用的模板(不用链表结构,而用调整数组大小方式)
- Node声明为私有的嵌套类后,将其方法和实例变量的访问限制在外部类中,从而无需将内部类的实例变量声明为private
- 非静态的嵌套类也称为内部类,从技术上看NODE类是内部类,尽管非泛型的类也可以是静态的
- Stack N o d e . c l a s s 是 内 部 类 编 译 生 成 的 字 节 码 文 件 ( J a v a 命 名 规 则 用 Node.class是内部类编译生成的字节码文件(Java命名规则用 Node.class是内部类编译生成的字节码文件(Java命名规则用分隔外部类和内部类)
- Java中有一个Stack类,但其添加了额外的方法,可被当做队列使用,因此其API是宽接口的一个典型例子(应避免此种情况)——我们使用某数据类型,不仅仅为了获得所需要的操作,也要准确地指定所需操作,从而避免意外操作
- 我们的实现以及Java的栈和queue库允许插入null值
- 若在迭代中调用push(),Stack的迭代器应该立刻抛出异常(快速出错的设计理念——》一个快速出错的迭代器)
- 数组尽管没有实现Iterable接口,但能使用foreach语句(String没实现,所以不能使用)
- main方法的args为命令行参数
- 为什么不实现一个单独的collection数据类型并实现我们所需要的各种方法,这样就能在一个类中实现所有这些方法然后广泛应用?
- 又是一个宽接口的例子,Java在ArrayList中实现了类似设计,但要避免这样做,因为无法保证高效实现所有这些方法,且API作为设计高效算法和数据结构的起点,设计简单的接口显示比复杂的更简单,另一方面要限制用例代码行为,使其清晰易懂(FIFO用队列,FILO用栈)