数据结构
一、概述
1、数据结构三要素
答:分别是:逻辑结构、存储结构、数据的运算
2、数据结构构成
答:数据构成见下图:
3、常见线性与非线性数据结构 && 顺序存储结构与链式存储结构
答:(1)线性结构指的是逻辑结构,而不是物理存储结构。
常用的线性结构有:线性表,栈(顺序栈和链式栈),队列,双队列,循环队列、一维数组,串。(其中栈和队列只是一种逻辑存在的,实际中不存在)
常见的非线性结构有:二维数组,多维数组,广义表,树(二叉树等),图。
(2)常见的顺序存储结构很多,如数组就是顺序存储,注意几种数据结构中:栈和队列都可以使用顺序存储结构,也可以链式存储结构;堆由于本来就是由一维数组进化而来,故也可以顺序存储;最重要注意,二叉树只有完全二叉树或满二叉树才可以顺序存储。
二、线性表
1、线性表
(1)定义:就是一个表,一个记录就是线性表的一个数据元素,表的长度就是数据元素的个数。
如图,线性链表由一个个结点组成,每个节点有数据域和指针域两部分组成:数据域(本结点存储的数据信息)与指针域(下一个结点的存储位置);
(2)线性链表不要求书写格式上连续,存在线性存储和链式存储,两种存储方式各有优劣,需根据数据操作类型选择合适的存储方式,没有优先级别。
(3)补充:循环链表:最后一个结点指向头结点,形成一个环;双向链表:比线性链表多一个指针域,用于指向直接前趋。
三、栈和队列、串、数组和广义表
1、栈
答:(1)分为顺序栈和链栈(不需要头结点);若数组实现,需要事先确定栈的容量;若用指针实现,可以无限增长。
(2)栈是在内存中开辟的一段连续的存储区,将栈中数据从栈底到栈顶依次存放。同时设置base和top指针,base指向栈底元素位置,top指向栈顶元素后一个位置。
①栈的插入与删除(入栈与出栈)都在栈顶操作;
栈为空时,base=top,若为顺序栈例如S(1:m),用下标代表地址的话,空栈base=top=1,向栈中插入一个元素,top就向栈顶方向自增1,这也是为什么非空时,top指向栈顶元素后一个位置。
栈为满时,top指针指向最后一个栈元素的后一位置,就是指向栈外。若为顺序栈S(1:m),那么栈满可以表示为top=m+1。
注:栈的数据溢出时,可以根据栈自增量进行拓展栈的容量。
②栈就是一种特殊的线性表。
2、队列
答:(1)顺序队列:需设置队首指针和队尾指针,需事先设置队的容量;链队列(指针实现,不需设定容量,可以设置头结点)
(2)队列是将数据从队头至队尾依次存放在一段连续的存储区中,该存储区不可拓展。分别设置front和rear指向队头元素位置和队尾元素的后一个位置。注意:front=rear时,队为空。
(3)队列的插入与删除
队列用数组表示,一般front在数组低下标处,rear在数组高下标处。所以插入删除如下:
①插入(入队)在队尾操作,rear自增1;删除(出队)在队头处操作,front自增1(注意,删除与栈操作不同)。
②队列的插入删除时间复杂度都为o(1),删除直接用尾指针即可;
(4)队列和线性表类似,也有顺序存储和链式存储两种方式。队列就是一种特殊的线性表。
3、循环队列
答:(1)因为队列的删除会造成rear-front小于队列容量,故常常使用循环队列解决此问题。循环队列就是当rear出界但队列因为删除使得front前空出一段存储空间时,可将rear对应数据移到front前面的区域,并依次存储,同时rear也要移到对应数据后一个位置。如果front=(rear+1 )%QueueSIze,则表示循环队列已满(循环队列满时,最后一个数据和首数据之间还空一个,rear此时指向这里)。
(2)判断循环队列中元素个数(m为队列设定容量):
如果 rear>front,n=rear-front;
如果 rear<front,n=m-(front-rear);
4、串
答:定义:就是字符串,有子串和主串之分。分为定长顺序存储(存储容量有限)和堆分配存储(存储容量动态分配)。
5、数组和广义表
答:(1)数组略;
(2)广义表定义:是一种非线性的数据结构,是线性表的一种推广。即广义表中放松对表元素的原子限制,容许它们具有其自身结构。广义表的数据元素除了可以是线性表中定义的原子外,还可以是表,具体的元素如下:
表结点hp:子表头指针域, tp:子表尾指针域
注:广义表采用链式存储结构
6、广义表及其运算
答:广义表是线性表的推广,只不过其原子类型不受限制,容许它们具有其自身结构,可以是子表等。例如:广义表LS=((a,b,c),(d,e,f))。广义表可以用树结构表示;
head(LS):取表头,返回值可以是单元素也可以是子表,即广义表第一个元素。
tail(LS):取表尾,将原广义表去掉表头后新生成的广义表就是返回值,注:tail返回值一定是广义表, 而且是和原表同级别的表。比如上述LS的tail运算结果就是((d,e,f)),就是原表去掉首元素后的结果。
5、稀疏矩阵与三元组
答:(1)稀疏矩阵:对于那些零元素数目远远多于非零元素数目,并且非零元素的分布没有规律的矩阵称为稀疏矩阵(sparse);
①稀疏矩阵的压缩:由于稀疏矩阵中非零元素较少,零元素较多,因此可以采用只存储非零元素的方法来进行压缩存储。因为非
零元素分布没有任何规律,所以在进行压缩存储的时侯需要存储非零元素值的同时还要存储非零元素在矩阵中的位置,即非零元素
所在的行号和列号。
②压缩方式:三元组、十字链表法(其中十字链表更适合矩阵的加法乘法等操作);
(2)三元组:
三元组可以采用顺序表示方法,也可以采用链式表示方法,这样就产生了对稀疏矩阵的不同压缩存储方式。顺序存储可如下:
顺序表中除了存储三元组外,还应该存储矩阵行数、列数和总的非零元素数目,这样才能唯一的确定一个矩阵,如图:
三元组做到下面三条便可实现矩阵的转置:
①将矩阵的行列值交换。
②将每个三元组中的 i 和 j 相互调换。
③重排三元组之间的次序,即行列互换后,还要恢复原存储的顺序。
四、树与二叉树
1、树和二叉树
答:(1)树的作用:用于表示元素和元素之间有分支和层次关系的数据结构;
(2)树的几个概念:度——结点的子树分支树个数;叶子——最下面的结点;深度——一个树的最大层次。
(3) 二叉树:二叉树的储存结构分为顺序存储和链式存储;
一个二叉树,叶子结点为N0,度为2的结点数为N2,那么N0=N2+1;
一个拥有N个结点的完全二叉树的深度为[log2N]+1;
顺序存储:只适合完全二叉树,按照二叉树的结点序号依次从左到右(或从上到下)存储。
链式存储:有两种形式(2指针域和3指针域),如下图。
2、线索二叉树
答:线索二叉树:以链表形式存储,结点结构如下:
3、二叉树线索化
答:(1)对二叉树线索化,实际上就是遍历二叉树,检查当前结点的左、右指针是否为空,如果为空,将它们改为指向前驱结点或后继结点的线索。
前驱与后继根据不同遍历有不同的值,如前序遍历ABC中B是A的后继,中序遍历结果为BAC,B就变成了A的前驱。
(2)过程:
实现二叉树线索化,树结点的结构需要增加左右两个tag,用于标识左右指针域是指向左右子树还是指向前继后驱。如图:
1)先将二叉树中左右指针域有空的结点标记出来,左指针空就Lf=1,右则Rf=1;
2)然后进行遍历(根据要求进行前序、中序或后序遍历),将遍历结果放在队列中;
3)根据1)的结果给有空指针的结点添加线索,Lf=1则将相应结点的左指针指向其所在队列位置的前一个元素(前驱),Rf=1则将相应结点的右指针指
向其所在队列位置的后一个元素(后继)。队列第一个元素左指针忽略,队列最后一个元素右指针忽略。
(3)二叉树线索化的意义:
若给二叉树每个节点增加一个左指针和右指针,那么共有2n个指针域。由于二叉树只有n-1个边,那么就会有n+1个指针域空着。所以,为了充分利用这些指针域,并把二叉树遍历信息保存下来,就可以使用二叉树线索化来实现。线索化后,只要根据线索表中的节点前驱后继关系进行连接就可以得到二叉树遍历序列。
4、赫夫曼树及应用
答:定义:赫夫曼树又名最优二叉树,是带全路径长度最小的二叉树。如下图:
注意:赫夫曼树并不是满二叉树,是正则二叉树(也叫正规二叉树或最优二叉树),在构造哈夫曼树时,是从叶子节点向根节点的方向进行的,每次都是两个两个成对来形成一个新的分支节点,所以不存在度为1的节点,即其中只有度为0和度为2的结点,因为:
n0 = n2 + 1;//这是二叉树中固有的公式(n0是度为0的结点数,n2是度为2的结点数),具体可见公式推理
n = n0 + n2;//n为所有结点数
所以 n = 2n0 - 1,即n0 = (n + 1) / 2;
又叶子结点n0对应的即是不同的编码,所以在赫夫曼编码中有多少个叶子节点就能得到多少个码字。
5、赫夫曼树编码
答: 赫夫曼树并不是满二叉树,是正则二叉树(也叫正规二叉树或最优二叉树),在构造哈夫曼树时,是从叶子节点向根节点的方向进行的,每次都是两个两个成对来形成一个新的分支节点,所以不存在度为1的节点,即其中只有度为0和度为2的结点(附带一个二叉树结论:n0=n2+1,其中,n0是度为0的结点数,n2是度为2的结点数)。结点关系有如下:n=n0+n2;
n=2*n2+1;
同时。赫夫曼树的带权值最优路径是所有叶子节点的带权路径之和。
此外,赫夫曼树还有一条规定:左右孩子权值之和为父结点权值,WPL=∑w*l(w为个叶子权值,l为叶子的(深度-1))。
注意:上述是常见的赫夫曼树,除此之外还有度不为2的,也叫最优m叉树。其结点也只有度为0和度为m两种,即X+n0=n(X为度m的结点数,n为总结点数)。那么其结点关系也有如下:
n=X+n0;
n=m*X=1;
赫夫曼树的生成:前提是给定所有叶子结点的权值序列。
1)从序列中找出权值最小的两个节点,作为最底层叶子节点并求和得到父节点,生成子树;
2)然后从剩下的序列和上述子树根节点中找出最小的两个并求和,生成新的子树;
3)重复1)、2)步骤。
如图:
赫夫曼编码(前缀编码):
1)将给定字符串根据每个符号出现次数的大小,重新排序成一个字符频率由小到大的表格,如图:
2)将字符频率作为权值,对字符进行赫夫曼树生成,如下图:
3)再对叶子进行编码,从上到下,所有左链结0,右链接为1。结果如下图:
故字符串”alibaba“占用的编码空间为:1*3+2*2+(3+3)*1=13(bit)。
6、树的孩子-兄弟链表存储方式(树转化为二叉树的方法)和 二叉树转化为森林
答:(1)孩子-兄弟链表存储方式是左指针指向孩子链,右指针指向兄弟链。这样就可以将普通树结构转化为二叉树结构,经过此转化后,原树的后序遍历变为二叉树的中序遍历,前序遍历依然是前序遍历(注意:树是没有中序遍历的,二叉树才有中序遍历)。
例如:
注意:树变成二叉树的情况下,
除根节点之外的每个非终端节点都有孩子,其下的分支一定有最终的叶子节点,右指针域为空;-----------------n-1个
其中最后一个非终端节点无右兄弟,右指针域也为空。-------------------加1个
因此,若树有N个非终端结点,那么右指针为空的结点共有n+1个。
(2)二叉树转化为森林正好与森林转化为二叉树相反,即:将二叉树结点的左孩子保留,右孩子变为结点的兄弟。
五、图
1、图的相关基础知识
答:由顶点和边构成图,根据边是否有向分为有向图和无向图,有向图记为G1=(V1,{A1}),无向图记为G2=(V2{E2})。边较少的称为稀疏图,反之稠密图。路径:无向图中从顶点v到v'称为路径;
第一和最后顶点相同的路径称为环,除了第一和最后顶点外,其余顶点不重复出现的称为简单环。
图的存储:可以用邻接矩阵、邻接表、十字链表、邻接多重表存储。
邻接矩阵:为方阵,阶数等于顶点数,矩阵元素就是边的权值(若两点无连接则权值为无穷大),元素所在行列则为边两端的顶点。注意:无向图的邻接矩阵具有对称性,所以一般采用压缩模式,只填写矩阵上三角或下三角。(可见,邻接矩阵大小只和图的顶点数有关)
邻接表:图的每一个顶点都建立一个单链表,该链表中的每个结点是以该顶点为尾的边(无向图则为依附于该顶点的边)。故同顶点数的有向图的邻接表结点数是无向图一半。
(1)从图中找到无前驱的顶点输出;
(2)删除该顶点及以其为尾弧;
(3)重复(1)(2),直至删完所有顶点。
2、图的遍历:
答:(1)深度优先搜索:类似树的先根遍历;(用栈作为辅助数据结构,这是根据结点遍历顺序决定的)(2)广度优先搜索:类似树的按层搜索。(用队列作为辅助数据结构,这是根据结点遍历顺序决定的)
广度优先搜索(BFS)是最适合解决无权值或权值相等的单源最短路径问题的。无权值或权值相等的单源最短路径是指:从图中某一点作为顶点出发,向终点搜索,找到最短的一条路径。
深度优先遍历图的方法是,从图中某顶点v出发:
①访问顶点v;
②依次从v的未被访问的邻接点(任何一个邻接点即可,不一定是深度最大的那个邻接点)出发,对图进行深度优先遍历;直至图中和v有路径相通的顶点都被访问;
③若此时图中尚有顶点未被访问,则从一个未被访问的顶点出发,重新进行深度优先遍历,直到图中所有顶点均被访问过为止。
深度优先排序(找到重复访问的顶点表示有环)。
有人说还有”最短路径“,我不太明白。
3、图的欧拉路与欧拉回路
图G存在一条路,经过G中每条边有且仅有一次,称这条路为欧拉路;
判断条件:
有向图:图连通,有一个顶点:出度-入度=1,有一个顶点:入度-出度=1,其余都是出度=入度。出度-入度=1的点做起点,入度-出度=1的点做终点;
无向图:图连通,只有两个顶点是奇数度,其余都是偶数度的。起点必须是奇数度点;
(2)欧拉回路
如果图G存在一条回路,经过G每条边有且仅有一次,称这条回路为欧拉回路。
判断条件:
有向图:图连通,所有的顶点出度=入度。
无向图:图连通,所有顶点都是偶数度。
4、图在含有共享子式的表达式中应用:
答:图在这种表达式中的应用可以避免二叉树表示的表达式不能对共享子式的内存重用。首先介绍二叉树表达式:(a+b)*(b*(c+d)+(c+d)*e)*((c+d)*e
即:按表达式顺序设置结点,父节点用作符号位,叶子结点用作数据位(左叶子用作左操作数,右叶子用作右操作数),结果传到父节点。
然后介绍图对该表达式的表示:
与二叉树表达式对比,即:将重复的结点去掉,有向弧重新连接。故图对表达式的表示中:结点数=去除重复后操作数+符号数(不去重复)。
5、图表示的事件与活动之最早、最迟发生/开始时间
答:如图:
图中节点代表事件,边代表活动(注意无向图中边都是从左-> 右, 即 a3 对应的是 <B,D> B->D 这个方向);
事件是瞬间发生就结束,活动发生后要持续一段时间;
(1)事件发生时间
事件 D 的最早发生时间:
从源点 A 开始到达 D 的所有路径加和的最大值 max{<a1,a3>,<a2,a5>} = 6;
事件 D 的最迟发生时间 :
首先求出汇点 F 的最早发生时间 max{<a1,a4,a8>,<a1,a3,a7>,<a2,a5,a7>,<a2,a6>} = 8
汇点 F 的最早发生时间 - max{汇点逆向到事件 D 的路径累加之和} = 8- max{<a7>} = 5;
(2)活动发生时间
活动a3的最早发生时间:
以 a3 该活动为出发点的事件
B---a3--->D , 即, B 事件的最早发生时间;
活动a3的最迟发生时间:
以a3 该活动对应的箭头所指向的事件的最迟发生时间 - a3 活动持续时间
B---a3--->D , 即, D 事件的最迟发生时间 - a3 活动的持续时间 = 5 - 2 = 3 。
综上所述,无论事件还是活动:
最早发生时间,事件=活动,是从前往后计算;
最迟发生时间,都是从后往前计算(活动最迟发生时间与活动持续时间有关)。
(3)补充:
AOE网( Activity On Edge Network ): 在带权有向无环图中,以顶点表示事件,有向边表示活动,边上的权值表示该活动持续的时间(上述例子就是AOE);
AOV网( Activity On Vertex Network ): 在有向图中若以顶点表示活动,有向边表示活动之间的先后关系。
6、求图的最短路径——迪杰斯特拉算法与弗洛伊德算法
答:(1)迪杰斯特拉算法
①迪杰斯特拉算法是典型的单源最短路径算法,用于计算图中一个源节点到其他所有节点的最短路径。主要特点是以起始点为中心向外层层扩展,直到扩展到终点为止。
②算法过程:
(a)初始化:用起点v到该顶点w的直接边(弧)初始化最短路径,否则设为∞;
(b)从未求得最短路径的终点中选择路径长度最小的终点u:即求得v到u的最短路径;
(c)修改最短路径:计算u的邻接点的最短路径,若(v,…,u)+(u,w)<(v,…,w),则以(v,…,u,w)代替;
(d)重复(b)(c),直到求得v到其余所有顶点的最短路径。
③举例1:
④举例2:若给出的是邻接矩阵,那么应该如下过程:
⑤时间复杂度=O(n^2);
(2)弗洛伊德算法
①弗洛伊德算法是求解图中任意两点间的最短路径的一种算法,它是一种经典的动态规划算法;
②算法思想:每次从 vi 到 vj 的所有可能存在的路径中,选出一条长度最短的路径。一共进行n次;
③时间复杂度=O(n^3);
7、二分图以及最大匹配
答:二分图:
把一个图的顶点划分为两个不相交集 U 和V ,使得每一条边都分别连接U、 V 集中的顶点。如果存在这样的划分,则此图为一个二分
图。如图1就是二分图,图2是图1的规范表现模式。
匹配:
在图论中,一个匹配(matching)是一个边的集合,匹配集合中任意两条边都没有公共顶点。例如,图3、图4 中红色的边就是图2 的匹配。
最大匹配:一个图所有匹配中,所含匹配边数最多的匹配,称为这个图的最大匹配。图 4 是一个最大匹配,它包含 4 条匹配边。
六、查找
1、查找及查找表
答:查找即从一种数据存储容器中查找到指定数据,需要使用的数据结构称为查找表,查找表分为:静态查找表和动态查找表;
(1)静态查找表:表固定,只用于查找元素信息。如顺序表(折半查找)、静态二叉树等;
(2)动态查找表:不仅用于查找元素,还有元素插入与删除,插入删除后查找表数据结构要进行调整。如二叉排序树、平衡二叉树、B树和B+树等;
2、最小堆(小根堆)与最大堆(大根堆)
答:堆是一种经过排序的完全二叉树。n个元素的序列{k1,k2,…,kn},当且仅当满足如下关系时被成为堆;
(1)Ki <= k2i 且 ki <= k2i+1 ;
(2) Ki >= k2i 且 ki >= k2i+1 ;(i = 1,2,…[n/2])
当满足(1)时为最小堆,当满足(2)时为最大堆。注意:优先队列实质就是最大/最小堆,最大优先队列就是大根堆实现的,最小优先队列就是小根堆实现的。
将数组转化为堆二叉树的过程叫“堆化树”,如下:
先按数组顺序一层层填从二叉树,然后从最底层开始与父节点交换不符合堆规定的结点。
堆的插入与删除:
堆的详细解释见:http://blog.csdn.net/morewindows/article/details/6709644/
①C++的STL中有大根堆和小根堆的实现:
priority_queue<int> qmax;//默认大根堆
priority_queue<int,vector<int>,greater<int>> qmin;//小根堆
上述插入、删除、读取堆顶元素分别使用:qmin.push(num)、qmin.pop()、num=qmin.top()函数,插入或删除元素后自动进行堆调整。
②除上述优先队列表示大根堆、小根堆外,还可以直接使用make_heap函数直接将vector构造成堆,只是这样每次插入删除后需要程序员自己重新调用make_heap函数调整堆结构。如下:
vector<int> v;//用于建堆的数组
make_heap(v.begin(),v.end());//建堆,默认最大堆,要建立最小堆可以添加第三个参数greater<int>()
v.push_back(input); //插入元素到数组尾部
make_heap(v.begin(),v.end()); //调整堆
v.pop_back(); //弹出数组末尾元素
make_heap(v.begin(),v.end());//调整堆
3、二叉排序树(又叫二叉搜索树或二叉查找树)
(2)二叉排序树的ASL是什么?
ASL就是平均查找长度。
ASL =∑PiCi(Pi 为查找第i个记录的概率,Ci为找到第i个记录数据需要比较的次数,Ci随查找过程的不同而不同。
1)满二叉树时,若每个记录的查找概率相等时,Pi =1/n;ASL = 1/n(1*20+2*21+.....+n*2n-1)=log2(n+1)-1;
2)要求查找成功的ASL最大,就是只有左子树或者只有右子树的情况,即顺序表以第一个数或最后一个数为根节点作二叉排序树。同样,若每个记录的查找概率相等时,Pi =1/n。∑PiCi=1/n∑(n-i+1)=(n+1)/2。
第一个数据做根节点,第二个数据与根节点比较,小于则放左孩子结点,大于则右孩子结点;第三个数据再与根节点比较,小于则表示属于左子树,若左子树已有结点,则像与根节点比较方式一样与左子树根节点比较及插入,大于则属于右子树,插入方式同左子树;第四个数据……,方法同上,直至插入完成。
例如,数据元素为(34,76,45,18,26,54,92,65),按照依次插入节点的方法生成一棵二叉排序树为:
4、二叉平衡树
右旋左旋:最小平衡子树根节点的右孩子的左孩子的子节点插入新元素,先绕根节点的右孩子节点右旋,再围根节点左旋;
左旋右旋:最小平衡子树根节点的左孩子的右孩子的子节点插入新元素,先绕根节点的左孩子节点右旋,再围根节点左旋。
具体方式图解: http://www.cnblogs.com/guyan/archive/2012/09/03/2668399.html
5、B树(或叫B-树)
答: (1)B树:② 除根结点和叶子结点外,其它每个结点至少有ceil(m/2)个孩子,至多有m个孩子;
③所有叶子结点都出现在同一层(这是与多路排序树或者多路平衡树不同的地方);
④每个结点存放至少M/2-1(取上整)和至多M-1个关键字;
⑤非叶子结点包含有儿子数-1个关键字;
⑥非叶子结点的关键字:K[1], K[2], …, K[M-1];且K[i] < K[i+1];
⑦非叶子结点的指针:P[1], P[2], …, P[M];其中P[1]指向关键字小于K[1]的子树,P[M]指向关键字大于K[M-1]的子树,其它P[i]指向关键字属于(K[i-1], K[i])的子树;
插入时分裂方法如下:
(5)删除方法:
②被删关键字Ki所在结点的关键字数目等于ceil(m/2)-1,则需调整。调整过程如上面所述。
③被删关键字Ki所在结点和其相邻兄弟结点中的的关键字数目均等于ceil(m/2)-1,假设该结点有右兄弟,且其右兄弟结点地址由其双亲结点指针Ai所指。则在删除关键字后,它所在结点的剩余关键字和指针,加上双亲结点中的关键字Ki一起,合并到Ai所指兄弟结点中。
6、B+ 树和B*树
答:B+ 树的+后面要空一格,英文名为B+ Tree;(1)B+树是B树的一种变形树,它与B树的差异在于:
①非叶子结点仅仅存储索引并用于指向分支,实际数据都存放在叶子结点中,并且有一个链头在树结构外的链表将所有的叶子结点链接在一起;
②由①,一个节点有m各分支,该节点就有m个关键字,即有k个子分支的结点必然有k个关键字;
(2)B*树是B+树的变形,在B+树基础上将每层非叶子结点层也用一个链表链接起来;
(2)总结B树和B+ 树:
①概括:B树是叶节点在同一层的多叉排序树,B+ 是在B树基础上将非叶节点改为用于存储指向子节点指针索引的多路平衡树(叶节点也在同一层);
②为什么要B树?在磁盘读写过程中,磁盘寻道(或叫做磁盘定位)是一个非常花费时间的过程,B树作用就是对磁盘存储结构进行优化,提高磁盘读取时定位的效率。
③为什么要B+ 树?由于B+树的分支结点均为索引且数据都存储在叶子结点中,叶子节点形成了一个有序的链表。所以B+ 树适合扫库,只需要扫一遍叶子结点就可进行扫库。
④通常B和B+树用于数据库索引,B一般适合数据库的磁盘节点查找,B+树适合数据库分段范围内查询以及插入删除操作,而hash_map适合内存
查找;
⑤B*树:在B+树基础上,为非叶子结点也增加链表指针,将结点的最低利用率从1/2提高到2/3;
7、红黑树以及其查找复杂度
答:(1)红黑树来源于二叉搜索树,其在关联容器如map中应用广泛,主要优势在于其查找、删除、插入时间复杂度小,但其也有缺点,就是容易偏向一边而变成一个链表。
红黑树是一种二叉查找树,但在每个结点上增加一个存储位表示结点的颜色,可以是Red或Black。也就是说,红黑树是在二叉查找树基础上进一步实现的;红黑树的五个性质:
性质1. 节点是红色或黑色;
性质2. 根节点是黑色;
性质3 每个叶节点(指树的末端的NIL指针节点或者空节点)是黑色的;
性质4 每个红色节点的儿子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点);
性质5. 从任一节点到其每个尾端NIL节点或者NULL节点的所有路径都包含相同数目的黑色节点。
(注:上述第3、5点性质中所说的NIL或者NULL结点,并不包含数据,只充当树的路径结束的标志,即此叶结点非常见的叶子结点)。
因为一棵由n个结点随机构造的二叉查找树的高度为lgn,所以顺理成章,二叉查找树的一般操作的执行时间为O(lgn)。但二叉查找树若退化成了一棵具有n个结点的线性链后,则这些操作最坏情况运行时间为O(n);
红黑树虽然本质上是一棵二叉查找树,但它在二叉查找树的基础上增加以上五个性质使得红黑树相对平衡,从而保证了红黑树的查找、插入、删除的时间复杂度最坏为O(log n)。
(2)左旋右旋
红黑树插入或删除后,一般就会改变红黑树的特性,要恢复红黑树上述5个性质,一般都要那就要做2方面的工作:
①部分结点颜色,重新着色②调整部分指针的指向,即左旋、右旋。
左选右旋如图所示:
左旋,如图所示(左->右),以x->y之间的链为“支轴”进行,使y成为该新子树的根,x成为y的左孩子,而y的左孩子则成为x的右孩
子。算法很简单,旋转后各个结点从左往右,仍然都是从小到大。
左旋代码实现,分三步:
1°、开始变化,y的左孩子成为x的右孩子;
2° y成为x的父结点;
3° x成为y的左孩子;
右旋类似,不再累述;
8、哈希表
答:哈希表存储的元素是键值对(Key-value),查询时根据关键字而直接访问在内存存储位置的数据结构。它通过计算一个关于键的函数,将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。这个映射函数称做散列函数,存放记录的数组称做散列表。
(1)哈希表优缺点:
优点:查询速度快,时间复杂度为常数O(1);
缺点:空间复杂度大,不仅要存储键值对,还要另外存储每个关键字对应的哈希函数。
(2)哈希表hash_map的原理:
hash_map基于hash table(哈希表)。首先分配一大片内存,形成许多桶,利用hash函数,对key进行映射到不同桶进行保存。
哈希表插入和查询过程如下:
其插入过程:
1、得到key;
2、通过hash函数得到hash值;
3、得到桶号(一般都为hash值对桶数求模);
4、存放key和value在桶内;
其取值过程是:
1、得到key;
2、通过hash函数得到hash值;
3、得到桶号;
4、比较桶的内部元素是否与key相等,若不相等,则没有找到;
5、取出相等的记录的value;
hash_map中生成元素地址用hash函数,解决冲突用比较函数(equal_to),哈希函数和比较函数也是哈希表应用中与用户相关的两项。一般存储常用类型的哈希表可以省略哈希函数和比较函数,因为模板库一般已经实现了。
9、哈希冲突
答:哈希冲突:对不同的关键字可能得到同一散列地址,即k1!=k2,而f(k1)=f(k2),这种现象称为碰撞,也叫哈希冲突,哈希冲突几乎不可避免。哈希地址唯一性保证主要从散列函数和冲突处理两方面着手,尽可能的采用冲突较少的散列函数,对于不可避免的冲突使用冲突处理算法进行处理。
(1)处理哈希冲突中最常用的两种方式:开放定址(Open Addressing)法和拉链(Chaining)法;
①开放定址法:
Hi=(h(key)+di)%m;//如果h(key)冲突,就计算Hi作为新的哈希地址,其中h(key)为哈希函数,m为哈希表长度,di为增量序列且0<di<m;
根据di的取值方式可将开放定址法分为:
1°di=1,2,3,...,m-1称为线性探测再散列;
2°di=1^2,-(1^2),2^2,-(2^2),……,±k^2称为二次探测再散列;
3°di=伪随机数探测再散列;
②拉链法:
将所有关键字为同义词的记录存储在统一线性链表中。即,对于哈希函数的值区间[0,m-1],则设立一个哈希表Chain ChainHash[m],每个表元素都是一个链表,凡是哈希地址为i的记录都插入到头指针为Chain-Hash[i]的链表中。注:同义词表示在哈希表中经过散列函数计算,得到的地址相同的两个或多个关键字之间的关系。
拉链法示例如下:
(2)开放定址法和拉链法的优劣比较:
①开放定址法易产生二次聚集(新地址又冲突或与非同义词冲突),拉链法不会二次聚集;
②开放定址法的哈希表结构固定,其删除操作没有拉链法的链表结构灵活;
③开放定址法为了减少冲突,一般要求装填因子α较小,即哈希表长度应较大,从而造成更多存储空间浪费;
④拉链法的缺点:指针需要额外的空间,故当结点规模较小时,开放定址法更适合。
(3)常见的哈希散列函数:直接定址法,数字分析法,折叠法,平方取中法,乘余取整法减去法,基数转换法,除留余数法,随机乘数法,字符串数值哈希法,旋转法,伪随机数法。
补充:常见的散列函数如“取与定址法”: H(key)= key % p ,则 p 最好选择小于等于m的最大素数(m是散列表中的存储单元数)。
(4)C++的STL中有两种常用的哈希表:map和hash_map
map是用红黑树实现的,所以查询时间复杂度为o(logn);
hash_map底层是用hash表存储的,查询时间复杂度是O(1);
两者如何选择:不一定因hash_map时间复杂度小就选hash_map,因为哈希表是用空间换时间,所以对空间复杂度要求较高的不适合选择hash_map。
10、哈希树
答:哈希树:又名默克尔树(Merkle Tree),是一种存储哈希值的树,其相比于普通哈希表的存储空间换时间,哈希树是一种结构简单、查询快速的非排序树结构。哈希树生成定理一般是“质数分辨定理”,即使用质数作为求余余数。哈希树常用于大数据处理。
补充一点Java中的hash表相关知识:
hash_table和hash_map都是Java中的哈希表实现,其中后者是以前者为基础实现的密,且前者自带同步机制而后者无同步机制,所以在使用hash_map时需要自己加锁,常有三种加锁:
①对整个hash_map加排它锁;
②根据需求对整个hash_map加读锁;
③将hash_map内存区分为多块,然后分别对每一块加锁。
第③种是对多线程效率影响最小的。
11、字典树(Trie树)
答:字典树是一种哈希树的变种,常用于大量数据中的查找、排序等,比如在几亿QQ号查找指定QQ,或在大量的英文词汇中搜索指定词汇词频等。
字典树核心思想:空间换时间。利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。
(1)字典树3个基本性质:
①根节点不包含字符,除根节点外每一个节点都只包含一个字符;
②从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串;
③每个节点的子节点各自包含一个不同的字符,如字典树存储英文单词,那么每个节点的有26个子节点,各节点分别包含一个不同的英文字母。
(2)字典树的构建
好比假设有b,abc,abd,bcd,abcd,efg,hii 这6个单词,我们构建的树就是如下图这样的:
字典树与字典很相似,当你要查一个单词是不是在字典树中,首先看单词的第一个字母是不是在字典的第一层,如果不在,说明字典树里没有该单词,如果在就在该字母的孩子节点里找是不是有单词的第二个字母,没有说明没有该单词,有的话用同样的方法继续查找.字典树不仅可以用来储存字母,也可以储存数字等其它数据。
(3)Trie的查找(最主要的操作):
(1) 每次从根结点开始一次搜索;
(2) 取得要查找关键词的第一个字母,并根据该字母选择对应的子树并转到该子树继续进行检索;
(3) 在相应的子树上,取得要查找关键词的第二个字母,并进一步选择对应的子树进行检索;
(4) 迭代过程;
(5) 在某个结点处,关键词的所有字母已被取出,则读取附在该结点上的信息,即完成查找。
(4)字典树数据结构:
typedef struct Trie
{
Trie *next[MAX];
int v; //根据需要变化
};
Trie *root;
①next是表示指向子节点的指针数组,Max每层有多少种类的数,如果只是小写字母,则26即可,若改为大小写字母为52,这里根据题意来确定;
②v可以表示一个字典树到此有多少相同前缀的数目(就是到此表示的单词出现的次数),这里根据需要应当学会自由变化。
(5)字典树查询的时间复杂度=o(l):
其中l是英文单词或者数值的长度,如hello的查询复杂度就为o(5)。为什么会是o(5)呢,有人乍一看字典树结构,应该是每个节点的查询都应该遍历26次啊,那样查询不就应该是o(26^5)吗?原因是这样的:每个节点的所有子节点(字母的子节点有26个)都被装入哈希表中了,然后取得时候用直接定址法查询,每个字母的查询只需一步即o(1),所以时间复杂度就是石o(l)。
(6)字典树空间优化:
使用hash_map代替next指针数组,因为next数组中可能有很多指针根本用不上,使用哈希表替换后就可以在创建字典树时根据需要向哈希表中添加指针,从而避免空指针浪费空间。
字典树是一个前缀树,为了优化空间可以考虑后缀树。后缀树由前缀树演变而来,后缀树中后缀的节点中存储的不止一个字母,而是一个路径上的公共后缀字符串,这样就可以大大减少节点数。
(7)总结:常见问题“有一个存放英文单词的文本文件,现在需要知道某些给定的单词是否在该文件中存在,若存在,它又出现了多少次?”就是典型的需要使用字典树的案例。但是对于中文词汇的查找,我认为字典树就不合适了,因为中文汉字有几千个,假设常用的汉字3000个,于是第一层中每个节点有3000子节点,每个子节点又有3000个子子节点,……,会造成树非常大。
12、集合
答:(1)集合也是一种数据存储容器,其相比一般线性存储结构有以下特点:
①数据之间没有关联性;
②数据不可重复;
(2)STL中的集合容器实现由:set、hashset;其中hashset是使用哈希算法实现的,底层使用hashMap实现,查找复杂度为O(1);
八、其他
1、布隆(bloom)过滤器
答:(1)布隆过滤器适用范围:可以用来实现数据字典查询,垃圾邮件过滤,数据的判重,或者集合求交集等;
(2)布隆过滤器原理:布隆过滤器严格说是一种算法,其是以哈希表为基础的拓展。实现原理就是使用比特数组存储关键字存在与否,某比特位为0表示对应的关键字不存在,否则存在。具体如下:
①首先需要k个hash函数,每个函数可以把key散列成为1个整数;
②初始化时,需要一个长度为n比特的数组,每个比特位初始化为0;
③ 某个key加入集合时,用k个hash函数计算出k个散列值,并把数组中对应散列值下标的比特位都置位为1;
④判断某个key是否在集合时,用k个hash函数计算出k个散列值,并查询数组中对应的比特位,如果所有的比特位都是1,认为在集合中,只要k个比特位中有一个不为1就表示不存在。
问题:为什么一个关键字要使用k个比特位联合判断呢?
答:个人认为是为了减少比特数组的长度,减小布隆过滤的空间复杂度。因为一个比特位对应一个关键字的话,k+2个比特位就是对应k+2个关键字,但k个关键字联合判断的话,k+2个比特位根据排列组合可知对应(K+2)*(K+1)/2个关键字。
(3)布隆过滤优缺点:
优点:查询速度快,不需要存储key值,所以相比哈希表大大减小空间复杂度;
缺点:①判断key在集合中时有一定的错误率,错误率随着数据量增大而增大;②只可以查询和插入,无法删除;
2、各种常见数据结构增删改查的时间复杂度
答:如下图: