历年真题(09~24)
2009 最短路径(Dijkstra青春版)
【2009统考真题】带权图(权值非负,表示边连接的两顶点间的距离)的最短路径问题是找出从初始顶点到目标顶点之间的一条最短路径。假设从初始顶点到目标顶点之间存在路径,现有一种解决该问题的方法:
①设最短路径初始时仅包含初始顶点,令当前顶点为初始顶点。
②选择离最近且尚未在最短路径中的一个顶点,加入最短路径,修改当前顶点u=v。
③重复步骤②,直到是目标顶点时为止
请问上述方法能否求得最短路径?若该方法可行,请证明;否则,请举例说明。
分析:咋一看貌似很有道理嘞,还是贪心的思路。那我们回忆一下局部贪心是否能一定得到最优解嘞?显然并不是所有的结果都是最优的对吧。
该方法不一定能够求得最优解(最短路径)。
因此,该方法并不总是能找到最短路径。特别是当存在多个路径且某些路径的间接距离较短时,贪心选择可能导致错误的结果。(不用写这句,应用题看答案是否正确而不是看你哔哩吧啦了多久)
2010 散列表(Hash)
【2010统考真题】将关键字序列(7,8,30,11,18,9,14)散列存储到散列表中。散列表的存储空间是一个下标从0开始的一维数组,散列函数为H(key)=(key×3)mod7,处理冲突采用线性探测再散列法,要求装填(载)因子为0.7。
1)请画出所构造的散列表。
2)分别计算等概率情况下,查找成功和查找不成功的平均查找长度。
注:
- 装载因子是一个很容易被遗忘的知识点。
- ASL成功和ASL不成功是根本不一样的哦,其关键就在于一个是key,而另一个是h(key),一个的次数是指找到(成功)一个的次数是指找不到(失败)。
2011 关键路径(AOE网)
注:时间余量为零(即这个事情拖不了一点)故该为关键事件,由关键事件所组成的路径就被称作关键路径。多大多小尾早头晚减权
l(i)=Vl(i)-weigh
2012 哈夫曼树(Huffman)
【2012统考真题】设有6个有序表A,B,C,D,E,F,分别含有10,35,40,50,60和200个数据元素,各表中的元素按升序排列。要求通过5次两两合并,将6个表最终合并为1个升序表,并使最坏情况下比较的总次数达到最小。请回答下列问题:
1)给出完整的合并过程,并求出最坏情况下比较的总次数。
2)根据你的合并过程,描述n(n≥2)个不等长升序表的合并策略,并说明理由。
分析:我的第一反应比较总次数最小?应该是要用Huffman吧。结果,嘿~还真是。
1)由于合并两个长度分别为m和n的有序表,最坏情况下需要比较m+n-1次,所以最坏情况下比较的总次数计算如下:
- 第1次合并:最多比较次数=10+35-1=44。
- 第2次合并:最多比较次数=40+45-1=84。
- 第3次合并:最多比较次数=50+60-1=109。
- 第4次合并:最多比较次数=85+110-1=194。
- 第5次合并:最多比较次数=195+200-1=394。
- 比较的总次数最多为44+84+109+194+394=825。
2)各表的合并策略是:对多个有序表进行两两合并时,若表长不同,则最坏情况下总的比较次数依赖于表的合并次序。可以借助于哈天曼树的构造思想,依次选择最短的两个表进行合并,此时可以获得最环情况下的最佳合并效率。
2013 折半查找
【2013统考真题】设包含4个数据元素的集合S={'do','for','repeat','while'}各元素的查找概率依次为P=0.35,P=0.15,P=0.15,p=0.35。将S保存在一个长度为4的顺序表中,采用折半查找法,查找成功时的平均查找长度为2.2。
1)若采用顺序存储结构保存S,且要求平均查找长度更短,则元素应如何排列?应使用何种查找方法?查找成功时的平均查找长度是多少?
2)若采用链式存储结构保存S,且要求平均查找长度更短,则元素应如何排列?应使用何种查找方法?查找成功时的平均查找长度是多少?
分析:折半查找会生成折半查找树,故其ASL应该是这样的
那折半查找默认要求应该是元素有序的,故其数据元素应该按照字典序排列(do<for<repeat<while)但是这题的查找概率并不是一样的,所以折半查找未必是最优。那我们用顺序存储结构保存的话如何使其ASL变短呢?其实也很简单就是将概率更大的元素放在前面,这样查找长度会比较短。链式当然也是一样的咯。(答案有更好的做法)
2014 图(邻接表)and 最短路径(Dijkstra)
分析:好家伙这一长串属实是怪吓人的,但是其实我们只关注题干(123)本身的会发现,这纯纯就是一道数据结构的设计题+Dijkstra算法的手算蛤。
这一看就是无向图嘛,那我们直接开始设计,定义一个结构体RXnode,接着定义一个Router ID用于标识路由器,再定义三个小结点Link1、Link2、Net1,最后来设置三个指针域,根据三个小结点的数据指向其相对应的结点。欸到这我才想起来,这不就是邻接表吗,又有顶点表结点,又有边表结点。那我们只需要对其稍加改造不就是第二题的答案了吗?剩下最后一个手算其实就很简单了,瞪眼秒杀法。
1)无向图
2)
cpp
typedef struct{
int ID, IP;
} LinkNode; // 定义链路结构体,包含ID和IP
typedef struct {
int Prefix, Mask;
} NetNOde; // 定义网络结构体,包含Prefix和Mask
typedef struct ArcNode{
bool Flag; // 判断是Link还是Net
union{
LinkNode Lnode; // 联合体用于存储LinkNode或NetNode
NetNode Nnode;
} NL;
int Merit; // 弧的度量值
struct ArcNode *next; // 指向下一个ArcNode的指针
} ArcNode; // 定义弧结构体
typedef struct VNode{
int RouterID; // 路由器ID
ArcNode *Link; // 指向相连的第一个弧的指针
struct VNode *next; // 指向下一个VNode的指针
} VNode; // 定义表结构体,用于构建顶点链表
3)
- 192.1.1.0:1
- 192.1.5.0:4
- 192.1.6.0:5
- 192.1.7.0:8
2015 邻接矩阵
分析:我们作为后来人显然已经背过该结论了,不过这个A^n邻接矩阵的含义到底是怎么推出来的呢?第一问撒撒水,第二问求A^2也还是很简单,可我们回顾一下求A^2的过程(a[0][0] = a[0][0]*a[0][0] + a[0][1]*a[1][0] + a[0][2]*a[2][0] + a[0][3]*a[3][0] + a[0][4]*a[4][0] = 3)是不是只有当乘积中的任意一方不为0时,才有1的贡献度。那我们再思考下一阶邻接矩阵中元素的含义:
- 对于无向图,邻接矩阵的第i行(或第i列)非零元素(或非无穷元素)的个数正好是顶点i的度。
- A[i][j]不为0是不是表示它们之间是有通路的。
再捋一下a[0][1]*a[1][0]=1是不是说明从1到0,和从0到1是不是都有通路,那这是不是一个来回,且这个通路的长度(2)您猜怎么着?嘿~恰好等于矩阵的次方数。那是不是第三列元素值的含义应该是0~4结点到3这个结点长度为2的路径数量。故第三问的答案就应该为:B^m中,B[i][j]表示的是从i出发走到点j走m步,有多少种走法。非零元素就代表存在长度为m的路径有多少条。
2016 树(性质)
【2016统考真题】若一棵非空k(k≥2)叉树T中的每个非叶结点都有k个孩子,则称T为正则k叉树。请回答下列问题并给出推导过程。
1)若T有m个非叶结点,则T中的叶结点有多少个?
2)若T的高度为h(单结点的树h=1),则T的结点数最多为多少个?最少为多少个?
注:正则的定义要求。
2017 最小生成树(MST)
【2017统考真题】使用Prim算法求带权连通图的最小(代价)生成树(MST)。请回答下列问题:
1)对下列图G,从顶点A开始求G的MST,依次给出按算法选出的边。
2)图G的MST是唯一的吗?
3)对任意的带权连通图,满足什么条件时,其MST是唯一的?
分析:Prim还是简单的吧,从某一个顶点开始构建最小生成树,依次加入当前剩余顶点中代价最小的顶点,直到加入所有顶点。
那MST是否唯一呢?显然是唯一的,因为这四条边的权值最小(AE会被自动排除因为如果有AE的话会造成环路,这是不允许的)所以无论你从哪个结点开始遍历最后的生成树都长这个样子。
从中我们可以得到启发,只要在一张带权连通图中的任意一个环各边的权值都不想等即可满足其生成树必然唯一。
2018 最小生成树(MST)
分析:读完题干我们发现,是不是只要考虑权值之和最少且能连上所有结点就完事了呗,那这是啥?不就是MST嘛。
那我们就有方向了,不就是考我们这俩算法生成的树呗。
用邻接表还是邻接矩阵其实都可以。TTL为5的话,意思应该就是最远传输距离吧,那看图便一目了然了,左边可以,右边不行。
2019 队列(存储结构、循环队列)
1)顺序存储无法满足要求②的队列占用空间随着入队操作而增加。根据要求来分析:要求①容易满足:链式存储方便开辟新空间,要求②容易满足:对于要求③,出队后的结点并不真正释放,用队头指针指向新的队头结点,新元素入队时,有空余结点则无须开辟新空间,赋值到队尾后的第一个空结点即可,然后用队尾指针指向新的队尾结点,这就需要设计成一个首尾相接的循环单链表,类似于循环队列的思想。设置队头、队尾指针后,链式队列的入队操作和出队操作的时间复杂度均为O(1),要求④可以满足。因此,采用链式存储结构(两段式单向循环链表),队头指针为front,队尾指针为rear。
2)该循环链式队列的实现可以参考循环队列,不同之处在于循环链式队列可以方便地增加空间,出队的结点可以循环利用,入队时空间不够也可以动态增加。同样,循环链式队列也要区分队满和队空的情况,这里参考循环队列性一个单元来判断。初始时,创建只有一个空闲结点的循环单链表,头指针front和尾指针rear均指向空闲结点,如下图所示。
- 队空的判定条件:front==rear
- 队满的判定条件:ront==rear->next
3)
4)
2020 哈夫曼树(Huffman)
【2020统考真题】若任意一个字符的编码都不是其他字符编码的前缀,则称这种编码具有前缀特性。现有某字符集(字符个数≥2)的不等长编码,每个字符的编码均为二进制的0、1序列,最长为L位,且具有前缀性。请回答下列问题
1)哪种数据结构适宜保存上述具有前缀特性的不等长编码?
2)基于你所设计的数据结构,简述从0/1串到字符串的译码过程。
3)简述判定某字符集的不等长编码是否具有前缀性的过程。
书本课后答案:
1)使用一棵二叉树保存字符集中各字符的编码,每个编码对应于从根开始到达某叶结点的一条路径,路径长度等于编码位数,路径到达的叶结点中保存该编码对应的字符。
2)从左至右依次扫描0/1串中的各位。从根开始,根据串中当前位沿当前结点的左子指针或右子指针下移,直到移动到叶结点时为止。输出叶结点中保存的字符。然后从根开始重复这个过程,直到扫描到0/1串结束,译码完成。
3)二叉树既可用于保存各字符的编码,又可用于检测编码是否具有前特性。判定编码是否具有前缀性的过程,也是构建二叉树的过程。初始时,二叉树中仅含有根结点,其左子指针和右子指针均为空。
依次读入每个编码C,建立/寻找从根开始对应于该编码的一条路径,过程如下:
对每个编码,从左至右扫描C的各位,根据C的当前位(0或1)沿结点的指针(左子指针或右子指针)向下移动。当遇到空指针时,创建新结点,让空指针指向该新结点并继续移动。沿指针移动的过程中,可能遇到三种情况:
- 若遇到了叶结点(非根),则表明不具有前缀性,返回。
- 若在处理C的所有位的过程中,均没有创建新结点,则表明不具有前缀性,返回。
- 若在处理C的最后一个编码位时创建了新结点,则继续验证下一个编码。
- 若所有编码均通过验证,则编码具有前缀特性。
2021 计数排序
分析:大概遍历一边代码,我们可以发现该函数实现的功能其实就是利用一个指针实现了数组按从小到大的排序。(基于计数排序的思想,count数组第i个元素记录着比该元素小的个数)
1)那自然就是 b[6] = {-10、10、11、19、25、25}
2)比较次数的话,因为每一趟i都是往后全部遍历一遍的,所以应该是n-1 + n-2 + ... + 1 = n(n+1)/2(一定是n-1开始蛤,因为咱自己是不需要和自己比对的)
3)既然它诚心诚意地问了,我便大发慈悲告诉它,并不稳定蛤。
我们可以看到25的相对次序发生了改变,所以是不稳定的,那怎么能够使其出现相同大小的情况,也不会改变相对次序呢?很显然就是调整一下边界的判断条件。
cpp
if(a[i]<=a[j]) count[j]++;
else count[i]++;
2022 堆排序
【2022统考真题】现有n(n>100000)个数保存在一维数组M中,需要查找M中最小的10个数。请回答下列问题
1)设计一个完成上述查找任务的算法,要求平均情况下的比较次数尽可能少,简述其算法思想(不需要编程实现)。
2)说明你所设计的算法平均情况下的时间复杂度和空间复杂度。
分析:比较次数较少,那就是要找的比较快咯,而且查找的又不是只有一个数的话,应该可以用小根堆来实现呀,也就是堆排序。那确定算法之后,只要简单说一下其思想过程是什么就好了。
堆的插入 :对于大(或小)根堆,要插入的元素放到表尾,然后与父节点对比,若新元素比父节点更大(或小),则将二者互换。新元素就这样一路"上升",直到无法继续上升为止。
还有一点需要注意的是,我们只需要10个最小数嘛,即咱只需要维护一个10元素大根堆就好啦。那为什么是大根堆嘞,原因很简单,你用小根堆其实不太好比较。大队堆则只需要你先拎出来10个元素塞进去,然后从第11起,你都和其堆顶(当前最大值)比较一次,如果小于的话,就将其插入并删除根顶。如此反复,到最后根中剩下的10个元素一定就是所要寻找的10个最小元素。
1)算法思想。
【方法1】定义含10个元素的数组A,初始时元素值均为该数组类型能表示的最大数MAX。
cpp
for M中的每个元素s
if(s<A[9)丢弃A[9]并将s按升序插入A;
当数据全部扫描完毕,数组A[O]~A[9]保存的就是最小的10个数。
【方法2】定义含10个元素的大根堆H,元素值均为该堆元素类型能表示的最大数MAX。
cpp
for M中的每个元素s
if(S<H的堆顶元素)删除堆顶元素并将s插入H;
当数据全部扫描完毕,堆H中保存的就是最小的10个数。
2)算法平均情况下的时间复杂度是O(n),空间复杂度是O(1)。
注:两个方法的思路其实差不多。
2023 外部排序
【2023统考真题】对含有n(n>0)个记录的文件进行外部排序,采用置换-选择排序生成初始归并段时需要使用一个工作区,工作区中能保存m个记录。请回答:
1)若文件中含有19个记录,其关键字依次是51,94,37,92,14,63,15,99,48,56,23,60,31,17,43,8,90,166,100,则当m=4时,可生成几个初始归并段?各是什么?
2)对任意的m(n>>m>0),生成的第一个初始归并段的长度最大值和最小值分别是多少?
分析:本题没什么好分析的,直接开干就完事了。
1)
由上图可知,可以生成3个归并段分别是{37,51,63,92,94,99}、{14,15,23,31,48,56,60,90,166}、{8,17,41,100}(但其实手算的话不需要这么复杂,直接看和抄录最小值即可)
2)那最牛犇的情况就是全部有序嘛,一次就干完了,那就是长度为n呗;那最菜的情况就是拎一个出来之后再也没有比它大(小),那就是长度为1呗。
好,我才是菜的那个,答案里面说,最小值是m。
2024 散列表(Hash)
王道打卡表
数组的应用
压缩存储
- 给自己出题:自己动手创造,画一个5行5列的对称矩阵
- 画图:按"行优先"压缩存储上述矩阵,画出一维数组的样子
- 简答:写出元素 i,j 与 数组下标之间的对应关系
- 画图:按"列优先"压缩存储上述矩阵,画出一维数组的样子
- 简答:写出元素 i,j 与 数组下标之间的对应关系
- 画图:假设你的对称矩阵表示一个无向图,画出无向图的样子
那我们知道由于是对称矩阵,其实没必要把所有的元素都存放在数组中,不然我们按上三角不然我们按下三角存其实都可以。
栈、队列的应用
- 写定义顺序存储的栈(数组实现),数据元素是 int 型。
- 基于上述定义,实现"出栈、入栈、判空、判满"四个基本操作。
- 定义链式存储的栈(单链表实现)。
- 基于上述定义,栈顶在链头,实现"出栈、入栈、判空、判满"四个基本操作。
- 定义链式存储的栈(双向链表实现)。
- 基于上述定义,栈顶在链尾,实现"出栈、入栈、判空、判满"四个基本操作。
cpp
typedef struct node{
int data[MAX_SIZE]; //用于存放数据
int top; //栈顶指针
} intStack; //定义顺序存储的栈(数组实现)
void initStack(intStack *s) { //初始化
s->top = -1;
}
int isFull(intStack s) { //判满
return s.top == MAX_SIZE - 1;
}
int isEmpty(intStack s) { //判空
return s.top == -1;
}
void push(intStack *s, int element) { //入栈
if (!isFull(*s)) {
s->data[++s->top] = element;
}
return -1;
}
int pop(intStack s){ //出栈
if(!isEmpty(*s)) {
return s->data[s->top--];
}
return -1;
}
cpp
// 链表节点结构定义
typedef struct Snode {
int data; // 数据域
struct Snode *next; // 指向下一个节点的指针
} Snode, *listStack; // listStack 是指向 Snode 的指针类型
// 初始化栈
void initStack(listStack &S) {
S = (Snode*)malloc(sizeof(Snode)); // 分配内存给头结点
S->next = NULL; // 头结点的下一个指针初始化为 NULL
}
// 判空函数
bool isEmpty(listStack S) {
return S->next == NULL; // 如果头结点的下一个指针为 NULL,则栈为空
}
// 入栈操作
bool push(listStack &S, int element) {
Snode *p = (Snode*)malloc(sizeof(Snode)); // 创建新节点
if (p == NULL) return false; // 检查内存分配是否成功
p->data = element; // 设置新节点的数据
p->next = S->next; // 新节点指向当前栈顶元素
S->next = p; // 更新栈顶为新节点
return true; // 返回成功
}
// 出栈操作
bool pop(listStack &S, int &x) {
if (!isEmpty(S)) { // 检查栈是否为空
Snode *p = S->next; // 获取栈顶元素
x = p->data; // 返回栈顶元素
S->next = p->next; // 更新栈顶为下一个元素
free(p); // 释放栈顶元素的内存
return true; // 返回成功
}
return false; // 栈为空,出栈失败
}
cpp
// 双向链表节点结构定义
typedef struct Dnode {
int data; // 数据域
struct Dnode *prior; // 指向前一个节点的指针
struct Dnode *next; // 指向下一个节点的指针
} Dnode, *dlistStack; // dlistStack 是指向 Dnode 的指针类型
// 初始化栈
void initStack(dlistStack &S) {
S = (Dnode*)malloc(sizeof(Dnode)); // 分配内存给头结点
S->next = NULL; // 头结点的下一个指针初始化为 NULL
S->prior = NULL; // 头结点的前一个指针初始化为 NULL
}
// 判空函数
bool isEmpty(dlistStack &S) {
return S->next == NULL; // 如果头结点的下一个指针为 NULL,则栈为空
}
// 入栈操作
bool push(dlistStack &S, int element) {
Dnode *p = (Dnode*)malloc(sizeof(Dnode)); // 创建新节点
if (p == NULL) return false; // 检查内存分配是否成功
p->data = element; // 设置新节点的数据
p->next = NULL; // 新节点的下一个指针初始化为 NULL
p->prior = S; // 新节点的前一个指针指向当前栈顶(头结点)
if (S->next != NULL) { // 如果栈不空
S->next->prior = p; // 更新原栈顶的前指针
}
S->next = p; // 更新栈顶为新节点
return true; // 返回成功
}
// 出栈操作
bool pop(dlistStack &S, int &x) {
if (!isEmpty(S)) { // 检查栈是否为空
Dnode *p = S->next; // 获取栈顶元素
x = p->data; // 返回栈顶元素
S->next = p->prior; // 更新栈顶为前一个元素
if (S->next != NULL) { // 如果新的栈顶不为空
S->next->next = NULL; // 断开新的栈顶与旧栈顶的连接
}
free(p); // 释放栈顶元素的内存
return true; // 返回成功
}
return false; // 栈为空,出栈失败
}
分析:最简单,也最容易想到应该就是,直接设置6个计数器来统计左右括号的出现,然后将其相对应的一一比对,只要出现不相等那就是不配对。当然这个简单背后所对应的就是如果面对表达式中的括号嵌套很深,那么可能出问题。
更好的方法是使用栈(stack)(毕竟本来就是在栈这块章节的问题hhhh)来处理括号匹配,这样可以有效地管理括号的配对关系。
使用栈的方法
- 遇到左括号时,压入栈。
- 遇到右括号时,检查栈顶元素 :
- 如果栈为空,说明没有对应的左括号,返回不配对。
- 如果栈顶元素与当前右括号不匹配,返回不配对。
- 如果匹配,弹出栈顶元素。
- 遍历结束后,检查栈是否为空 :
- 如果栈为空,说明所有括号都配对成功;
- 否则,说明有未配对的左括号。
cpp
bool isBalanced(const std::string& expression) {
std::stack<char> s;
std::unordered_map<char, char> brackets = {
{')', '('},
{']', '['},
{'}', '{'}
};
for (char ch : expression) {
if (ch == '0') {
break; // 遇到结束符
}
// 如果是左括号,压入栈
if (ch == '(' || ch == '[' || ch == '{') {
s.push(ch);
}
// 如果是右括号
else if (ch == ')' || ch == ']' || ch == '}') {
if (s.empty() || s.top() != brackets[ch]) {
return false; // 不配对
}
s.pop(); // 匹配成功,弹出栈顶元素
}
}
return s.empty(); // 栈空则配对成功
}
树的应用
并查集
function:
- 检测图中是否有环
- 实现Kruskal算法
- 判断无向图的连通性
|----------------------------------------------------------------------------|
| 写代码:定义一个并查集(用长度为n的数组实现) |
| 基于上述定义,实现并查集的基本操作------ 并 Union |
| 基于上述定义,实现并查集的基本操作------ 查 Find |
| 自己设计一个例子,并查集初始有10个元素,进行若干次Union操作,画出每一次Union后的样子 |
| 自己设计一个例子,基于上一步得到的并查集,进行若干次find操作(每次find会进行"路径压缩")。画出每次 find (路径压缩)后的样子 |
cpp
#define SIZE 13
int UFSets[SIZE]; //用一个数组表示并查集
//初始化并查集
void Initial(int S[]){
for(int i=0;i<SIZE;i++)
S[i]=-1;
}
//Find "查"操作,找 x 所属集合(返回 x 所属根结点)
int Find(int S[],int x){
while(S[x]>=0) //循环寻找 x 的根
x=S[x];
return x; //根的 S[]小于 0
}
//Union "并"操作,将两个集合合并为一个
void Union(int S[],int Root1,int Root2){//要求 Root1 与 Root2 是不同的集合
if(Root1==Root2)
return;
S[Root2]=Root1; //将根 Root2 连接到另一根 Root1 下面
}
cpp
//Union "并"操作,小树合并到大树
void Union(int S[], int Root1, int Root2) {
if (Root1 == Root2) return; // 如果两个元素已经在同一个集合中,则不进行操作
if (S[Root2] > S[Root1]) { // 假设Root2的树的节点数更少或两棵树节点数相等
S[Root1] += S[Root2]; // 将Root2树的节点数累加到Root1上
S[Root2] = Root1; // 将Root2的根节点指向Root1,即把Root2的树合并到Root1的树下
} else {
S[Root2] += S[Root1]; // 将Root1树的节点数累加到Root2上
S[Root1] = Root2; // 将Root1的根节点指向Root2,即把Root1的树合并到Root2的树下
}
}
//Find "查"操作优化,先找到根节点,再进行"压缩路径"
int Find(int S[],int x){
int root = x;
while(S[root]>=0)
root=S[root]; //循环找到根
while(x!=root){ //压缩路径
int t=S[x]; //t 指向 x 的父节点
S[x]=root; //x 直接挂到根节点下
x=t;
}
return root; //返回根节点编号
}
二叉排序树(BST)
二叉平衡树(AVL)
|-----------------------------------------------------------------------------------------------------|
| 自己设计一个例子,给出不少于10个关键字序列,按顺序插入一棵初始为空的二叉排序树,画出每一次插入后的样子 |
| 基于你设计的例子,计算二叉排序树在查找成功和查找失败时的 ASL |
| 基于你设计的例子,依次删除不少于4个元素,画出每一次删除之后的样子(需要包含四种删除情况------删一个叶子结点、删一个只有左子树的结点、删一个只有右子树的结点、删一个既有左子树又有右子树的结点) |
| 自己设计一个例子,给出不少于10个关键字序列,按顺序插入一棵初始为空的平衡二叉树,画出每一次插入后的样子(你设计的例子要涵盖LL、RR、LR、RL四种调整平衡的情况) |
| 基于你设计的例子,计算平衡二叉树在查找成功和查找失败时的 ASL |
二叉树
|--------------------------------------------------|
| 写代码:定义顺序存储的二叉树(数组实现,树的结点从数组下标1开始存储) |
| 基于上述定义,写一个函数 int findFather ( i ) ,返回结点 i 的父节点编号 |
| 基于上述定义,写一个函数 int leftChild ( i ) ,返回结点 i 的左孩子编号 |
| 基于上述定义,写一个函数 int rightChild ( i ) ,返回结点 i 的右孩子编号 |
| 利用上述三个函数,实现先/中/后序遍历 |
| 写代码:定义顺序存储的二叉树(数组实现,树的结点从数组下标0开始存储) |
| 基于上述定义,写一个函数 int findFather ( i ) ,返回结点 i 的父节点编号 |
| 基于上述定义,写一个函数 int leftChild ( i ) ,返回结点 i 的左孩子编号 |
| 基于上述定义,写一个函数 int rightChild ( i ) ,返回结点 i 的右孩子编号 |
| 利用上述三个函数,实现先/中/后序遍历 |
分析:都说了是要数组实现的话,那应该就是说我们得找找二叉树左孩子右孩子与父节点之间得关系才对。(也就是根据不同的数组下标去寻找其对应的父节点or孩子结点)那接下来再考虑下特殊情况,可能会存在空结点的对吧,所以需要再设置一个标志位来判断当前结点是否为空。
那又是二叉树又是从一开始其实很简单的,直接上代码。
cpp
typedef struct TreeNode {
int data;
bool empty;
} TreeNode;
void Initialize(TreeNode t[], int length) {
for (int i = 1; i <= length; i++)
t[i].empty = true; // 初始化所有节点为空
}
bool isEmpty(TreeNode t[], int index, int length) {
if (index < 1 || index >= length)
return true; // 超出范围
return t[index].empty;
}
int findFather(TreeNode t[], int index, int length) {
int tmp = index / 2;
return (tmp >= 1 && tmp < length && !isEmpty(t, tmp, length)) ? tmp : -1; // 返回父节点编号
}
int findLeftChild(TreeNode t[], int index, int length) {
int tmp = index * 2;
return (tmp < length && !isEmpty(t, tmp, length)) ? tmp : -1; // 返回左孩子编号
}
int findRightChild(TreeNode t[], int index, int length) {
int tmp = index * 2 + 1;
return (tmp < length && !isEmpty(t, tmp, length)) ? tmp : -1; // 返回右孩子编号
}
可如果是从0开始的话就要重新捋一捋了,首先0有1、2两个孩子,也就是1和2找爸爸都要找到0才行对吧,那1很明显根据C语言的要求 " / " 其实明显是还可以除以2来求的,可2除以2的话结果就不对了。那其实我们就再看看后面还是不是呢(也很简单就能验证,其实奇数端不受影响,而偶数端找爸爸的时候会多1,那就好办啦,将奇偶区分开就行。那再看回如何找孩子,先不看最特殊的0,我们看1的孩子3and4,2的孩子5and6。这个规律还是能够一眼就看出来的对吧,就是直接乘2加1或者加2。看回0,你会发现也是一样的道理对不对~
cpp
typedef struct TreeNode {
int data;
bool empty;
} TreeNode;
void Initialize(TreeNode t[], int length) {
for (int i = 0; i < length; i++)
t[i].empty = true; // 初始化所有节点为空
}
bool isEmpty(TreeNode t[], int index, int length) {
if (index < 0 || index >= length)
return true; // 超出范围
return t[index].empty;
}
int findFather(TreeNode t[], int index, int length) {
if (index == 0) return -1; // 根节点没有父节点
int tmp = (index % 2 == 0) ? (index / 2 - 1) : (index / 2);
return isEmpty(t, tmp, length) ? -1 : tmp; // 返回父节点编号
}
int findLeftChild(TreeNode t[], int index, int length) {
int tmp = index * 2 + 1;
return isEmpty(t, tmp, length) ? -1 : tmp; // 返回左孩子编号
}
int findRightChild(TreeNode t[], int index, int length) {
int tmp = index * 2 + 2;
return isEmpty(t, tmp, length) ? -1 : tmp; // 返回右孩子编号
}
那又如何来实现前/中/后序遍历呢?我想应该只需要使用深度优先即可。(两题的答案是一样的哦)
cpp
void preOrder(TreeNode t[], int root, int length) {
if (isEmpty(t, root, length)) {
return; // 退出递归条件
}
cout << t[root].data << " "; // 先访问根节点
preOrder(t, findLeftChild(t, root, length), length); // 访问左子树
preOrder(t, findRightChild(t, root, length), length); // 访问右子树
}
void midOrder(TreeNode t[], int root, int length) {
if (isEmpty(t, root, length)) {
return; // 退出递归条件
}
midOrder(t, findLeftChild(t, root, length), length); // 访问左子树
cout << t[root].data << " "; // 访问根节点
midOrder(t, findRightChild(t, root, length), length); // 访问右子树
}
void postOrder(TreeNode t[], int root, int length) {
if (isEmpty(t, root, length)) {
return; // 退出递归条件
}
postOrder(t, findLeftChild(t, root, length), length); // 访问左子树
postOrder(t, findRightChild(t, root, length), length); // 访问右子树
cout << t[root].data << " "; // 访问根节点
}
树
|-------------------------------------------------------------|
| 写代码:使用"双亲表示法",定义顺序存储的树(以及森林) |
| 写代码:使用"孩子表示法",定义链式存储的树(以及森林) |
| 对比:树的孩子表示法存储 v.s. 图的邻接表存储 v.s. 散列表的拉链法 v.s. 基数排序。你发现了什么? |
| 写代码:使用"孩子兄弟表示法",定义链式存储的树(以及森林) |
| 自己动手创造,画一个结点总数不少于10的树,并画出对应的"双亲表示法、孩子表示法、孩子兄弟表示法"三种数据结构的示意图 |
| 自己动手创造,画一个至少包含3棵树的森林,并画出对应的"双亲表示法、孩子表示法、孩子兄弟表示法"三种数据结构的示意图 |
1、使用"双亲表示法",定义顺序存储的树(以及森林)
cpp
#define MAX_TREE_SIZE 100 //树中最多结点数
typedef struct{ //树的结点定义
ElemType data; //数据元素(存放结点本身信息。)
int parent; //双亲位置域(指示本结点的双亲结点在数组中的位置。)
}PTNode;
typedef struct{ //树的类型定义
PTNode nodes[MAX_TREE_SIZE]; //双亲表示
int n; //结点数
}PTree;
2、 使用"孩子表示法",定义链式存储的树(以及森林)
cpp
struct CTNode{
int child; //孩子结点在数组中的位置
struct CTNode *next; // 下一个孩子
};
typedef struct{
ElemType data;
struct CTNode *firstChild; // 第一个孩子
}CTBox;
typedef struct{
CTBox nodes[MAX_TREE_SIZE];
int n, r; // 结点数和根的位置
}CTree;
3、树的孩子表示法存储 v.s. 图的邻接表存储 v.s. 散列表的拉链法 v.s. 基数排序。你发现了什么?
嗯~它们应该图都长一个样子,别的不造。
4、使用"孩子兄弟表示法",定义链式存储的树(以及森林)
cpp
typedef struct CSNode{
ElemType data; //数据域
struct CSNode *firstchild, *nextsibling; //第一个孩子和右兄弟指针
}CSNode. *CSTree;
5、自己动手创造,画一个结点总数不少于10的树,并画出对应的"双亲表示法、孩子表示法、孩子兄弟表示法"三种数据结构的示意图。
6、自己动手创造,画一个至少包含3棵树的森林,并画出对应的"双亲表示法、孩子表示法、孩子兄弟表示法"三种数据结构的示意图。
图的应用
邻接矩阵和邻接表
|-------------------------------------|
| 写代码:定义一个顺序存储的图(邻接矩阵实现) |
| 写代码:定义一个链式存储的图(邻接表实现) |
| 自己设计一个不少于6个结点的带权无向图,并画出其邻接矩阵、邻接表的样子 |
| 自己设计一个不少于6个结点的带权有向图,并画出其邻接矩阵、邻接表的样子 |
分析: 先考虑下邻接矩阵的长相,其实就是存边对吧,但是需要一个额外的数组用于存储顶点集。还需要几个变量用于存储图的顶点数和边数。
cpp
#define MaxVertexNum 100 //项目数目的最大值(自己定)
typedef char VertexType; //顶点对应的数据类型
typedef int EdgeType; //边对应的数据类型
typedef struct{
VertexType Vex[MaxVertexNum]; //顶点集
EdgeType Edge[MaxVertexNum][MaxVertexNum]; //邻接矩阵,边表(也就是权重)
int vexnum,arcnum; //图的当前顶点数和边数
}MGraph;
那邻接表和上头那个还是差蛮大的,是不是又有边表,又有顶点表。然后边表中是不是有个邻接点域(该弧所指向的顶点的位置(弧头))和指针域,而顶点表中存放的讯息则是一个顶点域和一个边表头指针。(个人觉得最重要的是要把图记下来,手搓代码反而是其次的)
cpp
#define MaxVertexNum 100 //图中顶点数目的最大值
typedef struct ArcNode{ //边表结点
int adjvex; //该弧所指向的顶点的位置
struct ArcNode *next; //指向下一条弧的指针
//InfoType info; //网的边权值
}ArcNode;
typedef struct VNode{ //顶点表结点
VertexType data; //顶点信息
ArcNode *first; //指向第一条依附该顶点的弧的指针
}VNode,AdjList[MaxVertexNum];
typedef struct{
AdjList vertices; //邻接表
int vexnum,arcnum; //图的顶点数和弧数
}ALGraph; //ALGraph是以邻接表存储的图类型
拓扑排序
|------------------------------------|
| 自己设计一个不少于6个结点的带权有向无环图,并画出其邻接矩阵的样子 |
| 用一维数组将你设计的有向无环图的邻接矩阵进行压缩存储 |
| 文字描述:基于你压缩存储的数组,如何判断结点 i、j 之间是否有边? |
| 基于你设计的带权有向无环图,写出所有合法的拓扑排序序列 |
| 文字描述:拓扑排序的过程 |
那不就是上三角矩阵压缩存储与k之间的关系嘛
排序算法的应用
堆排序
|--------------------------------------------|
| 自己设计一个长度不小于10的乱序数组,用堆排序,最终要生成升序数组,画出建堆后的状态 |
| 画出每一轮堆排序的状态 |
分析: 由于最终是要生成升序数组,故而我们应该使用大根堆。
基数排序
|----------------------------------------------------|
| 自己设计一个长度不小于15的乱序链表,每个数据元素取值范围0~99,用基数排序,最终要生成升序链表 |
| 画出每一轮基数排序的状态 |