算法
解决特定问题的求解步骤及描述,在计算机中为指令的有限序列,每条指令表示一个或多个操作。
算法特性
有穷性、确定性、可行性、输入、输出
算法设计要求
正确性、可读性、健壮性、高效率和低存储量需求
算法时间复杂度及大O阶推导
大O阶推导:
1.使用常数1代替运行时间中的所有常数加法
2.修改后的运行次数函数中,只保留最高阶项
3.如果最高阶项存在且系数不为1,去除该项的系数
常见的时间复杂度耗时排列:
O(1)
一般而言,所指的时间复杂度通常是指最坏情况的耗时时间。
线性表:零个或多个数据元素的有限序列
线性表的两种存储结构:顺序存储&链式存储
单链表结构&顺序存储结构对比
静态链表
用数组描述的链表叫做静态链表
静态链表的优缺点:
静态链表实际上是给没有指针的高级语言设计的一种实现单链表的方法,尽管存在一定缺陷,其设计思想十分巧妙。
栈与队列
栈是限定仅在表尾进行插入和删除操作的线性表
队列是只允许在一端插入数据在另一端删除数据的线性表
顺序栈与链栈对比
串
串是指零个或多个字符组成的有限序列,又叫字符串。
串的顺序存储一般使用定长数组进行定义,对于字符串操作存在的溢出问题,串值的存储空间在执行过程中动态分配堆内存,由动态分配函数malloc()和free()来管理
串的链式存储结构除了在串的连接操作会方便一些,总体不如顺序结构灵活,性能也不如顺序存储结构
树是n个结点的有限集,当n=0时,为空树。在任意一个非空树中有且仅有一个特定的称为根的结点;当n>1时,其余结点可以分为m个互不相交的有限集,每一个集合本身又是一棵树,称为根的子树
数的结点包含一个数据元素&若干个指向其子树的分支。结点拥有的子树的数目称为 结点的度。度为0的结点称为叶节点或终端结点,度不为0的结点,称为分支结点,分支结点中除了根节点,其他结点也称为内部节点。数的度为树内所有结点的最大值。
结点的层次从根结点开始定义,根为第一层,根的孩子为第二层。树中结点的最大层次称为树的深度或高度。
线性表与树结构的差异
树的抽象数据类型ADT
树的存储结构
对于树这种存在一对多的情况,单纯使用顺序存储无法满足其逻辑关系。结合顺序存储和链式存储可以实现对树的存储结构的要求。
常见的三种不同的表示法:双亲表示法、孩子表示法(双亲-孩子结合的表示法)、孩子兄弟表示法。
双亲表示法: 通过一定长度的结点数组存储结点(结点存储结点数据和双亲下标)
双亲表示法中根据结点的parent可以找到其双亲,但是无法找到结点的子结点,除非是遍历整个树。可以对结点的数据域进行扩展,增加长子索引。可以根据需求继续扩展结点数据域。
孩子表示法:
孩子表示法为在结点数组中,每个结点形成一个单链表的结构,链表中的下一个元素为该结点的兄弟。
兄弟表示法
兄弟表示法中,每个结点如果存在长子结点,有且只有一个,而结点紧邻右侧的兄弟若存在,有且只有一个。这样在结点数组中,每个结点的数据域中有两个指针,分别指向第一个长子和它右侧的兄弟。
这种表示法如果要找到双亲还需要再添加指针域指向双亲,不过这种表示法将复杂树转为了二叉树,这样可以利用二叉树的特性和算法来处理相应的操作。
二叉树
由n个结点构成的有限集,当n=0时,为空树,当n>0时,由一个根结点和两颗互不相交的,分别称为根结点的左子树和右子树的二叉树构成。
二叉树特点:
二叉树的五种基本形态:
特殊二叉树:
斜树
所有结点都只有左子树的二叉树称为左斜树,所有结点都有右子树的二叉树称为右斜树。斜树的结点数即为该树的深度。
满二叉树
一颗二叉树中,所有分支的结点都存在左子树和右子树,并且所有的叶子都在同一层上,这样的二叉树称为满二叉树。
满二叉树特点
叶子只能出现在最下一层
非叶子的结点的度一定是2 同样深度的树中,满二叉树的结点个数最多,叶子数最多。
完全二叉树
对一颗n各结点的二叉树按层序编号,若编号为i的结点与对应深度的满二叉树中的编号一致,则这样的二叉树称为完全二叉树。即满二叉树一定是完全二叉树,而完全二叉树不一定是满二叉树。完全二叉树是满二叉树的子集。
完全二叉树特点
叶子结点只能是最下两层
最下层的叶子一定集中在左部连续位置
倒数第二层,若有叶子结点,一定都在右部连续位置
如果结点的度为1,该结点只有左孩子,不存在只有右子树的情况
同样结点数的二叉树,完全二叉树的深度最小
二叉树的性质
二叉树的存储结构
二叉树的特殊性可以使用顺序结构存储按层序编号的结点,不存在的结点需要在数组对应编号处空缺。极端情况下,一颗深度为k的右斜树结点数为k,但需要 2^k - 1个存储单元。一般顺序存储结构适用于完全二叉树。
使用链式存储结构,设计一个数据域和两个指针域的结点链表来存储二叉树,这样的链表叫做二叉链表,如有必要可以再添加指向双亲的指针域,为三叉链表。
二叉树的遍历
二叉树的遍历是指从根结点出发,按照某种次序依次访问二叉树中的所有结点,使得每个结点被访问一次且仅被访问一次。
如果限制遍历方向从左向右,主要分为四种遍历方法:
1.前根序遍历
规则:二叉树为空,则空操作返回,否则先遍历根结点,然后遍历左子树,最后遍历右子树。
ABDHECFG
2.中根序遍历
规则:二叉树为空,则空操作返回,否则先遍历左子树,然后遍历根结点,最后遍历右子树。
HDBEAFCG
3.后根序遍历
规则:二叉树为空,则空操作返回,否则先遍历左子树,然后遍历右子树,最后遍历根结点。
HDEBFCGA
4.层序遍历
规则:二叉树为空,则空操作返回,否则按照层序从左至右依次访问结点。
二叉树的定义和遍历采用递归的方式 二叉树链表结构及遍历实现:
#define OK 1
#define ERROR 0
#define TRUE 1
#define FALSE 0
#define MAXSIZE 100
typedef int Status;
//用于构造二叉树的全局变量
int index = 1;
typedef char String[24];
String str; //字符数组的别名
Status StrAssign(String T, char *chars) {
int i;
if (strlen(chars) > MAXSIZE)
return ERROR;
else {
T[0] = strlen(chars);
for (i = 1; i <= T[0]; i++)
T[i] = *(chars + i - 1);
return OK;
}
}
typedef char TElemType;
TElemType Nil = ' ';
Status visit(TElemType e) {
printf("%c", e);
return OK;
}
typedef struct BiTNode {
TElemType data;
struct BiTNode *lchild, *rchild;
}BiTNode, *BiTree;
Status InitBiTree(BiTree *T) {
*T = NULL;
return OK;
}
//销毁二叉树
void DestroyBiTree(BiTree *T) {
if (*T) {
if ((*T)->lchild)
DestroyBiTree(&(*T)->lchild);
if ((*T)->rchild)
DestroyBiTree(&(*T)->rchild);
free(*T);//释放结点空间
*T = NULL;
}
}
void CreatBiTree(BiTree *T) {
TElemType ch;
ch = str[index++];
if (ch == '#')
*T = NULL;
else {
*T = (BiTree)malloc(sizeof(BiTNode));
if (!T)
exit(OVERFLOW);
//使用前根序的次序创建二叉树
(*T)->data = ch;
CreatBiTree(&(*T)->lchild); //构造左子树
CreatBiTree(&(*T)->rchild); //构造右子树
}
}
Status BiTreeEmpty(BiTree T) {
if (T)
return FALSE;
else
return TRUE;
}
int BiTreeDepth(BiTree T) {
int i, j;
if (!T)
return 0;
if (T->lchild)
i = BiTreeDepth(T->lchild);
else
i = 0;
if (T->rchild)
j = BiTreeDepth(T->rchild);
else
j = 0;
return i > j ? i + 1 : j + 1;
}
TElemType Root(BiTree T) {
if (BiTreeEmpty(T))
return Nil;
else
return T->data;
}
TElemType Value(BiTree p) {
return p->data;
}
void Assign(BiTree p, TElemType e) {
p->data = e;
}
//二叉树前根序遍历
void PreOrderTraverse(BiTree T) {
if (T == NULL)
return;
printf("%c", T->data); //先显示结点数据
PreOrderTraverse(T->lchild); //再遍历左子树
PreOrderTraverse(T->rchild); //再遍历右子树
}
//二叉树中根序遍历
void InOrderTraverse(BiTree T) {
if (T == NULL)
return;
InOrderTraverse(T->lchild); //先遍历左子树
printf("%c", T->data); //再显示结点数据
InOrderTraverse(T->rchild); //再遍历右子树
}
//二叉树后根序遍历
void PostOrderTraverse(BiTree T) {
if (T == NULL)
return;
PostOrderTraverse(T->lchild); //先遍历左子树
PostOrderTraverse(T->rchild); //再遍历右子树
printf("%c", T->data); //显示结点数据
}
int main()
{
int i;
BiTree T;
TElemType e1;
InitBiTree(&T);
StrAssign(str, "ABDH#K###E##CFI###G#J##");
CreatBiTree(&T);
printf("构造空二叉树后,树空否?%d(0:否,1:是) 树的深度=%d\n", BiTreeEmpty(T), BiTreeDepth(T));
e1 = Root(T);
printf("二叉树的根结点:%c\n",e1);
printf("二叉树前根序排列:\n");
PreOrderTraverse(T);
printf("\n二叉树中根序排列:\n");
InOrderTraverse(T);
printf("\n二叉树前跟序排列:\n");
PostOrderTraverse(T);
getchar();
return 0;
}
二叉树遍历性质
线索二叉树
利用二叉链表中的空指针域存放指向结点在某种次序下的前驱和后继结点的地址,将指向前驱和后继的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树称为线索二叉树。
线索二叉树中需要解决的一个问题是,如何知道一个结点的左指针域是指向其左孩子还是指向该结点的前驱,或者右指针域指向其右孩子还是该结点的后继,因此线索二叉树需要对原结点添加两个标志位,ltag和rtag,标志位只存储0和1,0表示指向其对应的孩子,1表示为对应前驱或后继。
线索二叉树实现:
typedef int Status; /* Status是函数的类型,其值是函数结果状态代码,如OK等 */
typedef char TElemType;
typedef enum {Link,Thread} PointerTag; /* Link==0表示指向左右孩子指针, */
/* Thread==1表示指向前驱或后继的线索 */
typedef struct BiThrNode /* 二叉线索存储结点结构 */
{
TElemType data; /* 结点数据 */
struct BiThrNode *lchild, *rchild; /* 左右孩子指针 */
PointerTag LTag;
PointerTag RTag; /* 左右标志 */
} BiThrNode, *BiThrTree;
TElemType Nil='#'; /* 字符型以空格符为空 */
Status visit(TElemType e)
{
printf("%c ",e);
return OK;
}
/* 按前序输入二叉线索树中结点的值,构造二叉线索树T */
/* 0(整型)/空格(字符型)表示空结点 */
Status CreateBiThrTree(BiThrTree *T)
{
TElemType h;
scanf("%c",&h);
if(h==Nil)
*T=NULL;
else
{
*T=(BiThrTree)malloc(sizeof(BiThrNode));
if(!*T)
exit(OVERFLOW);
(*T)->data=h; /* 生成根结点(前序) */
CreateBiThrTree(&(*T)->lchild); /* 递归构造左子树 */
if((*T)->lchild) /* 有左孩子 */
(*T)->LTag=Link;
CreateBiThrTree(&(*T)->rchild); /* 递归构造右子树 */
if((*T)->rchild) /* 有右孩子 */
(*T)->RTag=Link;
}
return OK;
}
BiThrTree pre; /* 全局变量,始终指向刚刚访问过的结点 */
/* 中序遍历进行中序线索化 */
void InThreading(BiThrTree p)
{
if(p)
{
InThreading(p->lchild); /* 递归左子树线索化 */
if(!p->lchild) /* 没有左孩子 */
{
p->LTag=Thread; /* 前驱线索 */
p->lchild=pre; /* 左孩子指针指向前驱 */
}
if(!pre->rchild) /* 前驱没有右孩子 */
{
pre->RTag=Thread; /* 后继线索 */
pre->rchild=p; /* 前驱右孩子指针指向后继(当前结点p) */
}
pre=p; /* 保持pre指向p的前驱 */
InThreading(p->rchild); /* 递归右子树线索化 */
}
}
/* 中序遍历二叉树T,并将其中序线索化,Thrt指向头结点 */
Status InOrderThreading(BiThrTree *Thrt,BiThrTree T)
{
*Thrt=(BiThrTree)malloc(sizeof(BiThrNode));
if(!*Thrt)
exit(OVERFLOW);
(*Thrt)->LTag=Link; /* 建头结点 */
(*Thrt)->RTag=Thread;
(*Thrt)->rchild=(*Thrt); /* 右指针回指 */
if(!T) /* 若二叉树空,则左指针回指 */
(*Thrt)->lchild=*Thrt;
else
{
(*Thrt)->lchild=T;
pre=(*Thrt);
InThreading(T); /* 中序遍历进行中序线索化 */
pre->rchild=*Thrt;
pre->RTag=Thread; /* 最后一个结点线索化 */
(*Thrt)->rchild=pre;
}
return OK;
}
/* 中序遍历二叉线索树T(头结点)的非递归算法 */
Status InOrderTraverse_Thr(BiThrTree T)
{
BiThrTree p;
p=T->lchild; /* p指向根结点 */
while(p!=T)
{ /* 空树或遍历结束时,p==T */
while(p->LTag==Link)
p=p->lchild;
if(!visit(p->data)) /* 访问其左子树为空的结点 */
return ERROR;
while(p->RTag==Thread&&p->rchild!=T)
{
p=p->rchild;
visit(p->data); /* 访问后继结点 */
}
p=p->rchild;
}
return OK;
}
int main()
{
BiThrTree H,T;
printf("请按前序输入二叉树(如:'ABDH##I##EJ###CF##G##')\n");
CreateBiThrTree(&T); /* 按前序产生二叉树 */
InOrderThreading(&H,T); /* 中序遍历,并中序线索化二叉树 */
printf("中序遍历(输出)二叉线索树:\n");
InOrderTraverse_Thr(H); /* 中序遍历(输出)二叉线索树 */
printf("\n");
return 0;
}
如果所用二叉树需经常遍历或查找结点时需要某种遍历序列中的前驱和后继,可以采用线索二叉链表的存储结构
图是由顶点的有穷非空集合和顶点之间的边的集合组成,通常表示为:G(V,E),其中,G表示一个图,V是图中的顶点的集合,E是图G中边的集合。
关于图的各种定义:
无向边&无向图
若顶点Vi到顶点Vj之间的边没有方向,则称这条边为无向边,用无序偶对(Vi,Vj)来表示。如果任意两个顶点之间的边都是无向边,则称该图为无向图。无向图表示法:G=(V1,{E1}),其中V1={A,B,C,D},E1={(A,B),(B,C),(C,D),(A,C)} (无向边小括号)
有向边&有向图
若顶点Vi到顶点Vj的边有方向,称该边为有向边,也叫作弧。使用有序偶
相关术语
在图中,如果不存在顶点到自身的边,且同一条边不重复再出现,则称该图为 简单图
在无向图中,如果任意两个顶点之间都存在边,则称该图为无向完全图。n的顶点的无向完全图的边的数目:n*(n-1)/2
在有向图中,如果任意两个顶点之间都存在方向互为相反的两条弧,则称该图为有向完全图。n个顶点的有向完全图的弧的数目:n*(n-1)
有些图的边或弧具有与他相关的数字,这种与图的边或弧相关的数叫做权,带权的图通常称为 网。
图G=(V,{E}),图G'=(V',{E'}),且 V属于V’,E属于E',称G为G'的子图。
无向图中,顶点v的度为与该顶点相关联的边的数目,即为TD(v)。图中边的数目为各顶点的度的和的一半。
有向图中,顶点v的度为入度和出度之和,入度为以顶点V为头的弧,记作ID(v),出度为以顶点V为尾的弧,记作OD(v)。有向图中的边的数目为顶点的ID(v),或者OD(v)。
图中顶点V到顶点V'之间的路径为顶点的序列,对于无向图该序列中相邻的点之间的边属于无向图的边的集合,对于有向图该序列中的相邻点组成的具有方向性,属于有向图的弧的集合。路径的长度为弧或边的数目。
连通图相关术语
在无向图中,如果顶点V到顶点V'有路径,称为V和V'是连通的。如果对于图中任意两个顶点都是连通的,则称该图为连通图。
无向图中的极大连通子图称为连通分量,连通分量强调:
而有向图中,如果对于每一对属于顶点集的Vi,Vj,从Vi到Vj和从Vj到Vi都存在路径,则称该有向图为强连通图,有向图中的极大强连通子图称作有向图的强连通分量。
连通图的生成树
一个连通图的生成树是一个极小的连通子图,含有图中全部的n个顶点,但只有足以构成一棵树的n-1条边。
如果一个有向图恰有一个顶点的入度为0,其他顶点的入度均为1,则是一棵有向树。一个有向图由若干有向树构成森林。
图存储结构
邻接矩阵
图的邻接矩阵存储方式是用两个数组来表示图。一个一维数组存储图中顶点信息,一个二维数组(为邻接矩阵)存储图中的边或弧的信息
无向图:
有向图:
无向图的邻接矩阵是对称阵,有向图的邻接矩阵不一 定是对称阵
网中存储权值:
邻接表
对于边数相对于顶点较少的图,使用邻接矩阵会对存储空间造成一定的浪费。这时可以采用将数组与链表相结合的存储方式,称为邻接表。
顶点数组中存储顶点信息和一个指向第一个邻接点的指针,边表结点由存储某顶点的下标和边表中下一个结点的指针域构成
无向图邻接表
有向图邻接表
有向图有方向,边表中可以按照以该顶点的弧尾储存,或者弧头存储
带权值的网图:
边表再添加权值域存储权值
十字链表
对于有向图来说,邻接链表是有缺陷的。关心出度问题,想了解入度情况,需要遍历整个图。将邻接链表和逆邻接链表结合。
顶点数组的数据元素除了数据域和出度指针域外,添加入度指针域,即指向弧头为该顶点的弧边结点
弧边结点为两个数据域两个指针域,数据域分别记录该弧的弧头和弧尾的顶点下标,指针域一个指向顶点出度的弧,一个指向顶点入度的弧
十字链表将邻接表和逆邻接表结合,可以方便找出某一个顶点的入度和出度的弧,因此在有向图中,十字链表是非常好的数据结构。
邻接多重表
对于无向图的邻接表,若关注的重点是顶点,那么邻接表可以满足要求,若是更关注边的操作,如对已访问的边做标记,删除一条边等,那么使用邻接表就会相对比较麻烦,要找到这条边对应的两个节点进行处理。使用邻接多重表结构:
| ivex | ilink | jvex | jlink |
ivex和jvex是与某条边依附的两个顶点在顶点表中的下标,ilink是顶点ivex所指的下一条边,jlink是顶点jvex所指的下一条边,在构造图的边结点时采用头插法,这样每个边结点会被两个指针所指:
边集数组
边集数组有两个一维数组构成。一个存储顶点信息,一个存储边的信息,边数组的每个数据元素由每条边的起点下标和终点下标以及权重组成。边集数组关注的是边的集合,边集数组中查找顶点的度需要扫描整个边数组,效率不高,更适合对边依次处理的操作,不适合对顶点的相关操作。
图的遍历
从图中某一顶点出发访遍图中其与顶点,且使每一个顶点仅被访问一次,该过程叫做图的遍历
深度优先遍历DFS
也称为深度优先搜索,简称DFS。从图中某个顶点v出发,访问此顶点,然后从v的未被访问的邻接点出发深度优先遍历图,直至图中所有和v有路径相通的顶点都被访问到。这里是对于连通图,若是非连通图,则对其连通分量扥别进行深度优先遍历,即在先前一个顶点进行一次深度优先遍历后,若图中尚有顶点未被访问,则另选图中一个未曾被访问的顶点作起始点,重复上述过程,直至图中所有顶点都被访问到为止。
深度优先其实就是一个递归过程,类似一棵树的前根序遍历过程。
广度优先遍历BFS
广度优先搜索类似于树的层序遍历,将与该顶点的邻接的顶点通过入队和出队,完成层序搜索。
深度优先适合有明确的目标,以找到目标为主要目的的情况,而广度优先适合在不断扩大搜索范围以找到相对最优解的情况。
最小生成树
一个连通图的生成树是一个极小的连通子图,包含图中所有的顶点,只有足以构成一棵树的n-1条边。构造连通网的最小代价生成树称为最小生成树。找出最小生成树的常用算法:
Prim算法
Prim算法从某一顶点开始
这样一轮检测完时,权值数组中储存的权值表示与当前权值最小的边的顶点有邻接关系的顶点对应的边的权值,下标数组中存储的是与当前权值最小的边的其中一个顶点邻接的未检索的其他顶点。对其他顶点重复进行上述操作,时间复杂度为0(n^2),针对上图的Prim算法结果:
Kruskal算法
Kruskal算法从边开始,依次选择权值最小的边来构成最小生成树,比较重要的两步:
因此,针对上图首先按照权值对边进行排序,得到:
使用Kruskal算法结果:
该算法与边有关,与判断环路相关的函数时间复杂度为loge ,对e条边的总体时间复杂度为O(eloge)
对比两个算法,Kruskal算法针对边展开,对于稀疏图Kruskal的效率会相对较高,而对于稠密图,即边比较多的情况下,Prim算法效率相对较高。
最短路径问题
对于非网图,由于没有权值,最短路径即为两顶点之间经过的边数最少的路径。
对于网图,由于带有权值,最短路径即为两顶点之间经过的边的权值之和最小的路径。
Dijkstra算法
Dijkstra算法是从网图的源点出发,依次计算到每个顶点的最短路径,直到最后目标点,并记录路径的前驱下标。
始终由当前的已知最短路径向与之相连的权值最小的路径扩散,此前与某一顶点直接相连的权值已经被记录在权值和的数组中,
若在扩散过程中发现间接路径的权值和小于直接路径的权值和,则之前的直接路径就会被间接路径覆盖掉,并更新前驱数组
这样就可以找出源点到其他每一个顶点的最小权值和路径。
对于以下的网图:
使用Djikstra算法得到的结果:
final数组用来确定从源点到哪些点的最短路径已经确定
D数组用来存储源点到对应的点的最短路径的权值和
P数组用来存储到对应路径的上一个点的下标
该算法的时间复杂度为O(n^2),如果想要找出所有点对其他点的最短路径,则需要在外层再循环图中顶点个数次,时间复杂度为O(n^3)
Floyd算法
Floyd算法是通过找出每一个点与下一个点之间的最短距离,并记录下标,最后得到完整的路径。
而判断每一个点与下一个点的最短距离是通过比较起始点到结束点是直接路径短还是通过中间点的路径短,如果中间距离短就更新起始点到结束点的记录的路径值。由于需要遍历计算中间点,以及起始点和结束点,因此需要3层循环来确定每一个点到另一个点的最短路径,时间复杂度为O(n^3)
对于以下网图:
使用Floyd算法得到的结果:
AOV网
在一个表示工程的有向图中,用顶点表示活动,用弧表示活动之间的优先级关系,这样的有向图为顶点表示活动的网,称之为AOV(Activity On VertexNetwork)网。AOV网中的弧表示活动之间存在某种制约关系。AOV网中不能存在回路。
设G=(V,E)是一个具有n个顶点的有向图,V中的顶点序列V1,V2,……Vn,满足若从顶点Vi到Vj有一条路径,则在顶点序列中顶点Vi必在顶点Vj之前。则称之为这样的顶点序列为一个拓扑序列。
拓扑排序
拓扑排序是对一个有向图构造拓扑序列的过程。构造时会有两个结果,如果此网的全部顶点都被输出,则说明它是不存在回路的AOV网;如果输出顶点少了,即便是少了一个,也说明这个网存在回路,不是AOV网。
拓扑排序算法
对AOV网进行拓扑排序的基本思想是:从AOV网中选择一个入度为0的顶点输出,然后删去此顶点,并删除以此顶点为弧尾的弧,继续重复此步骤,直到输出全部顶点或者AOV网中不存在入度为0的顶点为止。
对以下有向无环图:
构建邻接表:
采用拓扑排序得到的结果:
最开始将入度为0的顶点入栈,即顶点0,1,3,然后依次出栈,同时删除该点与邻接点的连接关系,即将对应邻接点的入度减一,若果发现出现新的入度为0的点,再次加入到栈顶,直到最终栈中不存在入度为0的点。 拓扑排序整个算法,需要遍历顶点n次得到初次的入度为0的点,然后执行入度减一的次数为边数e
因此整个算法的时间复杂度为O(n+e)
关键路径
在一个表示工程的带权有向图中,用顶点表示事件,用有向边表示活动,用边上的权值表示活动的持续时间,这种有向图的边表示活动的网,称之为AOE(Activity On Edge)网。AOE网中没有入边的顶点称为始点或源点,没有出边的顶点称为终点或汇点。对于一个工程一般都有一个开始和结束,因此正常情况下AOE网有一个源点有一个汇点。
AOE网路径上的各个活动所持续的时间之和称为路径长度,从源点到汇点具有最大长度的路径叫关键路径。
关键路径的算法:
找到AOE图中的每个活动的最早开始时间和最晚开始时间,然后比较他们,如果相等就说明这个活动在路径中没有空闲时间,则为关键活动,活动之间的路径就为关键路径
对于以下无环有向图,先使用拓扑序列算法求得各顶点的最早开始时间,再根据拓扑序列的反向顺序逆向求各顶点的最晚开始时间
使用关键路径算法得到的结果:
如果某一个工程存在多个关键路径,只提高某一条关键路径上的关键活动效率并不能提高整个工程的工期,必须同时提高在几条关键路径上的活动效率。
查找表是由同一类型的数据元素(或记录)构成的集合。
关键字是数据元素中某个数据项的值,也称为键值,用它可以标识一个数据元素。也可以标识一个记录的某个数据项,称为关键码。若此关键字可以唯一标识一个记录,则称为该关键字为主关键字。主关键字所在的数据项称为主关键码。
对于那些可以识别的多个数据元素或记录的关键字,称之为次关键字。
查找就是根据给定的某个值,在查找表中确定一个其关键字等于给定值的数据元素或记录。
查找表按照操作方式来分由两大种:静态查找表和动态查找表。
静态查找表
只作查找操作的查找表。它的主要操作有:
(1) 查询某个“特定的”数据元素是否在查找表中
(2)检索某个“特定”的数据元素和各种属性
动态查找表
在查找过程中同时插入查找表不存在的数据元素,或者从查找表中删除已经存在的某个数据元素。动态查找表的操作有:
(1)查找时插入数据元素
(2)查找时删除数据元素
为了提高查找的效率,需要专门为查找操作设置数据结构,这种面向查找的数据结构称为查找结构。
逻辑上,查找所基于的数据结构是集合,集合中的记录之间没有本质关联。为了得到较高的查找性能,就需要改变数据元素之间的关系,在存储时可以将查找集合组织成表、树等结构。
顺序表查找
顺序查找又叫线性查找,是最基础的查找技术,其查找过程: 从表中的第一个或最后一个记录开始,逐个查找记录的关键字和给定值进行比较,相等则查找成功,返回查找到的记录;直到最后一个或第一个记录,其关键字都不能和给定值匹配,则表中不存在所查找的记录,查找不成功。
顺序表查找的时间复杂度为O(n),当n较大时,查找效率低。
有序表查找
有序表是指数据元素已经按照某个顺序进行有序排列。在有序表的基础上,分为折半查找、插值查找、斐波那契查找。
折半查找
折半查找技术,又称为二分查找。前提是线性表中的记录必须是关键码有序,线性表必须采用顺序存储。折半查找的基本思想: 在有序表中,取中间记录作为比较对象,若给定值与中间记录的关键字相等,则查找成功,若给定值小于中间值,则在中间记录左半区查找;若大于中间值,则在中间记录右半区继续查找,不断重复上述过程,直到成功,或者所有区域为无记录,则不存在。
折半查找的时间复杂度为O(logn)
插值查找
插值查找根据要查找的关键字与查找表中的最大最小纪录的关键字比较之后的查找方法,根据要查找的值与最大最小值差的比例来分配下标和查找区域,在折半查找的基础上加以改进:
mid=low+((key-a[low])/(a[high]-a[low]))*(high-low)
该算法从时间复杂度来说也是O(logn),对于数据分布比较均匀的查找表其效率高于折半查找,但对于分布极不均匀的情况下并不太适合使用
斐波那契查找
斐波那契查找利用黄金分割原理实现。
其每次取的比较值的下标按照黄金分割比切开,分别比较左侧和右侧区域,因此首先根据查询数组的长度n在斐波那契数列中的位置,确定分割点的下标位置,并补齐数组长度n的后续位置,防止越界。如果比较结果给定值小于分割点下标对应值,则下一轮查询在左半区,查询个数为F[k-1],k为n在斐波那契数列中位置对应下标,如果比较结果给定值大于分割点下标位置,则下一轮查询在右半区进行,查询个数为F[k-2],因为F[k]=F[k-1] +F[k-2]
线性索引查找
对于无序的大量数据查找,通过索引快速查找所需数据。
索引是为了加快查找速度而设计的数据结构,该过程通过把关键字与它对应的记录相关联。
一个索引由若干个索引项构成,每个索引项至少应包含关键字和其对应的记录在存储器中的位置等信息。索引技术是组织大型数据库以及磁盘文件的一种重要技术。
索引按照结构可以分为:线性索引、树形索引、多级索引。
线性索引是将索引项集合组织为线性结构,也称索引表。
常见的三种线性索引:稠密索引、分块索引、倒排索引。
稠密索引
稠密索引是指在线性索引中,将数据集中的每个记录对应一个索引项。
稠密索引中的索引项按照关键码有序排列。
索引项序列有序,因此在查找关键字时可以使用折半查找、插值查找、斐波那契查找等方法,提高查找效率。
分块索引
稠密索引因为索引项与数据集的记录个数相同,所以空间代价很大。为了减少索引项的个数,对数据集进行分块,使其分块有序,对每一块建立索引项,从而减少索引项的个数。
分块有序,将数据集的记录分成若干块,并满足:
对于分块有序的数据集,将每块对应一个索引项,这种索引方法叫做分块索引。分块索引的索引项分为三个数据项:
分块索引表中查找分两步进行:
分块索引的平均查找长度为 n^(1/2)+1 ,其搜索效率比起顺序查找O(n)效率要高,不过比不上折半查找的log(n)效率。
倒排索引
倒排索引的通用结构:
记录号表中存储具有相同次关键字的所有记录的记录号(可以是指向记录的指针或是该记录的主关键字)。使用这种方式的索引方法为倒排索引。这种索引表中的每一项都包括一个属性值和具有该属性值的各记录地址,由于不是由地址来确定属性值,而是由属性值确定记录的位置,因此称为倒排索引。
二叉排序树
又称为二叉查找树,它或者是一棵空树,或者是具有下列性质的二叉树:
二叉排序树首先一颗二叉树,采用递归的定义方法,它的结点间满足一定的次序关系,左子树结点一定比双亲结点值小,右子树结点一定比双亲结点大
二叉排序树的构造,并不是为了排序,而是为了提高查找、插入、删除关键字的效率。在一个有序数据集上的查找,其效率是要高于无序数据集的,同时二叉树这样的非线性结构,有利于插入和删除的实现。
二叉排序树通过链式存储,保持了链式存储在执行插入或删除时不用移动元素的优点,找到合适的插入和删除位置后,仅仅修改指针即可。对于二叉排序树的查找,走的是从根结点到目标节点的路径,比较次数为给定值的结点在二叉排序树的层数,极端情况为根结点即为要查找结点,这样只需一次,最多不会超过树的深度。因此二叉排序树的查找性能取决于二叉排序树的形状,如果是极端的左斜树或右斜树,那么其查找效率比不上左右相对平衡的二叉树。因此,最好将二叉树构建为平衡二叉树。
平衡二叉树
平衡二叉树是一种二叉排序树,其中每一个节点的左子树和右子树的高度差至多等于1。平衡二叉树是一种高度平衡的二叉排序树。要么它是一棵空树,要么左子树和右子树都是平衡二叉树,且左子树和右子树的深度差绝对值不超过1。将二叉树上结点的左子树减去右子树深度的值称为平衡因子BF(Balance Factor)那么平衡二叉树的平衡因子只能是-1,0或1。也就是说,只要二叉树上有一个结点的平衡因子绝对值大于1,二叉树就是不平衡的。平衡二叉树的前提是:首先它是一颗二叉排序树
距离插入点最近的,且平衡因子绝对值大于1的结点为根结点的子树,称为最小不平衡子树
平衡二叉树实现原理
平衡二叉树构建的基本原理是在构建二叉排序树的过程中,每当插入一个结点时,检查是否因为新的插入而破坏了树的平衡性,如果是,找出最小不平衡子树。在保持二叉排序树的特性下,调整最小不平衡子树的各个节点之间的关系,进行相应的旋转,使之成为新的平衡子树。
二叉平衡树在构建过程中,如果出现最小不平衡子树,当最小不平衡子树的BF大于1时就右旋,如果小于-1就左旋,如果插入新节点后发现最小不平衡子树的BF与结点的BF符号相反,先将结点进行旋转,然后再反向旋转依次完成平衡操作。
对于数组int a[10] = { 3,2,1,4,5,6,7,10,9,8 };进行二叉平衡树算法得到的结果:
多路查找树(B树)
之前涉及的树结构,都是一个节点可以有多个孩子,但自身只存储一个元素。而二叉树的限制更多,结点最多只能有两个孩子。一个结点只能存储一个元素。在元素数量非常多的时候,树的度要么很大,要么深度值很大,这样在数据的存取时,会成为时间效率上的瓶颈,这样需要打破一个结点只能存储一个元素的限制,对此引入多路查找树。
多路查找树,其每一个节点的孩子树可以多于两个,每个结点可以存储多个元素。而且它是查找树,所有元素之间存在特定的排序关系。
对于每一个结点存储多少个元素,以及孩子树的数量,有4种常用的特殊形式:2-3树、2-3-4树、B树和B+树。
2-3树
2-3树每一个节点都具有2个孩子(也称为2结点)或3个孩子(也称为3结点)。
一个2结点包含一个元素和两个孩子(或没有孩子),与二叉排序树类似,左子树包含元素小于该元素,右子树包含元素大于该元素。2结点要么有2个孩子,要么没有孩子。
一个3结点包含一大一小两个元素和三个孩子(或没有孩子),一个3结点要么有3个孩子,要么没孩子。如果3结点右孩子,左子树包含小于较小元素的元素,右子树包含大于较大元素的元素,中间子树包含介于两元素之间的元素。
2-3树的所有叶子结点都在同一层次。
2-3-4树
2-3-4树是2-3树的扩展概念,包括了一个4结点,4结点包含小中大三个元素和4个孩子(或没有孩子),4结点有孩子的话,左子树小于最小的元素,第二子树大于最小元素小于中间元素,第三子树大于中间元素小于最大元素,右子树大于最大元素。
B树(B-tree)
B树是一种平衡的多路查找树,2-3树和2-3-4树都是B树的特例。结点最大的孩子数目称为B树的阶,因此2-3树是3阶B树,2-3-4树是4阶B树。
一个m阶的B树具有如下属性:
B+树
在B树结构中,如果要遍历B树,假设每个结点属于硬盘的不同页面,往返于每个结点之间意味着在硬盘的不同页面之间进行多次访问,如图:
当中序遍历所有结点时,需要从页面2->页面1->页面3->页面1->页面4->页面5
这样来回在硬盘的不停页面之间检索,时间性能低。
为了解决元素的遍历问题,在原有的B树结构基础上,加上新的元素组织形式,形成B+树。
B+树应文件系统所需而出的一种B树变形树。在B树中,每一个元素在该树中只会出现一次,可能在叶子结点,也可能在分支节点。在B+树中,出现在分支节点中的元素会被当作在该分支结点位置的中序后继者(叶子结点)中再次列出。每一个叶子结点都会保存一个指向后一叶子结点的指针。
一棵m阶的B+树和m阶的B树的差异在于:
散列表查找
散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f(key)。查找时,根据确定的对应关系找到给定值key的映射f(key),若查找集合中存在这个记录,则必定在f(key)的位置上。
这种对应关系f称为散列函数,又称为哈希函数(Hash)。按照这个思想,采用散列技术将记录存储在一块连续的存储空间中,这块连续的存储空间称为散列表或哈希表。关键字对应的记录存储位置称为散列地址。
散列过程
散列技术既是一种存储方法,又是一种查找方法。与线性表、树、图等结构不同的是,数据元素之间不存在某种逻辑关系,只与关键字关联。散列主要是面向查找的存储结构。
散列技术最适合的求解问题是查找与给定值相等的记录。对于查找来说,简化了比较过程,效率会提高。
散列技术不适合一个关键字对应多条记录的情况,范围查找,表中记录排序等情况。
散列函数构造方法
两个原则:
常用散列函数构造方法:
直接定址法
取关键字的某个线性函数值为散列地址:
f(key)=a*key+b
需要事先知道关键字的分布情况,适合查找表较小且连续的情况。现实应用中,此法虽简单,并不常用。
数字分析法
数字分析法适合处理关键字位数较多的情况,如事先知道关键字的分布且关键字若干位分布均匀,可以考虑此法。
平方取中法
比如关键字1234 平方 1522756 取中:227
此法比较适合不知道关键字分布,位数又不是很大的情况
折叠法
折叠法将关键字从左至右分割成相等几部分叠加求和,按照散列表的长度,取后几位做为散列地址
此法不需要知道关键字的分布,适合关键字位数较多的情况。
除留余数法
该方法为最常用的构造散列函数的方法,对于散列表长度为m的散列函数公式为:
f(key)=key mode p(p<=m)
若散列表长度为m,通常p为小于或等于表长(最好接近m)的最小质数或不包含小于20质因子的合数。
随机数法
选择一个随机数,取关键字的随机函数值作为其散列地址。也就是f(key)=random(key)。当关键字的长度不等时,可以采取此法。
处理散列冲突的方法
开放定址法
一旦发生散列地址冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。其公式是:
fi(key)=(f(key)+di) MOD m (di=1^2,-1^2...q^2,-q^2,q<=m/2) 二次探测法
fi(key)=(f(key)+di) MOD m (di是同一个随机种子生成的随机数) 随机探测法
再散列函数法
事先准备多个散列函数:
fi(key)=RHi(key) (i=1,2......k)
RGi就是不同的散列函数,每次发生散列地址冲突时,就换一个散列地址,相应地会增加计算时间。
链地址法
当发生地址冲突时,将所有关键字的同义词的记录存储在一个单链表中,这种表称为同义词子表,在散列表中只存储所有同义词表的头指针。如对关键字集合{12,67,56,16,25,37,22,29,15,47,48,34},使用除留余数,以12为除数,得到结构:
公共溢出区法
将所有冲突的关键字建立一个公共溢出区来存放
对给定值通过散列函数计算散列地址后,先与基本表的相应位置进行比对,如果想等,则查找成功,如果不相等,到溢出表进行顺序查找。相对于基本表而言,在冲突数据较少的情况下,公共溢出区的结构对查找性能来说还是较高。
散列查找表的性能分析
如果散列表中不存在冲突的情况,其查找效率是非常高效的,时间复杂度为O(1) 。实际应用中,冲突不可避免,散列查找的平均查找长度与以下因素有关:
散列函数是否均匀
散列函数的均匀程度影响冲突的频繁程度
处理冲突的方法
相同的关键字,相同的散列函数,处理冲突的方法不同,平均查找长度不同。比如线性探测会产生堆积,没有二次探测好,而链地址法处理不会产生冲突
散列表的装填因子
装填因子=记录个数/散列表长度 ,表示装满的程度,不管记录个数多大,选取合适的装填因子可以使平均查找长度限定在一个范围内,通常散列表的空间设置比查找集合要大,这样冲突的可能性相对较小。
排序
排序稳定性
假设Ki=Kj(1<=i<=n,1<=j<=n,i!=j),且在排序前的序列中ri领先于rj。如果排序后ri仍领先于rj,则称所用的排序方法时稳定的;反之,若可能使得排序后的序列中rj领先ri,则称所用的排序方法是不稳定的。
内排序与外排序
内排序是在排序整个过程中,带排序的所有记录全部被放置到内存中,外排序是由于排序的记录个数太多,不同时放在内存中,整个排序过程需要在内外存之间多次交换数据才能进行。
根据排序过程中借助的主要操作,将内排序分为:插入排序、交换排序、选择排序和归并排序。
冒泡排序属于交换排序;
选择排序是通过每一轮比较后挑选出最小的,再与对应位置进行交换,而不是每次比较都进行交换;
插入排序是将一个记录插入到已经排好序的有序表中,得到一个新的,记录增1的有序表;
希尔排序主要是将待排序序列分块完成大致有序,再将块缩小逐步完成排序
希尔排序的关键不是随便分组后各自排序,而是在分组时就应经在排序,将相隔的某个增量的记录组成一个子序列,实现跳跃式移动,使得排序效率增高
堆结构&堆排序
堆是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。如果按照层序的方式给结点1开始编号,结点编号之间满足如下关系:
堆排序算法
堆排序就是利用堆(假设利用大顶堆)进行排序的方法。其基本思想:
将待排序的序列构造成一个大顶堆。整个序列的最大值就是堆顶的根结点。将其移走(将其与对数组的末尾元素交换,此时末尾元素就是最大值),然后将剩余的n-1个序列重新构造成一个堆,这样就会得到n个元素中次大值,反复进行,直到得到最终有序序列。
堆排序的时间复杂度:
堆排序的主要时间消耗在重建堆和筛选的时间上
在重建堆的时候,将结点与其孩子进行比较和必要的互换,对于每个非终端结点,最多比较两次比较和互换操作,构建堆的时间复杂度为O[n]
在进行排序过程时们需要进行n-1次的取堆顶记录,第i次取到堆顶记录重建堆需要O[logi]的时间(完全二叉树的结点到根结点距离为[logi]+1),因此整体的时间复杂度为O[nlogn]
归并排序
归并在数据结构中的定义是将两个或两个以上的有序表组合成一个新的有序表。
**归并排序(Merging Sort)**就是利用归并的思想实现排序的方法。其原理是:假设初始含有n个记录,则可以看成是n个有序的子序列,每个子序列的长度为1,然后两两归并,得到[n/2]个长度为2或1的有序子序列,再两两归并,如此重复,直至得到一个长度为n的有序序列为止,这种排序方法称为2路归并排序。
一趟归并排序需要将原数组的n个元素进行两两归并,将结果放到排序后的数组中,需要将待排的序列中的所有记录扫描一遍,耗费O[n]时间,同时由完全二叉树的深度,整个归并排序需要logn次,因此整体的时间复杂度为O[nlogn]
而在空间复杂度上,归并排序在归并过程中需要与原始记录同样数量的存储空间存放归并结果以及递归深度为logn的栈空间,因此,总的空间复杂度为n+logn
同时,归并排序中由于存在两两比较,不存在跳跃,因此归并排序是一种稳定排序算法。
快速排序
快速排序与冒泡排序一样属于交换排序类。快速排序的实现,增大了记录的比较和移动的距离,将关键字较大的记录从前面直接移动到后面,关键字较小的记录从后面直接移动到前面来,减少总的比较次数和移动交换次数。
快速排序的基本思想:
通过一趟排序将待排序记录分割成可独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。
交换排序最好的情况下,中枢轴的分布正好是类似完全二叉树的结构,其时间复杂度为O[nlogn]
而如果是类似斜树情况下(最坏情况),中枢轴每次都在最前端,需要执行n-1次递归调用,因此比较次数为n-1,n-2....1的求和,即为n(n-1)/2,其时间复杂度为O[n^2]
空间复杂度,主要是由于递归调用会造成相应的栈空间使用,最好情况下,递归的深度为logn,空间复杂度为O[logn],最坏情况下,需要进行n-1次递归,因此空间复杂度为O[n]
**快速排序优化: **
中枢轴的选取
基础的快速排序中,在对子序列进行分割找到中枢轴位置时,中枢轴开始都是选取固定的位置,因此如果固定位置处的中枢轴的关键字大小不合适会造成性能瓶颈,因此在中枢轴的选取上可以采取三数取中。取三个关键字先进行排序,将中间数作为中枢轴,一般取左端,右端和中间三个数。
优化不必要的交换
在寻找中枢轴的位置时,不采用Swap交换的形式,而是采用替换的形式
pivotKey = L->arr[low];
L->arr[0] = pivotKey;
while (lowarr[high] >= pivotKey)
high--;
//Swap(L, low, high);
//使用替换代替交换,节省部分性能
L->arr[low] = L->arr[high];
while (low < high&&L->arr[low] <= pivotKey)
low++;
//Swap(L, low, high);
//使用替换代替交换,节省部分性能
L->arr[high] = L->arr[low];
}
L->arr[low] = L->arr[0];
return low;
优化小数组时的排序方案
当使用小数组时,可以使用直接插入排序,而不是继续使用快速排序来提高整体性能,因此可以检测排序长度,当长度大于某个定值时采用快速排序,而小于某个定值时,直接使用插入排序。
优化递归操作
通过减少递归次数来提高性能
//减少递归次数优化整体性能
while (low < high) {
pivot = Partition(L, low, high);
QSort(L, low, pivot - 1);
low=pivot+1;
}
//pivot = Partition(L, low, high);
//QSort(L, low, pivot - 1);
//QSort(L, pivot+1, high);