数据结构
文章目录
- 数据结构
- 线性结构与非线性结构
- 链表
- kmp算法
- 栈
- 二叉树
- 完全二叉树
- 二叉树的存储结构
- 二叉树的访问
- 树的深度
- 二叉树的层次遍历
- 由遍历序列构造二叉树
- 已知后序跟中序建立二叉树
- 线索二叉树
- 序言(土办法解决找前驱)
- 线索二叉树存储结构
- 中序线索二叉树
- 先序线索二叉树
- 后序线索二叉树
- 中序线索二叉树找中序后继
- 中序线索二叉树找中序前驱
- 对中序线索二叉树逆向中序遍历
- 先序线索二叉树找先序后继
- 先序线索二叉树找先序前驱
- 后序线索二叉树找后序前驱
- 后序线索二叉树找后序后继
- 总结
- 树
- 序言
- 双亲表示法
- 孩子表示法
- ♪ 孩子兄弟表示法
- 森林与二叉树的转换
- 二叉树转换成森林
- 小结
- 树与森林的遍历
- 树的先根遍历:
- **树的后根遍历**:
- 树的层次遍历:
- 小结:
- 森林的先序遍历
- 森林的中序遍历
- 小结
- 哈夫曼树
- 哈夫曼树定义:
- 哈夫曼树构造:
- 哈夫曼树性质:
- 哈夫曼编码:
- 图(Graph)
- 绪论:
- 顶点的度、入度、出度
- 路径、回路、连通性、连通图:
- 子图、生成子图、生成树:
- 边的权值、带权路径:
- 完全图:
- 图的存储
- 邻接矩阵法
- 存储结构
- 图的入度、出度
- 邻接矩阵存储带权图
- 性能分析
- 邻接表法:
- 邻接矩阵与邻接表对比
- 十字链表法(存储有向图)
- 邻接多重表(存储无向图)
- 邻接矩阵、邻接表、十字链表、邻接多重表对比
- 图的基本操作
- 图的遍历
- 广度优先遍历
- 深度优先遍历
- 最小生成树(最小代价树)
- Prim算法(普里姆)->处理顶点
- 运行原理
- Kruskal算法(克鲁斯卡尔)->处理边
- 运行原理
- 最短路径
- Dijkstra算法(迪杰斯特拉)
- Floyd算法(弗洛伊德)
- 关于最短路径前驱
- BFS、Floyd、Dijkstra对比
- 有向无环图描述表达式(DGA)
- 拓扑排序(AOV网)
- 拓扑排序
- 逆拓扑排序(DFS实现)
- 关键路径(AOE网)
- 关键路径
- 求关键路径的步骤:
- 总结
- 查找
- 概念
- 顺序查找
- 折半查找
- 分块查找
- 二叉排序树(BST)
- 查找操作
- 插入操作
- 创建二叉排序树
- 二叉排序树的删除操作
- 平衡二叉树(AVL)
- 分类
- LL调整:
- RR调整:
- LR调整
- RL调整
- 最少结点数、ASL
- B-树(即B树)
- 定义
- 五叉查找树
- 保证查找效率
- B-树知识回顾
- B树的插入
- B树的删除
- B+树
- 特性
- B+树的查找
- B-树与B+树的对比
- 散列表(Hash Table)
- 特性
- 处理冲突
- 拉链法:
- 开放定址法:
- 1.线性探测法:
- 2.平方探测法:
- 3.伪随机序列法:
- 4.再散列法
- 开放定址法删除操作:
- 散列查找
- 常见散列函数
- 除留余数法:
- 直接定址法:
- 数字分析法:
- 平方取中法:
- 总结
- 总结
- 排序
- 分类
- 插入排序
- 直接插入排序
- 优化——折半插入排序
- 希尔排序
- 冒泡排序
- 快速排序(内部排序最优)
- 选择排序
- 堆排序(属于选择排序)
- 建立大根堆
- 堆排序
- 堆中插入新元素
- 堆中删除元素
- 归并排序(Merge)
- 二路归并(合二为一)
- 四路归并
- m路归并
- 核心操作(代码实现)
- 基数排序
- 堆中插入新元素
- 堆中删除元素
- 归并排序(Merge)
- 二路归并(合二为一)
- 四路归并
- m路归并
- 核心操作(代码实现)
- 基数排序
线性结构与非线性结构
线性是线性,顺序是顺序,线性是逻辑结构,顺序是存储结构,两者不是一个概念,
线性是指一个元素后继只有唯一的一个元素或节点,非线性是一个元素后面可以有多个后继或前驱节点,
顺序是指存储结构连续,例如数组是顺序的,链表不是顺序的,但他们都是线性的。当然顺序也可以是非线性的,例如顺序结构存储非线性结构的二叉树!!!
常见非线性结构:
① 集合结构。特点: 集合中任何两个数据元素之间都没有逻辑关系,组织形式松散.
② 树形结构。特点:树形结构具有分支、层次特性,其形态有点象自然界中的树.
③图状结构。特点:图状结构中的结点按逻辑关系互相缠绕,任何两个结点都可以邻接。
链表
链表在初始化时,包括头结点在内的所有结点均需要先分配空间,因为结点本身是指针,创建时是空指针,得给指针找到指向的结点才行。
struct ListNode
{
int val;
struct ListNode *next;
};
int main()
{
int n;
struct ListNode *pHead;
pHead = (struct ListNode *)malloc(sizeof(struct ListNode));
pHead->val=NULL;
pHead->next=NULL;
scanf("%d",&n);
struct ListNode *p=pHead;
struct ListNode *q;
for(int i = 0; i<n; i++)
{
q = (struct ListNode *)malloc(sizeof(struct ListNode));
scanf("%d",&q->val);
q->next=p->next;
p->next=q;
p=q;
}
p=pHead->next;
for(int i = 0; i<n; i++)
{
printf("%d",p->val);
p=p->next;
}
return 0;
}
/**
* struct ListNode {
* int val;
* struct ListNode *next;
* };
*/
/**
*
* @param pHead ListNode类
* @return ListNode类
*/
//不带头结点链表的原地逆置 三个一组,每次都将中间节点放到头上 因为第一个结点本身就是头结点,需要一点点后移
struct ListNode* ReverseList(struct ListNode* pHead ) {
// write code here
if (pHead == NULL) {
return NULL;
}
struct ListNode* p = pHead, *head = pHead, *q, *l;
while (p->next!= NULL) {
l=p->next;//第二个结点
q=l->next;//第三个结点
l->next=head;//第二个结点放到头上 这里每次都要指向头结点下一个结点每次都要放在头上
head=l;//重换头
p->next=q;//当前结点指向第三个结点
}
return head;
}
kmp算法
//下标均从1开始
//存储结构
#define MAXSIZE 255//宏定义格式:#define 标识符 字符串
//typedef 类型名1 类型名2;
typedef struct{
char ch[MAXSIZE];
int length;
}SString;
//算法匹配实现
int Index_KMP(SSTring S,SString T,int next[])
{
int i =1,j=1;
while(i<=S.length&&j<=T.length)
{
if(j==0||S.ch[i]==T.ch[j])
{
i++;
j++;
}else
{
j=next[j];
}
}
if(j>T.length)
return i-T.length;//返回主串的字串首字母地址
else{
return 0;
}
}
//next数组计算部分
void get_Next(String T,int next[])
{
int i = 1,j=0;
next[1]=0;
while(i<T.length)
{
if(j==0||T.ch[i]==T.ch[j])
{
i++;j++;
next[i]==j;//if Pi==Pj ;next[j+1]=next[j]+1;
}
else{
j=next[j];
}
}
}
栈
后入先出,有一个top指向栈顶,有一个length表示栈长度
栈有分为顺序栈跟链栈
//顺序栈实现代码
// 操作:
// push x:将 加x\x 入栈,保证 x\x 为 int 型整数。
// pop:输出栈顶,并让栈顶出栈
// top:输出栈顶,栈顶不出栈
#include <stdio.h>
#include<string.h>//这里用到了 pop push top 所以需要串比较,用strcmp函数,相等则为0 否则为 -1 或 1
typedef struct stact {
int top;//指向栈顶元素
int length;//表示长度
} stact;
int main() {
char s[5];
int x;
int n ;
stact e;
e.top = -1;
e.length = 0;
int num[100001];
scanf("%d", &n);
int j = 0;
for (int i = 0; i < n; i++) {
scanf("%s %d", s, &x);
if (strcmp(s, "push") == 0) {
num[++e.top] = x;
e.length++;
} else if (strcmp(s, "pop") == 0 && e.length != 0) {
printf("%d\n", num[e.top--]);
e.length--;
} else if (strcmp(s, "top") == 0 && e.length != 0) {
printf("%d\n", num[e.top]);
} else {
printf("error\n");
}
}
return 0;
}
二叉树
n0=n2+1
n=n1+n2+n0
n=n1+2n2+1(树的结点个数==总度数+1)
联立推出n0=n2+1
完全二叉树
由于 n 0 = n 2 + 1 所以 n 0 + n 2 一定是奇数 n 1 一定为 0 或者 1 (单分支结点最多有一个,性质决定) 所以若完全二叉树有 2 k (偶数)个结点, 由于 n 0 + n 2 为奇数,所以 n 1 = 1 ; 又因为 n 0 = n 2 + 1 , 所以 n 0 = k , n 2 = k − 1 由于n_0=n_2+1 所以 n_0+n_2一定是奇数\\ n_1一定为0或者1(单分支结点最多有一个,性质决定)\\ 所以若完全二叉树有2k(偶数)个结点,\\由于n_0+n_2为奇数,所以n_1=1;\\又因为n_0=n_2+1,所以n_0=k,n_2=k-1 由于n0=n2+1所以n0+n2一定是奇数n1一定为0或者1(单分支结点最多有一个,性质决定)所以若完全二叉树有2k(偶数)个结点,由于n0+n2为奇数,所以n1=1;又因为n0=n2+1,所以n0=k,n2=k−1
附图:
二叉树的存储结构
//顺序存储,需要按照满二叉树的方式存储,下标反应结点关系,所以下标从1开始 n/2就是父节点
//只适合存储完全二叉树
#define MAXSIZE 100
struct TreeNode{
ElemType value;//结点中的数据元素
bool isEmpty;//结点是否为空
};
struct TreeNode T[MAXSIZE];
二叉树的访问
法1:每一个结点都会被访问三次,需要先脑补出空结点(从你的全世界路过法)
法2:分支逐层展开法,附图2 法2在一般情况下比较快
先序
中左右
第一次被路过就被访问
中序
左中右
第二次被路过时被访问
后序
左右中
地三次被路过时被访问
附图:
//以先序为例,时间复杂度O(h)
void PreOrder(Bitree T)
{
if(T!=NULL)
{
visit(T);
PreOrder(T->lchild);
PreOrder(T->rchild);
}
}
树的深度
int treeDeep(Bitree T)
{
if(T==NULL)
{
return 0;
}else
{
int l=treeDeep(T->lchild);
int r=treeDeep(T->rchild);
//树的深度=MAX(左子树深度,右子树深度)+1
return l>r? l+1 : r+1;
}
}
二叉树的层次遍历
算法思想:
①初始化一个辅助队列
②根结点入队
③若队列非空,则队头结点出队,访问该结点,并将其左、右孩子插入队尾( 如果有的话)
④重复③直至队列为空
//层次遍历代码
void LevelOrder(Bitree T)
{
LinkQueue Q;
InitQueue(Q);
Bitree p;
EnQueue(Q,T);//将T的根节点送入队列
while(!isEmpty(Q))
{
DeQueue(Q,p);//队首出队送入b;
visit(p);
if(p->lchild!=null)
{
EnQueue(p->lchild);
}
if(p->rchild!=null)
EnQueue(p->rchild);
}
}
//二叉树结点(存储)
typedef struct BiTNode{
char data;
struct BiTNode *lchild,*rchild;
}BiTNode,*BiTree;
//链队结点代码
typedef struct LinkNode{
BiTNode *data;//这边为了节省时间,保存的是结点的指针,而非结点本身
struct LinkNode *next;
}LinkNode;
//定义队列
typedef struct{
LinkNode *front,*rear;
}LinkQueue;
由遍历序列构造二叉树
前序+中序
后序+中序
层序+中序
注:必须有中序
key:找到树的根结点,并根据中序序列划分左右子树,再找到左右子树根结点
前序+中序 前序遍历的第一个数是根结点
中序+后序 后序遍历的最后一个数是根结点
中序+层序 层序第一个结点为根结点,第二个结点为左子树的根结点,第三个数为右子树的根结点
已知后序跟中序建立二叉树
最后先序输出序列
输入后序遍历序列:
FGDBCA,
再输入中序遍历序列:
BFDGAC,则
输出该二叉树的先序遍历序列:
ABDFGC。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef char ElementType;
typedef struct BiTNode{
ElementType data;
struct BiTNode *lchild;
struct BiTNode *rchild;
}BiTNode,*BiTree;
BiTree CreatBinTree(char *post,char*in,int n);
void preorder( BiTree T );
int main()
{
BiTree T;
char postlist[100];
char inlist[100];
int length;
scanf("%s",postlist);
scanf("%s",inlist);
length=strlen(postlist);
T=CreatBinTree(postlist,inlist, length);
preorder( T );
return 0;
}
void preorder( BiTree T )
{
if(T)
{
printf("%c",T->data);
preorder(T->lchild);
preorder(T->rchild);
}
}
//建树代码
BiTree CreatBinTree(char *post,char*in,int n )
{
BiTree T;
int i;
if(n<=0) return NULL;
T=(BiTree)malloc(sizeof(BiTNode));
T->data=post[n-1];
for(i=0;in[i]!=post[n-1];i++);//让中序序列遍历到根结点位置
T->lchild=CreatBinTree(post,in,i);
T->rchild=CreatBinTree(post+i,in+i+1,n-i-1);
return T
}
线索二叉树
本质:将n+1个空链域利用起来指向前驱和后继
核心:对先序、中序、后序遍历算法的改造,当访问一个结点的时候,连接该结点与前驱结点的线索信息
知识点:n个结点的二叉树有n+1个空链域
设置一个q跟一个pre,找p的前驱跟后继(以中序遍历为例):
前驱:移动q,判断q是否走到p结点,pre比q慢一步,当p=q时,pre所指结点就是p的前驱
后继:当p=q时,移动一下q到下一个结点,此时q所指结点即位p的后继
操作:
中序线索二叉树 | 先序线索二叉树 | 后序线索二叉树 | |
---|---|---|---|
找前驱 | √ | × | √ |
找后继 | √ | √ | × |
注:解决的办法是用土办法重新遍历一次或者改用三叉链表
序言(土办法解决找前驱)
易错点:
- 最后一个结点的rchild、rtag的处理
- 先序线索化中,注意处理爱的魔力转圈圈问题(死循环),当ltag==0,才能对左子树先序线索化
- pre是全局结点变量,在建立线索二叉树的函数中定义(需要传参),在程序的全局部分定义(不用传参咯)
- 若是要传pre参数,需要用引用类型,得能在全局修改pre的值
土办法找目标结点的前驱办法:跟中序遍历序列一样,只不过在visit函数中进行pre、p、q的处理
其中p为目标结点,q为搜索结点,pre为搜索结点的前驱
//定义辅助全局变量,用于查找p的前驱
BitNode *p; //p指向目标结点
BitNode *pre=null; //pre指向当前访问结点的前驱
BitNode *final=null;//记录最终结果
//某节点的找前驱的操作
void visit(BitNode *q)
{
if(q==p)//访问的当前结点刚好是结点p
{
final = pre;//找到p的前驱
}else
{
pre = q;//pre指向当前访问的结点
}
}
//中序遍历
void InOrder(Bitree T){
if(T!=null){
InOrder(T->lchild);//递归遍历左子树
visit(T);//访问根结点
InOrder(T->rchild);//递归遍历右子树
}
}
线索二叉树存储结构
//线索二叉树结点
typedef struct BiTNode{
ElemType data;
struct BiTNode *lchild;//左孩子
struct BiTNode *rchild;//右孩子
int ltag,rtag;//左右线索标记位,默认标记是0,表示指向孩子 是线索则tag=1,是孩子则tag=0
}ThreadNode,* ThreadTree;
//全局变量pre,指向当前访问结点的前驱
ThreadNode * pre =null;
中序线索二叉树
细节注意:
1.pre定义的是全局结点变量
2.最后还要检查pre的人child是否为null,如果是,则令rtag=1;
(因为当遍历最后一个结点的时候,pre在倒数第二个结点位置,当遍历完最后一个结点,pre才会来到最后一个结点的地方;
此时遍历已经结束,最后一个被访问的结点的右子树没有进行判断,但最后一个被访问的结点已经没有后继,所以只需要修改当前pre
的rtag=1即可)
//王道书的代码 比较简化
//中序线索化
void InThread(ThreadTree T,ThreadNode &pre)//二叉树T 以及前驱结点 pre,pre必须是引用类型
{
if(T!=null)
{
InThread(T->lchild,pre);//遍历左子树
ThreadNode t=T;//此处写成结点是为了方便理解 嘿嘿
if(t->lchild==null)//左子树为空,建立前驱线索
{
t->ltag=1;
t->lchild=pre;
}
if(pre != null && pre->rchild==null)//前驱结点的右子树为空,建立前驱结点的后继线索
{
pre->rtag=1;
pre->rchild=t;
}
pre=t;
Inthread(T->rchild,pre);//遍历右子树
}
}
//中序线索化二叉树创建
void CreateInThreadTree(ThreadTree T)
{
ThreadTree pre = null;//默认为空
if(T!=null)
{
InThread(T,pre);
if(pre->rchild==null)
pre->rtag=1;
}
}
//线索二叉树结点
typedef struct BiTNode{
ElemType data;
struct BiTNode *lchild;//左孩子
struct BiTNode *rchild;//右孩子
int ltag,rtag;//左右线索标记位,默认标记是0,表示指向孩子 是线索则tag=1,是孩子则tag=0
}ThreadNode,* ThreadTree;
//全局变量pre,指向当前访问结点的前驱
ThreadNode * pre =null; //这个pre是个细节 真全局
//t表示当前结点
void visit(ThreadNode *t)
{
if(t->lchild==null)//左子树为空,建立前驱线索
{
t->lchild=pre;
t->ltag=1;
}
if(pre!=null&&pre->rchild==null)//前驱结点的右子树为空,建立前驱结点的后继线索
{
pre->rchild=t;
pre->rtag=1;
}
pre = t; pre移动到当前结点
}
//中序遍历二叉树,一边遍历一边线索化
void InThread(ThreadTree T)
{
if(T!=null)
{
InThread(T->lchild);//访问左子树
visit(T); //访问当前结点
InThread(T->rchild);//访问右子树
}
}
// 中序线索化二叉树T
void CreateInThread(ThreadTree T)
{
pre = null; //pre初始化为空
if(T!=null) //非空二叉树才能线索化
{
inThread(T);
if(pre->rchild==null)
{
pre->rtag=1;//处理遍历的最后一个结点
}
}
}
线索化:指向前驱、后继的指针被称为线索
先序线索二叉树
跟中序线索化代码基本一致,除了需要调整访问次序之外
只加了一个ltag判断左子树是否已经变成线索
因为先序遍历先访问根结点 然后访问左右子树,当前访问节点的左子树为空时,会先将当前结点的左子树处理为线索,所以需要进行判断一下接下来的结点是否为线索,非线索才访问,防止死循环(爱的魔力转圈圈)
//王道书的代码 比较简化
//先序线索化
void preThread(ThreadTree T,ThreadNode &pre)//二叉树T 以及前驱结点 pre,pre必须是引用类型
{
if(T!=null)
{
//先处理根结点
ThreadNode t=T;//此处写成结点是为了方便理解 嘿嘿
if(t->lchild==null)//左子树为空,建立前驱线索
{
t->ltag=1;
t->lchild=pre;
}
if(pre != null && pre->rchild==null)//前驱结点的右子树为空,建立前驱结点的后继线索
{
pre->rtag=1;
pre->rchild=t;
}
pre=t;
if(T->ltag==0)//判断左子树是否已经变成了线索,防止爱的魔力转圈圈
{
preThread(T->lchild,pre);//遍历左子树
}
prethread(T->rchild,pre);//遍历右子树
}
}
//先序线索化二叉树创建
void CreatePreThreadTree(ThreadTree T)
{
ThreadTree pre = null;//默认为空
if(T!=null)
{
preThread(T,pre);
if(pre->rchild==null)
pre->rtag=1;
}
}
后序线索二叉树
后序线索化不会出现先序线索化的转圈问题,所以后序线索化跟中序代码一致,只需调整一下访问顺序(左右根)
中序线索二叉树找中序后继
//找到以p当前结点为根的子树中,第一个被中序遍历的结点(左根右)
ThreadNode *Firstnode(ThreadNode *p)//返回第一个被遍历的结点
{
//循环找到最左下的结点(不一定是叶节点,eg:最坐下结点下面只有一个右孩子)
while(p->ltag==0)//表示有孩子
p=p->lchild;
return p;
}
//在中序线索二叉树中找到结点p的后继结点
ThreadNode *Nextnode(ThreadNode *p)
{
//右子树的最左下结点
if(p->ltag==0)
{
return Firstnode(p->rchild);//当前结点右子树中第一个被访问的结点
}else
return p->rchild;//rtag==1,已经被线索化了,右子树所指向的就是后继
}
中序线索二叉树找中序前驱
//找到以p当前结点为根的子树中,最后一个被中序遍历的结点(左根右)
ThreadNode *Lastnode(ThreadNode *p)
{
//循环找到当前结点的最右下结点(不一定是叶节点,eg:最右下结点下面只有左孩子)
while(p->rtag==0)
p=p->rchild;
return p;
}
//在中序线索二叉树中找到结点p的前驱结点
ThreadNode *Prenode(ThreadNode *p)
{
if(p->ltag==0)
{
return Lastnode(p->ltag);//当前结点左子树中最后一个被访问的结点
}else
return p->lchild;//ltag==1,已经被线索化了,左子树所指就是前驱
}
对中序线索二叉树逆向中序遍历
//对中序线索二叉树逆向中序遍历
void RevInoder(ThreadTree T)
{
for(ThreadNode *p=Lastnode(T);p!=null;p=Prenode(p))
{
visit(p);
}
}
先序线索二叉树找先序后继
由于是 根 左 右 的访问次序
若rtag==1,则next=p->rchild;
若rtag==0:
- 有左孩子则左孩子是先序后继
- 无左孩子有右孩子,则右孩子是先序后继
先序线索二叉树找先序前驱
- 若ltag==1,则next=p->lchild;
- 若ltag==0(有左孩子)
- 由于是 根 左 右 的遍历,所以左右子树只能找到孩子,找不到前驱
- 除非用土办法,再从头先序遍历一遍序列找到前驱结点。
- 改成三叉链表的形式,除了存孩子之外再加一个parent指针。(要会三叉链表的基本分析方法)
后序线索二叉树找后序前驱
由于是 左 右 根 的访问次序
若ltag==1,则next=p->lchild;
若ltag==0:
- 有右孩子则右孩子是后序前驱
- 无右孩子,则后序前驱是左子树按照左右根遍历的最后一个结点,即左孩子
后序线索二叉树找后序后继
- 若rtag==1,则next=p->rchild;
- 若rtag==0(有右孩子)
- 由于是 左 右 根 的遍历,所以左右子树只能找到前驱,找不到后继
- 除非用土办法,再从头后序遍历一遍序列找到后继结点。
- 改成三叉链表的形式,除了存孩子之外再加一个parent指针。(要会三叉链表的基本分析方法)
总结
树
序言
树是一种递归定义的数据结构
树是n (n≥0)个结点的有限集合,n=0时,称为空树,这是一种特殊情况。在任意一棵非空树中应满足:
1)有且仅有一个特定的称为根的结点。
2)当n>1时,其余结点可分为m (m>0)个互不相交的有限集合T1,T2,…,Tm,其中每个集合本身又是一棵树,并且称为根结点的子树。
双亲表示法
顺序存储
每个结点中保存双亲的 ”指针“ (数组下标)
基本操作
-1的地方是删除了某个结点,但是没用其他结点填充,所以删除数据时尽量用叶子结点(表中最下面的结点)填掉相应的坑
优点:找双亲方便
缺点:找孩子需要遍历整个表,对比孩子的parent域。
孩子表示法
找孩子方便,但是找双亲就麻烦咯,需要遍历整个表,在某个结点的孩子中找到要找的结点,即找到该结点的双亲
♪ 孩子兄弟表示法
重点学会对树与二叉树的转化
左孩子 右兄弟
左表示孩子,右表示兄弟
注:将一般树的操作转化成对二叉树的操作
//孩子兄弟表示法
typedef struct CSNode{
elemtype data;//数据域
struct CSNode *firstchild,*nextsibling;//分别是左指针跟右指针
}CSNode,*CSTree;
森林与二叉树的转换
二叉树转换成森林
本质是用二叉链表存储森林
小结
树与森林的遍历
树的先根遍历:
若树非空,先访问根结点,再依次对每棵子树进行先根遍历
void PreOrder(TreeNode *T)
{
if(T!=null)
{
visit(T);//访问根结点
while(T还有下一个子树R)
{
PreOrder(R);//先根遍历下一棵子树
}
}
}
注:树的先根遍历与这棵树对应的二叉树的先序序列相同。
树的后根遍历:
若树非空,先依次对每棵子树进行后根遍历,最后再访问根结点。
void PostOrder(TreeNode *T)
{
if(T!=null)
{
while(T还有下一个子树R)
{
PostOrder(R);//后根遍历下一棵子树
}
visit(T);//访问根结点
}
}
注:树的后根遍历序列跟这棵树相应二叉树的中序序列相同
树的层次遍历:
用队列实现
- 若树非空,则根结点入队
- 若队列非空,队头元素出队并访问,同时将该元素的孩子入队
- 重复2直到队列为空
小结:
树的先根遍历与后根遍历是树的深度优先遍历
树的层次遍历是树的广度优先遍历
森林的先序遍历
两层递归嵌套(转二叉树(左孩子右兄弟)比较好)
若森林非空,则按如下规则进行遍历:
- 访问森林中的根结点
- 先序遍历第一棵树中根结点的子树森林
- 先序遍历除去第一棵树之后剩余的树构成的森林
效果等同于一次对各个子树进行先根遍历
森林的中序遍历
若森林非空,则按如下规则进行遍历:
- 中序遍历森林中第一棵树的根结点的子树森林
- 访问第一棵树的根结点
- 中序遍历除去第一棵树之后剩余的树构成的森林
效果等同于依次对各个子树进行后根遍历
小结
树 | 森林 | 二叉树 |
---|---|---|
先根遍历 | 先序遍历 | 先序遍历 |
后根遍历 | 中序遍历 | 中序遍历 |
注:与二叉树的对照是将树与森林采用孩子兄弟表示法转化成二叉树后,进行的遍历
哈夫曼树
引言
结点的权:有某种现实意义的数值(如:表示结点的重要性等) 是数值
结点的带权路径长度:从树的根到该结点的路径长度(经过的边数)与该结点的权值的乘积
树的带权路径长度:树中所有叶结点的带权路径长度之和(WPL,Weighted Path Length) 只算叶子结点
W P L = ∑ i = 1 n w i l i WPL=\sum_{i=1}^{n} w_il_i WPL=i=1∑nwili
哈夫曼树定义:
在含有n个带权叶结点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也叫最优二叉树
哈夫曼树构造:
- 将这n个结点分别作为n棵仅含一个结点的二叉树,构成森林F。
- 构造一个新结点:从F中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和。
- 从F中删除刚才选出的两棵树,同时将新得到的树加入F中。
- 重复步骤2和3,直至F中只剩下一棵树为止。
哈夫曼树性质:
- 每个初始结点最终都成为叶结点,且权值越小的结点到根结点的路径长度越大
- 哈夫曼树的结点总数为2n-1(初始n个结点,每次两两结合,共结合n-1次,每次结合生成一个新结点,所以n+n-1=2n-1)
- 哈夫曼树不存在度为1的结点
- 哈夫曼树不唯一,但WPL相等且最优
下图 WPL=31
哈夫曼编码:
固定长度编码–每个字符用相等长度的二进制位表示
可变长度编码–允许对不同字符用不等长的二进制位表示
若没有一个编码是另一个编码的前缀,则称这样的编码为前缀编码
定义:
有哈夫曼树得到哈夫曼编码————字符集中的每个字符作为一个叶子结点,各个字符出现的频度作为结点的权值,根据之前介绍的方法构造哈夫曼树(最小权值法) 注:每个字符都必须作为叶子结点出现,防止冲突,前缀编码方式
哈夫曼树不一致,所以哈夫曼编码不一致
图(Graph)
顶点V:Vertex
边E:edge
绪论:
定义:
图G由顶点集V和边集E组成,记为G=(V,E),其中V(G)表示图G中顶点的有限非空集; E(G)表示图G中顶点之间的关系(边)集合。若V={v1, v2,…,vn},则用**|V|表示图G中顶点的个数,也称图G的阶**,E={(u,v) | u∈V, v∈V},用|E|表示图G中边的条数。
注:线性表可以是空表,树可以是空树,但图不可以是空,即V一定是非空集
分类:
-
**无向图:**若E是无向边( 简称边)的有限集合时,则图G为无向图。边是顶点的无序对,记为(v, w)或(w,v),因为(v,w)=(w,v), 其中v、w是顶点。可以说顶点w和顶点v互为邻接点。边(v,w)依附于顶点w和v,或者说边(v,w)和顶点v、w相关联。 (用圆括号)
-
**有向图:**若E是有向边(也称弧)的有限集合时,则图G为有向图。弧是顶点的有序对,记为<v,w>,,其中v、w是顶点,v称为弧尾,w称为弧头,<v,w>称为从顶点v到顶点w的弧,也称v邻接到w,或w邻接自v。<v,w>≠<w,v> <用尖括号>
-
简单图:
- 不存在重复的边
- 不存在顶点到自身的边
-
多重图:
图G中的某两个顶点之间的边数多于一条,又允许顶点通过同一条边和自己关联
顶点的度、入度、出度
**对于无向图:**顶点v的度是指依附于该顶点的边的条数,记为TD(v)。(无入度出度之说)
对于有向图:
- 入度是以顶点v为终点的有向边的数目,记为ID(v);
- 出度是以顶点v为起点的有向边的数目,记为0D(v)。
- 顶点v的度等于其入度和出度之和,即TD(v) = ID(v) + OD(v)。(边的2倍)(入度=出度=边数)
路径、回路、连通性、连通图:
**路径:**顶点vp到顶点vq之间的一条路径是指顶点序列,Vp,…,vq(顶点集)
**回路:**第一个顶点和最后一个顶点相同的路径称为回路或环
**简单路径:**在路径序列中,顶点不重复出现的路径称为简单路径。
**简单回路:**除第一个顶点和最后一个顶点外,其余顶点不重复出现的回路称为简单回路。
**路径长度:**路径上边的数目
点到点的距离:从顶点u出发到顶点v的最短路径若存在,则此路径的长度称为从u到v的距离。若从u到v根本不存在路径,则记该距离为无穷(∞)。
**连通:**无向图中,若从顶点v到顶点w有路径存在,则称v和w是连通的。
**强连通:**有向图中,若从顶点v到顶点w和从顶点w到顶点v之间都有路径,则称这两个项点是强连通的。(两顶点不一定是紧挨着的)
↓ ↓ ↓
连通图:若图G中任意两个顶点都是连通的,则称图G为连通图,否则称为非连通图。(说的是无向图哦)
强连通图:若图中任何一对顶点都是强连通的,则称此图为强连通图。
**连通分量:**无向图的极大连通子图称为连通分量。(包含尽可能多的顶点和边)
**强连通分量:**有向图的极大强连通子图称为有向图的强连通分量。
注:连通<=连通分量<=完全图(连通分量不能推出连通图哦)
考点:
对于n个顶点的无向图G:
若G是连通图,至少有n-1条边
若G是非连通图,则最多可能有
C n − 1 2 C_{n-1}^2 Cn−12对于n个顶点的有向图G:
- 若G是强连通图,则最少有n条边(形成回路)
子图、生成子图、生成树:
**子图:**首先得是个图,顶点和边是原图存在的。
**生成子图:**满足顶点包含原图的所有顶点,缺少的是边。
无向图的极大连通子图称为连通分量。(子图必须连通,且包含尽可能多的顶点和边)
有向图的极大强连通子图称为有向图的强连通分量。
生成树:连通图的生成树是包含图中全部顶点的一个极小连通子图。(边尽可能的少,n个顶点n-1条边)
注:生成树中多加一个边,就会形成回路
树是一种不存在回路,且连通的无向图,n个顶点n-1条边。所以n个顶点的图,边数大于n-1一定有回路。
**有向树:**一个顶点的入度为0、其余顶点的入度均为1的有向图,称为有向树。
**生成森林:**在非连通图中,连通分量的生成树构成了非连通图的生成森林。
边的权值、带权路径:
边的权:在一个图中,每条边都可以标上具有某种含义的数值,该数值称为该边的权值。
**带权图/网:**边上带有权值的图称为带权图,也称网。
带权路径长度:当图是带权图时一条路径上所有边的权值之和,称为该路径的带权路径长度。
完全图:
顶点数为n
无向完全图边数:n(n-1)*2
有向完全图边数:n(n-1)
图的存储
图的存储结构:
- 邻接矩阵法
- 邻接表法
- 十字链表法
- 邻接多重表法
邻接矩阵法
存储结构
#define MAX 100 //顶点数最大值
typedef struct{
char Vex[MAX]; //顶点表(用Char类型示意顶点可以是更发杂的类型)
int Edge[MAX][MAX]; //邻接矩阵,边表(也可以用bool或枚举类型变量表示边)
}MGraph;
即两点之间有边,则矩阵对应位置存储1,没有边则矩阵中存储0
图的入度、出度
第i个结点的度 = 第i行 (或第i列) 的非零元素个数
第i个结点的出度 = 第i行的非零元素个数
第i个结点的入度 = 第i列的非零元素个数
邻接矩阵存储带权图
#define MAX 100 //顶点数最大值
#define INFINITY 最大的int值 //宏定义常量 无穷
typedef char VertexType;//顶点的数据类型
typedef int EdgeType;//带权图中边上权值的数据类型
typedef struct{
VertexType Vex[MAX]; //顶点
EdgeType Edge[MAX][MAX]; //边的权
int vexnum,arcnum; //图的当前顶点数和弧数(边数)
}MGraph;
性能分析
空间复杂度: o(|v|^2) ————只和顶点数相关,和实际的边数无关
适合用于存储稠密图
无向图的邻接矩阵是对称矩阵,可以压缩存储-(只存储上三角区/下三角区)
邻接表法:
邻接表的表示方式不唯一,连接的边无序
无向图找度:
顶点对应的边数即可
有向图找度:
入度+出度
- 出度:结点连接的边数
- 入度:需要遍历整个邻接表,找其他顶点中对应的该结点
邻接矩阵与邻接表对比
邻接表 | 邻接矩阵 | |
---|---|---|
空间复杂度 | 无向图:O(|V|+2|E|);有向图:O(|V|+E) | O(|V|^2) |
适用于 | 稀疏图 | 稠密图 |
表示方法 | 不唯一 | 唯一 |
计算度、入度、出度 | 计算有向图的度、入度不方便,其余方便 | 必须遍历对应的行或列 |
找相邻的边 | 找有向图的入边不方便,其余方便 | 必须遍历对应行或列 |
十字链表法(存储有向图)
(了解)
邻接多重表(存储无向图)
(了解)
解决邻接表冗余问题
邻接矩阵、邻接表、十字链表、邻接多重表对比
图的基本操作
尖括号<>表示有向边,圆括号()表示无向边
图的遍历
与树联系
- 广度优先遍历(BFS)
- 深度优先遍历(DFS)
对无向图进行BFS/DFS遍历调用BFS/DFS函数的次数=连通分量数
对于连通图,只需调用一次BFS/DFS
对于有向图进行BFS/DFS遍历
调用BFS/DFS函数次数要具体问题具体分析
若起始顶点到其他顶点均有路径,则只需要调用一次BFS/DFS函数
对于强连通图,从任意顶点 出发都只需要调用一次BFS/DFS
广度优先遍历
思路:
首先访问起始顶点v,接着从v出发,依次访问v的各个未访问过的邻接顶点w1,w2,…,wj
然后依次访问w1…wj相邻的各个未被访问过的邻接顶点;
再从这些被访问的结点出发,访问他们所有未被访问过的邻接顶点,直至图中所有顶点都被访问完
实现:
需要借助队列,分层查找。
bool visited[MAX];//标记数组
void BFSTraverse(Graph G)//广度优先遍历
{
for(int i=0;i<G.vexnum;i++)
{
visited[i]=false;//初始化标记数组
}
InitQueue(Q);//初始化队列
for(int i=0;i<G.vexnum;i++)
{
if(!visited[i])//对每个连通分量调用一次bfs
BFS(G,i);
}
}
void BFS(Graph G,int v)//从顶点v出发遍历图
{
visit(v);//访问结点v
visited[v]=true;//标记访问
EnQueue(Q,v);//将v入队
while(!isEmpty(Q))//队列非空
{
DeQueue(Q,v);//对头元素出队,并置为v
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w))//检测v的所有邻接顶点
{
if(!visited[w])//w为v的未被访问过的邻接顶点
{
visit(w);//访问顶点w
visited[w]=true;//对w顶点进行标记
EnQueue(Q,w);//顶点w入队
}
}
}
}
深度优先遍历
类似树的先根遍历,注意是先根不是先序!!!
bool visited[MAX];//设置标记
void DFSGraph(Graph G)
{
for(int i = 0;i<G.vexnum;i++)
{
visited[i]=false;//初始化标记
}
for(int i =0;i<G.vexnum;i++)
{
if(!visited[i])//对于一般图是否全部被遍历的判定
{
DFS(G,i);
}
}
}
//深度遍历函数
void DFS(Graph G,int v)
{
visit(v);//访问v结点
visited[v]=true;//标记访问结点
for(int w = FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w))//w为v的邻接顶点
{
if(!visited[w])
{
DFS(G,w);
}
}
}
最小生成树(最小代价树)
对于一个带权连通无向图G = (V,E),生成树不同,每棵树的权(即树中所有边上的权值之和)也可能不同。设R为G的所有生成树的集合,若T为R中边的权值之和最小的生成树,则7称为G的最小生成树(Minimum-Spanning-Tree, MST。
- 最小生成树可能有多个,但边的权值之和总是唯一且最小的。
- 最小生成树的边数 = 顶点数 -1。砍掉一条则不连通,增加一条边则会出现回路
- 如果一个连通图本身就是一棵树,则其最小生成树就是它本身
- 只有连通图才有生成树,非连通图只有生成森林
Prim算法(普里姆)->处理顶点
从某一个顶点开始构建生成树;每次将代价最小的新顶点纳入生成树,直到所有顶点都纳入为止。
时间复杂度:O(V^2)
运行原理
原理:建立一个结点数组,一个代价数组(下标是结点),将初始结点标记,然后看跟初始结点连接的其他结点到初始节点的长度,更新到代价数组中;重点来了:将代价最小的结点加入,更新结点数组,并查看跟新加入的数组相连的其他结点到新结点的距离,并更新代价数组下标,然后循环加入就行了。
Kruskal算法(克鲁斯卡尔)->处理边
每次选择一条权值最小的边,使这条边的两头连通(原本已经连通的就不选)直到所有结点都连通
时间复杂度:O(Elog|E|)
运行原理
原理:将边递增排序,每次加入权值最小的边,判断结点是否已经连通,连通则判断次小的边的结点是否连通,依次判定就行。
最短路径
基础是BFS的改造算法,在visited的时候做的处理
BFS算法求单源最短路径只适用于无权图,或所有边的权值都相同的图
Dijkstra算法(迪杰斯特拉)
带权路径长度-一当图是带权图时,一条路径上所有边的权值之和,称为该路径的带权路径长度
与Prim算法思想类似 dist[]与lowcost[]记录类似
时间复杂度O(V^2)
不适合负权图
Floyd算法(弗洛伊德)
时间复杂度:O(V^3)
空间复杂度:O(V^2)
Floyd算法:求出每一对顶点之间的最短路径
使用动态规划思想,将问题的求解分为多个阶段
对于n个顶点的图G,求任意一对顶点 vi->Vj 之间的最短路径可分为如下几个阶段:
#初始:不允许在其他顶点中转,最短路径是?
#0:若允许在Vo中转,最短路径是?
#1:若允许在 Vo、V1中转,最短路径是?
#2:若允许在 Vo、V1、V2中转,最短路径是?
…
#n-1: 若允许在 Vo、V1、V2…Vn-1 中转,最短路径是?
//......准备工作,根据图的信息初始化矩阵 A 和 path
for(int k=0;k<n;k++)//考虑以Vk作为中转点
{
for(int i=0;i<n;i++)//遍历整个矩阵,i为行号,j为列号
{
for(int j =0;i<n;j++)
{
if(A[i][j]>A[i][k]+A[j][k])//以Vk为中转点的路径更短
{
A[i][j]=A[i][k]+A[k][j];//更新最短路径长度
path[i][j]=k;//更新中转点
}
}
}
}
关于最短路径前驱
其实每一个前驱都找好了,当无中转点的时候,找到了两点之间的最短路径
此时已经为有一个中转结点的情况埋下铺垫,中转结点即是第一轮的目标结点
当有一个中转点的时候,目标结点的path就修改成第一个中转点
当有两个中转点时,相当于是在第一个中转点跟目标结点之间插入的,所以修改的路径是地一个中转点到目标结点的距离,
而修改的path直接修改成第二个中转点即可
(写的不好,还没完全吃透)
注:负权回路解决不了哦
BFS、Floyd、Dijkstra对比
BFS 算法 | Dijkstra 算法 | Floyd 算法 | |
---|---|---|---|
无权图 | √ | √ | √ |
带权图 | × | √ | √ |
带负权值的图 | × | × | √ |
带负权回路的图 | × | × | × |
时间复杂度 | 矩阵:O(V^2) 邻接表:O(V+E) | O(V^2) | O(V^3) |
通常用于 | 求无权图的单源最短路径 | 求带权图的单源最短路径 | 求带权图中各顶点间的最短路径 |
注:也可用 Dijkstra 算法求所有顶点间的最短路径,重复 V 次即可,总的时间复杂度也是O(V^3)
有向无环图描述表达式(DGA)
最终的形态不唯一
步骤:
- 把各个操作数不重复地排成一排
- 标出各个运算符的生效顺序 (先后顺序有点出入无所谓)
- 按顺序加入运算符,注意“分层”
- 从底向上逐层检查同层的运算符是否可以合体
拓扑排序(AOV网)
顶点驱动型
AOV网:Activity On Vertex Network,用顶点表示活动的网:用DAG图(有向无环图)表示一个工程。顶点表示活动,有向边<Vi,Vj>表示活动Vi必须先于活动Vj进行。
拓扑排序:找到做事的先后顺序,每次删除图中入度为0的点
拓扑排序的实现:
- 从AOV网中选择一个没有前驱 (入度为0)的顶点并输出
- 从网中删除该顶点和所有以它为起点的有向边。
- 重复1和2直到当前的AOV网为空或当前网中不存在无前驱的顶点为止
逆拓扑排序:每次删除出度为0的顶点
(采用邻接矩阵会方便一些)
拓扑排序
每次删除输出入度为0的点
逆拓扑排序(DFS实现)
void dfs(Graph G,int v)
{
visited[v]=true;
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w))
{
if(!visited[w])
{
dfs(G,w);
}
}
print(v); //每次都是输出栈顶元素,即最深处的元素 ,也就是出度为0的点
}
关键路径(AOE网)
边驱动型
在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销(如完成活动所需的时间),称之为用边表示活动的网络,简称AOE网(Activity On Edge NetWork)
性质:
AOE网具有以下两个性质:
- 只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始
- 只有在进入某顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发生。另外,有些活动是可以并行进行的
在AOE网中仅有一个入度为0的顶点,称为开始顶点 (源点),,它表示整个工程的开始;也仅有一个出度为0的顶点,称为结束顶点(汇点),它表示整个工程的结束。
关键路径
从源点到汇点的有向路径可能有多条,所有路径中,具有最大路径长度的路径称为关键路径,而把关键路径上的活动称为关键活动
完成整个工程的最短时间就是关键路径的长度,若关键活动不能按时完成,则整个工程的完成时间就会延长
事件Vk的最早发生时间ve(k)————决定了所有从Vk开始的活动能够开工的最早时间
活动ai的最早开始时间e(i)————指该活动弧的起点所表示的事件的最早发生时间
事件Vk的最迟发生时间vl(k)————它是指在不推迟整个工程完成的前提下,该事件最迟必须发生的时间
活动ai的最迟开始时间l(i)————它是指该活动弧的终点所表示事件的最迟发生时间与该活动所需时间之差
活动4i的时间余量d(i)=l(i)-e(i),表示在不增加完成整个工程所需总时间的情况下,活动ai可以拖延的时间;若一个活动的时间余量为零,则说明该活动必须要如期完成,d(i)=0即l(i)=e的活动ai是关键活动。
由关键活动组成的路径就是关键路径
求关键路径的步骤:
- 求所有事件的最早发生时间ve()
- 求所有事件的最迟发生时间vl()
- 求所有活动的最早发生时间e()
- 求所有活动的最迟发生时间l()
- 求所有活动的时间余量d() 时间余量d(i)=0的活动就是关键活动,由关键活动可得关键路径
添:
关键活动耗时增加,则整个工程的工期将增加
缩短关键活动的时间,可以缩短整个工程的工期
当缩短到一定程度时,关键活动可能变为非关键活动
总结
查找
概念
查找:在数据集中寻找满足条件的数据元素的过程称为查找
**查找表:**用于查找的数据集合
操作:
- 查询某特定元素是否在查找表内
- 检索满足条件的某个特定数据元素的各种属性 (1和2属于静态查找操作)
- 在查找表中插入一个数据元素
- 从查找表中删除某个特定元素 (3和4属于动态查找操作)
**平均查找长度(ASL) :**一次查找的长度是指需要比较的关键字的次数,平均查找长度是所有查找过程中进行关键字的比较次数的平均值
ASL=Σ Pi Ci (Pi是查找第i个元素的概率)(Ci是找到第i个元素所需要的比较次数)
顺序查找
又称线性查找,对顺序表和链表都适用
//数据类型
typedef struct{
ElemType *elem;
int Length;
}SSTable;
/*
在数据集SSTable中找到对应Key关键字的elem,并返回elem的下标
注意:设置了哨兵,所以elem下标从1开始
*/
int Search_Seq(SSTable st,ElemType key)
{
st.elem[0]=key;//设置哨兵,则数组从1开始
int i;
for( i = st.length;st.elem[i]!=key;i--)
return i;
}
折半查找
又称二分查找,仅适用于有序的顺序表
int Binary_Search(SeqList L,ElemType key)
{
int low=0,high=L.length-1,mid;//数据结构跟前面顺序查找一样
while(low<=high)
{
mid=(low+high)/2;//向下取整
if(L.elem[mid]==key)
{
return mid;
}else if(L.elem[mid]<key)
{
low=mid+1; //从后半部分查找
}else{
high=mid-1;//从前半部分查找
}
}
return -1; //查找失败
}
如果序列有偶数个元素,则mid分割之后,左半部分比右半部分少一个元素(向下取整所致)
奇数个元素,则分割之后左右元素数量相等
基于折半查找可以构造判定树,且判定树 右子树结点数-左子树结点数=0 或 1(向下取整)(叶子朝向一致)
注:只有最下面一层结点不满,元素为n时,树高⌈ l o g 2 ( n + 1 ) log_2(n+1) log2(n+1)⌉
折半查找判定树一定是平衡二叉树
易错题目1:
技巧题:下列二叉树中,可能成为折半查找判定树(不含外部结点)的是
分块查找
块间有序,块内无序
//索引表结构
typedef struct{
Elemtype maxValue;
int low,high;
}Index;
//顺序表存储实际大小
ElemType List[100];
分块查找步骤:
- 在索引表中确定待查记录所在的块(索引表内存储块内最大关键字),可以顺序查找或折半查找
注:折半查找时,索引表最终停在low>high,要在low所指分块中查找
- 在块内顺序查找
二叉排序树(BST)
二叉排序树,又称二叉查找
- 要么是空树,要么 左子树结点值<根结点结点值<右子树结点值
- 进行中序遍历,可以得到一个递增的有序序列
查找操作
注意:只要设定好递归出口,递归的return可以不要
//树的结构
typedef struct BSTNode{
int node;
struct BSTNode *lchild,*rchild;
}BSTNode,*BSTree;
//查找值为key的结点(非递归)
BSTNode *BST_Search(BSTree T,int key)
{
while(T!=null&&T->node!=key)//树空或相等时,停止循环
{
if(key<T->node)
{
T=T->lchild;//key小于当前结点转向左边
}else
{
T=T->rchild;//key大于当前结点转向右边
}
}
return T;//包含了T==null的情况了
}
//递归实现查找关键字
BSTNode *BST_Search(BSTree T, int key)
{
if (T == NULL || T->node == key)//跳出条件
return T;
if (T->node < key)
{
return BST_Search(T->rchild, key);///key大于当前结点转向右边
}
else
{
return BST_Search(T->lchild, key);key小于当前结点转向左边
}
}
插入操作
//递归插入
int BST_Insert(BSTree &T, int key)
{
if (T == NULL)
{
T = (BSTree)malloc(sizeof(BSTNode));
T->node = key;
T->lchild = T->rchild = NULL;
return 1;
}
else if (T->node==key)//二叉树不允许有两个相等结点
{
return 0;//插入失败
}
else if (T->node < key)
{
return BST_Insert(T->rchild, key);
}
else
{
return BST_Insert(T->lchild, key);
}
}
//非递归插入(自己写的)
int BST_Insert(BSTree &T, int key)
{
if (T == NULL)
{
T = (BSTree)malloc(sizeof(BSTNode));
T->node = key;
T->lchild = T->rchild = NULL;
return 1;
}
else
{
BSTree t=T;
while (t != NULL) //T不为空
{
if (t->node < key&& t->rchild!=NULL)
{
t = t->rchild;//当前结点小于key,且右子树不为空,右转
}
else if (t->node < key&& t->rchild == NULL)//当前结点小,但右子树为空,key放到右子树
{
t->rchild->node = key;
t->rchild->lchild = T->rchild->rchild = NULL;
return 1;
}
if (t->node > key&&t->lchild != NULL)//当前结点大,且左子树不为空
{
t = t->lchild;
}
else if (t->node > key&&t->lchild == NULL)//当前结点大,但左子树为空
{
t->lchild->node = key;
t->lchild->lchild = t->lchild->rchild = NULL;
return 1;
}
if (t->node == key)//有相同结点,插入失败
return 0;
}
}
}
创建二叉排序树
利用二叉排序树的插入操作
序列顺序不同所构造的树会不一样
//关键代码
void Creat_BSTree(BSTree &T,str[],n)//树T,待加入序列数组str[],数组内元素数量n
{
T=null;
for(int i = 0;i<n;i++)
{
BST_Insert(T, n[i]);//循环插入元素
}
}
二叉排序树的删除操作
分类(假设当前结点是Z):
若被删除结点z是叶结点,则直接删除,不会破坏二叉排序树的性质
若当前结点Z只有一棵左子树或右子树,则让当前结点Z的子树成为Z父结点的子树,替代Z的位置。
若结点Z有左、右两棵子树,则令Z的直接后继(或直接前驱)替代Z,然后从二叉排序树中删去这个直接后继(或直接前驱),(删直接后继或直接前驱的操作是分类1或2)这样就转换成了第一或第二种情况。
Z的前驱:Z的左子树的最右下结点
Z的后继:Z的右子树的最左下结点
平衡二叉树(AVL)
AVL树:树上任意结点的左子树和右子树的高度之差不超过1.
**结点的平衡因子:**左子树高-右子树高(-1 0 1)
//平衡二叉树结点
typedef struct AVLNode{
int key; //数据域
int balance;//平衡因子
strcut AVLNode *lchild,*rchild;
}AVLNode,*AVLTree;
每次调整的对象都是“最小不平衡子树”(从插入点往回找到第一个不平衡点,调整以该结点为根的子树)
分类
只有左孩子,才能进行右旋操作;只有右孩子,才能进行左旋操作
LL | 在A的左孩子的左子树中插入导致不平衡 |
---|---|
RR | 在A的右孩子的右子树中插入导致不平衡 |
LR | 在A的左孩子的右子树中插入导致不平衡 |
RL | 在A的右孩子的左子树中插入导致不平衡 |
LL调整:
LL平衡旋转(右单旋转):由于在结点A的左孩子(B)的左子树(BL) 上插入了新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要一次向右的旋转操作。将A的左孩子B向右上旋转代替A成为根结点,将A结点向右下旋转成为B的右子树的根结点,而B的原右子树则作为A结点的左子树。
代码思路:
实现f向右下旋转,p向右上旋转:其中f是爹(最小失衡树),p是左孩子,gf是f的爹
- f->lchild=p->rchild;
- p->rchild=f;
- gf->lchild/rchild=p;
RR调整:
RR平衡旋转(左单旋转)。由于在结点A的右孩子(B)的右子树(BR)上插入了新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要一次向左的旋转操作。将A的右孩子B向左上旋转代替A成为根结点,将A结点向左下旋转成为B的左子树的根结点,而B的原左子树则作为A结点的右子树。
代码思路:
实现f向左下旋转,p向左上旋转:其中f是爹(最小失衡树),p是右孩子,gf是f的爹
- f->rchild=p->lchild;
- p->lchild=f;
- gf->lchild/rchild=p;
LR调整
LR平衡旋转(先左后右双旋转) 。由于在A的左孩子(B) 的右子树(BR)上插入新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先左旋转后右旋转。先将A结点的左孩子B的右子树的根结点C向左上旋转提升到B结点的位置,然后再把该C结点向右上旋转提升到A结点的位置。
RL调整
RL平衡旋转(先右后左双旋转)。由于在A的右孩子 (B)的左子树(BL)上插入新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要进行两次gh旋转操作,先右旋转后左旋转。先将A结点的右孩子B的左子树的根结点C向右上旋转提升到B结点的位置,然后再把该C结点向左上旋转提升到A结点的位置。
最少结点数、ASL
Nh表示深度为h的平衡二叉树含有的最少结点数。
则有 n 0 = 0 n 1 = 1 n 2 = 2 n_0=0 \quad n_1=1 \quad n_2=2 n0=0n1=1n2=2
n h = n h − 1 + n h − 2 + 1 n_h=n_{h-1}+n_{h-2}+1 nh=nh−1+nh−2+1
n个结点的平衡二叉树的最大深度O(log₂N),平衡二叉树的平均查找长度为O(log₂N)
B-树(即B树)
定义
B树,又称多路平衡查找树,B树中所有结点的孩子个数的最大值称为B树的阶,通常用m表示。一棵m阶B树或为空树,或为满足如下特性的m叉树:
- 树中每个结点至多有m棵子树,即至多含有m-1个关键字
- 若根结点不是终端结点,则至少有两棵子树。(由任何一个结点都绝对平衡决定)
- ✌️ 除根结点外的所有非叶结点至少有**⌈m/2⌉棵子树**,即至少含有**⌈m/2⌉-1个关键字**(m就是分支个数最大值)
- 所有的叶结点都出现在同一层次上,并且不带信息(可以视为外部结点或类似于折半查找判定树的查找失败结点,实际上这些结点不存在,指向这些结点的指针为空)。
大部分学校算B树的高度时不包括最下面的叶子结点(失败结点)
问:含有n个关键字的m阶B树,最小高度、最大高度是多少?
**最小高度:**让每一个结点尽可能的满,每个结点最多有m-1个关键字,m个分叉,则
n<=(m-1)(1+m+ m 2 m^2 m2 +…+ m h − 1 m^{h-1} mh−1)= m h m^h mh - 1 (每个结点有m-1个关键字*分叉数)
即
h > = l o g m ( n + 1 ) h>=log_m(n+1) h>=logm(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个分支),(由叶子结点推算高度)则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 h<=log_{⌈m/2⌉}\frac{n+1}{2}+1 h<=log⌈m/2⌉2n+1+1
注意区分结点跟关键字!!!,除根结点外,每个结点内最少有⌈m/2⌉个关键字
思路二
五叉查找树
- 5叉查找树最少有一个关键字,2个分叉;最多4个关键字,5个分叉
- 结点内关键字有序
//5叉排序树的定义
typedef struct Node{
ElemType key[4]; //最多四个关键字
struct Node *child[5];//最多五个孩子
int sum; //结点中有几个关键字
};
保证查找效率
若每个结点内关键字太少,导致树变高,要查更多层结点,效率低
策略:m叉查找树中,规定除了根节点外,任何结点至少有⌈m/2⌉个分叉,即至少含有⌈m/2⌉-1个关键字
不够平衡,树会很高,要查很多层结点
策略:m叉查找树,规定对于任何一个结点,其所有子树的高度都要相同
B-树知识回顾
注意区分终端结点跟叶子结点,叶子结点对应查找失败的情况,n个关键字共有n+1种失败情况
B树的插入
核心要求:
对m阶B树————除根节点外,结点关键字个数 ⌈m/2⌉<=n<=m-1
子树0<关键字1<子树1<关键字2<子树2<…
新元素一定是插入到最底层“终端节点”,用“查找”来确定插入位置
在插入key后,若导致原结点关键字数超过上限,则从中间位置(⌈m/2⌉)将其中的关键字分为两部分,左部分包含的关键字放在原结点中,右部分包含的关键字放到新结点中,中间位置(⌈m/2⌉)的结点插入原结点的父结点。若此时导致其父结点的关键字个数也超过了上限,则继续进行这种分裂操作,直至这个过程传到根结点为止,进而导致B树高度增1。
B树的删除
- 若被删除关键字在终端节点,则直接删除该关键字(要注意节点关键字个数是否低于下限 ⌈m/2⌉ -1)
若被删除关键字在非终端结点,则用直接前驱或直接后继来替代被删除的关键字
直接前驱:当前关键字左侧指针所指子树中“最右下”的元素
直接后继:当前关键字右侧指针所指子树中“最左下”的元素
对非终端结点关键字的删除,必然可以转化为对终端结点的删除操作
兄弟够借。若被删除关键字所在结点删除前的关键字个数低于下限,且与此结点右(或左)兄弟结点的关键字个数还很宽裕,则需要调整该结点 右(或左)兄弟结点及其双亲结点 (父子换位法)
右兄弟很宽裕时,就是用当前结点的后继、后继的后继来填空
左兄弟很宽裕时,用当前结点的前驱、前驱的前驱来填补空缺
本质:要永远保证 子树0<小于关键字1<子树1<关键字2<子树2…………
兄弟不够借。若被删除关键字所在结点删除前的关键字个数低于下限,且此时与该结点相邻的左、右兄弟结点的关键字个数均=⌈m/2⌉-1,则将关键字删除后与左(或右)兄弟结点及双亲结点中的关键字进行合并
直接后继(删77):
B+树
特性
与分块查找联系
一棵m阶的B+树需要满足下列条件:
- 每个分支结点最多有m棵子树(孩子结点);(与m阶B树区分)
- 非叶结点至少有两棵子树,其他分支结点至少有⌈m/2⌉棵子树。
- 结点的子树与关键字的个数相等。
- 所有叶结点包含全部关键字及指向相应记录的指针,叶结点中将关键字按大小顺序排列,并且相邻叶结点按大小顺序相互链接起来。(可通过p直接顺序遍历,树的建立是为了缩短查找时间)
- 所有分支结点中仅包含它的各个子结点中关键字的最大值及指向其子结点的指针。
B+树的查找
在分支中若是找到相等的点,查找并不结束,分支结点只是索引,最终要找到相应的叶子结点才行。
B+树无论查找成功与否,最终都会走到最下面一层结点。
B-树与B+树的对比
m阶B-树 | m阶B+树 | |
---|---|---|
类比 | 二叉查找树的进化——>m叉查找树 | 分块查找的进化——>多级分块查找 |
关键字与分叉 | n个关键字对应n+1个分叉(子树) | n个关键字对应n个分叉 |
结点包含的信息 | 所有结点中都包含记录的信息 | 只有最下层的叶子结点才包含记录的信息 (可使树更矮) |
查找方式 | 不支持顺序查找; 查找成功时,可能停在任何一层结点,查找速度不稳定 | 支持顺序查找。 查找成功或者失败都会到达最下一层结点,查找速度稳定。 |
相同点:除根结点外,最少⌈m/2⌉个分叉(确保结点不要太空)任何一个结点的子树都要一样高(确保绝对平衡)
散列表(Hash Table)
特性
是一种数据结构。
特点:数据元素的关键字与其存储地址直接相关
若不同的关键字通过散列函数映射到同一个值,则称它们为“同义词“
通过散列函数确定的位置已经存放了其他元素,则称这种情况为“冲突”
处理冲突
拉链法:
拉链法:(又称链接法、链地址法)处理“冲突”:把所有“同义词”存储在一个链表中。
开放定址法:
开放定址法:是指可存放新表项的空闲地址既向它的同义词表项开放,又向它的非同义词表项开放。
其数学递推公式为:
H i = ( H ( k e y ) + d i ) % m H_i=(H(key)+d_i) \% m Hi=(H(key)+di)%m
i=0,1,2,…………,k(k<=m-1);m表示散列表表长; a i a_i ai为增量序列; i 可理解为“第i次发生冲突”
1.线性探测法:
d i d_i di=0,1,2……,m-1;即发生冲突时,每次往后探测相邻的下一个单元是否为空
空位置的判断也算作一次查找
弊端:
线性探测法很容易造成同义词、非同义词的“聚集 (堆积)”现象严重影响查找效率
产生原因————冲突后再探测一定是放在某个连续的位置
2.平方探测法:
注:散列表长度m必须是一个可以表示成4i+3的素数才能探测到所有位置(非重点)
当 d i = 0 2 , 1 2 , − 1 2 , 2 2 , − 2 2 … … … , k 2 , − k 2 d_i=0^2,1^2,-1^2,2^2,-2^2………,k^2,-k^2 di=02,12,−12,22,−22………,k2,−k2 时,称为平方探测法,又称二次探测法;
其中k<=m/2比起线性探测法更不易产生“聚集 (堆积)”问题
3.伪随机序列法:
d i d_i di 是一个伪随机序列,如 d i d_i di = 0,5,24,11,…………
4.再散列法
再散列法 (再哈希法):除了原始的散列函数 H(key)之外,多准备几个散列函数当散列函数冲突时,用下一个散列函数计算一个新地址,直到不冲突为止。
H i = R H i ( K e y ) i = 1 , 2 , 3 … … , k H_i=RH_i(Key)\\i=1,2,3……,k Hi=RHi(Key)i=1,2,3……,k
开放定址法删除操作:
采用“开放定址法”时,删除结点不能简单地将被删结点的空间置为空,否则将截断在它之后填入散列表的同义词结点的查找路径,可以做一个“删除标记”, 进行逻辑删除
eg:bool标记
散列查找
查找长度————在查找运算中,需要对比关键字的次数称为查找长度
最理想的情况时O(1)
装填因子α=表中记录数/散列表长度 (装填因子会直接影响散列表的查找效率)
常见散列函数
散列函数的设计要结合实际关键字的分布考虑,不要教条化
除留余数法:
H(key) = key % p
散列表表长为m,取一个不大于m但最接近或等于m的质数p(质数(素数):除1与本身无其他因子) 取质数是为了减少关键字的冲突,用质数取模,分布更均匀,冲突更少
直接定址法:
H(key) = key 或 H(key) = a * key + b
其中,a和b是常数。这种方法计算最简单,且不会产生冲突。它适合关键字的分布基本连续的情况,若关键字分布不连续,空位较多,则会造成存储空间的浪费。
例:班级学号
数字分析法:
选取数码分布较为均匀的若干位作为散列地址
设关键字是r进制数(如十进制数),而r个数码在各位上出现的频率不一定相同,可能在某些位上分布均匀一些,每种数码出现的机会均等;而在某些位上分布不均匀,只有某几种数码经常出现,此时可选取数码分布较为均匀的若干位作为散列地址。这种方法适合于已知的关键字集合,若更换了关键字,则需要重新构造新的散列函数。
例:以“手机号码”作为关键字设计散列函数
平方取中法:
取关键字的平方值的中间几位作为散列地址
具体取多少位要视实际情况而定。这种方法得到的散列地址与关键字的每位都有关系,因此使得散列地址分布比较均匀,适用于关键字的每位取值都不够均匀或均小于散列地址所需的位数。
总结
散列查找是典型的“用空间换时间”的算法,只要散列函数设计的合理,则散列表越长,冲突的概率越低。
总结
排序
稳定性:关键字相同的元素的相对位置在排序后不变,就是稳定的
分类
内部排序更加关注算法的时间复杂度跟空间复杂度;
外部排序还需要考虑如何使读写磁盘的次数更少
插入排序
**插入排序:**关键元素前面的元素已经排好序。
算法思想:每次将一个待排序的记录按其关键字大小插入到前面已排好序的子序列中直到全部记录插入完成。
算法稳定
直接插入排序
空间复杂度:O(1)
时间复杂度:最好顺序排放:O(n) 最坏逆序排序O( n 2 n^2 n2)
//直接插入排序
void InsertSort(int A[], int n)
{
int temp, j;
for (int i = 1; i <= n; i++) //将各元素插入已经排好序的序列种
{
if (A[i] < A[i - 1])//若A[i]关键字小于前驱
{
temp = A[i];//用temp暂存A[i]
for (j = i - 1; j >= 0 && A[j] > temp; j--)//检查前面已经排好序的元素
{
A[j + 1] = A[j];//所有大于temp的元素都后移
}
A[j + 1] = temp;//复制到插入位置
}
}
}
//直接插入排序(带哨兵)
void InsertSort(int A[],int n)
{
int i,j;
for(int i=2;i<=n;i++)
{
if(A[i]<A[i-1])
{
A[0]=A[i];
for(j=i-1;A[0]>A[j];j--)
{
A[j+1]=A[j];
}
A[j+1]=A[0];
}
}
}
优化——折半插入排序
优化的是查找的时间,移动的次数不变,未本质改变排序时间 O( n 2 n^2 n2)
//折半插入排序
void InsertSort(int A[], int n)
{
int i, j, low, high, mid;
for (int i = 2; i <= n; i++)
{
A[0] = A[i];
low = 1; high = i - 1;
while (low <= high)
{
mid = (low + high) / 2;
if (A[mid] > A[0])//找左子表
{
high = mid - 1;
}
else //else处保证了稳定性
low = mid + 1;//找右子表
}
for (j = i - 1; j >= high + 1; j--)//判断需要插入的位置(即low的位置)
{
A[j + 1] = A[j];
}
A[j + 1] = A[0];
}
}
希尔排序
时间复杂度:无法用数学手段证明, 最坏:O( n 2 n^2 n2),d=1 当n在某个范围的时候,可达O( n 1.3 n^{1.3} n1.3)
不稳定 仅适用于顺序表,无法用于链表
每次将增量d缩小一半,直到d=1为止
//思路二(未实现):每次处理一组,处理完一组之后再处理下一组,更好理解
//希尔排序(下面写的是保证每组当前有序的元素个数相等,然后再后移扫描 ,i的for循环)
void ShellSort(int A[], int n)
{
int d, i, j;
for (d = n / 2; d >= 1; d = n / 2)//修改步长
{
for (i = d + 1; i <= n; i++)//i用来查找每组 后<前 的元素
{
if (A[i] < A[i-d])//该组 出现后<前的元素
{
A[0] = A[i];//暂存
for (j = i - d; j > 0 && A[0] < A[j]; j = j - d)//寻找插入位置
{
A[j + d] = A[j];
}
A[j + d] = A[0];//插入
}
}
}
}
冒泡排序
基于“交换”的排序: 根据序列中两个元素关键字的比较结果来对换这两个记录在序列中的位置
包括:冒泡排序,快速排序
时间复杂度: 最坏逆序: O( n 2 n^2 n2) 最好有序:O(n)
空间复杂度:O(1)
算法稳定
//冒泡排序,每次处理好一个关键字
//从后往前冒泡
void BubbleSort(int a[], int n)
{
for(int i = 0;i<n-1;i++)
{
bool flag=false;//是否发生交换的标志
for(int j = n-1;j>i;j--)//一趟冒泡排序
{
if(a[j-1]>a[j])//逆序交换
{
int temp=a[j];
a[j]=a[j-1];
a[j-1]=temp;
flag=true;
}
}
if(!flag)//没有交换说明已经有序,提前结束即可
return;
}
}
//自写(n个关键字,n-1趟就够了)从前往后冒泡
void BubbleSort(int a[], int n)
{
int temp;
for (int i = 1; i < n; i++)
{
bool flag = false;
for (int j = 0; j < n-i; j++)
{
if (a[j] > a[j+1])
{
temp = a[j];
a[j] = a[j+1];
a[j+1] = temp;
flag = true;
}
}
if (!flag)
break;
}
}
快速排序(内部排序最优)
将第一个元素作为枢轴,划分两边;通过递归(划分)进行缩小左右子表范围
时间复杂度:O(n*递归层数) 最好(划分均匀):O(nlogn) 最坏(本身有序):O(n^2) (与树高联系)
空间复杂度:O(递归层数) 最好:O(logn) 最坏:O(n)
不稳定
每一层的quiksort只需要处理剩余元素,时间复杂度<O(n) 把n个元素组织成二叉树,二叉树的层数就是递归调用的层数,n个结点的二叉树,最小高度⌈ l o g 2 ( n + 1 ) log_2({n+1}) log2(n+1)⌉,最大高度是 n;
void QuikSort(int a[], int low, int high)
{
if (low < high)
{
int center = Partition(a, low, high);//获取枢轴位置
QuikSort(a, low, center - 1);//划分左子表
QuikSort(a, center + 1, high);//划分右子表
}
}
//划分函数,用第一个元素将待排序列划分为左右两个部分
int Partition(int a[], int low, int high)
{
int pivot = a[low];//划分枢轴
while (low < high)
{
while (low < high&&high >= pivot)high--;//当右边>=枢轴时,high左移
a[low] = a[high];//当前元素小于枢轴,放到左边
while (low < high&&low <= pivot)low++;//当左边<=枢轴时,low右移
a[high] = a[low];//当前元素大于枢轴,放到右边
}
a[low] = pivot;//判断完毕,枢轴元素放入最终位置,此时左边小于枢轴,右边大于枢轴
return low;//返回当前枢轴位置
}
**快速排序:**关键节点前面的元素都比它小,后面的元素都比它大;
初 始:25,84,21,46,13,27,68,35,20
第一趟:20,13,21,25,46,27,68,35,84
第二趟:13,20,21,25,35,27,46,68,84
第三趟:13,20,21,25,27,35,46,68,84”
用快速排序,第一趟取出25,25的左侧元素都小于25,25的右侧元素都大于25;
第二趟从20,13,21中取出20,排成13,20,21,则20左侧元素都小于20,20右侧元素都大于20,同理第二趟从46,27,68,35,84中取出46,排成35,27,46,68,84,则46左侧元素都小于46,右侧元素都大于46;
第三趟35,27排为27,35;
完成排序。
思路:
设要排序的数组是A[0]……A[N-1],首先任意选取一个数据(通常选用数组的第一个数)作为关键数据,然后将所有比它小的数都放到它前面,所有比它大的数都放到它后面,这个过程称为一趟快速排序。值得注意的是,快速排序不是一种稳定的排序算法,也就是说,多个相同的值的相对位置也许会在算法结束时产生变动。
一趟快速排序的算法是:
1)设置两个变量i、j,排序开始的时候:i=0,j=N-1;
2)以第一个数组元素作为关键数据,赋值给key,即key=A[0];
3)从j开始向前搜索,即由后开始向前搜索(j–),找到第一个小于key的值A[j],将A[j]和A[i]互换;
4)从i开始向后搜索,即由前开始向后搜索(i++),找到第一个大于key的A[i],将A[i]和A[j]互换;
5)重复第3、4步,直到i=j; (3,4步中,没找到符合条件的值,即3中A[j]不小于key,4中A[i]不大于key的时候改变j、i的值,使得j=j-1,i=i+1,直至找到为止。找到符合条件的值,进行交换的时候i, j指针位置不变。另外,i==j这一过程一定正好是i+或j-完成的时候,此时令循环结束)。
选择排序
**选择排序:**从剩余元素后面找最小(最大)元素加入有序子序列;
时间复杂度:O(n^2)
空间复杂度:O(1)
不稳定
适用于顺序表跟链表
//n个元素进行n-1躺处理
//简单选择排序
void SelectSort(int a[],int n)
{
for(int i = 0;i<n-1;i++)//n个元素,n-1趟
{
int min=i;//标记最小元素位置
for(int j = i+1;j<n;j++)
{
if(a[j]<a[min])
{
min=j;//找到更小元素,更新标记
}
}
if(min!=i)//最小元素位置非当前i的位置,交换最小元素
{
int temp=a[i];
a[i]=a[j];
a[j]=temp;
}
}
}
堆排序(属于选择排序)
选择排序: 每一趟在待排序元素中选取关键字最小(或最大)的元素加入有序子序列
若n个关键字序列L[1…n] 满足下面某一条性质,则称为堆(Heap):
- 若满足: L(i)≥L(2i)且L(i)≥L(2i+1) (1<=i<=n/2)————大根堆(大顶堆)
- 若满足: L(i)≤L(2i)且L(i)≤L(2i+1) (1<=i<=n/2)————小根堆(小顶堆)
堆:顺序存储的完全二叉树
大根堆:完全二叉树中,根≥左子树、右子树
小根堆:完全二叉树中,根≤左子树、右子树
建立大根堆
- 检查所有非终端结点,看是否满足大根堆的要求,不满足则进行调整。 非终端结点:⌊n/2⌋
- 按照从后往前的顺序检查,即从⌊n/2⌋结点开始往前检查,不符合则互换
- 检查当前结点是否满足 根≥左子树、右子树,不满足则将当前结点与更大的孩子互换
- 若元素互换破坏了下一级的堆,则采用相同的方式继续往下调整(小元素不断下坠)
//建立大根堆
void BuildMaxHeap(int a[],int len)//len是数组的长度
{
for(int i = len/2;i>0;i--)
{
HeapAdjust(a,i,len);
}
}
//堆调整函数
//将以k为根的子树调整为大根堆
void HaepAdjust(int a[],int k,int len)
{
a[0]=a[k];//暂存子树的根结点
for(int i = 2*k; i<=len;i=i*2)
{
if(i<len&&a[i]<a[i+1])//左小于右 让i++,到右子树 ;i<len 保证有右兄弟(数组下标从1开始)
i++;
if(a[0]>a[i])//筛选结束,a[0]存的是最初根结点的元素
{
break;
}else
{
a[k]=a[i];//将a[i]放到双亲结点
k=i;//修改k值方便继续向下筛选
}
}
a[k]=a[0];//被筛选的结点值放入最终位置
}
堆排序
时间复杂度与空间复杂度根HeapAdjust调整函数密切相关
一个结点每下坠一层,最多需要对比关键字两次(左右孩子均有)
若树高为h,某结点在第i层,则将这个结点向下调整最多只需要“下坠”h-i 层,关键字对比次数不超过 2(h-i)
堆建立的过程中,关键字的对比次数不超过4n,建堆时间复杂度:O(n)
每下坠一层,最多对比关键字两次,因此每一趟排序时间复杂度不超过O(h)=O( l o g 2 n log_2n log2n)共n-1趟,所以堆排序:
时间复杂度:O( n l o g 2 n nlog_2n nlog2n)
空间复杂度:O(1)
不稳定
每一趟将堆顶元素加入有序子序列(与待排序序列的最后一个元素交换)
并将待排序元素序列再次调整为大根堆(小元素不断下坠)修改len的值 len-1
基于大根堆的堆排序得到递增序列
//建立大根堆
void BuildHeap(int a[],int len);
//将以k为根结点的子树调整为大根堆
void HeapAdjust(int a[],int k,int len);
//堆排序的完整逻辑
void HeapSort(int a[],int len)
{
BuildHeap(a,len);//初始化堆
for(int i=len;i>1;i--)
{
swap(a[i],a[1]);//交换堆顶与堆底元素
HeapAdjust(a,1,i);//交换完之后把剩余元素整理成堆
}
}
堆中插入新元素
对于小根堆,新元素放到表尾,与父节点对比,若新元素比父节点更小,则将二者互换。新元素就这样一路“上升”,直到无法继续上升为止。
堆中删除元素
被删除的元素用堆底元素替代,然后让该元素不断“下坠”,直到无法下坠为止
归并排序(Merge)
将两个或者多个有序的序列合并成一个
二路归并(合二为一)
每选出一个小元素只需对比关键字一次
时间复杂度:O(nlog2n) (每一趟时间复杂度O(n),共log2n趟)
空间复杂度:O(n),来自于辅助数组B (递归不如这个大)
四路归并
每选出一个小元素需要对比关键字3次
m路归并
每选出一个元素需要对比关键字m-1次
核心操作(代码实现)
把数组内的两个有序序列合并成一个
int *B=(int *)malloc(n*sizeof(int));//辅助数组B
//a[low……mid]与a[mid+1……high]各自有序,将两个部分归并
void Merge(int a[],int low,int mid,int high)
{
int i,j,k;
for(k=low;k<=high;k++)
B[k]=a[k];//将a中元素复制到B中
for(i=low,j=mid+1,k=i;i<=mid&&j<=high;k++)//一个从low开始 一个从mid开始 k是归并数组,初始是low
{
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(int a[],int low,int high)
{
if(low<high)
{
int mid=(low+high)/2;
MergeSort(a,low,mid);//左半部分归并
MergeSort(a,mid+1,high);//右半部分归并
Merge(a,low,mid,high);
}
}
二路归并树形态上就是一棵倒立的二叉树
第h层最多 2 h − 1 2^h-1 2h−1个结点,若树高h,满足n<= 2 h − 1 2^{h-1} 2h−1
即h-1>=⌈ l o g 2 n log_2n log2n⌉
结论:n个元素的二路归并,趟数=⌈ l o g 2 n log_2n log2n⌉
基数排序
基数排序:
基数排序:个人理解即为先按照个位排序,在这个基础上按照十位排序,直到最高位为止,每一次排序都是将0~9对应的数字放在一个桶中,故又称桶子法(牛客)
空间复杂度:O®
时间复杂度:O(d(n+r))
稳定
需要r个辅助队列
一趟分配O(n),一趟收集O®,总共d趟分配、收集 (把关键字拆分为d个部分,每个部分可能取得r个值)
//基数排序通常基于链式存储实现
typedef struct LinkNode{
ElemType data;
struct LinkNode *next;
}LinkNode,*LinkList;
typedef struct{//链式队列
LinkNode *front,*rear;
}LinkQueue;
基数排序不是基于”比较“的排序算法
n/2;i>0;i–)
{
HeapAdjust(a,i,len);
}
}
//堆调整函数
//将以k为根的子树调整为大根堆
void HaepAdjust(int a[],int k,int len)
{
a[0]=a[k];//暂存子树的根结点
for(int i = 2k; i<=len;i=i2)
{
if(i<len&&a[i]<a[i+1])//左小于右 让i++,到右子树 ;i<len 保证有右兄弟(数组下标从1开始)
i++;
if(a[0]>a[i])//筛选结束,a[0]存的是最初根结点的元素
{
break;
}else
{
a[k]=a[i];//将a[i]放到双亲结点
k=i;//修改k值方便继续向下筛选
}
}
a[k]=a[0];//被筛选的结点值放入最终位置
}
#### 堆排序
> 时间复杂度与空间复杂度根HeapAdjust调整函数密切相关
>
> 一个结点每下坠一层,最多需要对比关键字两次(左右孩子均有)
>
> 若树高为h,某结点在第i层,则将这个结点向下调整最多只需要“下坠”**h-i** 层,关键字对比次数不超过 **2(h-i)**
>
> 堆建立的过程中,关键字的对比次数不超过4n,**建堆时间复杂度:O(n)**
> 每下坠一层,最多对比关键字两次,因此每一趟排序时间复杂度不超过O(h)=O($log_2n$)
>
> 共n-1趟,所以堆排序:
>
> **时间复杂度:O($nlog_2n$)**
>
> **空间复杂度:O(1)**
>
> **不稳定**
> 每一趟将堆顶元素加入有序子序列(与待排序序列的最后一个元素交换)
>
> 并将待排序元素序列再次调整为大根堆(小元素不断下坠)修改len的值 len-1
>
> *基于大根堆的堆排序得到递增序列*
```C
//建立大根堆
void BuildHeap(int a[],int len);
//将以k为根结点的子树调整为大根堆
void HeapAdjust(int a[],int k,int len);
//堆排序的完整逻辑
void HeapSort(int a[],int len)
{
BuildHeap(a,len);//初始化堆
for(int i=len;i>1;i--)
{
swap(a[i],a[1]);//交换堆顶与堆底元素
HeapAdjust(a,1,i);//交换完之后把剩余元素整理成堆
}
}
[外链图片转存中…(img-KGXEriXE-1673513105163)]
堆中插入新元素
对于小根堆,新元素放到表尾,与父节点对比,若新元素比父节点更小,则将二者互换。新元素就这样一路“上升”,直到无法继续上升为止。
堆中删除元素
被删除的元素用堆底元素替代,然后让该元素不断“下坠”,直到无法下坠为止
归并排序(Merge)
将两个或者多个有序的序列合并成一个
二路归并(合二为一)
每选出一个小元素只需对比关键字一次
时间复杂度:O(nlog2n) (每一趟时间复杂度O(n),共log2n趟)
空间复杂度:O(n),来自于辅助数组B (递归不如这个大)
四路归并
每选出一个小元素需要对比关键字3次
m路归并
每选出一个元素需要对比关键字m-1次
核心操作(代码实现)
把数组内的两个有序序列合并成一个
int *B=(int *)malloc(n*sizeof(int));//辅助数组B
//a[low……mid]与a[mid+1……high]各自有序,将两个部分归并
void Merge(int a[],int low,int mid,int high)
{
int i,j,k;
for(k=low;k<=high;k++)
B[k]=a[k];//将a中元素复制到B中
for(i=low,j=mid+1,k=i;i<=mid&&j<=high;k++)//一个从low开始 一个从mid开始 k是归并数组,初始是low
{
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(int a[],int low,int high)
{
if(low<high)
{
int mid=(low+high)/2;
MergeSort(a,low,mid);//左半部分归并
MergeSort(a,mid+1,high);//右半部分归并
Merge(a,low,mid,high);
}
}
二路归并树形态上就是一棵倒立的二叉树
第h层最多 2 h − 1 2^h-1 2h−1个结点,若树高h,满足n<= 2 h − 1 2^{h-1} 2h−1
即h-1>=⌈ l o g 2 n log_2n log2n⌉
结论:n个元素的二路归并,趟数=⌈ l o g 2 n log_2n log2n⌉
[外链图片转存中…(img-vVdhXqRl-1673513105164)]
基数排序
基数排序:
基数排序:个人理解即为先按照个位排序,在这个基础上按照十位排序,直到最高位为止,每一次排序都是将0~9对应的数字放在一个桶中,故又称桶子法(牛客)
空间复杂度:O®
时间复杂度:O(d(n+r))
稳定
需要r个辅助队列
一趟分配O(n),一趟收集O®,总共d趟分配、收集 (把关键字拆分为d个部分,每个部分可能取得r个值)
//基数排序通常基于链式存储实现
typedef struct LinkNode{
ElemType data;
struct LinkNode *next;
}LinkNode,*LinkList;
typedef struct{//链式队列
LinkNode *front,*rear;
}LinkQueue;
[外链图片转存中…(img-xKlClNEd-1673513105164)]
基数排序不是基于”比较“的排序算法
[外链图片转存中…(img-Jv89sJ6o-1673513105165)]