前言
编程题部分全部经过实际运行,均为有效,文章趋于口语化,有些解释缺乏逻辑严谨性,且编者水平有限,疏漏之处在所难免,恳请读者批评指正,不胜感激。
计2104 Melody
2023年2月28日
大纲
题型:编程题3题,综合题4题。
一、编程题:
1、链表的类型定义;邻接矩阵表示图的类型定义;链接表表示图的类型定义;vector数组表示图的定义和使用方法。
2、链表中结点的插入和删除操作,时间复杂度分析。
3、图的连通分量的计算方法:DFS、BFS和并查集。
4、基于有序序列进行二分查找的实现原理和实现方法,时间复杂度分析。
二、综合题
包括画图、计算和算法描述等方面。
1、广义表的结构图以及广义表的表头、表尾、表长和深度。
2、哈夫曼树的构建步骤、构建过程以及带权路径长的计算方法。
3、最小生成树(Kruskal算法、Prim算法)的一般步骤。有一个图生成最小生成树的过程。
4、二叉查找树中插入和删除一个键值的一般步骤、由一个键值序列生成一棵二叉查找树的过程、在一棵二叉查找树中删除一个键值的过程。
注意:考试试题都来自与教材的习题,但是部分题目的数据和要求会做适当的调整。
编程题
链表
单向链表
为了克服顺序表不能充分利用存储空间的这一缺点,可以采用非连续的方式存储线性表的元素,元素之间的先后关系通过指针表示,这种线性表的链式存储结构称为链表
链表的结点由包含数据信息的数据域和包括指向后继结点的指针的链域组成,通常用链表的第一个结点表示链表,而通常的做法是在链表的第一个结点前添加一个不包含任何数据信息的结点,称为头结点,链域为空指针时称为空链表
typedef int datatype; //定义存储的数据类型
typedef struct clNode
{
datatype data; //数据域
clNode * next; //链域
clNode():next(NULL){}
}* chainList;
插入操作
功能:在链表中结点 的后面插入数据域为
的结点
。
算法步骤:
-
为
动态存储空间,并指定其数据域的值为
;
-
将
的链域指向
的链域;
-
将
的链域指向
;
void cl_insert(chainList p, datatype x) {
chainList q = new clNode; // 1.为q动态存储空间,并指定其数据域的值为x;
if (q == NULL) {
cout << "插入结点失败!" << endl;
return;
}
q->data = x;
q->next = p->next; // 2.将q的链域指向p的链域;
p->next = q; // 3.将p的链域指向q;
}
时间复杂度为 O(1)
删除操作
功能:删除链表 header 中结点 p 的直接后继。
算法步骤:
-
保存 p 的直接后继 q ;
-
将 p 的链域重新定义为 q 的链域
-
释放 q 所占用的存储空间
void cl_delete(chainList p) {
if (p == NULL)
return;
chainList q = p->next; // 1 保存p的直接后继q;
if (q == NULL)
return;
p->next = q->next; // 2.将p的链域重新定义为q的链域
delete q; // 3.释放q所占用的存储空间
q = NULL;
}
时间复杂度为 O(1)
双向链表
为了增加链表的操作灵活性,使得访问某个结点后,可以返回到已访问过的结点,在每个结点中添加一个指向其直接前驱的链域,将链域细分为前驱链域和后继链域
typedef int datatype; //定义存储的数据类型
typedef struct dclNode
{
datatype data; //数据域
dclNode * pre; //前驱链域
dclNode * next; //后继链域
dclNode():pre(NULL),next(NULL){}
}* dchainList;
插入操作
由于拥有前驱链域和后继链域,因此可以实现在结点前后插入新结点
-
在结点 p 的后面插入一个值为 x 的结点
void dcl_insert_post(dchainList& p, datatype x) {
dchainList q = new dclNode; // 插入的结点q
if (q == NULL) {
cout << "插入结点失败!" << endl;
return;
}
dchainList nxt = p->next; // 获取原先p后面的结点nxt
q->data = x;
if (nxt != NULL)
nxt->pre = q; // nxt 的直接前驱为 q
q->next = nxt; // q 的直接后继为 nxt
q->pre = p; // q 直接前驱为 p
p->next = q; // p 直接后继为 q
}
时间复杂度为 O(1)
-
在结点 p 的前面插入一个值为 x 的结点
void dcl_insert_pre(dchainList& p, datatype x) {
dchainList q = new dclNode;
if (q == NULL) {
cout << "插入结点失败!" << endl;
return;
}
dchainList pre = p->pre;
q->data = x;
p->pre = q; // p 的直接前驱为 q
q->next = p; // q 的直接后继为 p
if (pre != NULL) {
pre->next = q; // pre的直接前驱为 q
q->pre = pre; // q的直接前驱为 p
}
else
p = q; // p为空表,q为第一个数据
}
时间复杂度为 O(1)
删除操作
void dcl_delete(dchainList& h, datatype x) {
dchainList dcl = h; //dcl为待删除结点
//寻找结点
while (dcl != NULL) {
if (dcl->data == x)
break;
dcl = dcl->next;
}
if (dcl == NULL)
return; // 找不到对应结点
dchainList pre = dcl->pre;
dchainList nxt = dcl->next;
if (pre != NULL)
pre->next = nxt;
if (nxt != NULL)
nxt->pre = pre;
if (dcl == h) // 如果删的是头结点,删完后会无法访问利用h链表
h = nxt; // 因此要将h指针转向nxt
delete dcl;
dcl = NULL;
}
时间复杂度为 O(1)
循环链表
在不带头结点的单向链表中,最后一个结点的链域指向第一个结点,称为循环链表
与单向链表完全一样
typedef int datatype; //定义存储的数据类型
typedef struct clNode
{
datatype data; //数据域
clNode * next; //链域
clNode():next(NULL){}
}* chainList
插入操作
//在结点p的后面插入一个值为x的结点
void rcl_insert(chainList& p, datatype x)
{
chainList q = new clNode;
if (q == NULL) {
cout << "插入结点失败!" << endl;
return;
}
q->data = x;
if (p == NULL){ //若插入结点p为空,即甚至连链表都没有
q->next = q; //使q链域指向自己,实现循环特性
p = q;
}
else{
q->next = p->next;
p->next = q; //维护尾结点的next指针
}
}
时间复杂度为 O(1)
删除操作
void rcl_delete(chainList& p) {
if (p == NULL)
return;
chainList q = p->next;
if (p == q) {
delete p; //只有一个节点时,删除后变为空链表
p = NULL;
return;
}
p->next = q->next;
delete q; q = NULL;
}
时间复杂度为 O(1)
图
图由顶点和连接顶点的边构成,即 G=(V,E) , V 为顶点集合,E 为边的集合,边可以没有方向,此时称为无向图,有方向时称为有向图,边还可以赋予权重,称为带权图
邻接矩阵表示法
利用二维数组(矩阵)表示一个图,每一个元素表示相应两个顶点之间的关系,具体方法是:将每个顶点进行编号(从1开始),第 u 行的第 v 个元素表示 u→v 的关系
#define INF 0x3f3f3f3f //定义无穷大
typedef string datatype; //定义存储的数据类型
const int eNum = 102; //边的最大数量
struct adjMatrix {
datatype data[eNum]; //顶点的数据信息
int edge[eNum][eNum]; //邻接矩阵
int v; //顶点的数量
int e; //边的数量
};
以上图为例,邻接矩阵表示法的二维数组如下:
可见,不可达的关系为 INF,在无向图中,邻接矩阵各元素关于对角线对称。
链接表表示法
链接表表示法是将图的每一个顶点的邻接点存放一个链表中,因此每一个顶点对应一个链表,所有链表的头结点放在一个数组(edges)中。
//结点定义
struct vertex {
int u; //邻接点的编号
int w; //权重,无权图可以忽视该属性
vertex* next; //链域
vertex(int _u, int _w) :u(_u), w(_w), next(NULL) {}
};
//链表表示法
const int vNum = 200; //最大顶点数
typedef string datatype; //定义存储的数据类型
typedef struct llNode {
datatype data[vNum]; //顶点的数据信息
vertex* edges[vNum]; //边表
int v; //顶点数
int e; //边数
llNode() :v(0), e(0) {
for (int i = 0; i < vNum; i++)
edges[i] = NULL;
}
}*linkList;
以上图为例,链接表示法如下所示:
Vector数组表示法
使用STL容器中的 vector 表示一个动态数组,数组中每一个vector表示图中的一个顶点,利用 .push_back
函数往vector中添加边关系
struct edge {
int v; //邻接点
int w; //权重,无权图忽略该属性
edge(int _v, int _w) :v(_v), w(_w) {}
};
const int vNum = 200; //最大顶点数
typedef string datatype; //定义存储的数据类型
typedef struct vgNode {
vector<edge> edges[vNum]; //边表
datatype data[vNum]; //数据信息
int v; //顶点数
int e; //边数
}vecGraph;
利用Vector创建图:
void create_vecGraph(vecGraph& g)
{
int i, u, v, w;
int e;
cin >> g.v >> g.e; //输入顶点数和边数
for (i = 1; i <= e; i++)
{
cin >> u >> v >> w; //输入边的信息,无权图忽略w,且将下列语句中的w变为1
g.edges[u].push_back(edge(v, w)); //添加一个u→v的边
g.edges[v].push_back(edge(u, w)); //添加一个v→u的边,实现无向,有向图忽略该语句
}
}
实际应用中可以采取如下更简单的表示方法
int v; //定点数
vector<int>g[vNum]; //无权图
vector<pair<int,int>>g1[vNum]; //有权图,pair中的first表示邻接点,second表示权重
图的搜索
图的搜索是指从图的任一顶点出发,访问图的所有顶点,且每个顶点只访问一次。
DFS
深度优先搜索(Depth-First Search, DFS) 是从某个顶点 v_1 出发对图进行搜索,每一步都沿着某一个分支路径向下搜索,当不能继续向下搜索时,则回退到所经过路径的上一个顶点。具体实现中,需要对已访问的顶点做标记,即涂色法,将未访问的顶点涂为白色(0),已访问的顶点涂为黑色(1)
算法步骤如下:
-
初始化图,将所有顶点涂为白色,任选一顶点 v
-
将 v 涂成黑色,进入第3步
-
选择一个白色邻接点 u 回到第2步,如果所有邻接点均为黑色,进入第4步
-
回退到 v 的上一个邻接点 w 回到第3步
-
遍历整个图查看是否存在白色顶点,若有,则进入第2步,否则遍历结束
using namespace std;
const int vNum = 200; //最大顶点数
const int eNum = 102; //边的最大数量
typedef int datatype;
bool vis[vNum]; //标记每一个顶点的颜色
int dfn[vNum], cnt = 0; //dfs 存放DFS序列,cnt为序列的编号
//对图g的顶点cur所在的连通分量进行深度优先搜索,初始出发顶点为cur
void dfs(vector<datatype>g[vNum], int v) {
dfn[++cnt] = v; //选择顶点v,深度优先编号为cnt
vis[v] = true; //将v涂成黑色
for (int i = 0; i < g[v].size(); i++) {
if (!vis[g[v][i]]) //若为白色邻接点
dfs(g, g[v][i]); //选择继续搜索
}
}
//对图g进行深度优先搜索,v为顶点的数量
void dfs_traverse(vector<int>g[vNum], int v) {
memset(vis, 0, sizeof(vis)); //初始化图,将每个点涂成白色
for (int i = 1; i <= v; i++)
if (!vis[i]) //如果存在白色顶点则从该顶点开始搜索
dfs(g, i);
}
BFS
广度优先搜索(Breadth-First Search,BFS) 是从某个顶点开始,按层进行遍历,只有当一层所有顶点都遍历后再进入下一层的遍历。具体实现中,同样需要对已访问的顶点做标记,即涂色法,将未访问的顶点涂为白色(0),已访问的顶点涂为黑色(1)
算法步骤如下:
-
初始化图g,将所有顶点涂为白色,定义一个队列 q ,将出发顶点涂为黑色并加入队列 q
-
若 q 为空,则进入第4步,否则从 q 中取出一个顶点 u 进入第3步
-
从 u 出发,将每一个白色邻接点加入队列 q ,并将 v 涂为黑色,进入第2步
-
如果还有白色顶点,则将该顶点加入队列 q 进入第2步,否则搜索结束
bool vis[vNum]; //标记顶点的颜色
int bfn[vNum]; //bfs序列
void bfs(vector<datatype>g[vNum], int cur) {
int cnt = 0;
datatype v, u;
queue<int>q;
bfn[++cnt] = cur;
vis[cur] = true;
q.push(cur); //将初始出发点加入队列
while (!q.empty()) {
u = q.front();
q.pop();
for (int i = 0; i < g[u].size(); i++) {
v = g[u][i];
if (!vis[v]) { //顶点v为白色
bfn[++cnt] = v;
vis[v] = true; //加入队列前将v设置为黑色
q.push(v); //将v加入队列
}
}
}
}
void bfs_traverse(vector<datatype>g[vNum], int v) {
memset(vis, 0, sizeof(vis)); //初始化图,将每个点涂成白色
for (int i = 1; i <= v; i++)
if (!vis[i])
bfs(g, i);
}
并查集
并查集(Disjoint Set)是利用森林来描述一些不相交的集合,并支持集合的合并操作和查询操作。
一般来说,一个并查集对应2个操作: 1、查找函数( Find()函数 ) 2、合并集合函数( Join()函数 )
Find()
首先我们需要定义一个数组 pre[1000]; (数组长度依题意而定)。为了方便使用,数据类型为一个结构体,这个数组记录了每个人的上级是谁。这些人从0或1开始编号(依题意而定)。比如说pre[16]=6就表示16号的上级是6号。如果一个人的上级就是他自己,那值为 -1 或他自己(依据实际情况而定)
typedef int datatype;
const int vNum = 200;
struct mqNode
{
int pa;
datatype data;
mqNode():pa(-1),data(0){}
}mq[vNum];
int find(int x) //查找x的教主
{
//while (mq[x].pa != -1)
while (mq[x].pa != x) //如果x的上级不是自己
x = mq[x].pa; //x继续找他的上级,直到找到教主为止
return mq[x].pa;
}
然而会存在这么一个场景,刚好是单支树结构(一字长蛇形)。试想,如果最后真的形成单支树结构,那么它的效率就会及其低下(树的深度过深,那么查询过程就必然耗时)。
如图,夏侯惇和许褚的查询很长很长,下次再查还会很长很长,所以不妨将他们的上级直接设置为操场,这样只需要查询一次就行了
优化版代码
int find(int x) //查找结点 x的根结点
{
if (mq[x].pa == x) return x; //递归出口:x的上级为x本身,即x为根结点
return mq[x].pa = find(mq[x].pa); //此代码相当于先找到根结点 rootx,然后pre[x]=rootx
}
join()
合并操作将一个元素所在集合的根结点的父节点设置为另一个集合的根节点
void join(int x, int y)
{
x = find(x);
y = find(y);
if (x != y)
mq[x].pa = y;
}
连通分量
在一个图 G 中,对于任意两个顶点 u 和 v ,若 u 可以到达 v , v 也可以到达 u,则称图 G 为连通图,有向图称为强连通图,有向图的极大连通子图称为强连通分量,因为无向图本质上是一种特殊的有向图,所以下文均以有向图为例介绍。
Kosaraju 算法(DFS)
该算法的原理就跟连通图的定义一样,进行两次DFS ,第一次DFS检测 u 是否能到达 v,第二次DFS检测 v 是否能到达 u 。算法原理是:如果第一次DFS证明了任意 u 到达 v ,第二次DFS证明了任意 v 能到达 u ,则符合连通图的定义,此时能得到极大连通子图,极大连通子图的数量即为强连通分量
算法步骤如下:
-
定义如下辅助量:
leave[vNum]
:按顶点处理完毕先后顺序存放顶点vector<int>gt[vNum]
:图 g 的反图 g^t -
第一次DFS:按顶点处理完毕先后顺序存放在 leave 中
-
第二次DFS:按 leave 的最后一个顶点开始,逆序对 g^t 进行DFS,每次能访问的顶点属于同一连通分量,直到所有顶点都加入某个强连通分量
int leave[vNum], belong[vNum], cnt = 0;
bool vis[vNum];
//对原图g进行DFS,得到顶点处理完毕的序列
void dfs(vector<int>g[vNum], int cur)
{
vis[cur] = true;
for (int i = 0; i < g[cur].size(); i++)
if (!vis[g[cur][i]])
dfs(g, g[cur][i]);
leave[++cnt] = cur;
}
//对反图gt进行DFS,将能访问的顶点设置为同一个强连通分量,当前强连通分量的编号为cnt
void dfst(vector<int>gt[vNum], int cur)
{
vis[cur] = true;
belong[cur] = cnt;
for (int i = 0; i < gt[cur].size(); i++)
if (!vis[gt[cur][i]])
dfst(gt, gt[cur][i]);
}
int Korsraju(vector<int>g[vNum], vector<int>gt[vNum], int v)
{
memset(vis, 0, sizeof(vis));
for (int i = 1; i <= v; i++)
if (!vis[i])
dfs(g, i);
memset(vis, 0, sizeof(vis));
cnt = 0;
for(int i=v;i>=0;i--)
if (!vis[leave[i]]) {
cnt++;
dfst(gt, leave[i]);
}
for (int i = 1; i <= v; i++)
cout << i << " " << belong[i] << endl;
return cnt;
}
Tarjan算法(DFS)
Tarjan算法提取极大连通子图的特点,即一个连通子图中,必然存在一个“大环”,能参与连通,图中各结点要么是这个“大环”中的一部分,要么可以与大环中其他结点拼接形成“小环”或本身与该大环“连通”
上图中,0-1-2-3-4-5-0为极大连通图的“大环”,6与2-3-4拼接形成小环
上图中,0-1-2-3-4-5-6-0为极大连通图的“大环”,7本身能跟这个“大环”连通
上图中,0-1-2-3-4-5-0为极大连通图的“大环”,而6既不能连通“大环”,也不能与其中的结点拼成小环,故独立成为另一个极大连通子图
因此,算法核心是,任取一结点作为根结点,利用一个数组记录{1,2,3,4,5,6}的根结点是[0],利用一个栈来将上述结点维护归类到一块,当出现条件 dfn[u] == low[u]
时,栈内元素部分属于统一极大连通子图
算法步骤如下:
-
定义如下辅助量:
dfn[vNum]
:DFS序,同时还能作为是否被访问过的依据(等效于vis[vNum])low[vNum]
:根结点DFS序,这里可以理解为找大哥,如果找了半天发现自己就是大哥(根节 点)就说明可以出栈了stk
:栈,两个功能,1.是否被访问。2.是否明确属于某个强连通分量instk[vNum]
:表示结点 v 是否在栈中 -
进行DFS
1.1 初始化 u 结点:
low[u] = dfn[u] = cnt++
,并将 u 压入栈,标记instk[u]=1
1.2 考察邻接点 v 的颜色
1.2.1 若为白色,则未访问,对 v 进行DFS,之后更新
low[u] = min(low[u], low[v]);
1.2.2 若为黑色,则已访问,在保证在栈内的情况下更新
low[u] = min(low[u], low[v]);
1.3 此时
low[u]
必然已被更新:1.3.1
dfn[u] != low[u]
:说明 u 不是最大连通子图的根结点,不进行操作1.3.2
dfn[u] == low[u]
:说明 u 是根结点,重复出栈,出栈的结点均为极大连通子图内结点,直到最后一个出栈的是 u ,出栈结束
int dfn[vNum]; //DFS序
int low[vNum]; //根节点DFS序
int belong[vNum]; //标记
int cnt = 1; //DFS序自增
int bcnt = 0; //强连通分量数
bool instk[vNum]; //判断是否在栈内
stack<int>stk;
void dfs(vector<int>g[vNum], int u) {
low[u] = dfn[u] = cnt++;
stk.push(u);
instk[u] = 1;
for (int i = 0; i < g[u].size(); i++){
int v = g[u][i];
if (!dfn[v]) {
dfs(g, v);
low[u] = min(low[u], low[v]); //回溯过程low一次,更新途中的low
}
else if (instk[v]) { //是否碰到环
low[u] = min(low[u], low[v]); //且在栈内则low一次
}
}
//回溯到根节点
if (dfn[u] == low[u]) {
bcnt++;
int i;
do {
i = stk.top();
stk.pop();
belong[i] = bcnt;
instk[i] = 0;
} while (i != u);
}
}
void Tarjan(vector<int>g[vNum], int n) {
for (int i = 1; i <= n; i++)
if (!dfn[i])
dfs(g, i);
}
并查集
根据并查集的join()
特点可知,单一的并查集能且仅能处理无向图的连通分量,因为在join的过程中不能明确体现谁出谁入。
#include<iostream>
#include<vector>
#include<stack>
using namespace std;
const int vNum = 200; //最大顶点数
const int eNum = 102; //边的最大数量
typedef int datatype;
struct mqNode
{
int pa;
datatype data;
mqNode() :pa(-1) {}
}mq[vNum];
int find(int x) //查找结点 x的根结点
{
if (mq[x].pa == -1) return x; //递归出口:x的上级为x本身,即x为根结点
return mq[x].pa = find(mq[x].pa); //此代码相当于先找到根结点 rootx,然后pre[x]=rootx
}
void join(int x, int y)
{
x = find(x);
y = find(y);
if (x != y)
mq[x].pa = y;
}
int main()
{
//FILE* file;
//freopen_s(&file, "cin.in", "r", stdin);
int n, m;
cin >> n >> m;
int u, v;
int res = 0;
while (m--) {
cin >> u >> v;
join(u, v);
for (int i = 1; i <= n; i++) {
cout << mq[i].pa << " ";
}cout << endl;
}
for (int i = 1; i <= n; i++) {
cout << mq[i].pa << " ";
if (mq[i].pa == -1) {
res++;
}
}
cout << "连通分量为" << res << endl;
}
DFS+并查集
这是一个十分类似Tarjan的算法,可以求有向图的强连通分量,可参考博客强连通/缩点—dfs+并查集做法 博客园
对于dfs的过程中,根据情况dfs的目标结点 v 有如下三种情况:
-
搜索完:已经是某个极大连通子图中的结点,不需要管
-
搜索中:说明此时出现了回边,如下图
此时必定构成环,因此说明 0,1,2 必然为同一个连通子图,使用并查集合并
-
未搜索:继续进行dfs
因此实现细节有:
-
定义一个标记用的数组vis[],但这里使用深度记录 dep[] 更具有逻辑性,值为正整数时表示搜索的深度,值为-1时表示已经搜索完毕,需忽略,值为0时表示未搜索,需要进行dfs
-
初始化mq数组,使得每个结点的父结点设置为本身(而不是-1)
void init() { for (int i = 0; i < vNum; i++) { mq[i].pa = i; } }
完整代码:
#include<iostream>
#include<vector>
#include<stack>
using namespace std;
const int vNum = 200; //最大顶点数
const int eNum = 102; //边的最大数量
typedef int datatype;
struct mqNode
{
int pa;
datatype data;
mqNode() :pa(-1) {}
}mq[vNum];
void init()
{
for (int i = 0; i < vNum; i++)
mq[i].pa = i;
}
int find(int x) //查找结点 x的根结点
{
if (mq[x].pa == x) return x; //递归出口:x的上级为x本身,即x为根结点
return mq[x].pa = find(mq[x].pa); //此代码相当于先找到根结点 rootx,然后pre[x]=rootx
}
void join(int x, int y)
{
x = find(x);
y = find(y);
if (x != y)
mq[x].pa = y;
}
int dep[vNum];
void dfs(vector<datatype>g[vNum], int u)
{
for (int i = 0; i < g[u].size(); i++) {
datatype v = g[u][i];
//若dep[v]=0,说明未搜索,需要进行搜索
if (!dep[v]) {
dep[v] = dep[u] + 1;
dfs(g, v);
}
//若v的根节点仍然在搜索中,说明碰到回边/在回溯回边的过程中,join
if (dep[find(v)] > 0) {
join(u, v);
}
}
dep[u] = -1;//搜索结束
}
void solve(vector<datatype>g[vNum], int n)
{
for (int i = 1; i <= n; i++)
if (!dep[i])
dfs(g, i);
}
int main()
{
//FILE* file;
//freopen_s(&file, "cin.in", "r", stdin);
init();
int n, m;
cin >> n >> m;
int u, v;
int res = 0;
vector<datatype>g[vNum];
while (m--) {
cin >> u >> v;
g[u].push_back(v);
}
solve(g, n);
for (int i = 1; i <= n; i++) {
if (mq[i].pa == i)
res++;
}
cout << "连通分量为" <<res << endl;
}
查找
本文只介绍有序线性表的查找方法
二分查找
二分查找(Binary Search) 也称折半查找,是一种分治算法,基本思想是:将位于顺序表中间的键值与查找键值比较如果两者想相等,则查找成功;否则以中间元素为分割点,将表分为两个子表,然后在子表中重复上述操作,直到查找到相应元素或者子表为空。用递归实现。
具体步骤如下:
-
定义整型变量 left 和 right,分别表示表的左端点和右端点,初始为0和n-1
-
比较中间元素:令
mid=(left+right)/2
:1.1
sl[mid] ==k
查找成功,结束1.2
sl[mid] >k
说明查找元素应在左半区间,令right =mid-1
重复第1步1.3
sl[mid] <k
说明查找元素应在右半区间,令left = mid+1
重复第1步
//递归实现二分查找
int binary_search(vector<keytype>sl, int left, int right, keytype k) {
if (left > right)
return -1;
int mid = (left + right) / 2;
if (sl[mid] == k)
return mid;
if (sl[mid] > k)
return binary_search(sl, left, mid - 1, k);
else
return binary_search(sl, mid + 1, right, k);
}
//非递归实现二分查找
int binary_search(vector<keytype>sl, keytype k) {
int left = 0;
int right = sl.size() - 1;
int mid;
while (left <= right) {
mid = (left + right) / 2;
if (sl[mid] == k)
return mid;
if (sl[mid] > k)
right = mid - 1;
else
left = mid + 1;
}
return -1;
}
每次比较后范围缩小一半。设一共经过 x 轮查找,每轮的长度为 \frac{n}{2^m} ,假设最坏的情况下在子表长度为1的时候找到该键值,则
因此时间复杂度为 O(logn)
综合题
广义表
广义表是线性表的推广,除包含类型相同的元素外,还可以包含具有自身结构的元素,表示为 gt=(a_1,a_2,...,a_n) 其中每个元素 a_i 可以是一个原子,又或者是一个广义表,n为 gt 的长度,称为表长,原子为广义表中不可再分的数据元素,gt 中的广义表称为 gt 的子表。
称第一个元素a_1 为gt 的表头,其余元素组成的表称为表尾,因此,表头可能是原子,也可能是表,而表尾一定是一个广义表,子表嵌套的最大层次称为广义表的深度(从1开始)
例:表C = ( ( a,b ) , c , ( d , e , f ) ) ,表长为3,表头为广义表(a, b),表尾为(c , ( d , e , f )),表的深度为2
表的结构
表的结构由3部分组成,分别为 tag 标记位、ele 元素、 next指针
typedef char datatype;
typedef struct gtNode {
bool tag; //tag为false表示院子,为true表示字表
union {
datatype data; //原子的值
gtNode* link; //字表的头结点
}ele;
gtNode* next;
gtNode():tag(false),next(NULL){}
}* gTable;
这里使用了union共用体,意思是data和link共享同一片存储空间,具体里面存的是data还是link由tag决定,这样做可以更有效地利用存储空间,避免浪费。
请注意,广义表的头结点(不是表头)通常为空结点,因为当广义表作为别的表的子表时,头结点将用来连接前后表
哈夫曼树
基本概念
1、什么是路径?
在一棵树中,从一个结点往下可以达到的结点之间的通路,称为路径。
如图,从根结点A到叶子结点I的路径就是A->C->F->I
2、什么是路径长度?
某一路径所经过的“边”的数量,称为该路径的路径长度
如图,该路径经过了3条边,因此该路径的路径长度为3
3、什么是结点的带权路径长度?
若将树中结点赋给一个带有某种含义的数值,则该数值称为该结点的权。从根结点到该结点之间的路径长度与该结点的权的乘积,称为该结点的带权路径长度。
如图,叶子结点I的带权路径长度为 3(\text{边的数量})\times3(\text{结点的权重为})=9
4、什么是树的带权路径长度?
树的带权路径长度规定为所有叶子结点的带权路径长度之和,记为WPL。
如图,该二叉树的带权路径长度 WPL=2\times 2 + 2 \times 6 + 3 \times 1 + 3 \times 3 + 2 \times 2 = 32
5、什么是哈夫曼树?
给定n个权值作为n个叶子结点,构造一棵二叉树,若该树的带权路径长度和(WPL)达到最小,则称该二叉树为哈夫曼树,也被称为最优二叉树
根据树的带权路径长度的计算规则,我们应该尽可能地让权值大的叶子结点靠近根结点,让权值小的叶子结点远离根结点,这样便能使得这棵二叉树的带权路径长度达到最小。
构建
下面给出一个非常简洁易操作的算法,来构造一棵哈夫曼树: 1、初始状态下共有n个结点,结点的权值分别是给定的n个数,将他们视作n棵只有根结点的树。 2、合并其中根结点权值最小的两棵树,生成这两棵树的父结点,权值为这两个根结点的权值之和,这样树的数量就减少了一个。 3、重复操作2,直到只剩下一棵树为止,这棵树就是哈夫曼树。
Q:哈夫曼树的结点数是多少?
A:结合上面的Gif,假设有5个结点,
第一次结合产生一个新的结点,此时只有4个结点需要结合,
第二次结合又产生一个新的结点,此时只有3个结点需要结合,
第三次结合又产生一个新的结点,此时只有2个结点需要结合,
第四次结合又产生一个新的结点,此时结合完成。
故可知:n个结点需要结合n-1次,所以结点数=n+(n-1)=2n-1 个
代码实现
哈夫曼树结点的定义
typedef struct hutNode
{
datatype data; //叶结点名字
int w, idx;
int lc, rc; //左右孩子
bool operator<(const hutNode& hn)const
{
return w > hn.w; //目的是为了构建小顶堆,实测按定义构建小顶对比较麻烦
}
}huTree[M<<1];//结点数为2n-1,所以开两倍空间刚刚好
构建哈夫曼树
//data和w分别为叶结点的数据信息和叶结点的权重,m为叶结点的数量
void huTree_create(huTree& t, const datatype data[], int w[], int m)
{
int i;//保留i
hutNode hn1, hn2, hn3;
priority_queue<hutNode> f;
for (i = 1; i <= m; i++)
{
t[i].data = data[i - 1];
t[i].w = w[i - 1];
t[i].idx = i;
t[i].lc = t[i].rc = -1;
f.push(t[i]);
}
while (f.size() > 1)
{
hn1 = f.top(), f.pop(); //取第一个最小的元素
hn2 = f.top(), f.pop(); //取第二个最小的元素
hn3.w = hn1.w + hn2.w, hn3.idx = i; //生成他们的父结点
hn3.lc = hn1.idx;
hn3.rc = hn2.idx;
f.push(hn3);
t[i++] = hn3;
}
t[0] = f.top(), t[0].idx = 0; //将根结点的编号设置为0
}
最小生成树
对于一个具有 n 个顶点的无向连通图,如果只保留其中 n-1 条边,仍然保持连通性,任选一个顶点作为根结点,此时形成一棵树,这就是图的生成树,显然BFS树和DFS树都是生成树,而边的权重之和最小的生成树称为最小生成树
Prim算法
Prim算法的核心是将权重边存到小顶堆中,每次取出一个离生成树最近的边出来连接顶点
如图,假设【1-2】 是算法过程中的生成树,则能连接该生成树的边为图中蓝色边,取权重最小的边 [2-4] ,于是顶点④成为了生成树中的一部分,重复此操作n-1次,因为最小生成树的边数只有n-1条,即只需要找n-1次权重最小边
//prim算法
//这里图方便用edge存图
struct edge
{
int v;
int w;
edge(int _v ,int _w) :v(_v), w(_w) {}
};
struct node
{
int u, v, w;
node(int _u = 0, int _v = 0, int _w = 0) :u(_u), v(_v), w(_w) {}
bool operator<(node n)const
{
return w > n.w; //方便定义小顶堆
}
};
bool vis[vNum]; //标记顶点是否在生成树中
void prim(vector<edge>g[vNum], int n, vector<edge>t[vNum], int& min_w, int& max_w)
{
priority_queue<node>pq; //优先队列小顶堆
int step = n - 1, u, v;
for (int i = 0; i < g[1].size(); i++)
{
edge tmp = g[1][i]; //将第一个顶点加入最小生成树,并将附近的边存入小顶堆
pq.push(node(1, tmp.v, tmp.w));
}
vis[1] = 1; //标记第一个顶点在生成树中
while (step--)
{
node tmp = pq.top(); //取出小顶堆中权重最小的
pq.pop();
v = tmp.v;
min_w = min(min_w, tmp.w);
max_w = max(max_w, tmp.w);
//如果v已经保存好了,那就不找v了,找下一个
while (vis[v])
{
tmp = pq.top();
pq.pop();
v = tmp.v;
}
u = tmp.u;
vis[tmp.v] = 1; //要保存v
t[v].push_back(edge(u, tmp.w)); //构建无向边
t[u].push_back(edge(v, tmp.w)); //构建无向边
//把v与邻接点的「带权边」加入到小顶堆
for (int i = 0; i < g[v].size(); i++)
{
edge tmp = g[v][i];
if (!vis[tmp.v])
pq.push(node(v, tmp.v, tmp.w));
}
}
}
Kruskal算法
Kruskal算法 思路类似Prim算法,将边存起来,但更简单暴力,不从生成树附近的边考虑,而是直接将图中的所有边进行权重排序,依次考虑权重最小的边,若边的两个顶点不属于同一棵子树,则进行合并,若属于同一子树,则说明是回边/横线边,不考虑该边,如此重复,合并的边就构成了最小生成树
struct edge
{
int u;
int w;
edge(int _u, int _w) :u(_u), w(_w) {}
};
struct mqNode
{
int pa;
datatype data;
mqNode():pa(-1){}
}mq[vNum];
int find(int x)
{
while (mq[x].pa != -1)
x = mq[x].pa;
return mq[x].pa;
}
void join(int x, int y)
{
x = find(x);
y = find(y);
if (x != y)
mq[x].pa = y;
}
struct node1
{
int u, v, w;
node1(int _u = 0, int _v = 0, int _w = 0) :u(_u), v(_v), w(_w) {}
bool operator<(node1 n)const
{
return w > n.w;
}
};
node1 edges[eNum];
void kruskal(vector<edge>g[vNum], int n, vector<edge>t[vNum])
{
int cnt = 0, num = 0;
//把所有边存起来
for (int i = 1; i <=n ; i++)
{
for (int j = 0; j < g[i].size(); j++)
{
edge tmp = g[i][j];
if (i < tmp.u)
edges[cnt++] = node1(i, tmp.u, tmp.w);
}
}
//对边进行排序
sort(edges, edges + cnt);
for (int i = 0; i < cnt; i++)
{
int u = edges[i].u, v = edges[i].v,w=edges[i].w;
if (find(u) != find(v))
{
join(u,v);
t[u].push_back(edge(v, w));
t[v].push_back(edge(u, w));
if (++num == n - 1)
break;
}
}
}
二叉查找树
二叉查找树(binary Search Tree,BST) 是指二叉树中的每一个结点都大于其左子树中的所有结点的键值,同时都小于右子树中所有结点的键值。
定义如下:
typedef int keytype;
typedef struct bstNode {
keytype key;
bstNode* lc, * rc;
bstNode():lc(NULL),rc(NULL){}
}* bstTree;
按中根遍历会得到一个递增的序列,根据该特点,5的直接前驱为他的左子树的根节点3,5的直接后继为右子树的最左结点9,
查找
根据二叉查找树的特点,算法步骤如下:
-
如果 t 为空,则不存在值为 x 的结点,查找结束,否则将根结点的键值 k_1 与 k 比较
1.1 k=k_1:找到该结点,查找结束
1.2 k<k_1:说明要找的结点在左子树,跳到左子树重复第1步
1.3 k>k_1:说明要找的结点在右子树,跳到右子树重复第1步
bstNode* bst_search(bstTree t, keytype k) {
if (t == NULL)
return NULL;
if (k == t->key)
return t;
if (k < t->key)
return bst_search(t->lc, k);
if (k > t->key)
return bst_search(t->rc, k);
}
插入
在不改变已有结构的前提下,将一个新的结点插入一颗二叉查找树中,应该按查找一样的方法查找插入位置(空子树)。
如图,若要插入一个键值为4的结点,从根结点5开始:4比5小,因此在5的左子树中,4比3大,因此在3的右子树中,子树为空子树,因此在这插入
可以肯定的是,新插入的结点一定是叶结点
因此,算法步骤如下:
-
如果 t 为空,则找到了位置,创建 t ,令键值为 k ,结束,否则对比 t 的键值 k_1 与 k
1.1 k<k_1:说明要插入的结点在左子树,跳到左子树重复第1步
1.2 k>k_1:说明要插入的结点在右子树,跳到右子树重复第1步
1.3 k=k_1:说明已存在结点,插入失败
bool bst_insert(bstTree& t, keytype k) {
if (t == NULL) {
t = new bstNode;
t->key = k;
return true;
}
if (k < t->key)
return bst_insert(t->lc, k);
if (k > t->key)
return bst_insert(t->rc, k);
return false;
}
删除
删除结点首先要查找,查找到要删除的结点,但直接删除往往会出现问题:
若直接删除结点3,会导致整棵树分成了三部分,二叉树,3的左子树,3的右子树,而删除操作不应该改变二叉查找树的特性,因此要将删除的结点转化为删除叶结点,因为叶结点没有子树。转化方法是:转而将该结点的直接前驱或直接后继的键值取代该结点,并转为删除该结点的直接前驱或直接后继。上文提到,结点的直接前驱是左子树的根节点,直接后继是右子树的最左结点,最简单的是转为删左子树,但这里以优先删直接后继为例
算法步骤如下:
-
查找该结点(查找过程见上方)
1.1 该结点为叶结点,则直接删除,删除结束。
1.2 不为叶结点,且右子树不为空,这时转为删除直接后继,寻找右子树的最左结点,并取代该结点,重复第1步
1.3 不为叶结点,且右子树为空,这时转为删除直接前驱,将左孩子(左子树的根节点)取代该结点,并转为删除左孩子,重复第1步
bool bst_delete(bstTree& t, keytype k) {
bstNode* tmp;
if (t == NULL)
return false;
if (k < t->key)
return bst_delete(t->lc, k); //进入左子树继续找要删的键值
if (k > t->key)
return bst_delete(t->rc, k); //进入右子树继续找要删的键值
if (k == t->key) { //找到了要删的键值
if (t->lc == NULL && t->rc == NULL){ //该结点为叶结点
delete t;
t = NULL;
return true;
}
if (t->rc != NULL) { //右子树非空
tmp = t->rc; //右子树根节点
while (tmp->lc)
tmp = tmp->lc; //找到右子树的最左结点
keytype k1 = tmp->key;
bst_delete(t, k1); //转为删除该结点
t->key = tmp->key; //将该结点的键值取代本结点
return true;
}
if (t->rc == NULL) { //右子树为空
tmp = t->lc; //找到该结点的左孩子
keytype k1 = tmp->key;
bst_delete(t, k1); //转为删除该结点
t->key = tmp->key; //将该结点的键值取代本结点
return true;
}
}
}