算法第四版(谢路云译)
前言:这本书是算法和数据结构方面的经典书籍,使用java语言描述,强力推荐好好看看这本书,对于考研和找工作都极其有帮助。
官方网站:http://algs4.cs.princeton.edu/home/有部分源代码和部分课后习题答案。
个人练习代码:https://github.com/morefans/AlgorithmsFourthEdition
第1章 基础
1.1 基础编程模型
l Java程序的基本结构;原始数据类型与表达式;语句;简便记法;数组;静态方法;API;字符串;输入输出;二分查找。
l 以下为“答疑”和“练习”中的知识点:
l Math.abs(-2147483648)返回-2147483648(整数溢出)。
1.2 数据抽象
l 使用抽象数据类型;抽象数据类型举例;抽象数据类型的实现;更多抽象数据类型的实现;数据类型的设计。
l 以下为“答疑”和“练习”中的知识点:
1.3 背包、队列和栈
l 许多基础数据类型都和对象的集合有关。具体来说,数据类型的值就是一组对象的集合,所有操作都是关于添加、删除或是访问集合的对象。背包、队列和栈就是,三者不同之处在于删除或是访问对象的顺序不同。
l 用到泛型和迭代:(Iterable接口要实现publicIterator<Item> iterator()方法)
public class Bag<Item> implementsIterable<Item>
public class Queue<Item> implementsIterable<Item>
public class Stack<Item> implementsIterable<Item>
原始数据类型则使用Java的自动装箱将boolean、byte、char、short、int、float、double、long转换为Boolean、Byte、Character、Short、Integer、Double、Long。并且用自动拆箱转换回来。
l 背包:是一种不支持从中删除元素的集合数据类型,它的目的就是帮助用力收集元素并迭代遍历所有收集到的元素,迭代的书序不确定且与用例无关。
l 先进先出队列(FIFO):一种基于先进先出策略的集合类型,用集合保存元素的同时保存它们的相对顺序,并且入列顺序和出列顺序相同。
l 下压栈(LIFO):一种基于后进先出策略的集合类型,元素的处理顺序和它们被压入的顺序正好相反。
l 顺序栈实现:先实现定容栈FixedCapacityStackOfStrings类,只能存储String类型,并且大小固定;然后用泛型实现FixedCapacityStack类;再添加resize(int capacity)方法并更改push和pop使之可以调整自动大小保证不会溢出且使用率大于四分之一,即ResizableStack类;最后实现Iterable接口,完成ResizingArrayStack类。
l 对象游离:Java垃圾收集策略是回收所有无法被访问的对象的内存。保存着一个不需要的对象的引用就称为游离。要避免对象游离只要将引用设为null即可。
l 链表栈实现:结点类Node,表头插入结点,表头删除结点,表尾插入结点,链表的遍历。然后实现Stack类
l 链表队列的实现,背包就是去掉pop()的Stack。
l 自己尝试实现顺序队列:固定大小的循环队列FixedCapacityQueue类,可调整大小可迭代遍历的循环队列ResizingArrayQueue类。
l 其他数据结构(除去背包、队列、栈):父链接树、二分查找树、字符串、二叉堆、散列表(拉链法)、散列表(线性探测法)、图的邻接链表、单词查找树、三向单词查找树。
l 以下为“答疑”和“练习”中的知识点:
l 为什么Java不允许泛型数组:专家们仍在争论这一点,初学者请先了解共变数组(covariantarray)和类型擦除(type erasure)。
l Stack的内部类Node经过javac编译后会生成一个Stack$Node.class文件。
l Java数组支持foreach遍历(for(inti : int[] array))。
l 应当避免使用宽接口(实现了很多功能以提升适用性),因为这样无法保证高效,并且可能出现意外情况,其实是累赘。
l 可以用Stack类来将十进制转化为二进制(习题1.3.5),结合Stack和Queue来反转队列(习题1.3.6),中序表达式转化为后续表达式(习题1.3.10),有专门的链表练习最好自己多动手写代码,双向队列(双向链表实现和动态数组实现),
1.4 算法分析
l 科学方法:1、细致地观察真实世界的特点,通常还要有精确地测量;2、根据观察结果提出结社模型;3、根据模型预测未来的时间;4、继续观察并核实预测的准确性;5、如此反复知道确认预测和观察一致。
l 一个程序运行的总时间主要和两点有关:执行每条语句的耗时(取决于计算机、Java编译器和操作系统),执行每条语句的频率(取决于程序本身和输入)。二者相乘并将程序中所有指令的成本相加得到运行总时间。
l 时间复杂度(程序运行时间的增长数量级)和空间复杂度。
l 理解问题规模的上界和时间复杂度的下界。
l 注意事项:大常数,非决定性的内循环,指令时间,系统因素,不分伯仲,对输入的强烈依赖,多个问题参量。
l Java对象要有24字节的对象头,而数组如int数组就是24+4N字节(N为奇数时还有填充字节),double数组则是24+8N字节。String对象总共会使用40个字节(16字节表示对象,3个int实例变量个需要4字节,加上数组引用的8字节和4个填充字节)+(24+2N)字节字符数组(字符串间共享)。
l 以下为“答疑”和“练习”中的知识点:
l 双调查找,仅用加减实现的二分查找(斐波那契查找),扔鸡蛋,扔两个鸡蛋,两个栈可以实现队列(三种方法,要注意细节),一个队列实现栈,两个队列实现栈,热还是冷。
1.5 案例研究:union-find算法
动态连通性,union-find算法,三种实现。
1、 用集合来保存,标识为某一个触点,即quick-find算法。
2、 用森林来保存,每个触点的id[i]指向自己或连通分量中另一个触点,指向自己的则是根触点,为quick-union算法,但是每次两树合并是不确定的,不保证比quick-find算法快。
3、 用森林保存,改进quick-union,为加权quick-union算法,保存树的节点数,每次把小树连接到大树上,保证对数级别。
4、 使用路径压缩的加权quick-union,是当前最优算法,但并非所有的操作都能在常数时间内完成。
第2章 排序
2.1 初级排序算法
l 排序算法可以分为两类:除了桉树调用需要的栈和固定数目的实例变量之外无需额外内存的原地排序算法,以及需要额外内存空间来存储另一份数组副本的其他排序算法。
l 选择排序:选择数组最小放到数组前面,每次选择最小放到数组第k小的位置。N2/2次比较和N次交换,O(n2),运行时间和输入无关,数据移动是最少的。
l 插入排序:数组左端有序,每次插入一个元素是比较,较小则前移。平均情况需要~N2/4次比较和~N2/4次交换,最坏情况需要~N2/2次比较和~N2/2次交换,最好情况需要N-1次比较和0次交换。插入排序对部分有序数组很有效,事实上,当倒置的数量很少时,插入排序很可能比本章中任何算法都要快。
l 希尔排序:基于插入排序,间隔h进行间隔元素插入排序,h逐渐减小为1。高效的原因是权衡了子数组的规模和有序性。可以用于大型数组。运行时间达不到平方级别。属于O(n1.5)级别。有经验的程序员有时会选择希尔排序,因为对于中等大小的数组它的运行时间是可以接受的,它的代码量很小,且不需要使用额外的内存空间,在下面的几节中我们会看到更加高效的算法,但是除了对于很大的N,它们可能只会比希尔排序快2倍(可能还达不到),而且更复杂。
l 通过提升速度来解决其他方式无法解决的问题是研究算法的设计和性能的主要原因之一。
l 以下为“答疑”和“练习”中的知识点:
l 所有主键相同插入排序比选择排序更快。逆序数组选择排序比插入排序更快。元素只有三种值的随机数组插入排序仍然是平方级别的。出列排序,按照限定条件每次选出最小(大)值然后将有序的放入底部。
2.2 归并排序
l 归并排序:将数组分成两半分别排序,然后将结果归并起来。是分治思想的体现。
l 自顶向下的归并:从大分到小,使用递归,但实际上最终还是从两个元素开始归并。需要0.5NlgN至NlgN次比较,最多需要访问数组6NlgN次。
l 自底向上的归并:从两个到多,使用迭代。需要0.5NlgN至NlgN次比较,最多需要访问数组6NlgN次。
l 归并排序告诉我们,当能够用其中一种方法解决一个问题时,你都应该试试另一种。
l 没有任何基于比较的算法能够保证使用少于lg(N!)~NlgN次比较将长度为N的数组排序。(借用二叉比较树可以证明)
l 以下为“答疑”和“练习”中的知识点:
l 所有元素相同时归并排序运行时间是线性的。如果是多个值重复则归并排序仍然是线性对数的。
归并排序的三项改进:用插入排序加快小数组的排序速度,用array[mid]array[mid+1]检测数组是否已经有序,通过在敌对中交换参数来避免数组复制。
链表排序(选择排序,插入排序,冒泡排序,所有排序)。
归并有序的队列,然后自底向上实现有序队列的归并排序。自然的归并排序(自适应归并排序),利用数组中已有的有序部分。
打乱链表(直接的就是每次随机选出一个,但运行时间是平方级别的。或者利用数组来实现打乱,O(n)时间复杂度和O(n)空间复杂度。题目要求线性对数级别时间和对数级别空间。最开始的思路我的思路是递归实现的,每次随机排序左右两个链表,但我只是简单的将两个链表的前后顺序打乱,这样的结果是邻近的元素仍然是在附近的,并没有实现,没有想出来突破点。只要靠搜索引擎了。百度“打乱链表”居然没有结果,只有百度“shuffling a linked list”才有结果而且都是英文的。用谷歌搜索“shufflinga linked list”发现了一个人在Quora上发的讨论,然后看了他在StackOverflow上的回答,但是代码是Python的,不过差不多能看懂,然后才发现,合并两个链表时,应该是每次随机从一个链表中选出头元素来合并成新链表,这样才是真正的打乱。然后写出代码就行了,这里迭代的写法可能麻烦点,因为要靠大小来确定左右链表的大小或者左右链表的尾结点。)。
间接排序,不改变数组的归并排序,返回int[] perm,perm[i]为第i小的元素位置,其实就是对perm进行归并,只不过比较时要用array和perm来取元素比较。
三向归并排序,把数组分成三部分而不是两部分进行归并排序,其实就是多一些判断,本质上还是差不多的,运行时间仍然是线性对数级别的。
2.3 快速排序
l 可能是应用最广泛的排序算法了,原因是实现简单,适用于各种不同的输入数据且在一般应用中比其他排序算法都要快得多,而且是原地排序,时间复杂度是O(nlgn)。
l 快速排序是一种分支的排序算法,它将一个数组分成两个子数组,将两部分独立地排序,切分(partition)的位置取决于数组的内容。
l 快速排序平均需要~2NlnN次比较(以及1/6的交换),最多需要约N2/2次比较,但随即打乱数组能够预防这种情况。
l 快速排序的改进:小数组插入排序更快,所以小数组用插入排序。三取样切分,取三个元素比较用中间大小的元素作为切分元素。熵最优排序,重复值较多时用三向切分,即分为大于、等于、小于。对于只有若干不同主键的随机数组,三向切分快速排序是线性的。对于大小为N的数组,三向切分快速排序需要~(2ln2)NH次比较,其中H为由主键值出现频率定义的香农信息量。
l 以下为“答疑”和“练习”中的知识点:
l 将数组平分希望提高性能,用现有算法,是做不到的。
将已知只有两种主键值得数组排序。
非递归的快速排序,借助栈,存取每个要进行切分的子数组首尾位置。
快速三向切分,用将重复元素放置于子数组两端的方式实现一个信息量最优的排序算法。
2.4 优先队列
l 优先队列是一种抽象数据类型,它表示了一组值和对这些值的操作,优先队列最重要的操作就是删除最大元素和插入元素。
l 优先队列的一些重要的一个应用场景包括模拟系统、任务调度、数值计算等。
l 优先队列初级实现:无序数组实现(惰性方法),有序数组实现(积极方法),链表实现(无序或者有序)。这些要不就是插入操作为O(n)要不就是删除为O(n)。而使用堆的队列,可以插入删除都为O(logn)。
l 堆(二叉堆):实际上就是一棵二叉树,一般用数组实现。第0个元素不使用,k的子结点是2k和2k+1。而k的父结点是k/2向下取整。插入元素:将新元素加到数组末尾,增加堆的大小并让这个新元素上浮到合适的位置。删除最大元素:从数组顶端删去最大元素并将数组的最后一个元素放到顶端,减小堆的大小并让这个元素下沉到合适的位置。
l 对于一个含有N个元素的基于堆的优先队列,插入元素操作只需不超过lgN+1次比较,删除最大元素操作需要不超过2lgN次比较。
l 索引优先队列:仅对索引进行优先队列排序。
l 堆排序:将所有元素插入一个查找最小元素的优先队列,然后再重复调用删除最小元素的操作来将他们按顺序删去。用下沉操作由N个元素构造堆只需少于2N次比较以及少于N次交换。
l 先下沉后上浮(sink、swim):大多数在下沉排序期间重新插入堆的元素会被直接加入到堆底,Floyd在1964年观察发现,我们正好可以通过免去检查元素是否到达正确位置来节省时间,在下沉中总是直接提升较大的子结点直至到达堆底,然后再使元素上浮到正确的位置。这个想法几乎可以将比较次数减少一半,但是这种方法需要额外的空间,因此在实际应用中只有当比较操作代价较高时才有用。(例如在进行字符串或者其他键值较长类型的元素排序时)
l 堆排序在排序复杂性研究中有这着重要的地位,因为它是我们所知的唯一能够同时最优地利用空间和时间的方法,在最坏的情况下也能保证~2NlgN次比较和恒定的额外空间。空间紧张时如嵌入式系统或低成本的移动设备中很流行。但现代系统的许多应用中很少使用它,因为它无法利用缓存,很少和相邻元素进行比较,因此缓存未命中的次数要远远高于大多数比较都在相邻元素之间进行的算法,如快速排序、归并排序,甚至是希尔排序。另一方面用堆实现的优先队列在现代应用程序中越来越重要,因为它能在插入操作和删除最大元素操作混合的动态场景中保证对数级别的运行时间。
l 以下为“答疑”和“练习”中的知识点:
l MaxPQ使用泛型的Item是因为这样delMax()的用力就不需要将返回值转换为某种具体的类型,比如String。一般来说,应该尽量避免在用例中进行类型转换。
堆中不利用第0个元素是因为能够稍稍简化计算,另外将第0个元素的值用作哨兵在某些堆的应用中很有用。
可以用优先队列实现栈、队列、随机队列等数据结构。
2.5 应用
l 将各种数据排序(实现Comparable接口的数据类型皆可以):交易事务,指针排序,不可变的键(如String、Integer、Double、File),廉价的交换(引用),多种排序方法,多键数组(定义比较器Comparator),使用比较器实现优先队列,稳定性(能否保留重复元素的相对位置)。
l 用Comparator接口来代替Comparable接口能够更好地将数据类型定义和两个该烈性的对象应该如何比较的定义区分开来。
l 我们应该使用哪种排序算法:取决于应用场景和具体实现。快速排序是最快的通用排序算法。(选择排序,插入排排序,希尔排序,快速排序,三向快速排序,归并排序,堆排序)插入排序和归并排序是稳定的,其余不稳定。归并排序不是原地排序,其余是原地排序,(三向)快速排序空间复杂度为lgN,归并排序空间复杂度为N,其余为1。插入排序效率取决于输入情况,快速排序的效率由概率保证。
l 归约:为某个问题而发明的算法正好可以用来解决另一种问题。中位数与顺序统计(第k大,可以用快速排序的切分在线性时间内找出第k大的元素)。平均来说基于切分的选择算法的运行时间是线性级别的。
l 排序应用一览:商业计算,信息搜索,运筹学,事件驱动模拟,数值计算,组合搜索。A*算法。
l 以下为“答疑”和“练习”中的知识点:
l
第3章 查找
3.1 符号表
l 符号表最主要的目的就是将一个键和一个值联系起来。
l 每个键只对应一个值(表中不允许存在重复的键)。存入键值对和已有键冲突则新值替换旧值。键不能为空(null),值不能为空(null)。
l 有序符号表,键为Comparable对象。
l 有序数组中的二分查找,在N个键的有序数组中进行二分查找最多需要lgN+1次比较,无论是否成功。
l 以下为“答疑”和“练习”中的知识点:
l
3.2 二叉查找树
l 二叉查找树BST是一棵二叉树,其中每个结点都含有一个Comparable的键(以及相关联的值)且每个结点的键都大于其左子树中的任意结点的键而小于右子树的任意结点的键。
l 查找,插入,递归和非递归方式,最大键和最小键,向上取整和向下取整,选择操作,排名,删除最大键和删除最小键,删除操作,范围查找。
l 在由N个随机键构造的二叉查找树中插入操作和查找命中与未命中的平均所需的比较次数都为~2lnN(约1.39lgN)。(即二叉查找树中查找随机键的成本比二分查找高约39%)。
l 以下为“答疑”和“练习”中的知识点:
l 二叉树检查,有序性检查,等值键检查,非递归迭代器keys(),按层遍历(使用Queue)。
3.3 平衡查找树
l 我们将一棵标准的二叉查找树中的结点称为2-结点(含有一个键和两条链接),3-结点则是两个键和三条链接。
l 2-3查找树:空树或者由2-结点和3-结点组成的树。完美平衡的2-3查找树中的所有空连接到根节点的距离应该是相同的。
l 查找,向2-结点中插入新键,向一棵只含有一个3-结点的树中插入新键,向一个父节点为2-结点的3-结点中插入新键,向一个父节点为3-结点的3-结点中插入新键,分解根节点,局部变换不会影响全局有序性和平衡性。
l 在一棵大小为N的2-3树中,查找和插入操作访问的结点必然不超过lgN个。
l 红黑二叉查找树:用标准的二叉查找树(完全由2-结点构成)和一些额外信息(替换3-结点)来表示2-3查找树。链接分为两种:红链接将两个2-结点连接起来构成一个3-结点,黑脸节则是2-3树中的普通链接。
l 红黑树的性质:所有基于红黑树的符号表实现都能保证操作的运行时间为对数级别(范围查找除外,它所需要的额外空间和返回的键的数量成正比)。一棵大小为N的红黑树的高度不会超过2lgN。一棵大小为N的红黑树中,根结点到任意结点的平均长度为~1.001lgN。从根节点到所有空链接的路径上的黑链接的数量相同。
l 平衡树是由根生长的,每次都有根结点分裂且保持平衡,每次分裂则树高度加一。
l 由路径向下的算法可以用递归或迭代,而由路径向上的算法一般用递归node=operate(node);的方法。
l 删除算法:保证当前结点不是2-结点,可以将另一子树的结点旋转借一个过来,等删除后再平衡回来。
l 以下为“答疑”和“练习”中的知识点:
l 只允许红色左链接的存在能够减少可能出现的情况,因此实现所需要的代码会少得多。
l 如果按照升序将键插入一棵红黑树中,树的高度是单调递增。如果按照降序将键插入一棵红黑树中,树的高度是逐渐递增然后一下子减下来又逐渐递增一直循环。
l
3.4 散列表
l 散列表的查找算法:第一步用散列函数将被查找的键转化为数组的一个索引,第二步就是一个处理碰撞冲突的过程(拉链法和线性探测法)。
l 散列函数和键的类型有关,对于每种类型的键我们都需要一个与之对应的散列函数。一个典型的例子就是社会保险。
l 整数散列最常用的方法就是除留取余法。键为0到1之间的浮点数,可以将它乘以M并四舍五入得到一个0到M-1之间的索引值,但这会导致键的高位起的作用更大,修正方法则是将键表示为二进制数后再使用除留取余法。字符串也可以当做是整数来处理,组合键也可以如此。(Java的hashCode方法)
l 均匀散列假设:我们使用的散列函数能能够均匀并独立地将所有的键散布于0到M-1之间。在一张含有M条链表和N个键的散列表中(在均匀散列假设成立的前提下),任意一条链表中的键的数量均在N/M的常数因子范围内的概率无限趋向于1。
l 拉链法:将大小为M的数组中的每个元素指向一条链表,每条链表中的结点都存储了散列表为该元素的索引的键值对。
l 开放地址散列表:依靠数组中的空位来解决散列表中碰撞冲突的策略。线性探测法:属于开放地址散列表,当碰撞发生时(当一个键的散列值已经被另一个不同的键占用),我们直接检查散列表中下一个位置(索引值加1)。(动态调整数组大小来保证使用率在1/8到1/2之间,到达1会无限循环)
l 散列表的使用率:α=N/M(N为键总数,M为散列表大小)。拉链法中α是每条链表的长度一般大于1,线性探测表中α是表中已被占用的空间的比例,不可能大于1。
l Knuth在1962年的推导:在一张大小为M并含有N=αM个键的基于线性探测的散列表中,基于均匀分布假设,α约为1/2时查找命中所需要的探测次数约为3/2,未命中所需要的约为5/2。
l 散列表并非包治百病的灵丹妙药:每种类型的键都需要一个优秀的散列函数;性能保证来自于散列函数的质量;散列函数的计算可能复杂而且昂贵;难以支持有序性相关的符号表操作。
l 以下为“答疑”和“练习”中的知识点:
l 完美散列函数:散列函数得到的每个索引都不相同即没有碰撞。
3.5 应用
l 我应该使用符号表的哪种实现:对于典型的应用程序,应该在散列表和二叉查找树之间进行选择。相对于二叉查找树,散列表的有点在于代码简单,且查找时间最优(常熟级别,只要键的数据类型是标准的或者简单到我们可以为它写出满足或近似满足均匀性假设的高效散列函数即可)。二叉查找树相对于散列表的有点在于抽象结构更简单(不需要设计散列函数),红黑树可以保证最坏情况下的性能且它能够支持的操作更多(如排名、选择、排序、范围查找)。根据经验法则,大多数程序员的第一选择都是散列表,在其他因素更重要时才会选择红黑树。键是长字符串时有另外的选择。(另外注意原始数据类型代替Key类型的节省,重复键的处理,Java标准库的应用)
l 集合用例:过滤器,白名单黑名单。字典类用例:电话黄页,字典,基因组学,编译器,文件系统,DNS。索引类用例:商业交易,网络搜索,电影和演员。反向索引:互联网电影数据库,图书索引,文件搜索。
l 稀疏向量:google的PageRank算法。
未完待续