数据结构与算法
第一章:概述
1. 数据和数据结点
数据是对客观事物的描述形式和编码形式的统称。
数据是由数据元素组成的,数据元素又称为数据结点,简称结点。
每个数据元素又包括多个数据项,每个数据项又称为结点的域,其中,用来唯一标识结点的域称为关键字。
2.数据结构、逻辑结构、物理结构
一个有穷的结点集合D,以及该集合中各结点之间的关系R,组成一个数据结构,表示成B=(D, R);
D和R是对客观事物的抽象描述,R表示结点间的逻辑关系,所以(D, R)表示的数据的逻辑结构。
数据结构在计算机内的存储形式称为存储结构,也称为物理结构。
3. 数据结构的种类
表结构(一对一)、树结构(一对多)、图结构(多对多)、散结构(结点之间没有关系,或者说存在特殊关系-无关关系)
4. 抽象、抽象数据类型、抽象数据类型的表示
抽象 - 从一般意义上将,抽象是指“抽取事物的共性,忽略个性;体现外部特征,掩饰具体细节”。
抽象数据类型简称ADT(abstract data type), 是将“数据”连同对其的“处理操作”(即运算)封装在一起而形成的复合体。注意: ADT是对一个确定的数学模型,以及定义在该模型上的一组操作的抽象表示,不涉及具体的实现。
抽象数据类型的表示 - 如可以将有序表有关的数据和处理操作封装成一个ADT,涉及的数据可能有元素个数、数据元素等,涉及的操作可能有查找、插入、删除等,其描述如下:
ADT Orderlist // Orderlist 为抽象数据类型的名字 { 数据对象: D={......} 数据关系: R={......} 基本操作: InitList(&L) // 构造一个空的有序列表L InsetElem(&L, e) // 在有序列表中插入新元素e,使表仍有序 DeleteElem(&L, e) // 删除元素 ListLength(L) // 返回表中的元素个数 }
5. 算法的定义、算法与数据结构、程序的关系、算法的描述
算法是有穷规则的集合, 而规则规定了解决某一问题的运算序列,同时,算法还应该具有“有穷性、确定性、可行性以及输入数据、输出数据”。
当人们着手解决一个问题的时候,要先确定求解的算法,然后为算法选择满则处理要求的数据结构,将算法的计算步骤分解为对数据结构的运算,再设计实现数据结构运算的算法,最终编写求解程序。程序不仅要忠实于算法,并且还要确保算法能达到预期的运行效率,Wirth公式指出“算法+数据结构=程序”。
算法可以由自然语言描述(通俗的文字)、流程图描述、类语言(伪代码)描述。
6. 时间复杂性与空间复杂性
算法的时间需求称为算法的时间复杂性或时间复杂度。
算法的空间需求称为算法的空间复杂性或空间复杂度。
7. 时间复杂性的大O表示法
算法的时间复杂性是指输入数据量n的函数,记为T(n),我理解的T就是指Time,即时间。比如,某排序算法,对10000个元素排序所用的时间要比100个元素排序的时间长,其中10000和100就是输入数据量。评价一个算法的时间复杂性,就是要设法找出函数T(n)。
为了简化计算和统一标准,通常假定算法每执行一条基本语句需要耗时一个时间单位,于是可以根据算法的循环次数和递归调用次数,计算出总共执行了多少条基本语句,从而确定T(n)。
一般,不可能精确计算,而只能估算T(n),做法: 求出T(n)随着输入数据量n增长而变化的趋势,也就是当n趋近于无穷的时候T(n)的极限情况,这种极限情况称为算法的渐进时间复杂性。
计算T(n)的渐进时间复杂性时,若能找出常数c > 0和非负函数 f(n)>=0,并证明在n大于某个值时,满足: T(n)<=cf(n),那么就可以用大O记号表示为: T(n) = O(f(n))。 读作 “T(n)是O fn 的”。
采用大O记号表示算法的时间复杂性时,只需要求出最高阶,忽略最低阶和常数系,从而大大降低了计算难度,同时也能比较客观的反映出当n很大时,算法的时间性能。
如:
- O(1) 常数时间,即算法时间和输入量n没有关系。
- O(logn) 对数时间。对数阶不写底数(与之无关)。
- O(n) 线性时间,即算法时间用量和n成正比。
- O(nlogn) 非常理想的阶。
- O(n2) 平方阶时间。
- O(n3) 立方阶...
8. 最坏情况和平均情况
所谓最坏情况是指,对于具有相同输入数据量的不同输入数据,算法时间用量的最大值。
所谓平均情况是指,对于所有相同输入数据量的各种不同输入数据,算法耗时的平均值。
第二章:表结构
1. 表(线性表)、表长、空表、前驱、后继等定义和术语
表或线性表是按某种先后次序排列的结点序列。
结点个数n称为表长,若n=0为空表。第一个结点称为表头结点,或首结点; 最后一个结点称为表尾结点或尾结点。一般,首结点在最左端,尾结点在最右端。
排在一个非前驱结点x之前的结点称为x的前驱结点,简称前驱; 排在一个非后继结点y之后的结点称为y的后继结点,简称后继。
如果表中的结点某个域是按照从小到大的顺序排列,则称为有序表。
2. 线性表的基本运算(9种)
- 查找 - 根据结点值查找到存储地址;
- 插入 - 在表中插入一个新结点;
- 删除 - 删除表中的某个结点;
- 存取 - 给定结点在表中的序号i,求第i个结点的存储地址;
- 更新 - 修改结点中某个域的值;
- 合并 - 把两个表合并成一个表; 如将每个班级的学习成绩单合并为整个年级的成绩单。
- 分裂 - 把一个表分裂成两个子表,或多个子表。如把英语成绩表按照75分分成两个。
- 复制 - 为某个表生成一个副本;
- 排序 - 重排无序表中的结点,使之成为有序表;这部分单独成一章。
3. 线性表的存储方法
顺序存储: 以结点为单位,按结点在表中的次序一次存储。顺序存储的表成为顺序表。 结点在表中的逻辑次序与其在存储器中的物理次序一致。 相邻结点存储在相邻的存储单元中。
链式存储: 每个表元素对应链表的一个存储界定。链式存储的表成为链式表结点含有值域和链域。分别用来存储元素的值和其后继结点的地址(单向链表)。表尾结点的链域值为空(NULL),表示没有后继结点。每个链表都用一个指针变量如Head指向首结点,且一个链表就是指表头指针和一串相继的结点的总称。
4. 顺序表的插入与删除
插入可以分为无条件和有条件插入,无条件直接插入在表尾即可,有条件则需要先将i后的结点向后移动,然后再插入。
删除可以分为删除指定结点和删除非指定结点,删除指定结点即将该结点之后的结点依次向前移动一位进行覆盖, 删除非指定结点的情况比较少见,不做介绍。
效率分析:
1)在表尾处进行插入和删除的时间用量最少,O(1).
2)在随即的某个i处插入和删除,事件复杂性正比于移动元素个数为O(n-i), 最坏情况和等概率的情况事件复杂性均为O(n),效率很低。
3)顺序表的存储效率高,因为只需要存储元素值,不需要存储结点间的关系。
使用与顺序表存储的几种情况
1)表的长度不大
2)不做或少做插入或删除
3)插入或删除只在表的断点进行。
5.顺序表的查找(针对有序表)
1)顺序查找,即从左往右查找或从右往左查找,都是使用for遍历,一旦if成立,则停止查找。其时间复杂性在最坏情况下和平均情况下都是O(n),是效率最低的查找方法。
2)使用监督元的顺序查找,若从左向右查找,则监督元在表尾添加;若从右往左查找,则监督员在表头添加。如从右向左,在即for(a[0]=x,i=n;a[i]!=x;i--); 注意,循环体为空。 如果找到,则i不为0;如果找的到,则i为0,少了一次判断。
3)自动调频查找
4)二分查找
二分查找的思路很简单,但前提必须是有序表,且是顺序存储的,结点是等长的。 然后我们将首结点的index设为left,右结点的index为right,中间的mid, 每次先判断left是否大于right,大于,则找不到,终止;小于,则可以,继续。接着判断和mid的关系,若比mid小,则将right成为mid,在左边范围找,继续判断left是否大于right。。。若比mid大,则left为mid,在右边范围找,继续判断left是否大于right,如此重复即可... 二分查找又称作折半查找。其平均复杂性为 O(logn)。
6. 链表中的指针以及相关概念
在c语言中可以使用malloc和free来分配结点和回收几点,在C++中可以使用new 和 delete来分配结点和回收结点。
一般称p为指针,如head指针,他们都是指针,在一个节点中的数据域内也是指针。
指针所指向的结点用p->表示。即p->表示一个结点。
指针所指向的结点的值域用p->data表示。
指针所指向的结点的链域用p->next表示,即p->next也是一个指针。
p的值即p->的地址。即p的值是一个结点的地址。 所以p->next的值就是p->next->的地址。
7. 链表中插入结点和删除结点
插入结点( 注意,一般我们把p->看作要插入的结点)
1) 在表头处插入结点: p->next = head; head = p; 注意: 其中 = 就是赋值操作,这里即地址间的赋值。
2) 在表中间(包括表尾)插入:首先,假设在q->之后插入p->,那么插入的方法是: p->next=q->next; q->next=p;
删除结点(注意,一般我们把p->看作要删除的结点)
1)在表头处删除结点: p=head; head=head->next; free(p); 注意:这里p的作用是暂时接手指针,作为一个桥梁,最后过河拆桥即可。 我们知道虽然p是一个指针,但是p的值是所指向结点的地址,所以地址都没有了,自然这个结点就被删除了。
2)在非表头处删除结点: 首先,假设删除在q->之后的那个结点,那么删除的方法是:p = q->next; p->next = q->next; free(p);
8. 链表的种类
1. 按产生方式分为: 动态链表和静态链表
2. 按结点中的链域数: 分为单向链表和双向链表,双向链表有两个链域,左链域指向前驱结点,右链域指向后继结点。
3. 按是否有附加结点(往往用作监督元结点)可以分为: 单纯链表、带附加表头结点的加头链表和带附加表尾结点的加尾链表。
4. 按链表是否循环可以分为:循环链表和非循环链表。
9. 链表的遍历
链表的遍历,就是逐一“访问”链表中的每一个结点。对于单向链表,就是从标头结点起,一个结点一个结点地向后进行访问。使用一个“滑动”的访问指针,每访问一个结点后,就让访问指针滑向“下一个结点”。
即先使访问指针p指向第一个结点,如果链表没有遍历完就执行下面的步骤:访问p->,p=p->next; 最后遍历完毕后返回。
10. 栈的基本概念
只允许在同一端进行插入或删除的表叫做栈,或堆栈。允许插入和删除的那一端叫做栈顶,另一端叫做栈底。
栈插入叫做入栈,或压栈;栈删除叫做退栈,或弹出一个元素。
指针top用来指向栈顶,每当进退栈时,就“移动”栈顶指针top,使其始终指向当前栈顶。
栈是一种后进先出结构,又称为“后进先出表”,或者是LIFO。
如将子弹“压入”弹卡中就是入栈,射击时弹出子弹就是“退栈”。
11. 队的基本概念
允许在一端插入,在另一端删除的表叫做队,或队列。
允许插入的一段叫做队尾,允许删除的一端叫做队头。队的插入和删除分别成为进队和出队。
指针first和last分别指向队头和对尾,每当进队时,就移动last指针到当前队尾; 每当出队时,就移动first指针到当前对头。
队就像我们平时排队一样,先进先出,所以又成为先进先出表,即FIFO。
12. 栈和队列的存储方式
栈和队列既可以使用顺序存储方式,也可以使用链式存储方式,即顺序栈和顺序队列、链式栈和链式队列。
由于顺序存储更为方便,所以一般情况下没有必要采用链式存储。
13. 顺序栈的进退栈算法
进栈:
// m - 栈空间大小 // top - 栈顶指针 // s - 存储在数组s中的栈 // x - 要求进栈的元素 // 返回值:SUCC - 进栈成功;FAIL - 进栈失败 if (top == m-1) return FAIL; s[++top]=x; return SUCC;
如果栈满,还要求进栈,就会栈溢出。我们可以用动态数组来解决这种问题。
退栈:
// m - 栈空间大小 // top - 栈顶指针 // s - 存储在数组s中的栈 // x - 接收退栈元素 // 返回值:SUCC - 进栈成功;FAIL - 进栈失败 if (top == EMPTY) return FAIL; x = s[top--]; return SUCC;
如果栈空,还要求退栈,就会退栈失败。
说明: 这两个函数中都没有循环语句,也没有递归调用,故其消耗时间都是O(1)的,即进栈和退栈的工作量和栈的长度无关,属于最优的算法。
14. 使用动态数组的进退栈方法
单CPU无法同时处理多个工作,所以要让多余的工作进栈。。总之,元素x要求进栈,就必须确保进栈,而不能像上面一样发生栈溢出的情况,否则程序无法正常运行。
因此要给栈分配足够大的空间,但是如果不知道应该多少合适,就可以使用动态数组,先调用malloc函数为栈顶分配空间,一旦发生溢出,就调用realloc函数,要求追加空间。
使用动态数组并不能保证绝对不出错,因为调用malloc函数和realloc函数时,也会因为“存储空间用完”而造成分配失败。
15. 链式栈的进退栈算法
虽然一般情况下使用顺序栈式比较好的,但是在有些情况下还是需要使用到链式栈,这里我们用单向单链表结构实现链式栈,表头指针top作为栈顶指针。开始时,栈空,top等于NULL。
进栈函数:
// top - 栈顶指针 // s - 存储在数组s中的栈 // x - 要求进栈元素 // 返回值:SUCC - 进栈成功;FAIL - 进栈失败 p=(ptr)malloc(sizeof(snode)); // 申请结点 if (p == NULL) return FALL; // 申请失败,进栈不成功 p -> data =x; // x进栈 p->next=top; top=p; return SUCC; // 进栈成功
退栈函数:
// top - 栈顶指针 // s - 存储在数组s中的栈 // x - 接收退栈元素 // 返回值:SUCC - 进栈成功;FAIL - 进栈失败 if(top==NULL) return FALL; x=top->data; p=top; top=top->next; free(p); return SUCC;
注意: 对于栈而言,top指向指针是从上往下指的,退栈之后,删除最上面的结点,top指针向下移动。
16. 进队和出对算法
比较麻烦的是既要考虑对头指针和队尾指针,又要考虑队满和队空的情况。
开始时设置数组q[m]数组存储一个队,且有first=last=0,其中first表示头指针,last表示尾指针,两者均为0,所以说这个队列是空的。
但是为了编程方便,我们不能让first指向队的第一个结点,last指向队的最后一个结点,因为这样在一番入队和出队之后没有办法判断对空与否,所以方法有二,一:尾指针前置。即将尾指针移到后面; 二:头指针后置,即将头指针移到前面。这样的规定之后,如果说队列为空时,我们就可以通过first=last=i来判断了。
这样,开始first=last=0, 接着,如果出队的多,入队的少,那么最终first就会赶上last,导致last=first=i。 但是如果说没有出队的了,只有不停的入队,那么尾指针就会转一圈赶上头指针,同样地,last=first=i,那么可以得出结论:不论是队空还是队满,都有可能导致first=last=i; 那么我们怎么做出区分呢?
方法一:使用计时器count。 进队时,count++, 出队时,count--,所以一旦first=last=i就表示队空或者队满,如果说count=0,则表示对空,如果count=m则表示队满。
方法二:利用首尾指针first和last的相对位置,利用first=conut判断是否为空。利用last将要赶上first表示队满。
分析: 第一种方法简单,但是要引入计时器; 第二种方法不用引入计时器,但是他以last-first的值来判断是否将要满,这样就会导致我们最多存储m-1个元素。
入队函数:
if((last+1)%m==first)return FAIL; q[last]=x; last=(last+1)%m; return SUCC;
出队函数:
if(first==last)return FAIL; x=q[first]; first=(first+1)%m; return SUCC;
17. 链式队的进出队算法
和上面的说法类似,为了便于操作,链式队采用单向加头链表结构,first为首指针,last为尾指针。每当进队时,将新结点插在表尾处,并将last移动向新结点; 每当出队时,将队头元素所占结点作为新的头结点,释放原来的头结点。 对空时,frist和last同时指向头结点。
进队函数:
先申请一个结点,然后如果申请不到了,就返回错误。 将要插入队列的x置于新节点的值域,将指针p赋值给last->next,这样,就把p->给接到最后面了, 然后再将last指针指向p即可。
ptr p; p=(ptr)malloc(sizeof(snode)); // 申请结点 if(p==NULL)return FAIL; p->data=x; last->next=p; last=p; return SUCC;
出队函数:
prt p; if(first==last)return FALL; p=first; first=p->next; x=first->data; free(p); return SUCC;
18. 栈和队的应用举例
1. 程序中断
中断是操作系统实现程序“并发执行”的重要技术,当程序要求输入输出数据时,主机向外设发出了输入输出命令,由外设完成具体的输入输出操作。由于主机比外设的速度快得多,为了提高主机运行效率,主机在发出输入输出命令之后,并不会空等着外设传输完毕再工作,而是继续运行系统中的程序。 当外设完成了输入输出命令之后,就会向主机发出一个中断信号,主机接收到中断信号之后,就会执行一个程序即 “中断服务进程” ,这个中断服务程序再利用中断信号将主机原来执行的程序打断,打断的地方即为“断点”。为了在执行完中断服务程序之后且在之后的某个合适的时间能够准确地执行原来被打断的程序,在发生中断时,就要将断点“现场”(包括断点的地址、有关的寄存器值等)作为一个栈架保存在栈里,等到执行完中断服务程序,退栈(弹出元素),恢复现场,之后再继续执行原来被中断的程序。但是如果主机同时用到多个外设,那么执行一个中断服务程序时,可能又会收到另外一个中断信号,如果这个中断比上一个中断要求更为紧迫的话,那么当前正在执行的中断服务程序就会被新的中断服务程序利用新的中断信号打断,这个断点也要作为一个栈架保存在栈里,所以栈架和栈架之间服从后进先出“栈”规则。
2. 函数的嵌套调用和递归调用
函数的嵌套调用和程序中断是一样的,区别在于函数调用是主动的,调用点已知,而中断时被动的,所以函数调用称为软中断。
程序运行期间,每当遇到调用函数时,就要暂停正在执行的函数,转而执行被调函数,等到被调函数执行完了以后,再回过头来执行原来的函数。
函数的递归调用和嵌套调用处理方法一致。
3.表达式求值算法。
19. 矩阵
矩阵就是再线性代数中学习过的矩阵。矩阵是一种静态结构,它的元素数是固定的,不能在矩阵中插入或删除元素,数学上常见的矩阵运算有:矩阵转置、求和、求逆、矩阵相乘等。
20. 字符串
字符串是有穷的字符序列,字符个数称为串长,长度为0的是空串。
字符串也是有顺序存储和链式存储。顺序存储适合存储长度较小的,或者只做简单运算的串。如果串很大,而且插入、删除频繁,则需要采用链式存储。
串的链式存储包括定长结点的链式存储和变长结点的链式存储。定长结点即选定一个合适的大小m,作为结点的长度,于是每个结点可存储的有效字符数不超过m。变长结点是采用动态方式分配各行的存储结点。
链式存储通常是以行为单位的,以换行符为界限。每行对应一个存储结点。字符在结点内是顺序存储的。这种存储方式实际上是链式存储和顺序存储的混合。好处是:在大多数情况下,对串的修改是按行进行的,所以修改只局限于结点内部,不会影响其他的结点。 这就避免了因为插入和删除而影响到其他的结点。
21. 字符串的简单模式匹配算法
模式匹配也成为串匹配,或子串查找。 用于在某文本文件中查找某些特定的子串。其中,文本成为正文串,而要查找的内容称为模式串。 通常,正文串是一个大串,而模式串只是一个小串。
那么什么是匹配呢? 即段定一个长度为n的模式串,和一个长度为m的正文串,找出n中最小的下标i使得在m中从i开始的一段长度为n的子字符串刚好和长度为n的模式串相匹配。
这里我们知道最简单的方法就是采用“逐段适配法” --- 从正文串a的第一个字符开始,“截取”长度为n的子串,与模式串p进行试匹配,将对应的字符进行逐个比较,如果对应相等,那么说明匹配成功,否则失败。然后,再从a的第二个字符串开始截取n个字符,与模式串p试匹配....直到找到或最后也找不到。如果a剩余的长度不足n,就匹配失败,不再匹配。 可知,使用这种方法,最多需要匹配m-n+1次。最少1次。
匹配算法:
var stringP = "shijieshangzuishuaiderenshizhuzhenwei"; var stringS = "zhuzhenwei"; for (var i = 0; i <= stringP.length-stringS.length; i++) { for (var k = i, j = 0; j < stringS.length; j++,k++) { if (stringP[k] != stringS[j]) { break; } } if (j == stringS.length) { console.log(i); } } if (j != stringS.length) { console.log("error"); }
22. 字符串的KMP算法
第三章:树结构
基本概念: 数据结构之树
1. 二叉树的结构简单,存储效率也高,树运算的算法也相对简单,而且任何k元树(或森林)都可以方便地转化为二叉树,所以二叉树在数据结构中的地位十分重要。
普通二叉树和树的相互转化:普通二叉树可以转化成普通树和森林,森林和普通的树也可以转换成二叉树。所以,一般在处理树时我们可以将普通的树或森林转换成二叉树进行分析计算,必要时也可以将二叉树转换成树。
1)将普通树转化成二叉树
第一步: 在所有的亲兄弟结点之间添加一条连线;
第二步: 去除父节点和子节点之间的连线(第一个子节点和双亲结点之间的连线需要保留);
第三步: 将最开始处于树中的第一个结点作为新的二叉树的左孩子结点,其他均为右孩子结点,展开即可。展开之后可以发现在左下方的是孩子结点,在右下方的是兄弟结点。
整个转化过程如下所示:
如果需要将森林转化成二叉树,可以在森林的几个根上加上同一个根结点,然后再按照上面的方法分析,其他过程是相同的。最后转化成二叉树后可以去除根结点(附加根)。
2)将二叉树转化成为森林
这一过程就是上面的逆过程。
即先观察到: 处于一个结点左下方的是它的孩子结点,处于一个结点右下方的是他的兄弟结点。按照这样的关系即可还原,不再赘述。
2. 普通树的存储方法
(1)多重链接法
对于m元树,结点含有m个链域,分别用于指向各个儿子。 如果缺少某个儿子,则其相应的域值为空。这种存储方式简单,但空间利用率却不高。 只要用一个指针指向根节点,就可以很方便地查看树中的每一个结点。
每个结点含有m个链域,若树有n个结点,则共有nm个链域,但是实际上只用了其中的(n-1)个链域,其余的链域都是空的,所以利用率为 (n-1)/ nm ,最终约为1/m, 所以这种简单存储方式只适用于m比较小的情况。
(2) 左儿子右兄弟法
即结点只含有两个链域,一个指向第一个儿子,另一个指向下一个兄弟。 和上一种方法一样,他们都适合于“从上到下”的搜索,从父亲很容易找到儿子。如果要求从儿子找到父亲却很难。
(3) 父亲链接法
结点只含有一个链域father, 用于指向其父亲,这样便于从下层向上层搜索。如我们可以给树从上到下从左到右进行编号,然后皆可以使用父亲链接法了。
特点是很容易从儿子找到父亲,但是无法从父亲找到儿子。
3. 二叉树的存储方法
(1) 完全二叉树的顺序存储
对于n个结点的完全二叉树,我们可以从上到下从左到右进行编号,然后使得编号为i的结点放在a[i]中(a[0]空着不用)。
于是可以判定 a[1] 是根结点,a[2]和a[3]是根的两个儿子... a[i](i>1)的父结点必定是a[i/2](其中[x]表示不大于x的最大整数)。a[i]的左右儿子必定是a[2*i]和a[2*i+1](除非a[i]没有左右儿子)。
这种存储属于“顺序存储”,其好处在于,不需要链域,便可以由父亲快速地找到儿子,由儿子快速地找到父亲。
(2)普通二叉树的双链式存储
结点含有两个链域Lson和Rson,分别用来指向其左儿子和右儿子。
还可以定义一个指向根节点的指针,从根节点起,沿着左右儿子的链域可以搜索到树中的每个结点。
4. 二叉树的遍历
因为二叉树不是线性结构,所以在遍历时需要遵从一定的规律,方法很多,包括先序遍历、中序遍历和后序遍历。
规律是: 先(根左右)、中(左根右)、后(左右根)。
递归的遍历函数
(1) 先序遍历函数
void preorder(Bptr p) // p指向当前子树的根结点 { if(p==NULL)return; visit(p); preorder(p->Lson); preorder(p->rson); }
(2)中序遍历函数
void inorder(Bptr p) // p指向当前子树的根结点 { if(p==NULL)return; inorder(p->Lson); visit(p); inorder(p->rson); }
(3)后序遍历函数
void postorder(Bptr p) // p指向当前子树的根结点 { if(p==NULL)return; postorder(p->Lson); postorder(p->rson); visit(p); }
其中,visit(p)表示访问操作。访问的目的不同,访问操作的方式不同。
如果root是二叉树的根指针,那么:
先序遍历的主调函数是 preorder(root);
中序遍历的主调函数是 inorder(root);
后序遍历的主调函数是 postorder(root);
注意: 对上述算法,对n个结点的二叉树遍历时,每个结点至少访问一次,每访问一个结点就会产生一次递归调用,所以共有n次递归调用。因为n个结点的二叉树共有n+1个“空儿子”,所以共产生了n+1次空调用。空调用和非空调用的次数之和是2n+1, 每次调用要执行4条语句,所以算法的时间复杂度是O(n)的。
遍历函数的改进:空调没有任何的用处,只会白白的消耗时间,下面的是不含空调用的先序遍历的算法(中和后类似):
void preorder_1(Bptr p) // p指向当前子树的根结点 { visit(p); if(p->Lson!=NULL)preorder_1(p->Lson); if(p->Rson!=NULL)preorder_1(p->Rson); }
主调用语句改为:
if(root!=NULL) preorder_1(root);
5. 二叉树的构造
(1)构造二叉树的基本原理
n个结点的二叉树共有C(2n, n)/(n+1)颗不同的二叉树。
也就是说,同一个先序(中序、后序)序列可以对应C(2n, n)/(n+1)颗不同的二叉树。如 n=3时,共有C(6, 3)/4 = 5 中不同的二叉树。
即((6*5*4*3*2*1)/(3*2*1*3*2*1))/4
不难想象:
仅仅使用先序遍历或中序遍历或后续遍历是不能构造出唯一的二叉树的。
可以证明: 由先序遍历和中序遍历可以确定唯一的二叉树、 由中序遍历和后续遍历可以确定唯一的二叉树。
但是,由先序遍历和后序遍历不能确定唯一的二叉树。
(2)定理:用中序序列和先序序列可以唯一确定二叉树
证明: 对结点个数n进行归纳。
归纳基础: n=0时,二叉树为空,结论当然是正确的。
归纳假设: 假设结点数小于n的任何二叉树,都可以由其先序序列和中序序列唯一的确定。
归纳: 如果已知某颗二叉树具有n(n>0)个不同的结点,其先序序列和后序序列如下:
先: a1a2...........ai.............an
后: b1b2.......bi-1bibi+1.....bn
由结点的先序序列和中序序列的性质可知, a1是二叉树的根节点,而且a1必然在中序序列中出现。 也就是说,在中序序列中必有某个结点bi(1<=i<=n)就是根结点a1。
因为bi是根结点,所以中序序列的前段b1b2....bi-1必定是bi(也就是a1)左子树的中序序列,也就是说,bi的左子树有i-1个结点。 若i=1,那么结点bi没有左子树。
因此,在先序序列中,紧跟在根节点a1之后的i-1个结点a2....ai就是左子树的先序序列。
根据归纳假设,由子先序序列和子中序序列就可以唯一的确定根节点a1的左子树。
类似的,可以确定右子树。
综上所述,现在这颗二叉树的根节点已经确定,而且其左右子树都唯一确定了,所以整个二叉树也就唯一确定了。
(3) 构造算法
// i和j是先序序列的下标界, s和t是中序序列的下标界 // 存储先序和中序的是数组a和数组b // 返回值p是由a[i]至a[j]和b[s]至b[t]所构造的子二叉树根节点指针 int k; Bptr p; if(i>j)return NULL; p=new Bnode; p->data=a[i]; // 造根节点 k=s; while((k<=t)&&(b[k]!=a[i]))k++; // 在中序序列中找根 if(b[k]!a[i])exit(ERROR); // 没有找到根结点,就返回出错码ERROR p->Lson=creat(a,b,i+1,i+k-s,s,k-1); // 递归的构造左子树 p->Rson=creat(a,b,i+k-s+1,j,k+1,t); // 递归的构造右子树 return p; // 返回根节点指针
主调语句是“root=creat(a,b,0,n-1,0,n-1);”
6. 哈夫曼树
哈夫曼是美国数学家,他在1952年发明了哈夫曼编码,哈夫曼编码的主要的应用就是压缩文件。
基本概念:
从树中的一个结点到另一个结点之间的分支构成了两个结点之间的路径,路径上的分支数目(即短线数)称为路径长度。
树的路径长度就是从树根到每一个结点的路径长度之和。
带权的路径长度: 即我们考虑了带权的结点,那么结点的带权的路径长度就是从该结点到树根之间的路径长度与结点上权的乘积。
树的带权路径长度为树中所有的叶子结点的带权路径长度之和。
假设有n个权值{w1,w2...wn},构造一颗有n个叶子结点的二叉树,每个叶子结点带权为Wk,每个叶子的路径长度为Lk,我们通常记作,则其中带权路径长度WPL最小的二叉树称为赫夫曼树。也有不少树中称为最优二叉树。
既然哈夫曼树的效率这么高,那么我们怎么样才能构造出来呢?
下面即为构造哈夫曼树的算法描述:
1. 根据给定的n个权值{w1,w2,...,wn}构成n颗二叉树的集合F={T1,T2,...Tn},其中每颗二叉树Ti中只有一个带权的wi根节点,其左右子树均为空。
2. 在F中选取两颗根节点的权值最小的树作为左右子树构造一颗新的二叉树,且置新的二叉树的根节点的权值为其左右根节点上的权值之和。
3. 在F中删除这两颗树,同时将新得到的二叉树加入F中。
4. 重复2和3步骤,知道F只含有一棵树为止。 这棵树就是哈夫曼树。
写完之后就会发现权值大的就会靠近根结点,而权值小的就会靠近叶子结点。
参考《大话数据结构》。
7. 哈夫曼编码
通过哈夫曼编码我们可以大大节省文件所占用的内存。
大概的思想就是:对于一段内容,其中的字符很多都是重复的,所以我们没有必要完全的记录下来,可以根据不同字符的权重来做成哈夫曼树,然后再将哈夫曼树的的子树的左边换成0,右边换成1,这样进行编码之后就能大大减少了。
第四章:图结构
图是用于描述事物之间的最一般关系的数据结构,事物作为顶点,事物之间关系作为边,于是,非空有穷顶点集合V以及V上的顶点对所构成的边集E构成了一个图,记作G = (V, E);
有向边、无向边、加权边
表示方法:
在画图形时,通常用圆圈表示顶点,用链接两个顶点之间的线段表示一条边。 表示无向边的线段不带箭头; 表示有向边的线段带有箭头; 加权边的权标注在边的旁边; 顶点名称卸载圆圈内,或标注在圆圈旁边。
顶点的度:在有向图中,顶点v射出的边数,称为v的出度; 射入v的边数,称为v的入度;v的出度和入度之和就是v的度。
第五章: 排序
排序,将无序序列转变成有序序列的一种运算,如果参加排序的数据结点有多个域,那么排序往往是针对某个域而言的。
分类:
- 内部排序与外部排序 --- 内部排序,参加排序的数据量不大,可将其一次全部读入内存,然后再对其排序。 外部排序,参加排序的数据量较大,所以每次只能读取一部分数据到内存参加排序,其余的数据放在外存上(如磁盘)。 因为数据在外存上总是以文件方式存储的,所以说外部排序又称为数组排序。
- 比较排序和基数排序 --- 比较排序通过比较数据元素的大小,决定其先后次序的排序方法称为比较排序。 而不比较元素的大小,仅仅根据元素本身的取值确定其有序位置的为基数排序。
- 串行排序和并行排序 --- 只使用一个比较器,同一时刻只能比较一对元素的大小的排序方法称为串行排序方法。 而使用多个比较器,同一时刻可以比较多对元素的大小的排序方法称为并行排序方法。
- 原地排序 --- 辅助空间用量为O(1)的排序方法,称为原地排序。
- 稳定排序 --- 如果某种排序方法可以使得任何数值相等的元素,排序以后相对次序不变,那么这种排序方法就是稳定的,否则就是不稳定的。
- 自然排序 --- 输入的数据越有序,排序的速度越快的排序方法就称为自然排序方法。
排序方法:《数据结构》P280 - P918 共计38页
- 插入排序
- 直接插入排序
- 二分插入排序
- 希尔排序
- 交换排序
- 冒泡排序
- 快速排序
- 选择排序
- 一般原理和效率分析
- 树选排序
- 堆排序
- 合并排序
- 递归的合并排序
- 非递归的合并排序
- 基数排序
- 基本原理和实例
- 算法的实现和分析
- 外部排序
- 文件的组织结构
- 顺串的合并
参考书目: 《数据结构与算法》 陈卫卫 王庆瑞 主编 高等教育出版社 TP311.12281
考试大纲:http://www.wenkuxiazai.com/doc/33bf4976551810a6f4248648.html
http://yanzhao.bjut.edu.cn/attachment/2016101/14753016040373804.pdf
http://www.docin.com/p-1560579886.html
http://max.book118.com/html/2017/0311/95038026.shtm