前言:
数据结构这种东西,快速看了也只能概念上多理解了点,关键还是后续实践中的使用。好吧,本节依旧是data structures and algorithm analysis in c++ (second edition)中的笔记蜓点水般的笔记,书中第4部分的笔记,第3部分内容暂时先跳过(那是一些具体的应用例子)。本次的内容有栈和队列,链表,树,搜索二叉树,hash表,二叉堆。其中的队列,链表比较简单,树,二叉堆和hash表比较难。
Chap16:
如果类的数据成员是first-class(比如vector)的,则针对它的Big-Three会自动实现,不需要再去完成它。如果要实现copy constructor,则可以先实现copy assignment,然后在copy constructor里面调用copy assignment,因为copy相关操作只需copy对象中的数据成员.
栈和队列可以用两种方法实现,基于数组的和基于链表的,这两种方法的常见操作都是常量时间内完成,一般情况下,基于数组的实现使用起来速度要稍微快些,但是在队列的实现时,基于数组的稍微复杂些,也稍微多浪费了一些空间。
通常,当存储小的object时,可以采用基于数组形式实现的栈和队列,而当存储大的object则采用基于链表的实现形式。
Chap17:
在链表中,头结点的加入非常重要,头结点中没有数据成员,只有指针成员,它指向链表中的第一个元素,有了头结点,删除链表中的任意一个元素就很方便了。
友函数是不可逆的。
不完全类声明是因为在类的内部需要用到一些其它的类,而这些类目前还没有实现,它是告诉编译器这些类在后面会实现,所以提前声明是可以的。
设计一个链表,可以设计相对应的3个类,链表类本身Llist,链表的结点类LListNode,遍历链表的位置的类LListItr. 其中LListItr主要是负责交互的,有时候还需要设计ConstListIter。
循环链表和双向链表可以结合起来用。
从普通的链表中继承后可以形成排序链表,即链表中的元素值(不是指针)的大小是有序的。
Chap18:
树的实现可以是递归或者非递归,非递归的实现理解起来更直接,递归的实现代码更紧凑。
树中某个节点的高度(Height)表示的是改节点到与该节点相连的最深节点之间的长度。
一个节点的尺寸(size)指的是它的孩子节点的个数(包括它自己本身),所以叶子节点的尺寸为1.
由于树中某个节点的子节点数目不确定,如果给每个节点固定的指针个数的话,则只能取节点中最大孩子数作为最终的指针个数,这样会浪费很多存储空间。解决该问题常见的方法为第一个孩子/下一个兄弟(first child/next sibling)法,即每个节点给出2个指针,其中一个指针指向它的最左边孩子,另一个指针指向它的下一个兄弟,如果没有相应的节点,则置空。
先序遍历和后序遍历访问每个节点的时间都是常量时间,遍历整棵树的时间是线性的。
表达树,哈夫曼树,二叉搜索树,优先队列是二叉树的常见应用。
二叉树中可以实现一个方法将2颗树融合成一棵树。
*&p等价于*(&p),这里的p是指针变量的引用。
引用传递和指针传递是不同的,虽然它们都是在被调函数栈空间上的一个局部变量,但是任何对于引用参数的处理都会通过一个间接寻址的方式操作到主调函数中的相关变量。而对于指针传递的参数,如果改变被调函数中的指针地址,它将影响不到主调函数的相关变量。如果想通过指针参数传递来改变主调函数中的相关变量,那就得使用指向指针的指针,或者指针引用。所以当像一个函数传递指针引用时,如果在该函数的内部delete掉了这个引用,则在函数返回前需要将这个引用设置为NULL。
树中节点的复制采用的是先序遍历的方法,而节点的size,height,和makeEmpty则采用的是后序遍历的方法。
先序遍历,中序遍历和后序遍历可以采用栈来实现,而层次遍历可以采用队列来实现。采用栈来实现前面3种树的遍历时,需要给每个节点一个访问计数器,比如后续遍历,则节点的访问计数器为1时表示访问的是它的左子数,为2时表示的是右子数,为3才表示访问的是当前节点,其它的遍历方式类似处理。
Chap19:
二叉搜索树是指二叉树中一个节点左子树的所有值都比该节点的值小,而右子树的所有值都比该节点值大的树。因此如果采用中序遍历的话则将表示对该树的所有节点进行从小到大的排序。二叉搜索树中允许有相同大小的节点(也得看其严格的程度如何)。
使用二叉搜索树来查找某个节点是比较容易的,查找最小值和最大值更容易。比较麻烦的是删除一个节点,当删除的节点是叶子节点时可以直接删除,当删除的节点只有一个孩子的时候,直接用孩子节点代替删除的节点。当删除的节点有2个孩子时,用所删除节点右子树中最小的那个节点(也就是右子树中最左边那个节点)代替删除的节点,由于最小的那个节点要么是叶子节点要么只有单个孩子(且只能是右还在),所以当它被移走后时的处理和前面的处理类似。
如果一个类中有重载的函数,且重载的函数有一个是虚函数,则它的重载函数也是虚函数?
如果在一个集合中随机选取数字来构造搜索二叉树的话,那么构造出的平衡树的概率比非平衡树的概率要大。
一棵二叉树的内部通路长度(internal path length)定义为这棵树中所有节点的深度(depth)之和。它常被用来评价树的一次成功搜索的代价。如果数的排列为等概率的话,则一颗二叉树的内部通路平均长度约为1.38NlogN.
一颗二叉树的外部通路长度定义为叶子节点后续的所有N+1个NULL节点(这些节点也通常被称为外部树节点)的深度之和。它常被用来评价树中一次非成功搜索的代价(或者执行插入操作的代价)。
外部通道长度用EPL(T)表示,内部通道长度用IPL(T)表示,N表示二叉树中节点的个数,则满足EPL(N)=IPL(N)+2N.
对二叉搜索树的常见操作的平均时间复杂度为O(log(N)).但如果输入的是有序数据的话,则会出现最坏的操作时间情况,这主要是因为数中节点不平衡造成的,因此需要对树增加一些外部的结构约束,比如说不允许某些节点的深度相对其它节点来说太深,平衡二叉树就是用这种思想,平衡二叉树在进行删除和插入操作时较复杂,用时较多,但是在搜索时速度要比标准的搜索二叉树要快。
平衡二叉树的条件是树的深度为O(log(N)).
AVL树将平衡二叉树的条件减弱了,它只需满足树中所有节点的左子树和右子树的高度最大相差1.
高为H的树至少用C^H个节点,AVL树至少有F(H+3)-1个节点。
在AVL树中插入一个数字,有可能引起该树不再是AVL树了,因此需要对不满足AVL特征的节点进行旋转,按照情况常见的有单次旋转和双旋转两种。
单旋转又分为两种,第1种为在当前节点左子树的左边插入节点引起的,这时候的单旋转为旋转当前节点的左子树。第2种情况为在当前节点右子树的右边插入节点引起的,这时候的单旋转为旋转当前节点的右子树。
双旋转也分为两种情况,第1种为在当前节点左子树的右边插入节点引起的,这时候需先选择当前节点左子树的右子树,然后选择当前节点的左子树。第2种情况为在当前节点右子树的左边插入节点引起的,这时候需先旋转当前节点右子树的左子树,然后旋转当前节点的右子树。
红黑树的性质:红黑树属于二叉搜索树;树中的每个节点都被着色成红色或者黑色;根节点是黑色的;如果一个节点是红色的,则它的孩子节点必须是黑色的;某个节点到一个NULL节点的路径中必须含有相同数量的黑色节点。
如果根节点到NULL节点的路径中含有B个黑节点,则该树最少含有2^B-1个黑节点。红黑树的搜索时间复杂度为log(N).
当向红黑树中插入节点(从下到上插入)后导致连续的两个节点都是红色的,则违背了红黑树的定义,因此需要对其进行旋转和变色。符号标记:当前节点为P,其子节点为X,父节点为G,兄弟节点为S,假设P本来就是红色的,如果插入的X也是红色的,则旋转和变色可以将其分为2大类。第一类是当S为黑色的时候,如果X是插入到P的左边,则进行一次G的左子树旋转和适当的变色即可,如果X是插入到P的右边,则需先进行P的右子树旋转然后进行G的左子树旋转和相应适当的变色。第二大类情况是当S也为红色时,比较难处理,所以一般要避免这种情况的插入。
当红黑树从上到下改变颜色时,如果碰到一个节点的2个子节点,就需要将该节点和其2个子节点的颜色反转,反转后如果该节点和父节点都是红色的,则用前面的方法通过一次或两次的旋转解决(这个时候不会出现S节点为红色的情况,因为这是从上到下扫描的,如果出现了该情况则在上一次就已经处理掉了)。
在实现红黑树类时,需要设置2个标志(sentinel),一个是nullNode,用来表示NULL指针,并且它总是黑颜色。一个是header,伪根节点,它的右节点指向真实的根节点,并且它的元素值定义为负无穷。
当针对一个节点操作时可以采用循环,当针对批量节点时,可以采用递归思想。
在红黑树中,当一个节点有2个红色孩子节点或者新插入一个节点时,需要进行颜色反转。
对红黑树进行删除时只能删除红色节点,如果删除了黑色节点的话,则不满足红黑色的最后一条性质。
当进行从上到下删除节点时(此处只考虑黑色的节点,因为红色的节点直接删除即可),也需要分父节点的兄弟节点是否为红色节点这两种情况来旋转或反转颜色。
AA树的性质:在红黑树的基础上要求左孩子节点不能为红色。这一条件比红黑树操作起来简化了不少。另外树中节点不再用颜色表示,而是采用等级(level)表示。叶子节点的等级为1,红节点的等级和其父节点的相同,黑节点的等级比父节点的小1.
水平链接是用来连接一个节点和其等级相同点的子节点的(其实就是连接一个节点和其红色的右节点)。因此不可能出现两条连续的水平链接线,如果一个节点的level大于或等于2,则它有2个子节点。
和前面的红黑树一样,如果在树的底部插入节点的话,则只能插入红色节点,此时可能需要对树进行旋转使之满足AA树的性质。
AA树的插入如果破坏了AA树的性质,则可以采用连续skew和split方法进行处理。其中skew主要是处理出现向左的水平链接线,split主要是处理出现连续的向右链接线。
set比list的功能要多,因而实现起来要复杂。
在实现set时可以用一个栈来存储到当前所有节点的路径。
当用二叉树树来存储大量的数据时,如果内存不够,则需要对硬盘进行读取数据,而硬盘读取数据的速度比内存要慢几个数量级,并且此时用Big-Oh来分析其时间复杂度是不合理的。为了减小访问数据的时间,需要减小树中节点的高度,可以采用更多分支的树。B树(B-tree)就是其中一种。
B树需要满足下面这些特征:数据只在叶子节点存储,非叶子节点存储进行判断的关键值(key)。假设B树中每个节点最多只能有M棵子树,则非叶子节点中最多只能有M-1个关键值。根节点子树范围必须在2~M之间。其它非叶子节点的孩子节点数必须在M/2~M之间。叶子节点中含有的数据值个数必须在L/2~L之间。
Chap20:
Hash表只支持元素的部分操作,比如说常见的插入,删除,查找都是在常量时间内完成的。
Hash函数是将一个条目(item)转换成一个小的数组索引(index)值。
Hash表中常见的问题是出现访问冲突,解决该问题的方法常见的有linear probing, quadratic probing, separate chaining.
假设采用linear probing时,其装载率为λ,则hash表中每1/(1-λ)出现一个值(假设独立的情况下),非独立时,其值约为(1+1/(1-λ)^2)/2。在linear probing时hash函数的结果不均匀地占据着表的单元,形成区域,容易造成一次聚集。
Quadratic probing时,如果有冲突则每次不是加k,而是加k^2.
如果当表格的尺寸为质数,且采用quadratic probing的方法解决冲突的话,则当表中有一半是空的情况下,每个新进来的元素都可以被插入,且每个单元被尝试插入不会到2次。如果对表格进行扩张的话,则需保证它的尺寸是素数,并且原hash表中的成员需要重新计算它在新表中的位置。
Quadratic probing可以解决一次聚类问题,但是同样它也会出现二次聚类问题,Double hashing(即利用第二个hash函数)可以解决该问题。
Separate chaining hashing就是常见的在hash表中遇到了冲突的地方就在该位置以链表的形式一直加入新元素,只要链表不太长,它的操作时间依旧比较小。
编译器使用hash表来保存源代码中所声明的变量,这时候hash表也叫做符号表,hash表在游戏设计(特别是棋类游戏),拼写检查等领域也经常被使用到。
Chap21:
二叉堆(binary heap)的性能是介于二叉搜索树和队列之间,它常被用来实现优先队列。
一个有N个节点的完全二叉树的深度为floor(log(N)).完全二叉树中不需要左指针和右指针了,因为它用数组存储。
Heap-order的特性:
所有的父节点要么不小于子节点,要么不大于子节点。且它是完全二叉树,二叉搜索树不一定是完全二叉树。
Heap-order最简单的操作就是找出最小值,因为根节点就是最小值。在heap-order的插入操作中,先将元素插入到最后面,然后比较它与它的父节点值的大小,如果比它大,就ok。比它小,就需要和父节点互换,继续与新的父节点比较,直到满足条件为止。
最优完全二叉树如果高度为H,则其含有的节点数N为2^(H+1)-1,所有节点的高度之和为N-H-1.
通常情况下,堆排序算法速度没有快速排序算法速度快,但是堆排序实现起来更容易。
参考资料:
data structures and algorithm analysis in c++ (second edition),mark allen Weiss.