本文十分零散,目的仅做自己整理使用,暂不对读者负责,阅读需谨慎。学习数据结构的材料是《大话数据结构》。
数据结构包含线性结构、树形结构、图结构,重要的操作包括查找与排序(还有其他的各种操作)。线性结构中有线性顺序表、链表、静态链表,链表中有单链表、双向链表、循环链表,线性结构的典型应用有栈、队列与串(后面还没有写栈和队列,串),树形结构中有双亲表示法、孩子表示法、孩子兄弟表示法、二叉链表、霍夫曼树,图形结构中有邻接矩阵法、邻接表法、边集数组法,查找算法包括线性查找、折半查找、插值查找、斐波那契查找、二叉排序树查找、散列查找,排序算法包括交换类、选择类、插入类、归并类。
线性结构是最简单的结构,每一个数据元素只有一个前驱和一个后驱。
实现线性结构可以采用顺序存储结构和链式存储结构。
静态链表:如果一种语言中既没有指针也没有对象引用机制,为了实现链表这种数据结构,可以基于数组建立静态链表。静态链表中的数组的每个元素中除了数据本身的数据项,还保存了一个后驱元素的数组下标,称为游标。这有点类似于树形结构当中的双亲表示法,区别在于双亲表示法当中可以有多个结点中存储同一个结点的下标,而静态链表当中每个元素的下标只能保存在一个元素的游标里。静态链表的数组中未被使用的元素组成了一个备用链表。删除结点时,该结点所在的数组元素会进入成为备用链表的第一个结点;增加结点时,会取用备用链表的第一个结点所在的数据元素。这样一来,在静态链表中插入结点或删除结点时,只需要修改结点中的游标,不需要像顺序线性表那样把后面的元素都移位了。
单链表还可以改进为循环链表或双向链表。循环链表:把单链表最后一个结点的空指针指向单链表的头指针,就形成了循环链表。这样从任何一个链表开始都可以对整个链表进行遍历,典型的应用例子为约瑟夫问题。双向链表:在单链表的结点中再多加入一个指针指向结点的前驱,就得到了双向链表,虽然比单链表更耗费空间,双向链表使用起来更加灵活。
线性表有两个非常重要的应用,分别是栈和队列。“两栈共享空间”是对顺序栈的优化,可以最大限度利用开辟的空间。“循环队列”是对顺序队列的优化。在可控元素数量时,最好用顺序栈和顺序队列,否则用链栈和链队列。
串(线性存储结构、链式存储结构。模式匹配算法:朴素模式匹配算法是真实诚、真是慢啊,KMP模式匹配算法避免了i值不必要的回溯,KMP模式匹配算法改进避免了j值不必要的回溯)
线性表中每个数据元素只有一个前驱和一个后驱,而树形结构中每一个数据元素有一个前驱和多个后驱。数据结构要能保存数据本身以及数据相互之间的关系,表示该关系的载体就是前驱和后驱。
在树形结构当中,一个结点的前驱称为这个结点的双亲结点,一个结点的后驱称为这个结点的孩子结点。如果多个孩子结点的双亲结点是同一个,那么这些孩子结点互相称为兄弟结点。其他概念还有祖先、子孙、深度等等。。。
树形结构的存储结构包括:双亲表示法,孩子表示法,孩子兄弟表示法。双亲表示法:使用数组存储所有结点,通过结点所在数组元素的下标确定各个结点,结点中包含一个双亲域用来保存每一个结点的双亲结点的下标,根节点的双亲域保存数字-1。同样也可以在每个结点中设置孩子域和兄弟域,但由于这种设计太过于浪费空间,所以不作考虑。孩子表示法:通用使用数组来存储所有结点,通过下标来确定结点,与双亲表示法不同的是,孩子表示法的数组的元素中没有双亲域,更没有孩子域和兄弟域,而是包含了一个链表的头指针,每个树结点指向的链表中保存了这个树结点的所有孩子的下标(注意只是下标,不是保存了孩子结点)。孩子兄弟表示法:放弃了使用数组来保存树结点,而是完全采用链式结构,即每个结点中除了保存结点自身的数据之外,还保存了两个指针,这两个指针分别指向了该树结点的第一个孩子结点和第一个兄弟结点。这样一来,这种链式结构就可以表示任何树形结构。孩子兄弟表示法最神奇的地方在于它把任意的树形结构转化成了二叉树结构。二叉链表:在物理结构上与孩子兄弟表示法完全相同。三叉链表:在二叉链表的基础上结点中增加一个指针指向双亲结点。
(所以双亲表示法和孩子表示法的应用是什么,有什么优点?)
操作,这样的操作包括初始化、取数据、插入结点、删除结点、删除树、遍历(先序、中序、后序)、二叉排序树。遍历,是生成二叉树、删除二叉树、。。。的基础。 线索二叉树:把二叉树叶子节点的空指针利用起来,指向遍历的前驱或后驱。线索二叉树与二叉树占用空间相同,遍历效率更高,因为二叉树遍历需要递归,而线索二叉树只需要迭代。(线索二叉树还有什么好处?需要复习线索二叉树遍历的代码。)
树的典型应用:霍夫曼树。用于压缩数据文件。
我在写霍夫曼树这个程序的时候,体会了c语言指针的应用,主要是避免野指针、在调用函数参数的时候应该怎样使用指针。
顶点,边,弧。图中每个顶点之间的相互连接有两种,一种是有方向性的,一种是没有方向性的。这就去分出了有向图和无向图。这时候我们要回想到之前的线性表和树结构中当每个结点之间的连接都是有方向性的。
这里面没有包含有向图和无向图混合的那种图,也就是说同一个图结构当中各个顶点之间的连接有些是有方向的,有些是没方向的。
该说图结构的存储方式了。首先,可以考虑用顺序存储结构来保存每个顶点,用一个矩阵来保存边或弧,矩阵中每一个元素可以为布尔变量表示边或者弧是否存在,也可以保存边或弧的权值,矩阵元素行数代表边或弧的起点,列数代表终点。这种方法叫做“邻接矩阵法”,适合稠密图,不适合稀疏图。
那有没有适合稀疏图的纯用顺序存储结构的存储方式呢,有,这就是“边集数组”,它用数组保存顶点,用顺序存储结构保存每一条边或弧的信息,顺序存储结构中每个元素保存了边或弧的起点、终点、权值。
类比树的存储结构中有“孩子表示法”,图的存储结构中有“邻接表法”,二者都是把顺序存储结构和链式存储结构相结合的方式。顺序存储结构保存顶点的数据以及单链表的头指针,称作顶点数组。在单链表中保存每一个顶点有关的边或弧的信息,叫做顶点的边表。如果表示有向图,顶点边表的结点数就是顶点的出度,为了知道顶点的入度,还可以建立逆邻接表。在邻接表的基础上优化,可以得到适于表达有向图的“十字链表”,它把表示有向图的邻接表与逆邻接表合并起来,每个边表结点中保存了弧的起点(弧尾)和终点(弧头)以及统计出度的指针和入度的指针。对“十字链表”进行改造,得到适合无向图的“邻接多重表”。
没有一种纯粹的链式存储结构来表示图,这是因为链式存储结构要求结点是统一化的,比如二叉链表中每一个结点都有至多一个前驱和至多两个后驱。而图中每一个顶点的前驱与后驱的数量非常多变。
图的这个操作(算法),包括:初始化图结构、删除图结构、取得一个顶点的数据、插入一个顶点、删除一个顶点、遍历、最小树生成、最短路径、拓扑排序、关键路径。图的遍历方法分为深度优先遍历和广度优先遍历。深度优先遍历利用递归实现;广度优先遍历利用一个辅助队列实现。最小树生成有两种方式:一种是连续地生长出一棵树;另一种是先长出一棵树的各个部分,最终组合成一棵树;可以说第一种方法是深度优先生成树,第二种方法是广度优先生成树。求最短路径有两种方式,第一种是求某个顶点到任意一个顶点的最短路径,叫Prim算法,第二个方法是直接求出所有顶点到任意一个点的最短路径,叫Kruskal算法。
Prim算法使用了三个辅助向量:第一个向量用来保存某个顶点到另一个顶点的最短路径是否求出来,这是一个布尔变量;第二个向量中保存了最短路径中某一个顶点的前驱是谁;第三个向量记录了某一个顶点到另一个顶点的最短路径的长度是多少。Prim算法通过迭代循环算出最短路径。在每一次循环中都做了两件事情:第一件事情是在现在所知道的所有已确定最短路径的顶点往下一步的点中选择路径最短的那一个作为确认这个顶点的最短路径已经找到;第二件事情是根据刚刚确认找到最短路径的那个点去更新其他还没找到最短路径的顶点的现在的路径长度,即为下一个循环做准备。我认为这个迭代算法中最牛逼的一点是,后面的每一个迭代都以前面的所有迭代为基础,如果少了任何一环,后面都不成立;由于有一个肯定成立的开始,所以后面的每一次循环都得以依次成立;这就是递推思想的应用吧。
Kruskal算法能够一劳永逸得把所有点到任意一个点的最短路径求出来。该方法使用了两个辅助矩阵:一个矩阵以保存前驱顶点的方式来保存最短路径,另一个矩阵直接保存了所有顶点的最短路径的长度。Kruskal算法通过迭代循环求出结果,迭代的标志是中转顶点。如果起点到中转顶点的距离加上中转顶点到终点的距离小于现在保存的起点到终点的距离,那么就更新成最短距离,并把终点的前驱改成中转顶点。我认为这里面有一个很神奇的事情就是,保存最小路径的矩阵当中每一个元素所在的行数和列数,代表了终点和起点,所以那个保存前驱的矩阵在初始化的时候,矩阵每一列的元素保存的数都是它的列数;保存最短路径的那个矩阵在初始化的时候,每一列的元素保存的都是他这个元素所代表的顶点的前驱是列数代表的顶点时的直接路径长度,所以如果这两个点之间是直接相连的,那么这个直接路径长度就是他们之间的边的权值,如果这两个点不是直接相连的,那么这个直接路径长度就是无穷大。第一层循环迭代是以中转顶点为循环的标志,第二层循环迭代和第三层循环迭代是对每一个点进行遍历操作,操作的内容就是以第一层迭代的中转顶点进行更短路径更新,当所有迭代完成之后得到的矩阵就是最短路径。这里要注意的是,每层迭代的标志变化的方向必须是一致的。可能需要实验验证。
拓扑排序应用了栈这种线性表结构。总的来说,拓扑排序是由迭代循环而达成的,每一次迭代循环当中包括的步骤有出栈,遍历,减1,进栈。在循环迭代之前需要有一个初始化过程,即第一次进栈。栈的特性是后进先出。其实这里面可以用栈,也可以不用栈,用队列也可以;甚至不用队列,一个随机进出的线性表都可以。因为这个作为辅助的线性表唯一的作用就是把入度为零的顶点存进去,之后把它输出,并作为下一个遍历、入读减1的对象,只要顶点的入度减为零,就说明他完全可以输出了,无论先后。算法的默认初始状态是,图结构采用邻接表存储,顶点数组中已经保存了每个顶点的入度。
而在求关键路径时需要保存拓扑排序时,就必须得用栈了,因为只有这样才能得到一个拓扑逆序。求关键路径时,要在拓扑排序时求得每个事件的最早发生时间,再根据拓扑逆序求得每个事件的最晚发生时间。前面的事件的最早发生时间加上活动的时长就等于后面的事件的最早发生时间,所以求最早发生时间要用拓扑顺序。后面的事件的最晚发生时间减去活动的时长就等于前面的事件的最晚发生时间,所以求最晚发生时间要用拓扑逆序。
那这个时候如果最早发生时间和最晚发生时间相等的话就说明已经找到关键路径的顶点了?不过这时候找到的只是点,还不是路径。所以接下来要求活动的最早发生时间和最晚发生时间,活动的最早发生时间就是他前面事件的最早发生时间,活动的最晚发生时间就是他后面的事件的最晚发生时间减去活动的时长。如果活动的最早发生时间和最晚发生时间相等,那么就说明这个活动的安排没有调整的余地,也就是关键活动,即关键路径。
那么关键路径上的顶点是不是这些事件的最早发生时间和最晚发生时间相等呢?没错。但由于我们只要规划怎么执行哪些活动,所以关心的是关键路径,而不是关键事件。
也许是因为一些对图的基本操作与对树、链表的基本操作都是相同的,所以书上就没有专门写这一部分。
首先有一个线性的查找,线性查找是无序的查找。
然后是有序的查找,有序的查找包括折半查找,插值查找和菲波那契查找。折半查找因为随着迭代把搜索的区间对数级缩小,所以比线性查找要快得多;插值查找因为根据键值来调整区间缩小的程度,所以效率比折半查找高;斐波那契查找只涉及加减运算,不涉及乘除,因此要更快一些。
然后就是索引,分为稠密索引、分块索引和倒排索引。
以上都是静态查找,接下来是动态查找。
动态查找当然要属二叉排序树查找了,在具体操作中先定义了二叉排序树查找,然后插入结点,依次可以构建二叉排序树,删除结点时,既要找到合适的继任者,还要安排好继任者遗留的子树。
然后就是要建立平衡二叉树,这里面引入了一个平衡因子的概念,来表示左右子树的深度差,根据平方因子就可以对子树进行左旋或右旋来调整,使其变平衡。
书上的算法是在构建二叉排序树的时候,每次插入结点时对树进行调整,使其保持平衡状态。书上没有对现存二叉排序树平衡化的算法。我还搞不太清楚的地方:对平衡因子的设置与修改是怎样判断的。
接下来就是B数。B树的设计是用来降低查找树的层级,应用在涉及外存的查找中。 无论如何插入结点和删除结点,B树中所有的叶子结点都在同一个层次,这真是太厉害了。
B+树是在叶子结点加上线索,适合范围查找。
最后一个是散列表查找,散列表结构中每一个数据的存储位置是把该数据传入散列函数得到的。散列函数有很多种,比如直接定址、数字分析法(有一个关键词“抽取”)、平方取中法、折叠法、除留余数法、随机数法。我不明白的是怎样能够确保一个散列函数所指向的存储空间在使用散列表的过程中具有一个较高的装填因子。
散列冲突:如果两个不同的数据传入散列函数得到的地址发生冲突时,我们就称这两个数据是同义词。解决地址冲突的方法有开放取址法(线性探索,二次探索,随机数探索)、再散列函数法、链地址法、公共溢出空间法。
排序分为内排序和外排序。内排序是指数据只在内存当中的排序。这里提到的都是内排序。
冒泡排序、快速排序是交换法,简单选择排序、堆排序是选择法,直接插入排序和希尔排序是插入法,归并排序是归并法。
他们前者的时间复杂度都为n^2,后者的时间复杂度都为n*logn 这些算法没有严格的谁优谁劣,都有各自的长处与不足。
再接再厉,差得远哪!