大一下的时候学过数据结构,但是面试的时候发现一些基础知识都忘的差不多了,所以打算借这本书重新学习一下算法与数据结构.使用的语言是JAVA.IDE是Eclipse,相关设置请看以下两篇文章:
配置算法(第4版)的Java编译环境
Eclipse直接运行算法第4版例子(重定向和读取指定路径文件)
注意数据文件如果直接用记事本看是不会显示换行的,可以用notepad++等软件查看.
1.1 基础编程模型
- JAVA基础语句
- 数组创建
1.3 背包、队列和栈
-
队列:先进先出(FIFO)
- 下压栈:后进先出(LIFO)
- 求值算法:
- 将操作数压入操作数栈
- 将运算符压入运算符栈
- 忽略左括号
- 遇到右括号时,弹出一个运算符,弹出所需数量的操作数,并将运算结果压入操作数栈
1.4 算法分析
- 2-sum问题:先归并排序,然后对于a[i],在j>i的范围内用二分查找寻找a[j]=-a[i],时间复杂度为O(NlogN)
- 3-sum问题:先归并排序,然后对于a[i]和a[j](j从i+1开始遍历),在k>j的的范围内用二分查找寻找a[k]=-a[i]-a[j],时间复杂度为O(N^2logN)
1.5 union-find算法
- union-find算法也就是经典的并查集算法,用于解决动态连通性问题.通过对这个算法的一步步更新可以体会到算法优化的思想和好处.
- 基本思想:对于给定的两个触点,判断它们所在的连通分量是否相同,将查找连通分量称为find.如果未连通,则将这两个触点以及分别与它们连通的触点连通,将连通称为union.
- 基础实现:使用一个id数组,保证在同一连通分量中的所有触点在id数组中的值是全部相同的.
- find:返回触点在id数组中对应的值.
- union:分别对p和q进行find操作,如果对应值相等不做操作,不相等则遍历id数组,将所有与p的对应值相等的id值改为q的id值.
- quick-union:改变id数组的定义,每个触点所对应的id元素都是同一个分量中另一个触点的名称,这种联系称为链接.由一个触点的链接一直跟随下去一定会到达根触点,即链接指向子集的触点.当且仅当两个触点的根触点相同时它们存在于同一个连通分量中.
- find:返回一个触点链接的根触点.
- union:分别对p和q进行find操作,如果对应值相等不做操作,不相等则将p的根触点链接到q的根触点上.
- 加权quick-union:为了防止树极度不均衡,记录每一棵树的大小并总是将较小的树连接到较大的树上.在程序中引入size数组记录各个触点的根节点所对应的分量的大小.
- find:返回一个触点链接的根触点.
- union:分别对p和q进行find操作,如果对应值相等不做操作,不相等则判断对应值的根节点分量的大小,将小树的根触点链接到大树的根触点上.
- 路径压缩:为find函数添加一个循环,将在路径上遇到的所有节点都直接链接到根节点.
- 关于并查集,可以参考这篇文章.
2.1 初级排序算法
- 选择排序:时间复杂度为O(N^2).找到数组中最小的元素,将它和数组的第一个元素交换位置.然后在剩下的元素中重复这一步骤.特点是运行时间和输入无关以及数据移动是最少的.
- 插入排序:时间复杂度为O(N^2).遍历数组中的每一个元素,将它与左边的元素比较并插入到合适的位置中,这会使该元素左边的所有元素都是有序的.因为插入排序总需要找到某元素在其左边序列的位置,所以可以用二分查找进行这一过程,称为二分插入排序.插入排序对部分有序的数组很有效,例如:
- 数组中每个元素距离它的最终位置都不远
- 一个有序的大数组接一个小数组
- 数组中只有几个元素的位置不正确
- 希尔排序:时间复杂度小于O(N^2).插入排序的缺点在于只能交换相邻的元素.希尔排序的思想是使数组中任意间隔为h的元素都是有序的,即h有序数组.希尔排序需要一个递增序列来决定h的值,最后一个h必须为1.具体步骤是对于每一个h,遍历数组中i=h~N的所有a[i],将a[i]与a[i-h],a[i-2h]......进行插入排序,然后按递增序列减小h,直到h=1为止.
2.2 归并排序
- 归并排序:时间复杂度为O(NlogN),所需额外空间和N成正比.先递归地将一个数组分成两个子数组分别排序,然后将结果归并起来.在归并前可以添加判断条件,如果a[mid]<=a[mid+1]则跳过归并.
- 原地归并:将数组复制到一个辅助数组中,然后把归并的结果放回原数组.
- 如果其中半边的元素都用完了则放入另外半边的所有元素
- 如果其中半边的元素大于另外半边的元素则放入另外半边的元素,并令另外半边的下标加1.
- 自顶向下的归并排序:创建辅助数组,递归地对原数组的左半边和右半边排序后进行归并操作,终止条件为high<=low.
- 改进:递归会使小规模问题中方法的调用过于频繁,所以用不同的方法处理小规模问题能改进大多数递归算法的性能.使用插入排序处理小规模(比如长度小于15)的子数组一般可以将归并排序的运行时间缩短10%~15%.
- 自底向上的归并排序:创建辅助数组,从两两归并开始,每一轮归并中子数组的大小都会翻倍,直到可以将原数组分为两个子数组归并为止.这种方法很适合用链表组织的数据.
- 可以证明没有任何排序算法能用少于NlgN次比较将数组排序,这意味着归并排序是一种渐进最优的基于比较排序的算法.
2.3 快速排序
- 快速排序:时间复杂度为O(NlogN).将一个数组随机打乱(防止出现最坏情况),然后通过切分变为两个子数组,将两部分独立地排序,使得切分点左边的所有元素都不大于它,右边的所有元素都不小于它.递归地进行这一过程.
- 切分:一般策略是随意取a[low]作为切分元素,然后从数组的左端向右扫描,找到一个大于等于它的元素,再从数组的右端向左扫描,找到一个小于等于它的元素,交换它们的位置.如此继续,直到两个指针相遇时,将切分元素和左子数组最右侧的元素a[j]交换然后返回j
- 改进:
- 当子数组较小时(比如长度小于15)使用插入排序
- 取子数组的一小部分元素(比如3个)的中位数来切分数组,还可以将切分元素放在数组末尾作为哨兵来去掉数组边界测试.
- 如果数组含有大量重复元素,可以采用三向切分的办法,将数组分为小于切分元素,等于切分元素和大于切分元素三部分,它将排序时间降到了线性级别.
2.4 优先队列
- 优先队列是一种抽象数据类型,它应该支持删除最大元素和插入元素两种操作.
- 二叉树是一个空链接,或者是一个有左右两个链接的结点,每个链接都指向一棵子二叉树.
- 二叉堆:当一棵二叉树的每个结点都大于等于它的两个子结点时,它被称为堆有序.二叉堆是一组能够用堆有序的完全二叉树排序的元素,并在数组中按照层级储存(不使用数组的第一个位置).
- 堆的操作会进行一些简单的改动,打破堆的状态,然后再按照要求将堆的状态恢复.这个过程叫做堆的有序化.
- 由下至上的堆有序化(上浮):如果堆的有序状态因为某个结点变得比它的父结点更大而被打破,就需要交换它和它的父结点.位置k的结点的父结点的位置是k/2.重复这个过程直到它不再大于它的父结点为止.
- 由上至下的堆有序化(下沉):如果堆的有序状态因为某个结点变得比它的父结点更小而被打破,可以通过交换它和它的两个子结点中的较大者来恢复堆.位置k的结点的子结点为2k和2k+1.重复这个过程直到它的子结点都比它更小或是到达了堆的底部为止.
- 插入元素:将新元素加到末尾,增加堆的大小并让新元素上浮到合适的位置.
- 删除最大元素:从堆顶删去最大的元素,并将最后一个元素放到顶端,减小堆的大小并让这个元素下沉到合适的位置.
- 对于一个含有N个元素的基于堆的优先队列,插入元素和删除最大元素的时间复杂度都是O(lgN)
- 堆排序:时间复杂度为O(NlgN)
- 构造:从数组的中间元素开始,从右到左地对每个元素用下沉方法构造子堆.
- 排序:将最大的元素a[1]和末尾元素a[N]交换,将堆的大小-1,并对交换到堆顶的末尾元素使用下沉来修复堆,直到堆变空,此时数组中的元素已经有序.
- 因为大多数在下沉排序期间重新插入堆的元素会被直接加入到堆底,所以可以使用先下沉后上浮的方法优化,即直接提升较大的子结点直至到达堆底,然后再使元素上浮到正确的位置.
2.5 应用
- 插入排序和归并排序是稳定的,即不会改变重复元素的相对位置.选择排序,希尔排序,快速排序和堆排序则不是稳定的.
- 快速排序是最快的通用排序算法.
- 归约:为解决某个问题而发明的算法正好可以用来解决另一种问题
- 找出重复元素:首先将数组排序,然后遍历有序的数组,记录连续出现的重复元素即可.
- 排名:求两组数列的Kendall tau距离,即在两组排列中相对顺序不同的数字组数.某个排列和标准排列的Kendall tau距离就是其中逆序数对的数量.可以由其中一个排列确定一个标准索引,然后以这个标准索引为标准对两组数列进行归并排序,移动的次数即为Kendall tau距离.
- 查找中位数:使用快速排序的分割算法,当切分点j小于N/2时只用切分右数组,切分点j大于2/N时只用切分左数组,切分点j=N/2时a[j]即为中位数.如果是海量数据,则可以将数据用二进制表示,根据最大位是0或1划分为两个文件,然后不断对包含中位数的那个文件做此操作,直到可以将剩余的数全部读进内存时再使用快速排序.
3.1 符号表
- 符号表最主要的目的就是将一个键和一个值联系起来.它支持插入和查找两种操作.在本书的符号表中,每个键只对应着一个值,键和值都不能为空.
- 有序符号表是值键都为可比较对象的符号表.它具有最大键,最小键,向下取整(floor)和向上取整(ceiling)等操作,还可以进行排名(rank),选择(select)和范围查找.
- 一种简单的符号表实现是使用无序链表.插入和查找都需要对链表进行遍历,时间复杂度都为O(N).
- 有序符号表可以用一对平行数组来实现,一个存储键,一个存储值.用二分查找来实现rank函数,大大减少了每次查找所需的比较次数.查找的时间复杂度为O(lgN).但是插入需要移动大量元素,所以插入的时间复杂度仍为O(N).它适用于只需要大量查找的符号表.
3.2 二叉查找树
- 一棵二叉查找树(BST)是一棵二叉树,其中每个结点都含有一个Comparable的键以及值,且每个结点的键都大于其左子树中的任意结点的键而小于其右子树的任意结点的键.
- 每个结点还会有一个结点计数器,它给出了以该结点为根的子树的结点总数.size(x)=size(x.left)+size(x.right)+1
- 一棵二叉查找树代表了一组键的集合,而同一个集合可以用多颗不同的二叉查找树表示.如果将一棵二叉查找树的所有键按从左到右的顺序投影到一条直线上,那么会得到一条有序的键列.
- 用递归的方法查找:
- 如果树是空的,则查找未命中;
- 如果被查找的键较小就选择左子树;
- 如果被查找的键较大就选择右子树;
- 插入和查找的难度差不多,如果一个结点不存在,只需要将链接指向一个含有被查找的键的新结点,并更新搜索路径上每个父结点中结点计数器的值.两者的时间复杂度都为O(lnN).
- 排名也是递归实现的:
- 如果给定的键和根结点的键相等,返回左子树的结点总数t;
- 如果给定的键小于根结点,返回该键在左子树中的排名;
- 如果给定的键大于根结点,返回t+1加上它在右子树中的排名;
- 删除操作通过将x替换为它的后继结点(其右子树中的最小结点)完成.
- 将指向即将被删除结点的链接保存为t
- 将x指向它的后继结点min(t.right)
- 将x的右链接指向删掉后继结点的原右子树
- 将x的左链接设为t.left
- 修改结点计数器的值
- 使用中序遍历来进行范围查找,将符合条件的键放入一个队列,跳过不可能符合条件的子树.
- 在一棵二叉查找树中,所有操作在最坏情况下所需的时间都和树的高度成正比.因此在某些场景下二叉查找树是不可接受的.
3.3 平衡查找树
- 2-3查找树:一棵2-3查找树或为一棵空树,或由以下结点组成:
- 2-结点:含有一个键和两条链接,左链接指向的2-3树中的键都小于该结点,右链接指向的2-3树中的键都大于该结点
- 3-结点:含有两个键和三条链接,左链接指向的2-3树中的键都小于该结点,中链接指向的2-3树中的键都位于该结点的两个键之间,右链接指向的2-3树中的键都大于该结点
- 一棵完美平衡的2-3查找树中的所有空链接到根结点的距离都应该是相同的.这里用2-3树指代一棵完美平衡的2-3查找树.
- 2-3树的查找:先将键和根结点中的键比较,如果它和其中任意一个相等,查找命中,否则根据比较的结果找到指向相应区间的链接,并在其指向的子树中递归地继续查找,如果是空链接则查找未命中.
- 2-3树的插入:2-3树应该在插入后继续保持平衡.我们先进行一次未命中的查找
- 如果结束于2-结点,就将要插入的键保存在其中,把这个2结点替换为一个3结点
- 如果结束于根3-结点,就临时将新键存入该结点,使之成为4-结点,再把中键变为根结点,最小键变为它的左子树,最大键变为它的右子树
- 如果结束于父结点为2-结点的3-结点,先使其成为4-结点,再把中键移动到父结点中,最小键变为它的左子树,最大键变为它的右子树
- 如果结束于父结点为3-结点的3-结点,先使其成为4-结点,再把中间插入到它的父结点中,此时父结点也为一个4-结点,在这个结点上进行相同的变换,一直向上直到遇到2-结点或根结点
- 2-3树是由下向上生长的,因为插入后始终保持平衡,所以即使在最坏情况下插入和查找的时间复杂度也为O(lgN).2-3树的缺点是具有两种结构的结点,因此需要大量代码实现,额外开销很大
- 红黑二叉查找树(红黑树)是用来实现2-3树的一种简单的数据结构.它的基本思想是用标准的二叉查找树和一些额外的信息来表示2-3树.树中的链接被分为两种类型:黑链接是普通链接,红链接将两个2-结点连接起来构成一个3结点
- 红黑树的另一种定义是含有红黑链接并满足下列条件的二叉查找树,满足这样定义的红黑树和相应的2-3树是一一对应的:
- 红链接均为左链接
- 没有任何一个结点同时和两条红链接相连
- 该树是完美黑色平衡的,即任意空链接到根结点的路径上的黑链接数量相同
- 红黑树既是二叉查找树,也是2-3树(将由红链接相连的结点合并),所以能够同时实现二叉查找树中简洁高效的查找方法和2-3树中高效的平衡插入算法
- 旋转:如果出现了红色右链接或两条连续的红链接,就需要旋转.左旋转右链接也就是将两个键中较大者的左结点变为较小者的右结点,并将较大者作为根结点,较小者作为它的左结点.右旋转只需将左旋转中的左右对调即可.
- 向单个2-结点或树底部的2-结点插入新键:如果新键小于老键,新增一个红链接的结点即可.如果新键大于老键,新增的结点会产生一条红色的右链接,将其左旋转.
- 向3-结点插入大于两个键的新键:新键被连接到3-结点的右链接,直接将3-结点的两条链接都由红变黑,就得到了一棵由3个结点组成的平衡树
- 向3-结点插入小于两个键的新键:新键被连接到最左边的空链接,即产生了两条连续的红链接,只需将上层的红链接右旋转即可变为情况2
- 向3-结点插入介于两个键之间的新键:此时产生一左一右两条连续的红链接,只需将下层的红链接左旋转即可变为情况3
- 颜色转换:用于将子结点的颜色由红变黑,父结点的颜色由黑变红
- 每次插入后都要将根结点设为黑色,当根结点由红变黑时树的黑链接高度加1
- 插入:
- 如果右子结点是红色的而左子结点是黑色的,进行左旋转
- 如果左子结点是红色的且它的左子结点也是红色的,进行右旋转
- 如果左右子结点均为红色,进行颜色转换
- 不断地将红链接由中间键传递给父结点,直至遇到一个2-结点或根结点时,插入就完成了
- 红黑树查找,插入,删除和范围查询的时间复杂度在最坏情况下都为O(lgN)
3.4 散列表
- 使用散列表的查找算法分为两步.第一步是用散列函数将被查找的键转化为数组的一个索引,第二步是处理碰撞冲突(多个键散列到相同索引值的情况).
- 常用散列方法:
- 整数:散列最常用的方法是除留余数法.选择大小为素数M的数组,对于任意正整数k,计算k除以M的余数
- 浮点数:表示为二进制数然后再使用除留余数法
- 字符串:将每位字符表示为一个整数,然后一个比任何字符的值都大的数R来将字符串转化为一个R进制值,再在每一步用除留余数法
- 组合键:类似于字符串,将组合值用R进制表示并在每一步用除留余数法
- 拉链法:将大小为M的数组中的每个元素指向一条链表,链表中的每个结点都存储了散列值为该元素的索引的键值对.基本思想是选择足够大的M,使得所有链表都尽可能短
- 在一张含有M条链表和N个键的散列表中,未命中查找和插入的时间复杂度是O(N/M)
- 散列后键的顺序信息丢失了,所以散列表不适合用来寻找最值或范围查找
- 依靠数组中的空位解决碰撞冲突的方法叫开放地址散列表.其中最简单的是线性探测法:当碰撞发生时,检查散列表中的下一个位置,直到命中或遇到空元素为止.
- 开放地址散列表的删除需要将被删除键右侧的所有键重新插入散列表,以防之后的元素无法被查找.
- 元素在数组中聚集成的一组连续的条目叫键簇,为了保证性能,应该使键簇尽可能短小.可以证明数组的使用率应该在1/8~1/2之间,所以在每次插入元素前都需动态调整大小.
3.5 应用
- 使用put操作构造一张符号表以备get查询,这种应用叫做字典
- 一个键和多个值相关联的符号表叫做索引.用值来查找键的操作叫做反向索引.
- 使用散列表表示稀疏矩阵可以大大提高矩阵与向量相乘的效率.它所需的时间和N+非零元素成正比,而用数组表示的话则为N^2.N为矩阵的尺寸.
4.1 无向图
- 图是由一组顶点和一组能够将两个顶点相连的边组成的,一般用0至V-1表示一张含有V个顶点的图中的各个顶点,用v-w或w-v表示;连接v和w的边.边仅仅是两个顶点之间的连接的图称为无向图.
- 一条连接一个顶点和其自身的边称为自环.连接同一对顶点的两条边称为平行边.一般将含有平行边的图称为多重图,将没有平行边或自环的图称为简单图.
- 某个顶点的度数即为依附于它的边的总数.子图是由一幅图的所有边的一个子集组成的图.
- 路径是由边顺序连接的一系列顶点.简单路径是一条没有重复顶点的路径.
- 环是一条至少含有一条边且起点和终点相同的路径.简单环是一条不含有重复顶点和边的环.
- 如果从任意一个顶点都存在一条路径到达另一个任意顶点,那么这幅图是连通图.
- 图的密度是指已经连接的顶点对斩所有可能被连接的顶点对的比例,由此分出稀疏图和稠密图.
- 二分图是一种能够将所有结点分为两部分的图,其中图的每条边所连接的两个顶点都分别属于不同的部分.
- 一般采用邻接表数组来表示图.它将每个顶点的所有相邻顶点都保存在该顶点对应的元素所指向的一张链表中.它使用的空间和V+E成正比.添加一条边所需的时间为常数.遍历顶点v的所有相邻顶点所需的时间和v的度数成正比.
- 深度优先搜索(DFS)是搜索连通图的经典递归算法,所需的时间和顶点的度数之和成正比.使用的是栈:
- 在访问其中一个顶点时将它标记为已访问
- 递归地访问该顶点的所有没有被标记过的邻居顶点
- 广度优先搜索(BFS)是解决单点最短路径的经典算法,它所需的时间在最坏情况下和V+E成正比.使用的是队列,先将起点加入队列,重复以下步骤直到队列为空:
- 将与v相邻的所有未被标记过的顶点加入队列,删除v
- 取队列中的下一个顶点v并标记它
- DFS和BFS的区别在于DFS总是获取最晚加入的顶点,而BFS总是获取最早加入的结点.
- DFS更适合实现图的抽象数据类型,因为它能更有效地利用已有的数据结构.而union-find算法适合于只需要判断连通性的任务.
- 利用一个符号表保存字符和索引,一个数组保存反向索引和一张图就可以实现符号图.
4.2 有向图
- 一幅有向图是由一组顶点和一组有方向的边组成的,每条有方向的边都连接着有序的一对顶点.在有向图中,一个顶点的出度为由该顶点指出的边的总数;一个顶点的入度为指向该顶点的边的总数.用v→w表示有向图中一条由v指向w的边.
- 有向路径由一系列顶点组成,对于其中的每个顶点都存在一条有向边从它指向序列中的下一个顶点.有向环为一条至少含有一条边且起点和终点相同的有向路径.简单有向环是一条不含有重复的顶点和边的环.
- 和无向图类似,一般使用邻接表来表示有向图,用顶点v所对应的邻接链表中包含一个w顶点来表示边v→w.每条边都只会在其中出现一次.DFS和BFS同样可用于有向图.
- 拓扑排序:给定一幅有向图,将所有的顶点排序,使得所有的有向边均从排在前面的元素指向排在后面的元素.即有优先级限制下的调度问题.当且仅当一幅有向图是无环图时它才能进行拓扑排序.
- 有向无环图(DAG)是一幅不含有环的有向图.想要进行有向环检测,可以基于DFS,一旦找到一条边v→w且w已经存在于栈中,就找到了一个环.
- DFS遍历一幅图以后,有以下三种排列顺序:
- 前序:在递归调用之前将顶点加入队列,即dfs()的调用顺序
- 后序:在递归调用之后将顶点加入队列,即顶点遍历完成的顺序
- 逆后序:在递归调用之后将顶点压入栈,即这幅图的拓扑排序(如果是有向无环图)
- 如果两个顶点v和w是互相可达的,则称它们为强连通的.如果一幅有向图中的任意两个顶点都是强连通的,则称这幅有向图也是强连通的.两个顶点是强连通的当且仅当它们都在一个普通的有向环中.
- 通常用Kosaraju算法来计算强连通分量:
- 在给定的一幅有向图G中,计算它的反向图的逆后序排列
- 在G中进行DFS,但是要按照1得到的顺序来访问所有未被标记的顶点
- 所有在同一个递归DFS调用中被访问到的顶点都在同一个强连通分量中
4.3 最小生成树
- 加权图是一种为每条边关联一个权值或是成本的图模型.
- 图的生成树是它的一棵含有其所有顶点的无环连通子图.一幅加权无向图的最小生成树(MST)是它的一棵权值最小的生成树.
- 在计算最小生成树时,做以下约定:
- 只考虑连通图
- 边的权重不一定表示距离
- 边的权重可能是0或者负数
- 所有边的权重都各不相同
- 树的两个重要性质:
- 用一条边连接树中的任意两个顶点都会产生一个新的环
- 从树中删去一条边将会得到两颗独立的树
- 图的一种切分是将图的所有顶点分为两个非空且不重复的两个集合.横切边是一条连接两个属于不同集合的顶点的边.
- 切分定理:在一幅加权图中,给定任意的切分,它的横切边中的权重最小者必然属于图的最小生成树.
- 切分定理是解决最小生成树问题的所有算法的基础.这些算法都是一种贪心算法的特殊情况.即找到一种切分,它产生的横切边均不为黑色,将它权重最小的横切边标记为黑色,直到标记了V-1条黑色边为止.不同之处在于保存切分和判定权重最小的横切边的方式.
- 同样可以使用邻接表来表示加权无向图,只需要增加一个权重域.
- Prim算法:
- 一开始最小生成树只有一个顶点,然后会向它添加V-1条边.每次总是将下一条连接树顶点与非树顶点且权重最小的边加入树中.每一步总是为一棵树添加一条边.
- 使用优先队列来根据权重比较所有边
- 分为延时实现和即时实现.区别在于是否立即删除失效的横切边(即连接新加入的顶点和树中已有顶点之间的边).
- 延时实现所需空间与E成正比,所需时间与ElogE成正比.
- 即时实现不仅删除失效的边,而是仅保存非树顶点到树顶点的边中权重最小的那条,并在每次加入新顶点后检查是否需要更新.所需空间与V成正比,时间与ElogV成正比.
- Kruskal算法:
- 按照边的权重顺序处理它们,将边加入最小生成树中,加入的边不会与已经加入的边构成环,直到树中含有V-1条边为止.每一步总是连接森林中的两棵树(包括单顶点树).
- 在实现中,使用优先队列来将边按照权重排序,使用union-find来识别会形成环的边,以及一条队列来保存最小生成树的所有边.
- 所需空间与E成正比,所需时间与ElogE成正比.
4.4 最短路径
- 在一幅加权有向图中,从顶点s到顶点t的最短路径是所有从s到t的路径中的权重最小者.
- 最短路径树(SPT)包含了顶点s到所有可达的顶点的最短路径
- 边v->w的松弛是指检查从顶点s到w的最短路径是否是先从s到v,然后再由v到w,即.如果是,则更新,如果不是,则称这条边失效了.
- 顶点的松弛是指对该顶点指出的所有边进行松弛.
- 最优性条件:当且仅当对于从v到w的任意一条边e,这些值都满足distTo[w]<=distTo[v]+e.weight()时它们是最短路径的长度.这证明了判断是否为最短路径的全局条件与松弛时检测的局部条件是等价的.
- 通用最短路径算法:放松图中的任意边,直到不存在有效边为止.
- Dijkstra算法(注意只能解决正权重的加权有向图中的最短路径问题):
- 首先将distTo[s]初始化为0,distTo[]中的其他元素初始化为起点s到该顶点的距离,注意如果不相邻则为正无穷.
- 然后将distTo[]最小的非树顶点松弛并加入树中
- 重复2,直到所有的顶点都在树中或者所有的非树顶点的distTo[]值均为无穷大.
- 按照拓扑顺序放松顶点,就能在和E+V成正比的时间内解决无环加权有向图的单点最短路径问题.同理,要解决无环加权有向图的最长路径问题,只需将原图中所有边的权重变为负值,再求最短路径即可.
- 当且仅当加权有向图中至少存在一条从s到v的有向路径且该路径上的任意顶点都不存在于任何负权重环中时,s到v的最短路径才是存在的.
- Bellman-Ford算法:
- 在任意含有V个顶点的加权有向图中给定起点s,从s无法到达任何负权重环,可以解决其中的单点最短路径问题
- 将distTo[s]初始化为0,其他distTo[]元素初始化为无穷大,以任意顺序放松有向图的所有边,重复V-1轮
- 算法所需的时间和EV成正比,空间和V成正比.
- 套汇问题等价于加权有向图中的负权重环的检测问题.
5.1 字符串排序
- 字符串排序方法可以分为从右到左检查键中的字符的低位优先(LSD)和从左到右检查键中字符的高位优先(MSD).
- 键索引计数法是一种适用于小整数键的简单排序方法.它要求数组a[]中的每个元素都保存一个字符串和一个组号,原来元素是依据字符串排列的,现在希望按组号排列,在组内保持原顺序.键索引计数法排序N个键为0到R-1之间的整数的元素需要访问数组11N+4R+1次.
- 频率统计:使用一个数组count[]计算每个键出现的频率.如果键为r,就将count[r+1]加1.
- 将频率转换为索引:任意给定的键的起始索引均为所有较小的键所对应的出现频率之和,只需循环count[r+1]+=count[r]这个语句即可转换出一张索引表.
- 数据分类:有了索引表后,将所有元素移动到一个辅助数组aux[]中进行排序.每次移动后将count[]中对应元素的值加1.这保证了这种排序的稳定性.
- 回写:将排序结果复制回原数组中.
- 低位优先的字符串排序(LSD):将等长字符串(假设长度为W)排序可以通过从右向左以每个位置的字符作为键,用键索引计数法将字符串排序W次实现.这依赖于键索引计数法的稳定性.对于基于R个字符的字母表的N个以长为W的字符串为键的元素,LSD需要访问~7WN+3WR次数组.因为实际中R<
时间是O(WN). - 高位优先的字符串排序(MSD):首先用键索引计数法将所有字符串按照首字母排序,然后再递归地将每个首字母所对应的子数组排序(忽略首字母).对于MSD来说,将小数组切换到插入排序对于高位优先的字符串排序算法是必须的.另外它无法很好的处理等值键.对于基于R个字符的字母表的N个平均长度为w的字符串,MSD需要访问8N+3R到7wN+3wR之间次数组.
- 三向字符串快速排序:根据键的首字母进行三向切分,仅在中间子数组的中的下一个字符继续递归排序.它能够很好地处理等值键,有较长公共前缀的键,取值范围较小的键和小数组.另外它不需要额外的空间.
5.2 单词查找树
- 单词查找树(trie树)的每个结点都有R条链接,R为字母表的大小.但是一般其中都含有大量的空链接,可以被忽略.每条链接都对应着一个字符,每个键所关联的值保存在该键的最后一个字母所对应的结点中.
- 查找给定字符串键所对应的值的方式就是从根结点开始检查某条路径上的所有结点,这意味着到达树中表示尾字符的结点或者一个空链接.插入之前需要先进行一次查找,如果遇到了空链接则为键中还未被检查的每个字符创建一个对应结点,如果到达了尾字符结点则将该结点的值设为要插入的键所对应的值.
- 单词查找树的结点是一个值和大小为R的数组构成的.将基于含有R个字符的字母表的单词查找树称为R向单词查找树.
- 可以用递归的方法查找所有键,方法的参数是结点和该结点关联的字符串.如果一个结点的值非空,就将它相关联的字符串加入队列,然后递归访问它的链接数组所指向的所有可能的字符结点.
- 要删除一个键值对,先找到键所对应的结点并将它的值设为空,如果它有指向子结点的非空链接则删除结束,如果它的所有链接均为空就从数据结构中删去这个结点,再检查它的父结点的所有链接是否为空.
- 对于任意给定的一组键,单词查找树都是唯一的.查找需要访问数组的次数最多是键的长度加1.未命中查找平均所需检查的结点数量为(logR)N.树中的链接总数在RN到RNw之间.
- 三向单词查找树:用来避免R向单词查找树过度的空间消耗.每个结点都含有一个字符,三条链接和一个值.三条链接对应着当前字母小于,等于和大于结点字母的所有键.所需的空间在3N到3Nw之间.查找未命中平均需要比较字符~lnN次.
5.3 子字符串查找
- 子字符串查找问题:给定一段长度为N的文本和一个长度为M的模式字符串,在文本中找到一个和该模式相符的子字符串.一般来说M相对于N是很小的.
- 暴力子字符串查找算法就是遍历文本字符串,如果某字符和模式的第一个字符匹配则移动指向模式的指针,否则移动指向文本的指针.这种方法在最坏情况下需要~NM次字符比较(例如二进制文本).
- KMP子字符串查找算法(注:书中用确定有限状态自动机DFA的思想来讲解,个人感觉不好理解,本文用了大一学数据结构时的解释方法):
- 基本思想:当出现不匹配时,就能知晓一部分文本的内容,可以利用这些信息避免将指针回退到所有已知字符之前.
- 预处理:根据模式计算出一个转移数组next[].其中next[0]=-1,next[1]=0,next[j]表示模式字符串中第0位到第j-1位中相同的最长前缀和最长后缀的长度.next数组可以用模式匹配的思想编程得到.
- 发生不匹配:目标串的指针不变,若next[j]>=0,则将模式字符串右移j-next[j]位个字符,用模式的第next[j]个字符与文本的第i个字符比较.若next[j]=-1,则将模式字符串右移j+1个字符,用第0个字符与文本的第i+1个字符比较.
- 优化:如果根据next数组回退之后的位置的字符和现有字符相同,则必定是不匹配,仍要继续回退,因此建立nextval数组,将重复字符的回退位置都设为第一个该字符的回退位置.
- KMP算法适用于在重复性很高的文本中查找重复性很高的模式,它访问的字符不会超过M+N个.
- Boyer-Morre字符串查找算法:
- 使用数组right[]记录字母表中的每个字符在模式中出现的最靠右的地方,不存在则为-1.含义是如果该字符出现在文本中且在查找时造成了一次匹配失败,模式应该向右跳跃多远.
- 如果造成匹配失败的字符不包含在模式中,则将模式字符串向右移动j+1个位置
- 如果包含在模式字符串中,则将模式向右移动j-right['char']
- 如果这种方式无法增大i,就将i加1
- 一般情况下,Boyer-Moore需要~N/M次字符比较.
- Rabin-Karp指纹字符串查找算法:
- 基本思想:这是一种基于散列的算法.长度为M的字符串对应着一个R进制的M位数,将该数除以一个随机的素数Q并取余就可以将它保存在大小为Q的散列表中.
- 计算散列函数:在文本中将子字符串右移一位,对应的M位数操作就是将它减去第一个数字的值,乘上R,再加上最后一个数字的值.又因为在每次算数操作之后取余等价于在所有算数操作完成后取余,因此只需对上述操作的每一步先取余就可以得到下一位的子字符串的散列值.
- 技巧:为了避免有多个子字符串有相同散列值,可以将Q设为一个大于10^20的值,这样冲突的概率将小于10^-20.
5.4 正则表达式
- 正则表达式就是按照某种模式进行字符匹配的表达式.书中使用语言指代一个字符串的集合.模式的描述由连接,或(|),闭包(*)三种基本操作构成.
- 字符集描述集用于简化正则表达式
- 闭包的简写
- \.|*()是用来构造正则表达式的元字符,因此要用\开头的转义字符来表示
- 正则表达式模拟匹配程序的总体结构是:构造和给定正则表达式相对应的非确定有限状态自动机(NFA),模拟NFA在给定文本上的运行轨迹.
- NFA有以下特点:
- 长度为M的正则表达式中的每个字符在对应NFA中有且只有一个对应状态,NFA起始状态为0,并有一个虚拟的接受状态M
- 字母表中的字符所对应的状态都有一条指向模式中的下一个字符对应状态的黑边.
- 元字符所对应的状态至少有一条指出的红边,可能指向任意状态
- NFA的状态转换:
- 匹配转换:如果文本中的当前字符和当前字母表中的字符匹配,NFA可以扫过文本中的字符并由黑边转换到下一个状态
- ε转换:NFA可以通过红边转换到另一个状态而不扫描文本中的字符
- 使用长度为M+1的数组来保存正则表达式本身,这个数组也表示了匹配转换.使用有向图来表示ε转换.首先查找所有从状态0通过ε转换可达的状态来初始化可达性集合,然后如果有字符匹配了集合中的状态,就将集合改为这个字符通过匹配转换后到达的状态,再加上这些状态通过ε转换可达的状态.
- 判定一个长度为M的正则表达式所对应的NFA能否识别一段长度为N的文本所需的时间在最坏情况下和NM成正比.构造对应的NFA所需的时间和空间在最坏情况下与M成正比
5.5 数据压缩
- 现代计算机系统中的所有类型的数据都是用二进制表示的.用比特流表示比特的序列,用字节流表示可以看作固定大小的字节序列的比特序列.
- 数据压缩的基础模型由压缩盒(将比特流B转化为压缩版本C(B))和展开盒(将C(B)转化回B)组成,用|B|表示比特流中比特的数量,则C(B)/|B||称为压缩率.这种模型叫做无损压缩模型.常用于数值数据或可执行代码的压缩.
- 不存在能够压缩任意比特流的算法,因此无损压缩算法必须尽量利用被压缩的数据流中的已知结构(小规模字母表,较长的连续相同的位或字符,频繁使用的字符,较长的连续重复的位或字符).
- 游程编码(RLE)的基本原理是用长度代替具有相同值的连续符号,连续符号构成了一段连续"游程".适用于含有较长游程的数据,比如高分辨率的位图.
- 霍夫曼压缩的思想是用较少的比特表示出现较频繁的字符,它是一种前缀码(所有字符编码都不会成为其他字符编码的前缀).构造霍夫曼树的过程是:先找到频率最小的两个结点,然后创造一个以两者为子结点且频率为两者之和的新结点,不断重复这个过程知道所有结点被合并为一棵单词查找树.
- LZW压缩算法是为变长模式生成一张定长的符号表:
- 找出未处理的输入在符号表中最长的前缀字符串s
- 输出s的编码
- 继续扫描s之后的一个字符c
- 在符号表中将s+c的值设为下一个编码值
小结
第六章介绍了一些较为复杂的算法,不再记录在此文中.由于比赛,考试等因素的影响,历时三个月总算把这本《算法》粗略地看了一遍,复习了数据结构的同时也了解了很多新算法.接下来准备通过leetcode来加深对算法的理解和编程能力,这一过程也会在博客中进行记录.