基于2023王道数据结构指导书整理的相关知识点,希望可以帮到有需要的人,若转载请标明出处
线性表
顺序存储--------------顺序表
线性表
链式存储 : 单链表
双链表
循环链表
静态链表(借助数组实现)
线性表定义:具有相同数据类型的n(>=0)个数据元素的有限序列,表头元素、表尾元素,除第一个元素外,每个元素有且仅有一个直接前驱;除最后一个元素外,每个元素有且仅有一个后继,是一种逻辑结构
顺序表
逻辑上相邻的两个元素在物理位置上也相邻
最主要特点是可以随机访问,即通过首地址和元素序号可在O(1)时间内找到指定的元素
存储密度高,每个结点只存储数据元素,但插入和删除操作需要移动大量元素
算法平均时间复杂度 最好情况 最坏情况
插入操作 O(n) n 2 \frac{n}{2} 2n O(1) O(n)
删除操作 O(n) n − 1 2 \frac{n-1}{2} 2n−1 O(1) O(n)
按值查找 O(n) n + 1 2 \frac{n+1}{2} 2n+1 O(1) O(n)
单链表
区分一下头结点和头指针,头指针是指向链表第一个节点的地址;头结点是链表的第一个结点
引入头结点的好处:链表第一个位置上的操作和表的其他位置操作一致,无需进行特殊处理,头结点的数据与一般没有意义,某些情况下可以存放表长。开始结点是链表中存储数据的第一个结点
头插法建立单链表
LinkList List_HeadInsert(LinkList &L){
LNode *L=(LinkList)malloc(sizeof(LNode))
L->next=NULL; //初始化空链表
LNode *s=(LinkList) malloc(sizeof(LNode));
s->next=L->next; //核心两步骤
L->next=s;
}
尾插法建立单链表
LinkList List_TailInsert(LinkList &L){
LNode *L=(LinkList) malloc(sizeof(LNode));
L->next=NULL;
LNode *s=(LinkList) malloc(sizeof(LNode));
LNode *r=L; //尾指针
r->next=s; //关键步骤
r=s
}
插入结点操作,插到第i个位置上,头结点为第0个位置
先按序查找,找到第i-1个结点位置
i-1位置结点为 *p
新结点为 *s
s->next=p->next;
p->next=s;
对i结点进行前插操作,其实就转换为对i-1结点的后插,时间复杂度为O(n)
另外的一个做法,将*s插入到*p的后面,然后交换二者的数据部分
s->next=p->next;
p-next=s;
temp=p->data;
p->data=s->data;
s->data=p->data;
删除结点操作
p=GetElem(L,i-1);
q=p->next; //记录被删除结点
p->next=q->next;
free(q); //释放被删除结点
扩展:通过删除该结点后面的结点,并交换数据域来实现删除操作
q=p->next;
p->data=q->data;
p->next=q->next;
free(q);
双链表
插入操作
插入结点s,在结点p后插入s
s->next=p->next; ①
p->next->prior=s; ②
s->prior=p; ③
p->next=s; ④
注意:①②必须在④之前
产出操作
删除结点p
p->prior->next=p->next;
p-next->prior=p->prior;
free(p);
循环单链表
最后一个结点的尾指针指向头结点
初始化:L->next=L;
判空:L->next==L;
尾结点:P->next=L;
循环双链表
最后一个结点的尾指针指向头结点,头结点的prior指向尾结点
判空:L->next==L&&L->prior==L;
静态链表
形象展示,第一列是数组下标,第二列是data,第三列是next
typedef struct{
ElemType data;
int next; //指向下一个数据的数组下标
}
next为1代表结束
0 | 2 | |
---|---|---|
1 | b | 6 |
2 | a | 1 |
3 | d | -1 |
4 | ||
5 | ||
6 | c | 3 |
习题
天勤-习题精选-10-3
两个顺序表A[]和B有序[](按非递减),将其合并为一个有序表(非递减),合并结果放在A[]中,A的最大长度为maxSize;不另设新的顺序表存储空间
思路:从后往前比较,将较大者,放在A[i+j-1]处(将A看作合并后的数组,从该数组的后面开始赋值)
int comb(int A[],int &numberA,int B[],int numberB){
if(numberA+numberB>maxSize)
return -1;//数组上溢,合并失败
int i=numberA,j=numberB;
while(j>0){ //j>0,①是避免了B[j-1]数组越界,②是若B长度为0,不需要合并
if(i==0||A[i-1]<B[j-1]){ //i==0的作用,避免A的长度为0时,A[i-1]出现数组下标越界
A[i+j-1]=B[j-1];
j--;
}
else{
A[i+j-1]=A[i-1];
i--;
}
}
numberA+=numberB;
return numberA;
}
天勤-习题精选-10-4
两个数组A[]和B[],进行比较
思路:先找出A和B中前面对应相等的位置
int comb(int A[],int na,int B[],int nb){
int i=0,j=0;
while(i<na && j<nb && A[i++]==B[j++]);//循环比较相同的部分
i--; //原因有两个,假设是因为A、B循环到尾了而跳出循环,那么最后也会执行i++、j++,
j--; //若不进行--操纵,会导致
//若是因为A和B不相等跳出循环,最后执行了i++和j++,在接下来比较的时候,要先--
if(i==na-1 && j==nb-1) return 0;
if(i==na-1 && j!=nb-1) return -1;
if(i!=na-1 && j==nb-1) return 1;
if(A[i]>B[j]) return 1;
else return -1;
}
天勤-习题精选-10-5
int combM(int lists[][maxSize],int lens[],int m){
for(int i=m-;i>0;i--){
int flag=comb(list[0],lens[0],list[i],lens[i]); //调用了3的函数
if(flag==-1) break;
}
return flag;
}
天勤-习题精选-10-6
int comb(int A[],int na,int B[],int nb,int C[],int &nc){
if(na+nb>maxSize)
return -1;
int i=0,m=na-1,n=nb-1;
while(m>=0 && n>=0){
if(A[m]==B[n]) {
C[i++]=A[m];
m--;
n--
}
if(A[m]<B[n])
n--;
else
m--;
}
nc=i;
return nc;
}
时间复杂度O(na+nb)
天勤-习题精选-10-7
int comb(int A[],int na,int B[],int nb,int C[],int &nc){
if(na+nb>maxSize)
return -1;
int i=0,j=0,k=0;
while(i<na && j<nb){
if(A[i]<=B[j]){
if(k>0){
if(C[k-1]!=A[i])
C[k++]=A[i++];
else
i++;
}
else C[k++]=A[i++];
}
else{
if(k>0){
if(C[k-1]!=B[j])
C[k++]=B[j++];
else
j++;
}
else
C[k++]=B[j++];
}
}
while(i<na){
if(k>0){
if(C[k-1]!=A[i])
C[k++]=A[i++];
else
i++;
}
else
C[k++]=A[i++];
}
while(j<nb){
if(k>0){
if(C[k-1]!=B[j])
C[k++]=B[j++];
else
j++;
}
else
C[k++]=B[j++];
}
nc=k;
return nc;
}
天勤-习题精选-10-8
逆转不带头结点的单链表
h是首元结点
void reverse(LNode *&h){
if(h==null) return;
LNode *p=h->next;LNode *pr=null;
while(p!=null){
h-next=pr;
pr=h;h=p;p=p->next;
}
h->next=pr;
}
就地逆置带头结点的单链表
LNode* reverse(LNode *&L){
LNode *current=L->next;
L->next=null;
while(current){
LNode *currentNext=current->next;
current->next=L->next;
L->next=currentNext;
current=currentNext;
}
return L;
}
https://www.cnblogs.com/onlyblues/p/14529845.html 图解
天勤-习题精选-10-9
void separate(LNode *&LA,LNode *&LB,,LNode *&LC){
LNode *pa,*pb,*pc,*p=LA->next;
pa=LA;pb=LB=(LNode*)malloc(sizeof(LNode));
pc=LC=(LNode*)malloc(sizeof(LNode));
while(p!=null){
if(isdigit(p->data)){
pa-next=p;
pa=p;
}
if(isalpha(p->data)){
pb->next=p;
pb=p;
}
else{
pc->next=p;
pc=p;
}
p=p->next;
}
pa->next=null;pb->next=null;pc->next=null;
}
栈
只允许在一端进行插入或删除操作的线性表
**n个不同元素进栈,出栈元素不同排列的个数为 ** 1 n + 1 C 2 n n \frac{1}{n+1}C_{2n}^n n+11C2nn
顺序栈
用数组实现,根据题目不同条件,进行操作,大概思路就是设置一个int指针,通过对其的++或–来实现入栈出栈的功能
共享栈
利用栈底位置相对不变性,让两个顺序栈共享一个一维数组的空间,0号栈的栈底在数组的0号位置,1号栈的栈底在数组的高端
栈的链式存储
本质上就是一个头插法建立单链表的过程
栈的应用
括号匹配
中缀表达式转后缀表达式:
①遇到操作数直接放入
②遇到界限符。遇到’(‘入栈;遇到’)‘则依次弹出栈内运算符并加入后缀表达式,直到弹出’(‘为止,’('不加入后缀表达式
③遇到运算符,依次弹出栈中优先级高于或等于当前运算符的所有运算符,并加入后缀表达式,若碰到’('或栈空则停止,并将当前运算符入栈
用栈实行后缀表达式的计算
①从左往右扫描下一个元素,直到处理完所有元素
②若扫描到操作数,则入栈,并回到①,否则执行③
③若扫描到运算符,则弹出栈顶两个元素,执行相应操作,运算结果压回栈,回到①
用栈实现中缀表达式运算:
①初始化两个栈,运算符栈和操作数栈
②若扫描到操作数,则入栈
③若扫描到运算符或界限符,则按照中缀转后缀的逻辑,压入运算符栈(期间也会弹出运算符,每当弹出一个运算符,就需要弹出两个操作数进行运算,运算结果再压回栈)
递归要满足的两个条件:①递归体(递归表达式) ②递归出口(边界条件)
队列
操作受限的线性表,只允许在表的一端插入,在另一端输出
顺序队列
缺点:会形成假溢出,即data数组中仍然存在可用存放元素的位置,但是代码却判断队满
循环队列
把存储队列的表从逻辑上视为一个环
如何区分队空还是队满:
①牺牲一个单元来区分队空和队满,约定以队头指针在队尾指针下一位置作为队满的标志,
$(Q.rear+1) $ % M a x S i z e = = Q . f r o n t MaxSize==Q.front MaxSize==Q.front
( Q . r e a r − Q . f r o n t + M a x S i z e ) (Q.rear-Q.front+MaxSize) (Q.rear−Q.front+MaxSize)% M a x S i z e MaxSize MaxSize
②类型中增设表示元素个数的数据成员,队空为Q.size=0
③ 类型中增设tag数据成员,当tag=0时,是删除所导致的Q.front==Q.rear,为队空;tag=1时,是因插入导致,为队满
链式队列
是一个同时带有队头指针和队尾指针的非循环单链表
也是队列的逻辑头位置 队列的逻辑尾
队头指针(头结点) 队尾指针
所以在进行入队操作时,从队尾指针直接插入;进行出队操作时,从队头指针直接删除即可
注意题目是给你的队头/队尾指针,还是带头结点的链表,若是队头\队尾指针,那么进行插入操作时,队头队尾可能都要进行修改
双端队列
允许两端都可用进行入队和出队的队列
队列的应用
树的层序遍历
队列在计算机系统中的应用,第一个方面是解决主机与外部设备之间速度不匹配的问题,如打印缓冲区就是一个队列;第二个是解决由多用户引起的资源竞争问题,如FCFS策略,以及多级反馈队列
数组
对称矩阵
感觉不会考代码题,最多在选择题中出现,背公式也挺麻烦的,只需要记住其特征,现场推即可
比如说按照下三角存储,那么第1行:一个元素
第2行:两个元素
第i行:i个元素
因此元素 a i j a_{ij} aij在数组中的为止是 1 + 2 + . . . + i − 1 + j − 1 1+2+...+i-1+j-1 1+2+...+i−1+j−1,数组下标从0开始
三角矩阵
也就是线代中的上\下三角矩阵
其本质与对称矩阵是一样的,只需要在最后存储一个常量即可
三对角矩阵
可以记一下,在一维数组B中存放的下标为k=2i+j-3 i=(k+1)/3+1 (向下取整)
稀疏矩阵
在压缩后失去了随机存取特性
①三元组(行标、列标、值)
②十字链表法(详见图部分)
串
①子串,子串在主串中的位置以子串的第一个字符在主串的位置来表示
② 当两个串长度相等且每个对应位置的字符都相等时,两个串是相等的
③ 空格串 ≠空串
④ 截断:超过预定义长度的串值会被舍去
⑤ 可用一个额外的变量len来存放串长,也可以在串长的结束标记字符“\”,此时串长为隐含值
⑥ 块链存储表示 每个结点可以存放一个字符、也可以存放多个字符;每个结点称为块,整个链表称为块链
KMP算法
求next数组的步骤:当模式串第j个字符匹配失败时,从模式串的第next[j] 的继续往后匹配
① next[1]=0
②next[2]=1
next数组优化
树
基本概念和性质
①当n>1,其余结点可以分为互不相交的有限集
③结点的度:一个结点的孩子个数
④树的度:树中结点的最大度数
⑤结点的层次:第一层 最高层 :结点的高度
第二层
⑥树的深度,自顶向下,依次增大;树的高度,自底向上,依次增大
⑦有序树:各子树从左到右是有次序的,不能互换
⑧路径:两个结点之间所经过的结点序列构成,是自上而下的 路径长度:路径上所经过的边的个数
树的路径长度是从树根到每个结点的路径长度的总和
⑨森林:m(≥0)互不相交的树的集合
性质:
①树中结点个数等于所有结点度数之和+1
②n个结点的树,有n-1条边(除根节点外,每个结点都有唯一的入边)
③度为m的树种第 i i i层上至多有 m i − 1 m^{i-1} mi−1个结点
④高为h的m叉树最多有 ( m h − 1 ) / ( m − 1 ) (m^h-1)/(m-1) (mh−1)/(m−1)个结点,(等比数列求和)
⑤具有n个结点的m叉树的最小高度为 l o g m ( n ( m − 1 ) + 1 ) log_m (n(m-1)+1) logm(n(m−1)+1)向上取整
⑥高度为h的m叉树最少有h个结点、高度为h的度为m的树最少有m+h-1个结点
二叉树
二叉树的子树有左右之分,其次序不能颠倒
即使树中只有一个子树,也要区分其是左子树还是右子树;二叉树的结点次序不是相对于另一个结点而言,而是确定的
满二叉树
高度为h,含有 2 h − 1 2^h-1 2h−1个结点(等比数列求和)
对于编号为i的结点,左孩子为2i、右孩子为2i-1,双亲结点为i/2 (向下取整)
完全二叉树
当且仅当每个结点都与高度为h的满二叉树中编号为1~n的结点一一对应时,称为完全二叉树
①若 i<n/2(向下取整),是分支结点,否则为叶子结点
②叶子结点只可能出现在层次最大的两层上
③若有度为1的结点,则只可能有一个,且只有左孩子
④一旦出现编号为i的结点为叶子结点或度为1的结点,从该节点往后都是叶子结点
⑤若n为奇数,每个分支结点都有左右孩子;若n为偶数,n/2的分支结点只有左孩子
⑥ 非空二叉树上叶子结点数=度为2的结点数+1 n 0 = n 2 + 1 n_0=n_2+1 n0=n2+1
⑦结点i所在层次为 l o g 2 i ( 向下取整 ) + 1 log_2i(向下取整)+1 log2i(向下取整)+1
顺序存储
用一个一维数组,自上而下、自左向右存储。
完全二叉树和满二叉树用顺序存储比较合适
对于一般的二叉树,为了让数组下标满足能反应二叉树结点之间的逻辑关系,只能添加一些并不存在的空结点,让每个结点与完全二叉树上的结点相对照,会造成资源的浪费;如高度为h的有h给结点的二叉树,h层存储 2 h − 1 2^{h-1} 2h−1个结点
链式存储
n个结点的二叉链表中,有n+1个空链域
习题
①高度为h的二叉树,只有度为0和2的结点,则该二叉树最少含有 2 ( h − 1 ) + 1 2(h-1)+1 2(h−1)+1个结点 h-1是度为2的结点树
②王道P133 13
③王道 P134 18
二叉树的遍历
先序遍历:根、左、右
递归实现:
void PreOrder(BiTree T){
if(T!=null){
visit(T);
PreOrder(T->left);
PreOrder(T->right);
}
}
非递归实现
void PreOrder(BiTree T){
InitStack(S);
BiTree p=T;
while(p||!isEmpty(S)){
if(p){
visit(p);
push(S,p);
p=p->left;
}
else{
Pop(S,p);
p=p->right;
}
}
}
**中序遍历:**左、根、右
递归实现:
void InOrder(BiTree T){
if(T!=null){
InOrder(T->left);
visit(T);
InOrder(T->right);
}
}
非递归实现
void InOrder(BiTree T){
InitStack(S);
BiTree p=T;
while(p||!isEmpty(S)){
if(p){
Push(S,p);
p=p->left;
}
else{
Pop(S,p);
visit(p);
p=p->right;
}
}
}
后序遍历:左、右、根
递归实现
void PostOrder(BiTree T){
if(T!=null){
PostOrder(T->left);
PostOrder(T->right);
visit(T);
}
}
非递归实现
void PostOrder(BiTree T){
InitStack(S);
BiTree p=T;
BiTree r=null;
while(p||!isEmpty(S)){
if(p){
Push(S,p);
p=p->left;
}else{
GetTop(S,p);
if(p->right!=null && p->right!=r)
p=p=>right;
else{
Pop(S,p);
visit(p);
r=p;
p=null;
}
}
}
}
重置p=null:每次出栈访问完一个结点相当于遍历完以该结点为根的子树,需要将p置为null
层序遍历
void LevelOrder(BiTree T){
InitQueue(Q);
EnQueue(Q,T);
while(!IsEmpty(Q)){
DeQueue(Q,p);
vist(p);
if(p->left!=null)
EnQueue(Q,p->left);
if(p->right!=null)
EnQueue(Q,p->right);
}
}
由遍历序列构造二叉树
①先序遍历+中序遍历唯一确定一颗二叉树
②后序遍历+中序遍历唯一确定一颗二叉树
③层序遍历+中序遍历唯一确定一颗二叉树
线索二叉树
目的是为了加速查找结点的前驱和后继结点
lchild | ltag | data | rtag | rchild |
---|
ltag==1,lchild指向结点的前驱
==0,lchild指向左孩子
二叉树线索化是将二叉链表中的空指针改为指向前驱或者后继的线索,其实质就是遍历一次二叉树
中序线索二叉树的构造
void InThread(ThreadTree &p,ThreadTree &pre){
InThread(p->lchild,pre);
if(p->lchild==null){
p->lchild=pre;
p-ltag=1;
}
if(pre!=null && pre->rchild==null){
pre->rchild=p;
pre->rtag=1;
}
pre=p;
InThread(p-rchild,pre);
}
void main(ThreadTree T){
ThreadTree pre=null;
if(T!=null){
InThread(T,pre);
pre->rchild=null; //处理最后一个结点
pre->rtag=1
}
}
求中序线索二叉树中中序序列下的第一个结点
ThreadTree *FirstNode(ThreadNode *p){
while(p->ltag==0) p=p->lchild;
return p;
}
求中序线索二叉树结点p在中序序列下的后继
ThreadTree *NextNode(ThreadNode *p){
if(p->rtag==0) //找右子树中序遍历的第一个结点
return FirstNode(p->rchild);
else return p->rchild;
}
求中序线索二叉树中序序列下的最后一个结点
ThreadTree *LastNode(Thread *p){
while(p->rtag==0)
p=p->right;
return p;
}
求中序线索二叉树结点p在中序序列下的前驱
ThreadTree *PreNode(ThreadNode *p){
if(p->ltag==0)
return LastNode(p->lchild);
else return p->lchild;
}
习题
①在二叉树中有两个结点m和n,若m是n的祖先,则使用后序遍历可以找到从m到n的路径 王道P147 05
②一颗非空的二叉树的先序遍历和后序遍历序列正好相反,则该二叉树一定满足:只有一个叶子结点
③线索二叉树是一种物理结构
④后序线索二叉树的遍历仍需栈的支持
⑤ 王道P149-29、30
⑥ 先序序列a,b,c,d的不同二叉树的个数是 1 n + 1 C 2 n n \frac{1}{n+1}C_{2n}^{n} n+11C2nn
⑦若某非空二叉树的先序序列和后序序列正好相反,则该二叉树的形态:先序序列NLR,后序序列LRN
要满足 NLR=NRL 就要左子树或右子树为空,所以这样的二叉树每层只有一个结点
⑧若某非空二叉树的先序序列和后序序列正好相同,则NLR=LRN,N和L都为空,只有一个根节点
树的存储结构
①双亲表示法
用一组连续空间来存储每个结点,同时在每个结点中增设一个伪指针,指示其双亲结点在数组中的位置
可以很快得到每个结点的双亲结点,但求孩子时要遍历整个结构
区别树的顺序存储和二叉树的顺序存储结构,二叉树的顺序存储的数组下标既代表了结点的编号有代表了各结点之间的关系
②孩子表示法
将每个结点的孩子结点都用单链表链接起来形成一个线性结构,找子女是非常容易,但找双亲要遍历n个结点中孩子链表指针域所指向的n个孩子链表
③孩子兄弟表示法
又称二叉树表示法,以二叉链表作为树的存储结构
每个结点包括:结点值、指向结点第一个孩子结点的指针、指向结点下一个兄弟结点的指针(沿此域可以找到结点的所有兄弟结点)
最大优点是可以方便的将树转换为二叉树
树、森林、二叉树的转换
①F是一个森林,B是由F变换来的二叉树。若F中有n个非终端结点,则B中右指针域为空的结点有n+1;
②森林F转换为对应的二叉树T,F中叶结点的个数等于T中左孩子指针为空的结点个数
树和森林的遍历
树的遍历:
①先根遍历:先访问根结点,再访问根结点的每颗子树,与这棵树相应的二叉树的先序遍历相同
②后根遍历:先访问根结点的每个子树,再访问根结点,与这棵树相应的二叉树的中序遍历相同
森林的遍历:
①先序遍历森林:先访问第一棵树的根节点;先序遍历其子树;先序遍历除去第一颗树之后的子树森林
②中序遍历森林:中序遍历森林中第一颗树的根结点的子树;访问第一棵树的根节点;中序遍历除去第一棵树之后剩余的树构成的森林
哈夫曼树和哈夫曼编码
①带权路径长度,WPL(树的带权路径长度)
②哈夫曼树的构造
给n个结点,构造过程中新建了n-1个结点,构造的哈夫曼树结点总数为2n-1,不存在度为1的结点
③可变长度编码 前缀码:没有一个编码是另一个编码的前缀
④若度为m的哈夫曼树中,叶子节点个数为n,则非叶子结点个数为 n − 1 m − 1 \frac{n-1}{m-1} m−1n−1向上取整
⑤王道P186 13
图
顶点集V、边集E,图不可以是空图,也就是说图中顶点集V一定非空,但边集E可以为空
有向图
若E是有向边(也成为弧)的有限集合时。弧是顶点的有序对<v,w>,其中v是弧尾,w是弧头,<v,w>称为从v到w的弧,也称为从v邻接到w
无向图
E是无向边(简称边)的有限集合。(v,w),边依附于v,w;边和v、w相关联
简单图
①不存在重复边;②不存在顶点到自身的边;
完全图
在无向图中,边|E|的取值范围是0~ n ( n − 1 ) 2 ( C n 2 ) \frac{n(n-1)}{2}(C_n^2) 2n(n−1)(Cn2),当有 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1)条边时,是完全图,任意两个顶点之间都存在边
有向图中,边|E|的取值范围是0~ n ( n − 1 ) n(n-1) n(n−1),当有n(n-1)条弧时,是完全图,任意两个顶点之间都存在方向相反的弧
子图
V’是V的子集、E’是E的子集,则称G’是G的子图
若有满足V(G’)=V(G)的子图G’,称其为G的生成子图
并非所有V和E的任意子集都能构成G的子图,当E子集中的某些边关联的顶点不在这个子集中时
连通、连通图、连通分量
在无向图中,若从顶点v到w有路径存在,则称v和w是连通的。若图G中任意两个顶点都是连通的,则称图G是连通图。
无向图中的极大连通子图称为连通分量
若一个图有n个顶点,若边数小于n-1,那么必定是非连通图
非连通图最多可以有几条边?n-1个顶点构成一个完全图( C n − 1 2 C_{n-1}^2 Cn−12),此时再任意加入一条边,就变成连通图
强连通图,强连通分量
若一对顶点v、w,从v到w和从w到v之间都有路径,则称两个顶点是强连通的
图中任意一对顶点都是强连通的,则是强连通图
有向图中极大强连通子图称为连通分量
n个顶点,若是强连通图,则至少需要n条边(连成一个回路)
生成树、生成森林
连通图的生成树是包含图中所有顶点的极小连通子图。若图中有n个顶点,则其生成树含有n-1条边
对于生成树而言,砍去它的一条边,会变成非连通图,加上一条边,会变成一个回路
在非连通图中,连通分量的生成树构成了生成森林
顶点的度、入度和出度
在无向图中,顶点v的度是指依附于顶点v的边的条数记为TD(v)
无向图中,全部顶点的度的和是边数的2倍
在有向图中,入度(ID)是以v为终点的有向边数目;出度(OD)是以v为起点的有向边数目
TD=ID+OD
有向图中,全部顶点的入度之和=出度之和=边数之和
边的权和网
在图中,边可以标上某种具体含义,该数值称为边的权值,这种带权图,也成为网
稠密图、稀疏图
相对而言的,一般G满足|E|<|V|log|V|,是稀疏图
路径、路径长度和回路
顶点v到w之间的一条路径是指顶点序列v、x、y、z…w
路径上边的数目称为路径长度
第一个顶点和最后一个顶点相同的路径称为回路或环
若一个图有n个顶点,有大于n-1条边,此图一定有环
简单路径、简单回路
顶点不重复出现的路径是简单路径。除第一个和最后一个顶点外,其余顶点不重复出现的是简单回路
距离
若从v到w最短路径存在,最短路径长度称为距离
习题:①若无向图G有7个顶点,要保证图G在任何情况下都是连通的,则需要的边数至少为 C 6 2 + 1 C_6^2+1 C62+1
图的存储
邻接矩阵法
用一个一维数组存储图中顶点的信息,用一个二维数组存储图中边的信息(这个二维数组被称为邻接矩阵)
①无向图的邻接矩阵是对称矩阵(且唯一),对规模大的可以采用压缩存储
②空间复杂度为 O ∣ n 2 ∣ O|n^2| O∣n2∣ n为顶点V个数
③无向图的邻接矩阵第i行(或第i列)非零元素的个数是该顶点的度
④有向图第i行的非零元素个数是顶点i的出度,第j列非零元素的个数是顶点j的入度
⑤很容易确定两顶点之间是否存在边,但要确定图中有多少边,必须按行/列对每个元素进行检测,花费时间代价大
⑥适合稠密图存储
⑦ A n [ i ] [ j ] A^n[i][j] An[i][j]表示由顶点i到顶点j的长度为n的路径的数目
邻接表法
为图G的每个顶点 v i 建立一个单链表 v_i建立一个单链表 vi建立一个单链表,这个单链表就称为 v i v_i vi的边表。边表头指针和顶点数据信息采用顺序存储(称为顶点表)
顶点表结点:
data firstarc
顶点域 边表头指针
边表结点
adjvex nextarc
该弧/边所指向的顶点位置 指向下一条弧的指针
①若为无向图,存储空间为 O ( V + 2 E ) O(V+2E) O(V+2E),若为有向图 O ( V + E ) O(V+E) O(V+E)
②适合稀疏图的存储
③给定一个顶点,能很快的找到其所有的邻边,但要确定两个顶点之间是否存在边,查找效率较低
④图的邻接表表示并不唯一
⑤在有向图的邻接表中,求一个顶点的出度是很简单的,只需查找其邻接表,但求其入度是效率低的
十字链表
是有向图的一种链式存储方式
弧结点
tailvex | headvex | hlink | tlink | info |
---|
该弧的弧尾结点 该弧的弧头结点 指向弧头相同的下一条弧 指向弧尾相同的下一条弧 弧相关信息
顶点结点
data | firstin | firstout |
---|
结点信息 以该顶点为弧头的第一个弧结点 以该顶点为弧尾的第一个结点
弧头相同的弧在一个链表上,弧尾相同的弧在一个链表上
十字链表表示是不唯一的,但一个十字链表确定一个图
邻接多重表
是无向图的一种链式存储方式
边结点
mark | ivex | ilink | jvex | jlink | info |
---|
标记该边是否被搜索过 依附于该边的一个顶点 下一条依附于ivex的边 依附于改变的一个顶点 边相关信息
顶点结点
data firstedge
结点信息 指向第一条依附于该顶点的边
所有依附于同一顶点的边串联在同一链表中
其与邻接表的差别仅在于:同一条边在邻接表中用两个结点表示,而在邻接多重表中只有一个结点
将边作为一个实体拿出来就可以重复利用,解决了邻接表冗余的缺点
图的遍历
广度优先搜索
思路类似于二叉树的层序遍历
bool visited[MAX_VERTEX_NUM];
void BFSTraverse(Graph G){
for(int i=0;i<G.vexnum;i++)
visited[i]=false;
for(int i=0;i<G.vexnum;i++){
if(visited[i]==false)
BFS(G,i);
}
}
void BFS(Graph G,int v){
InitQueue(Q);
visit(v);
visited[v]=true;
Enqueue(Q,v);
while(!isEmpty(Q)){
Dequeue(Q,v);
for(w=FirstNeighbor(Q,v);w>=0;w=NextNeighbor(Q,v,w)){
if(visited[w]==false){
visit(w);
visited[w]=true;
}
Enqueue(Q,W);
}
}
}
算法性能分析:
空间复杂度O(|V|);
时间复杂度:采用邻接表存储方式时,每个顶点均需搜索一次,搜索任意顶点的邻接点时,每条边至少访问一次,O(|V|+|E|)
采用邻接矩阵存储方式时,要将整个矩阵遍历一次,O( ∣ V 2 ∣ |V^2| ∣V2∣)
广度优先搜索求解单源最短路径问题
void BFS_MIN_Distance(Graph G,int u){
int d[G.vexnum];
for(int i=0;i<G.vexnum;i++){
d[i]=∞;
}
d[u]=0;InitQueue(Q);
visited[u]=false;
Enqueue[Q,u];
while(!isEmpty(Q)){
Dequeue(Q,u);
for(w=FirstNeighbor(G,u);w>=0;w=NextNeighbor(G,u,w)){
if(visited[w]==false){
visited[w]=true;
d[w]=d[u]+1;
Enqueue(Q,w);
}
}
}
}
广度优先生成树,一给定的邻接矩阵的广度优先生成树是唯一的,但由于邻接表存储表示 不唯一,其广度优先生成树也不唯一
深度优先搜索
类似于树的先序遍历
bool visited[MAX_VERTEX_NUM];
void DFSTraverse(Graph G){
for(int i=0;i<G.vexnum;i++)
visited[i]=false;
for(int i=0;i<G.vexnum;i++){
if(visited[i]==false)
DFS(G,i);
}
}
void DFS(Graph G,int v){
visit(v);
visited[v]=true;
for(w=FirstNeighbor(Q,v);w>=0;w=NextNeighbor(Q,v,w)){
if(visited[w]==false)
DFS(G,w);
}
}
对于同一个图,基于邻接矩阵的遍历得到的DFS和BFS序列是唯一的,基于邻接表法得到的DFS和BFS序列是不唯一的
算法分析:空间复杂度O(|V|),需要一个辅助空间栈
时间复杂度:基于邻接矩阵O( ∣ V 2 ∣ |V^2| ∣V2∣)
基于邻接表O(|V|+|E|);
深度优先的生成树和生成森林,通过深度优先搜索,得到的是该图的所有连通分量,基于邻接表存储的深度优先生成树是不唯一的
对于无向图,调用BFS和DFS的次数等于该图的连通分量个数
对于有向图,不是这样的
最小生成树
一个连通图的生成树包含图的所有顶点,并且只含有尽可能少的边。对于生成树来说,若砍去它的一条边,则会使树变成非连通图;若给它增加一条边,则会形成图中一条回路
最小生成树:①不是唯一的,当图G中各边权值互不相等时,G的最小生成树是唯一的;若无向连通图G的边数比顶点数少一,那么它本身就是最小生成树;②最小生成树权值之和总是唯一的;③最小生成树的边数为顶点数减一
Prim算法
①随机选取一个顶点v加入树T
②选择一个与当前T中顶点集合距离最近的顶点,并将该顶点和对应的边加入树T
③重复,直至所有的顶点都加入到树中
算法时间复杂度为O( ∣ V ∣ 2 |V|^2 ∣V∣2),适用于求解边稠密的图的最小生成树
Kruskal算法
①先确定一条代价最小的边
②再确定一条代价最小的边(要求这条边所连接的两个顶点u和v属于不同的连通分量)
③重复,直至形成最小生成树
时间复杂度为O(|E|log|E|),适用于边稀疏而顶点较多的图
最短路径
Dijkstra算法
求单源最短路径问题,设置一个集合S记录以求得的最短路径的顶点,初始时把原点 v 0 v_0 v0放入S,集合S每并入一个新顶点时,都要修改原点 v 0 v_0 v0到集合V-S中顶点当前的最短路径长度
需要两个辅助数组:①dist[]:记录从源点到其他各顶点当前的最短路径长度
②path[]:path[i]表示从源点到顶点i之间的最短路径的前驱结点,在算法结束时,可以根据其值追溯得到最短路径
(102条消息) 迪杰斯特拉算法——最短路径_神石石的博客-CSDN博客_dijkstra算法path数组
步骤:①初始化结合S={0}dist[i]=arcs[0] [i]
②dist[j]=Min{dist[i]| v i ∈ V − s v_i∈V-s vi∈V−s}
③修改从 v 0 v_0 v0出发到集合V-S上任意顶点 v k v_k vk可达的最短路径长度,若dist[j]+arcs[j] [k]<dist[k],更新dist[k]=dist[j]+arcs[j] [k]
④重复②~③操作共n-1次,直到所有顶点都包含在S中
是基于贪心策略的,使用邻接矩阵时,时间复杂度为O( ∣ V ∣ 2 |V|^2 ∣V∣2),当边上带有负权值时,Dijkstra算法并不适用(已求得最短路径的顶点集,归入S内的结点的最短路径不再变更)
Floyd算法
求各顶点之间最短路径问题
基本思想:递推产生一个n阶方阵序列 A − 1 、 A 0 、 A k . . A n − 1 A^{-1}、A^0、A^k..A^{n-1} A−1、A0、Ak..An−1,其中 A k [ i ] [ j ] A^k[i][j] Ak[i][j]表示从顶点 v i 到 v j v_i到v_j vi到vj绕行第k个结点的运算步骤,是一个迭代的过程,每迭代一次,最短路径上就多考虑了一个顶点
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-glHaKpUR-1673089058472)(C:\Users\HP\Desktop\Floyd.jpg)]
算法时间复杂度为O( ∣ V ∣ 3 |V|^3 ∣V∣3),允许图中有带负权值的边,但不允许有包含负权值的边组成的回路,通过迪杰斯特拉算法来求全部顶点的最短路径,时间复杂度也为O( ∣ V ∣ 3 |V|^3 ∣V∣3).
有向无环图描述表达式
有向无环图(DAG图)是描述含有公共子式的表达式的有效工具
拓扑排序
AOV网(Activity on vertex),用顶点表示活动的有向无环图。
拓扑排序:当一个有向无环图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑排序:
①每个顶点出现且只出现一次
②若顶点A在序列中排在顶点B的前面,则在图中不存在从顶点B到顶点A的路径
拓扑排序算法实现:
bool TopologicalSort(Graph G){
InitStack(S);
for(int i=0;i<G.vexnum;i++){
if(indegree[i]==0)
Push(S,i);
}
int count=0; //记录已经输出的顶点数
while(!isEmpty(S)){
Pop(S,i);
print[count++]=i;
for(p=G.vertices[i].firstarc;p;p=p->nextarc){
v=p->adjvex;
if(!(--indegree[v]))
Push(S,v);
}
}
if(count==G.vexnum)
return true;
else
return false;
}
采用邻接表存储的拓扑排序的时间复杂度为O( ∣ V ∣ + ∣ E ∣ |V|+|E| ∣V∣+∣E∣),采用邻接矩阵实现的时间复杂度为O( ∣ V ∣ 2 |V|^2 ∣V∣2)
用深度优先遍历也可以实现拓扑排序,修改递归方式实现DFS算法,将输出顶点信息的语句移到退出递归前,则输出顶点序列是G的逆拓扑排序
逆拓扑排序:从AOV网中选择一个没有后继的顶点输出,删除和该顶点有关的所有边,重复操作
若一个顶点有多个直接后继,则拓扑排序的结果通常不唯一;若各个顶点已经排在一个线性有序的序列中,每个顶点有唯一的前驱后继关系,则拓扑排序结果唯一;对于一般的图来说,若其邻接矩阵是三角矩阵,则存在拓扑排序
有向图存在环路,则一定不存在拓扑排序;拓扑排序唯一,不可以唯一确定该图
关键路径
AOE网(Activity on Edge),用边表示活动,是有向无环图,AOE网中边有权值,而AOV网中,边仅表示顶点之间的前后关系
AOE网具有两个性质:①只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始
②只有在进入某顶点的各有向边所代表的活动都结束时,该顶点所代表的事件才能发生
从源点到汇点的所有路径中,具有最大路径长度的路径称为关键路径 ,关键路径上的活动称为关键活动
完成整个工程的最短时间就是关键路径的长度
事件 v k v_k vk的最早发生时间 v e ( k ) ve(k) ve(k)
事件 v k v_k vk的最迟发生时间 v l ( k ) vl(k) vl(k)
活动 a i a_i ai的最早发生时间 e ( i ) e(i) e(i)
活动 a i a_i ai的最迟发生时间 l ( i ) l(i) l(i)
活动 a i 的最迟开始时间和其最早开始时间的差额 d ( i ) = l ( i ) − e ( i ) a_i的最迟开始时间和其最早开始时间的差额d(i)=l(i)-e(i) ai的最迟开始时间和其最早开始时间的差额d(i)=l(i)−e(i)
指在不增加完成整个工程所需的总时间的情况下,活动 a i a_i ai可以拖延的时间。若d(i)=0,那么该活动是关键活动
注意:①不能任意缩短关键活动,可能会导致该关键活动变为非关键活动
②关键路径并不唯一
习题
①最短路径一定是简单路径(不含重复顶点的路径)
②深度优先遍历、拓扑排序、求关键路径可以判断一个有向图是否有环
查找
平均查找长度ASL,一次查找长度是指需要比较的关键字次数,而平均查找长度是所有查找过程中进行关键字比较次数的平均值
顺序查找
带哨兵机制的顺序查找(线性查找)一般线性表的顺序查找
int search(SSTable table,ElemType key){
table.elem[0]=key;
for(int i=table.len;table.elem[i]!=key;i--);
return i;
}
当返回0时,说明查找失败,通过该哨兵,我们避免了不必要的数组越界判断,提高了程序效率
A S L 成功 = n + 1 2 ASL_{成功}=\frac{n+1}{2} ASL成功=2n+1
A S L 不成功 = n + 1 ASL_{不成功}=n+1 ASL不成功=n+1
有序表的顺序查找
通过查找判定树来对查找失败的ASL进行分析
折半查找
二分查找,仅使用与有序的顺序表
int Binary_Search(SeqList L,ElemType key){
int low=0,high=L.TableLen-1,mind;
while(low<=high){
mid=(low+high)/2;
if(L.elem[mid]==key)
retrun mid;
else if(L.elem[mid]>key)
high=mid-1;
else
low=mid+1;
}
return -1;
}
通过判定树(折半查找的判定树是二叉平衡树来进行ASL分析),折半查找判定树实际上是一颗二叉排序树,中序序列是有序序列
时间复杂度为 O ( l o g 2 n ) O(log_2n) O(log2n)
给定一个长度为n的顺序表,按关键字大小顺序排列,若采用折半查找,查找一个不存在的元素,则比较的次数至少是 l o g 2 ( n + 1 ) − 1 log_2(n+1)-1 log2(n+1)−1,最多是 l o g 2 ( n + 1 ) log_2(n+1) log2(n+1) (最小的分支高度对应于最少的比较次数,最大的分支高度对应于最多的比较次数)
给定四个二叉树,问可能称为折半查找判定树的是(王道P263-21)
分块查找
又称为索引顺序查找,基本思想,将查找表分为若干子块,块内元素可以无序,但块之间是有序的(操作系统文件逻辑结构中的索引顺序文件),即第一个块中的最大关键字小于第二个块中的所有关键字
步骤:一是在索引表中确定待查记录的块,可以顺序查找也可以折半查找;二是在块内顺序查找
当块间和块内都顺序查找时,ASL=
当块间用折半查找,块内顺序查找时,ASL=
王道P264-07 408-13
包含4个数据元素的集合S={'do','for','repeat'.'while'},个元素的查找概率依次为0.35、0.15、0.15、0.35,将S保存在一个长度为4的顺序表中,采用折半查找,查找成功的ASL=2.2
1)若采用顺序存储结构,且要求平均查找长度更短,则元素如何排列,应使用何种查找方式,查找成功的ASL是多少
若各个元素的查找概率不同,那么折半查找的性能不一定优秀,我们尝试用顺序查找的方式来试一下
根据查找概率一次减少排列,for、repeat、do、while 这个序列,用顺序查找ASL=0.35+0.35*2+0.15*3+0.15*4=2.1<2.2
2)若采用链式存储结构,要求ASL更短,如何排列,使用何种查找方法
若采用链式,顺寻查找,和1)一样都是2.1
若采用二叉排序树的链式结构,ASL=0.15*1+0.35*2+0.35*2+0.15*3=2.0
树型查找
二叉排序树
定义:若左子树非空,则左子树所有结点的值均小于根结点值
若右子树非空,则右子树所有结点的值均大于根结点值
左右子树也分别是一颗二叉排序树
二叉排序树的查找
BSTNode *BST_Search(BiTreee T,ElemType key){
while(T!=null && key!=T->data){
if(T->data <key) T=T->rchild;
else T=T->lchild;
}
return T;
}
二叉排序树的插入
插入的结点一定是一个新添加的叶结点,且是查找失败时的访问路径上最后一个结点的左孩子或右孩子
int BST_Insert(BiTree &T,KeyType k){
if(T==NULL){
T=(BiTree) malloc(sizeof(BSTNode));
T->data=k;
T->lchild=T->rchild=NULL;
return 1;
}
else if(T->data==k) return 0;
else if(T->data >k) return BST_Insert(T->lchild,k);
else return BST_Insert(T->rchild,k);
}
二叉排序树的构造
不同关键字序列可能得到相同的二叉排序树
void Creat_BST(BiTree &T ,KeyType str[],int n){
T=NULL;
int i=0;
while(i<n){
BST_Insert(T,str[i]);
i++;
}
}
二叉排序树的删除
Ⅰ删除的是叶子结点,直接删除
Ⅱ若结点z只有一颗左子树或右子树,则让z的子树称为z父结点的子树,代替z的位置
Ⅲ若结点z有左子树和右子树,则让z的直接后继代替z的位置(右子树的最左结点)
二叉排序树的查找效率分析
其查找效率主要取决于树的高度,若二叉排序树的左、右子树的高度差不超过1,则这样的二叉排序树称为平衡二叉树,它的平均查找长度为 O ( l o g 2 n ) O(log_2 n) O(log2n),若是一个只有左(右)孩子的单支树,则其平均查找时间为O(n)
二分查找的判定树唯一,二叉排序树的查找不唯一,就维护表的有序性而言,二叉排序树无需移动结点,只需修改指针即可完成插入和删除操作,平均执行时间 O ( l o g 2 n ) O(log_{2} n) O(log2n), 而二分查找代价是O(n)。当有序表是静态表时,宜用顺序表作为其存储结构,采用二分查找实现;若有序表是动态查找表,应选择二叉排序树作为其逻辑结构
平衡二叉树
适用于以查为主,很少有插入或删除的场景
要保证任意结点的左、右字数高度差的绝对值不超过1
平衡二叉树的插入
每插入一个结点时,首先检查其插入路径是否因为此次操作而导致不平衡。若导致了不平衡,则先找到插入路径上离插入结点最近的平衡因子的绝对值大于1的结点A,在对以A为根的子树进行调整
注意:每次调整的都是最小不平衡二叉树
①LL平衡旋转(右单旋转),由于结点A的左孩子的左子树上插入新结点所导致不平衡
②RR平衡旋转(左单旋转),由于结点A的右孩子的右子树上插入新结点所导致不平衡
③LR平衡旋转(先左后右双旋转),由于结点A的左孩子的右子树上插入新结点导致不平衡
④RL平衡旋转(先右后左双旋转),由于结点A的右孩子的左子树上插入新结点导致不平衡
平衡二叉树的删除
①用二叉排序树的方法对结点w进行删除
②从结点w开始,向上回溯,找到第一个不平衡的结点z
③对以z为根的子树进行平衡旋转调整
平衡二叉树的查找
n h 表示深度为 h 的平衡树中含有的最少结点数,显然有 n 0 = 0 , n 1 = 1 , n 2 = 2 , 并且有 n h = n h − 1 + n h − 2 + 1 n_h表示深度为h的平衡树中含有的最少结点数,显然有n_0=0,n_1=1,n_2=2,并且有n_h=n_{h-1}+n_{h-2}+1 nh表示深度为h的平衡树中含有的最少结点数,显然有n0=0,n1=1,n2=2,并且有nh=nh−1+nh−2+1
其平均查找长度为 O ( l o g 2 n ) O(log_{2}n) O(log2n)
红黑树
是一颗满足以下性质的二叉排序树:
①每个结点是红色或者黑色
②根结点是黑色的
③叶结点(虚构的外部结点,NULL结点)都是黑色的
④不存在两个相邻的红结点
⑤对每个结点,从该结点到任一叶结点的简单路径上,所含黑结点数量相同
为了便于对红黑树的实现和理解,我们引入了n+1个叶结点,以保证红黑树的每个结点的左右孩子均非空
从某结点出发(不含该结点)到达一个叶结点的黑高(记为bh),黑高的概念是由性质⑤决定的
结论一:从根结点到叶结点的最长路径不大于最短路径的2倍,最短路径必然全部由黑结点构成,最长路径由红黑相间构成,此时红黑结点数量相同
结论二:有n个内部结点的红黑树高度≤ 2 l o g 2 ( n + 1 ) 2log_{2}(n+1) 2log2(n+1)
若根结点黑高为h,内部结点数至少为:总共h层的黑结点的满树形态 2 h − 1 2^h-1 2h−1
红黑树的“适度平衡“,任意结点的左右子树的高度,相差不超过2倍,C++中的map和set(java中的TreeMap和TreeSet)就是用红黑树实现的
红黑树的插入
插入只会满足红红不相邻这一性质
结论三:新插入红黑树中的结点初始着为红色,是因为若着黑色,那么每次插入都会破坏性质⑤(该结点所在路径比其他路径多一个黑结点)
①用二叉查找树插入法插入,并将结点z染为红色,若z的父结点是黑色,无需左任何调整
②若结点z是根结点,将z染为黑色,结束
③若结点z不是根结点,且z的父结点是红色,则分为下面三种情况
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LYLo7eJT-1673089058473)(C:\Users\HP\Desktop\笔记照片\红黑树插入.jpg)]
红黑树的删除
时间复杂度 O ( l o g 2 n ) O(log_{2} n) O(log2n), 处理方式和二叉排序树的删除一样
习题:
①平衡二叉树的高度为6,且所有非叶子结点的平衡因子均为1,则该平衡二叉树的结点总数为<==>具有6层结点的AVL至少有几个结点
②如果红黑树的所有结点都是黑色结点,那么它一定是一颗满二叉树
③红黑树是特殊的AVL(×) 是特殊的二叉排序树
④对于非空二叉排序树,删除某节点v后又插入该结点v,那么若v是叶子结点,则前后二叉排序树不变;若v是非叶子,则变
对于非空平衡二叉树,删除某节点v后又插入该结点v,那么若v是叶子结点,则前后二叉排序树可能不变;若v是非叶子,则可能不变
B树和B+树
B树,又称为多路平衡查找树,B树中所有结点的孩子个数的最大值称为B树的阶,通常用m表示
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9wdcl0yi-1673089058474)(C:\Users\HP\Desktop\笔记照片\5叉查找树.jpg)]
m阶B树是空树或满足如下特性的m叉树:
①树中每个结点至多m棵子树,即至多含有m-1个关键字
②若根结点不是终端结点,则至少有两颗子树
③除根结点外的所有非叶结点至少有m/2 (向上取整)颗子树,即至少含有m/2(向上取整)-1个关键字
④所有的叶结点出现在同一层,且不带信息(可视为外部节点,或类似于折半查找失败的结点)===>B树是平衡因子均为0的多路平衡查找树
含有n个关键字的m阶B树,最小高度和最大高度是多少(大部分B树的高度不算叶子结点的高度)
最小高度:让每个结点尽可能的满,有m-1个关键字,m个分叉
$ n≤(m-1)(1+m+…+m^{h-1})===>h≥log_{m}(n+1)$
最大高度:让每个结点尽可能的少,即根结点只有两个分叉,其他结点只有m/2(向上取整)个分叉,每层结点至少有:第一层1,第二层2、第三层2(m/2 向上取整)、第h层2 ( m − 2 向上取整 ) h − 2 (m-2向上取整)^{h-2} (m−2向上取整)h−2 ,第h+1层共有叶子结点(失败结点)2 ( m − 2 向上取整 ) h − 1 (m-2向上取整)^{h-1} (m−2向上取整)h−1
n个关键字的B树必有n+1个叶子结点,则n+1≥2 ( m − 2 向上取整 ) h − 1 (m-2向上取整)^{h-1} (m−2向上取整)h−1 ,即h≤ l o g ( m / 2 ) n + 1 2 + 1 log_{(m/2)}\frac{n+1}{2}+1 log(m/2)2n+1+1
B树的插入
①通过查找,找出插入该关键字的最底层中的某个非叶子结点
②插入后检查被插入结点内关键字的个数,当插入后的结点关键字个数大于m-1,则必须对结点进行分裂
从中间位置(m/2向上取整)将其中的关键字一分为二,中间位置的结点插入原结点的父结点,若此时导致父结点的关键字个数也超过了上限,则继续这种分裂操作直至过程传导至根结点为止,进而导致B树高度加一
B树的删除
①若被删除关键字在终端结点,且直接删除不会导致该结点关键字个数少于规定,则删除
②若删除的关键字在非终端结点,则用直接前驱或者后继来代替被删除的关键字,这就转换为了对终端结点关键字的删除
③若被删除关键字在终端结点,且直接删除会导致该结点关键字个数少于规定
若兄弟够借,(右兄弟很宽裕),用当前结点的后继、后继的后继来补填空缺
(左兄弟很宽裕),用当前结点的前驱、前驱的前驱来补填空缺
若兄弟不够借,将该结点与左/右兄弟以及双亲结点中的关键字进行合并
B+树
是应数据库所需而出现的一种B树的变形树
①每个分支最多有m棵子树
②非叶根结点至少有两颗子树,其他每个分支至少有m/2(向上取整)颗子树 关键字n的范围是 m / 2 ( 向上取整 ) ≤ n ≤ m m/2(向上取整)≤n≤m m/2(向上取整)≤n≤m
③结点的子树个数与关键字个数相等,具有n个关键字的结点含有n棵子树
④所有分支结点中仅包含它的各个子结点中关键字的最大值以及指向该子节点的指针,在所有B+树中,所有非叶节点仅起索引的作用,叶子结点包含了全部关键字
B树只支持随机查找,B+树支持随机和顺序查找
散列表
散列函数:将关键字映射成关键字对应的地址的函数,记为Hash(key)=Addr
散列函数可能会把两个或两个以上的不同关键字映射到同一地址,这种情况称为冲突,发生碰撞的不同关键字称为同义词
散列表建立了关键字和存储地址之间的一种直接映射关系,数据的关键字与其存储地址直接相关联
理想情况下, 对散列表进行查找的时间复杂度为O(1)
1、直接定址法 如H(key)=a×key+b 适合关键字的分布基本连续的情况,如存储同一个班级的学生信息
2、除留余数法:假定散列表表长为m,取一个不大于m但最接近或等于m的质数p,H(key)=key%p
3、数字分析法:适用于已知的关键字集合
4、平方取中法:取关键字的平方值的中间几位作为散列地址,适用于关键字的每位取值都不够均匀或均小于散列地址所需的位数,如身份证平方取中间5位
处理冲突的方法
1、开放定址法
①线性探测法:会导致大量元素在相邻的散列地址上聚集或堆积,大大降低查找效率(越早遇到空位置,就可以越早的确定查找失败)
删除元素时,不能简单的将被删结点空间置为空,否则将截断在它之后填入散列表同义词结点的查找路径,可以进行逻辑上的删除
②平方探测法:可避免出现堆积,但不能探测到散列表上的所有单元,但至少能探测到一半
③双散列法
④伪随机序列法
2、拉链法(实际应用中采用较多)
散列表的查找效率取决于三个因素:散列函数、处理冲突的方法和装填因子
装填因子α= 表中记录数 n 散列表长度 m \frac{表中记录数n}{散列表长度m} 散列表长度m表中记录数n,散列表的平均查找长度依赖于α,而不直接依赖于m或n,α越大,表示装填记录越满,发生冲突的可能性越大
排序
算法的稳定性,关键字相同的元素,在排序之后相对位置不变
插入排序
将一个待排序的记录按其关键字大小插入前面已经排好的子序列中,直到全部记录插入完成
直接插入排序
void InsertSort(ElemType A[],int n){
int i,j;
for(i=2;i<=n;i++){
if(A[i]<A[0]){
A[0]=A[i];
}
for(j=i-1;A[j]>A[0];j--){
A[j+1]=A[j];
}
A[j+1]=A[0];
}
}
用到了哨兵,就是A[0]
空间复杂度为O(1)
时间复杂度,最好的情况下为O(n),最坏的情况下为O(n×n)
平均为O(n×n)
适用于顺序存储和链式存储,是稳定的
折半插入排序
相比较于直接插入排序,折半插入排序在插入有序序列的过程中,采用了二分比较的思路,不再像直接插入那样,一个个进行比较
void InsertSort(ElemType A[],int n){
int i,j,low,high,mid;
for(i=2;i<=n;i++){
A[0]=A[i];
low=1;high=i-1;
while(low<=high){
mid=(high+low)/2;
if(A[0]<A[mid]) high=mid-1;
else low=mid+1;
}
for(j=i-1;j>high;j--){
A[j+1]=A[j];
}
A[high+1]=A[0];
}
}
减少了比较次数,约为O(n log2n),但移动次数并没有改变,依然为O(n × n)
对于数据量不大的排序表,往往有较好的性能,是稳定的排序方法
希尔排序
思路:将整个排序表,根据“增量”分为若干子表,对各个子分别进行直接插入排序,再对全体记录进行直接插入排序
void InsertSrot(ElemType A[],int n){
for(dk=n/2;dk>=1;dk=dk/2){
for(i=dk+1;i<=n;i++){
if(A[i]<A[i-dk]){
A[0]=A[i];
for(j=i-dk;j>0&&A[0]<A[j];j-=dk){
A[j+dk]=A[j];
}
A[j+dk]=A[0];
}
}
}
}
当n在某个特定范围,希尔排序的时间复杂度为O(n的1.3次方),最坏情况下,是O(n × n)
是不稳定的,只适用于顺序表
在待排序的元素序列基本有序的前提下,直接插入排序是效率最高的排序方法
交换排序
冒泡排序
基本思想:从后往前(或从前往后)两两比较相邻元素的值,若为逆序,则交换,直到序列比较完,称为第一趟冒泡,每趟都能将序列中最小(最大)的放到其在序列的最终位置,最多n-1趟
void BubbleSort(ElemType A[],int n){
for(int i=0;i<n-1;i++){
flag=false;
for(j=n-1;j>i;j--){
if(A[j]<A[j-1]){
flag=true;
swap(A[j-1],A[j]);
}
}
if(flag==false)
return;
}
}
空间复杂度为O(1)
时间复杂度:最好情况下为O(n),最坏情况/平均情况下为O(n × n)
稳定,适用于链表
快速排序
基本思想:是基于分治的,在待排序表中选一个枢轴pivot,通过一趟快速排序,将所有小于pivot放在其左边,将所有大于pivot放在其右边,然后分别递归的对这两个区间再进行快排,直到每部分只有一个元素或为空
void QuickSort(ElemType A[],int low,int high){
if(low<high){
int pivot=Partition(A,low,high);
QuickSort(A,low,pivot-1);
QuickSort(A,pivot+1,high);
}
}
int Partition(ElemType A[],int low ,int high){
ElemType pivot=A[low]; //以当前表第一个元素作为枢轴来进行划分
while(low<high){
while(low<high && A[high]>pivot) high--;
A[low]=A[high];
while(low<high && A[low]<pivot) low++;
A[high]=A[low];
}
A[low]=pivot;
return low;
}
空间复杂度 最好/平均O(log2n),最坏O(n)
时间复杂度 O(n × log2n),快速排序是所有内部排序算法中平均性能最优的排序算法
其在要排序的数据已基本有序的情况下,最不利于发挥其优势;当每次枢轴都把表长等分为长度相近的两个子表时,速度最快
选择排序
简单选择排序
每一趟(如第i趟)从后面的n-i+1个待排序元素中选择关键字最小的元素,作为有序子序列的第i个元素,直到n-1趟做完
void SelectSort(ElemType A[],int n){
for(int i=0;i<n-1;i++){
int min=i;
for(int j=i+1;j<n;j++){
if(A[j]<A[min]) min=j;
}
if(min!=i)
swap(A[min],A[i]);
}
}
空间复杂度O(1);
时间复杂度始终是O(n×n)
是不稳定的排序
堆排序
堆的定义:① L(i)≥L(2i),且L(i)≥L(2i+1),此时为大根堆,最大元素在根结点
② L(i)≤L(2i),且L(i)≤L(2i+1),此时为小根堆,最小元素在根结点
堆排序基本思路:首先将n个元素建成初始堆,堆顶元素就是最大值(以大根堆为例),输出堆顶元素后,通常将堆底元素送入堆顶,此时根结点已经不满足大根堆的性质,将堆顶元素下调,使其继续保持大根堆的性质,再输出堆顶元素
两个问题:①如何构造初始堆;将当前待排序的n个记录为作为一个大小为n的一维数组,然后向下取整parent=n/2开始比较(即为最后 一个非叶节点)
②输出堆顶元素后,如何将剩余元素调整成新的堆、
堆排序适合关键字较多的情况,如在1亿个数中选出前100个最大值
键堆时间为O(n),每次调整时间为O(h),平均情况下,堆排序的时间复杂度为O(nlog2n)
是不稳定的排序
删除时,被删除元素用堆底元素替代,然后让该元素不断下坠直到无法下坠
归并排序
归并是指将两个或两个以上的有序表组合成一个新的有序表
ElemType *B=(ElemType *)malloc((n+1)*sizeof(ElemType));
void Merge(ElemType A[],int low,int high,int mid){
for(int k=0;k<=high;k++){
B[k]=A[k];
}
for(int i=low,int j=mid+1,int k=i;i<=mid && j<=high;k++){
if(B[i]<B[j]) A[k]=B[i++];
else A[k]=B[j++]
}
while(i<=mid) A[k++]=B[i++];
while(j<=high) A[k++]=B[j++];
}
void MergeSort(ElemType A[],int low,int hight){
if(low<high){
int mid=(low+high)/2;
MergeSort(A,low,mid);
MergeSort(A,mid+1,high);
Merge(A,low,high,mid);
}
}
空间复杂度为O(n);
每趟归并的时间复杂度为O(n),需要log2n趟,所以O(n × log2n)
是一个稳定的排序,比较次数的数量级与序列初始状态无关
对10TB的数据文件进行排序,应使用的方法是归并排序
基数排序
不基于比较和移动进行排序,而是基于关键字各位的大小进行排序
每个结点由d元组构成
数据元素的关键字可以方便的拆分为d组,且d比较小;每组关键字的取值范围不大,即r比较小;数据元素个数n比较大
空间效率:需要r个队列,r个队头指针、r个队尾指针,O®
时间效率:需要进行d趟的分配和收集,一趟分配要O(n),一趟收集要O®,所以O(d(n+r))
是稳定的