本文是基于王道考研 数据结构所记的笔记。如有侵权,请告知删除。
版本号:v1.0.0
数据:信息的载体,是描述客观事物属性的数、字符及所有能输入到计算机中并被计算机程序识别和处理的符号的集合。
数据类型(集合+操作):
数据结构:
数据结构三要素:逻辑结构、物理结构、数据的运算
算法的基本概念:
算法的五个特性:
算法 VS 程序
算法效率的度量:
如何设计一个“好”算法
语句频度:该条语句可能重复执行的次数。
T(n):所有语句的频度之和,其中n为问题的规模。
时间复杂度:T(n) = O(f(n)),其中O表示T(n)与f(n)在n->正无穷时为同阶无穷大。
空间复杂度:算法消耗的存储空间,记S(n) = O(g(n))
线性表是一种逻辑结构。顺序表和链表是存储结构。
线性表的定义:
线性表的特性:
线性表的九种基本操作:
顺序表的定义:
顺序表的描述:
数组静态分配:
#define MaxSize 50
typedef struct{
ElemType data[MaxSize];
int length;
}SqList;
数组动态分配:
#define MaxSize 50
typedef struct{
ElemType *data;
int length;
}SqList;
//动态分配语句
//C L.data=(Elemtype*)malloc(sizeof(ElemType)*InitSize);
//C++ L.data=new ElemType[InitSize];
插入操作
bool ListInsert(SqList &L,int i,ElemType e){
//i对应顺序表的标号(1 - n),而非(0 - n-1)
if(i<1||i>L.length+1)
return false;
if(L.length>=MaxSize)
return false;
for(int j=L.length;j>=i;j--)
//插入前最后一个元素数组下标为n-1,插入后最后一个元素数组下标为n
L.data[j]=L.data[j-1];
L.data[i-1]=e;
L.length++;
return true;
}
/****时间复杂度****
**最好:O(1)
**平均:O(n)
**最坏:O(n)
****************/
删除操作
bool ListDelete(SqList &L,int i,ElemType &e){
if(i<1||i>L.length)
return false;
e=L.data[i-1];
for (int j=i;j<L.length;j++)
L.data[j-1]=L.data[j];
L.length--;
return true;
}
/****时间复杂度****
**最好:O(1)
**平均:O(n)
**最坏:O(n)
****************/
按值查找
int LocateElem(SqList L,ElemType e){
int i;
for (int i=0;i<L.length;i++)
if(L.data[i]==e)
return i+1;
return 0;
}
/****时间复杂度****
**最好:O(1)
**平均:O(n)
**最坏:O(n)
****************/
单链表的定义:
单链表的描述:
typedef struct LNode{
ElemType data;
struct LNode *next;
}LNode,*LinkList
使用技巧:
头插法建立单链表
LinkList List_HeadInsert(LinkList &L){
LNode *s;int x;
//初始化头结点
L=(LinkList)malloc(sizeof(LNode));
L->next=NULL;
scanf("d",&x)
while(x!=9999){
s=(LNode*)malloc(sizeof(LNode));
s->data=x;
//插入到头结点后面
s->next=L->next;
L->next=s;
scanf("%d",&x);
}
return L;
}
/****时间复杂度(最坏):O(n)****/
尾插法建立单链表
LinkList List_TailInsert(LinkList &L){
int x;
//初始化头结点
L=(LinkList)malloc(sizeof(LNode));
LNode *s;
LNode *r=L;//尾结点指针
scanf("%d",&x);
while(x!=9999){
s=(LNode*)malloc(sizeof(LNode));
s->data=x;
//插入到尾结点后面
r->next=s;
r=s;
scanf("%d",&x);
}
r->next=NULL;//注意不能忘记。
return L;
}
按序号查找&按值查找
LNode *GetElem(LinkList L,int i){
//时间复杂度:O(n)
int j=1;
LNode *p=L->next;
if(i==0)
return L;
if(i<1)
return NULL;
while(p&&j<i){
p=p->next;
j++;
}
return p;
}
LNode *LocateElem(LinkList L,ElemType e){
LNode *p=L->next;
while(p!=NULL&&P->data!=e)
p=p->next;
return p;
}
插入节点
/* 前插法 */
p=GetElem(L,i-1)
s->next=p->next;
p->next=s;
/* 后插法(第i个节点未知) */
p=GetElem(L,i)
s->next=p->next;
p->next=s;
/* 后插法(给定第i个节点*p) */
s->next=p->next;
p->next=s;
删除节点
/* 删除节点未知 */
p=GetElem(L,i-1);
q=p->next;
p->next=q->next;
free(q);
/* 删除给定节点*p */
q=p->next;
p->data=p->next->data;//数据前移
p->next=q->next;
free(q);
求表长
//无头节点,多了一个判空操作
int count=0;
p=head;
if(p==NULL){
return count;
}
while(p->next!=NULL){
count++;
p=p->next;
}
//有头节点
int count=-1;
p=head;
while(p->next!=NULL){
count++;
p=p->next;
}
双链表
typedef struct DNode{
ElemType data;
struct DNode *prior,*next;
}DNode,*DinkList
基本操作变化
插入操作:
前插法和后插法在O(1)时间内完成。
在表头、表中插和在表尾插不同。
//将*s结点插入到*p节点后面
s->next=p->next;
p->next->prior=s;
s->prior=p;
p->next=s;//该操作会失去第i+1节点的位置,应放在后面
删除操作:
//删除*q节点,*p为*q的前驱节点
p->next=q->next;
q->next->prior=p;
free(q);
//时间复杂度为O(1)
//在表尾删除不太一样
循环链表
循环单链表
循环双链表
判空操作:
循环单链表:L->next==L;
循环双链表:L->next==L;
L->prior==L;
静态链表
将地址改成下标(游标),通过数组实现
#define MaxSize 50
typedef struct DNode{
ElemType data;
int next;
}SLinkList[MaxSize];
顺序表和链表的区别
1)存取方式:
2)逻辑结构和物理结构:
3)基本操作:
4)内存空间:
怎样选择线性表的存储结构
顺序表(稳定) | 单链表(动态) | ||
---|---|---|---|
存储 | 规模难以估计 | Y | |
存储密度 | Y | ||
效率 | 按序号访问 | Y | |
频繁插入和删除 | Y | ||
环境 | 基于数组 | Y | |
基于指针 | Y |
三个常用操作
最值:最大值、最小值
逆置:线性表元素顺序逆置
顺序表:用两个标记来标记头(i)和尾(j),交换对应元素,当i>=j时结束。O(n)
单链表:用r指向尾结点,不进行更新,每次将头节点后面的节点插入到r后面。
//L指向头节点
p=L;
r=L;
while(r->next){
r=r->next;
}
while(p->next!=r){
//将第一个结点从头节点后面拿出去
temp=p->next;
p->next=temp->next;
//将第一个结点插入到r后面
temp->next=r->next;
r->next=temp;
}
归并:归并有序线性表
顺序表:
//O(n),n为合并后数组大小
int i=0,j=0,k=0;
for(;i<L1_Size&&j<L2_Size;k++){
if(L1[i]<L2[j])
L[k]=L1[i++];
else
L[k]=L2[j++];
}
while(i<L1_Size)
L[k++]=L1[i++];
while(j<L2_Size)
L[k++]=L2[j++];
单链表:
//L1,L2分别指向头节点,r指向新创建的头节点
while(p->next!=NULL&&q->next!=NULL){
if(p->next->data<q->next->data){
r->next=p->next;
p->next=p->next->next;
r=r->next;
}
else{
r->next=q->next;
q->next=q->next->next;
r=r->next;
}
}
if(p->next!=NULL) r->next=p->next;
if(q->next!=NULL) r->next=q->next;
free(p);free(q);
栈的顺序存储
顺序栈:采用顺序存储的栈
#define MaxSize 50
typedef struct{
ElemType data[MaxSize];
int top;
}SqStack;
栈空条件:S.top == -1
栈长:S.top+1
栈满条件:S.top == MaxSize-1
进栈:
bool Push(SqStack &S,ElemType x){
if(S.top == MaxSize-1)
return false;
S.data[++S.top] = x;
return true;
}
出栈:
bool Pop(SqStack &S,ElemType &x){
if(S.top == -1)
return false;
x=S.data[S.top--];
return true;
}
共享栈:将两个栈底设置在共享空间的两端,栈顶向空间中间延伸。
0号栈 top == -1
,1号栈 top == MaxSize
top1-top0 == 1
栈的链式存储
链栈:采用链式存储的栈。表头结点为栈顶结点,表尾结点为栈底结点。
typedef struct Linknode{
ElemType data;
struct Linknode *next;
}*LiStack;
//所有操作都在表头进行,与链表相似
出栈和入栈类似于单链表在头部插入和删除节点。
输出序列问题
连续输入和输出:
输入序列:1,2,3,…,n
栈的输出序列:n,…,3,2,1
队列的输出序列:1,2,3,…,n
非连续输入和输出:
输入序列:1,2,3,4
栈的输出序列:3124,3142不可以,1324,3214,4321可以
输入序列:1,2,3,…,k,…,n
特点:出栈序列中每一个元素后面所有比它小的元素组成一个递减序列。
合法出栈序列的个数:f(n)=C(2n,n)/(n+1)
C是组合数
最后一个出栈元素为k的序列:
1~k-1 | k+1~n | k |
---|---|---|
f(k-1) | f(n-k) |
总共有f(k-1)*f(n-k)
个序列
f(n)=f(0)*f(n-1)+f(1)*f(n-2)+...+f(n-2)*f(1)+f(n-1)*f(0)
且f(0)=f(1)=1
括号匹配
[(A+B)*C]-[E-F]
算法思想:
1)初始一个空栈,顺序读入括号。
2)若是右括号,则与栈顶元素进行匹配。
-若匹配,则弹出栈顶元素并进行下一个元素;
-若不匹配,则该序列不合法;
3)若是左括号,则压入栈中。
4)若全部元素遍历完毕,栈中非空则序列不合法。
表达式求值
[(A+B)*C]-[E-F]
前缀表达式:+ AB
中缀表达式:A + B
后缀表达式:AB +
中缀转后缀算法思想:
数字直接加入后缀表达式
运算符时:
a.若为’(’,入栈;
b.若为’)’,则依次把栈中的运算符加入后缀表达式,直到出现’(’,并从栈中删除’(’;
c.若为’+’,’-’,’*’,’/’,
-栈空,入栈;
-栈顶元素为’(’,入栈;
-高于栈顶元素优先级,入栈;
-否则,一次弹出栈顶运算符,直到一个优先级比它低的运算符或’('为止;
d.遍历完成,若栈非空依次弹出所有元素。
((A+B)*C)-(E-F) 转为后缀表达式
栈 | 表达式 | 后缀表达式 |
---|---|---|
(( | **((**A+B)*C)-(E-F) | |
((+ | ((A+B)*C)-(E-F) | AB |
( | **((A+B)***C)-(E-F) | AB+ |
(* | *((A+B)C)-(E-F) | AB+C |
((A+B)*C)-(E-F) | AB+C* | |
-(- | *((A+B)C)-(E-F) | AB+C*EF |
- | *((A+B)C)-(E-F) | AB+C*EF- |
遍历完成,弹出栈中元素 | AB+C*EF– |
递归
递归:若在一个函数,过程或数据结构的定义中又应用了它自身,则称它为递归定义的,简称递归。
斐波那契数列:0,1,1,2,3,5,…
fib(n)=fib(n-1)+fib(n-2), n>1 //递归表达式
=1, n=1 //递归出口
=0, n=0 //递归出口
int Fib(int n){
if(n==0)
return 0;//递归出口
else if(n==1)
return 1;//递归出口
else
return Fib(n-1)+Fib(n-2);
}
递归的精髓在于能否将原始问题转换为属性相同但规模较小的问题。
递归产生的问题:
将递归算法转换为非递归算法,往往需要借助栈来进行。
队列的顺序存储
顺序队采用顺序存储的队列。
循环队列:把存储队列的顺序队列在逻辑上视为一个环。取余(%MaxSize)
#define MaxSize 50
typedef struct{
ElemType data[MaxSize];
int front,rear;
}SqQueue;
front指向队首元素,rear指向队尾元素的下一个位置。(或front指向队首元素的前一个位置,rear指向队尾元素。)
初始时front == rear == 0
队长:(Q.rear + MaxSize- Q.front)%MaxSize
队满和队空存在冲突的解决方法:
front == rear
Q.front == (Q.rear+1)%MaxSize
Q.size == 0
Q.size == MaxSize
Q.front == Q.rear && tag == 0
Q.front == Q.rear && tag == 1
入队
bool EnQueue(SqQueue &Q,ElemType x){
if((Q.rear+1)%MaxSize == Q.front)
return false;
Q.data[Q.rear] = x;
Q.rear = (Q.rear+1)%MaxSize;
return true;
}
出队
bool DeQueue(SqQueue &Q,ElemType &x){
if(Q.rear == Q.front)
return false;
x=Q.data[Q.front];
Q.front = (Q.front+1)%MaxSize;
return true;
}
队列的链式存储
链队:采用链式存储的队列。
typedef struct{
ElemType data;
struct LinkNode *next;
}LinkNode;
typedef struct{
LinkNode *front,*rear;
}LinkQueue;
初始化:
void InitQueue(LinkQueue &Q){
Q.front = (LinkNode*)malloc(sizeof(LinkNode));//头结点
Q.rear = Q.front;
Q.front->next = NULL;
}
判空:Q.front == Q.rear
入队:尾插法
void EnQueue(LinkQueue &Q,ElemType x){
LinkNode *s = (LinkNode *)malloc(sizeof(LinkNode));
s->data = x;
s->next = NULL;
Q.rear->next = s;
Q.rear = s;
}
出队:头部删除
bool DeQueue(LinkQueue &Q,ElemType &x){
if(Q.front == Q.rear)
return false;
LinkNode *p = Q.front->next;
x = p->data;
Q.front->next = p->next;
if(Q.rear == p)//当只有一个数据元素节点时,还要修改rear
Q.rear=Q.front;
free(p);
return true;
}
数组的基本概念(逻辑结构)
数组的特点:
A[1...n][1...n]
中的任意元素ai,j,都有ai,j=aj,i(i<=i,j<=n),则称其为对称矩阵。
A[i][j]
数组下标:k=1+2+…+(i-1)+j-1+1-1A[1...n][1...n]
中上(下)三角区元素均为同一常量,则称为下(上)三角矩阵。
顺序存储
#define MAXLEN 255//预定义最大串长为255
//静态数组实现(定长顺序存储)
typedef struct{
char ch[MAXLEN];//每个分量存储一个字符
int length;//串的实际长度
}SString;
//动态数组实现(堆分配存储
typedef struct{
char *ch;
int length;
}HString;
HString S;
S.ch=(char*)malloc(MAXLEN*sizeof(char));//需要 手动free
S.length=0;
链式存储
typedef struct StringNode{
char ch;//每个结点的存一个字符
struct StringNode *next;
}StringNode,*String;
//存储密度低,每个字符(实际有用信息)1B,每个指针(辅助信息)4B
//改进一下
typedef struct StringNode{
char ch[4];//每个节点存多个字符
struct StringNode *next;
}StringNode,*String;
基于顺序存储实现基本操作
比较操作
int StrCompare(SString S,SString T){
for(int i=1;i<=S.length&&i<=T.length;i++){
if(S.ch[i]!=T.ch[i])
return S.ch[i]-T.ch[i];
}
return S.length-T.length;
}
定位操作
什么是模式匹配
在主串中找到与模式串相同的子串,并返回其所在位置。
int Index(SString S,SString T){
int k=1;//k指从S串的第k个位置开始匹配
int i=k,j=1;
while(i<=S.length&&j<=T.length){
if(S.ch[i]==T.ch[j]){
++i;++j;
}else{
k++;//检查下一个子串
i=k;j=1;
}
}
if(j>T.length){
return k;
}else{
return 0;
}
}
性能分析:
朴素模式匹配算法的缺点:当某些子串与模式串能部分匹配时,主串的扫描指针i经常回溯,导致时间开销增加
改进思路:
主串指针不回溯,只有模式串指针回溯
算法实现
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;
}
void get_next(SString T,int next[],int nextval[]){
int i=1,j=0;
next[1]=0;
while(i<T.length){
if(j==0||T.ch[i]==T.ch[j]){
++i;++j;
//若pi==pj,则next[j+1]==next[j]+1
next[i]=j;
}
else{
//否则令j=next[j],循环继续
j=next[j];
}
}
//nextval数组求法:
nextval[1]=0;
for(int j=2;j<+T.length;j++){
if(T.ch[next[j]]==T.ch[j])
nextval[j]=nextval[next[j]];
else
nextval[j]=next[j];
}
}
串的前缀:包含第一个字符,且不包含最后一个字符的子串;
串的后缀:包含最后一个字符,且不包含第一个字符的子串
KMP算法:当子串和模式串不匹配时,主串指针i不回溯,模式串指针 j=next[j]算法平均时间复杂度:O(n+m)
next数组手算方法:当第j个字符匹配失败,由前1~j-1个字符组成的串记为s,则:next[j]= S的最长相等前后缀长度+1
当第一个字符就匹配失败时,如果依然写成0+1,j依然是1,也依然是1,进入死循环,所以要设置next[1]=0,也可以得出next[2]=1.
例:模式串:ababaa
序号j | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
模式串 | a | b | a | b | a | a |
a | ab | aba | abab | ababa | ||
next[j] | 0 | 1 | 1 | 2 | 3 | 4 |
nextval[j] | 0 | 1 | 0 | 1 | 0 | 4 |
next[j]=
算法性能分析
树的定义
**树(逻辑结构)**是n(n≥0)个结点的有限集合,n=0时,称为空树。
而任意非空树应满足:
1)有且仅有一个特定的称为根的结点。
2)当n>1时,其余节点可分为m(m>0)个互不相交的有限集合,其中每一个集合本身又是一棵树,成为根节点的子树。
n个结点的树有n-1条边。
基本术语:
祖先结点和子孙结点
双亲结点和孩子结点
兄弟结点
树中一个结点的子结点的个数称为该结点的度。
树中最大度数称为树的度。
度大于0的结点称为分支节点。
度为0的结点称为叶子结点。
结点的层次:根节点为第一层。
结点的高度:从最低的叶子节点到根经历的层数(包括叶子结点所在层)
结点的深度:从根节点开始到对应结点经历层数(包括根节点所在层)
**树的高度(深度)**是树中结点的最大层数
有序树与无序树:子结点是否存在先后顺序
路径:树中两个结点之间的路径是由这两个结点之间所经过的结点序列构成的。
树中的分支是有向的,即从双亲结点指向孩子结点,所以路径一定是自上而下的。
路径长度:路径上所经历边的个数。
森林:m (m≥0)棵互不相交的树的集合
树的性质
树中的结点数等于所有结点的度数加1
度为m的树中第i层上至多有mi-1个结点( i≥1)
高度为h的m叉树至多有(mh -1)/(m-1)=m0+m1+mh+…+mh-1个结点
具有n个结点的m叉树的最小高度为[logm(n(m -1)+1)],[]是取上界
采用一组连续的存储空间来存储每个结点,同时在每个节点中增设一个伪指针,指示双亲结点在数组中的位置。根结点的下标为0,其伪指针域为-1。
#define MAX_TREE_SIZE 100
typedef struct{
ElemType data;
int parent;
}PTNode;
typedef struct{
PTNode nodes[MAX_TREE_SIZE];
int n;
}PTree;
孩子表示法
将每个结点的孩子结点都用单链表连接起来形成一个线性结构,n个结点具有n个孩子链表。
#define MAX_TREE_SIZE 100
typedef struct{
int child;
struct CNode *next;
}CNode;
typedef struct{
ElemType data;
struct CNode *child;
}PNode;
typedef struct{
PNode nodes[MAX_TREE_SIZE];
int n;
}CTree;
孩子兄弟(左孩子右兄弟)表示法
以二叉链表作为树的存储结构,又称二叉树表示法。
结点第一个孩子结点指针
结点值
结点下一个兄弟结点指针
typedef struct CSNode{
ElemType data;
struct CSNode *firstchild,*nextsibling;
}CSNode,CSTree;
对比
优点 | 缺点 | |
---|---|---|
双亲表示法 | 寻找结点的双亲结点效率高 | 寻找结点的孩子结点效率低 |
孩子表示法 | 寻找结点的孩子结点效率高 | 寻找结点的双亲结点效率低 |
孩子兄弟表示法 | 寻找结点的孩子结点效率高 方便实现树转换为二叉树 |
寻找结点的双亲结点效率低 |
树与二叉树的转换(左孩子右兄弟)
森林与二叉树的转换
树的遍历
按照某种方式访问树中的每个结点,且仅访问一次先根遍历
先根遍历:若树非空,则先访问根结点,再按从左到右的顺序遍历根结点的每棵子树。
后根遍历:若树非空,则先按从左到右的顺序遍历根结点的每棵子树,再访问根结点。
层次遍历
森林的遍历
先序遍历
若森林非空,则,
访问森林中第一棵树的根结点
先序遍历第一棵树的子树森林
先序遍历除去第一棵树之后剩余的树构成的子树森林.
森林的先序遍历序列与森林对应二叉树的先序遍历序列相同
中序遍历
若森林非空,则,
中序遍历第一棵树的根结点的子树森林
访问第一棵树的根结点
中序遍历除去第一棵树之后剩余的树构成的子树森林
森林的中序遍历序列与森林对应二叉树的中序遍历序列相同
遍历序列的对应关系
树 | 森林 | 二叉树 |
---|---|---|
先根遍历 | 先序遍历 | 先序遍历 |
后根遍历 | 中序遍历 | 中序遍历 |
并查集一种简单的集合表示。
通常用树的双亲表示法作为并查集的存储结构。
通常用数组元素的下标代表元素名,用根结点的下标代表子集合名,根结点的双亲结点为负数。
基本操作
示例
实现
#define SIZE 100
int UFSets[SIZE];
void Initial(int S[]){
for(int i=0;i<size;i++)
s[i]=-1;
}
int Fine(int S[],int x){
while(S[x]>=0)
x=S[x];
return x;
}
void Union(int S[],int Root1,int Root2){
S[Root1]+=S[Root2];
S[Root2]=Root1;
}
二叉树的定义
二叉树是n (n≥0)个结点的有限集合。
1)n=0时,二叉树为空;
2)n>0时,由根结点和两个互不相交的被称为根的左子树和右子树组成。左子树和右子树也分别是一棵二叉树。
二叉树 VS 度为2有序树
1)二叉树可以为空,而度为2的有序树至少有三个结点。
2)二叉树的孩子结点始终有左右之分,而度为2有序树的孩子结点次序是相对的。
特殊二叉树
1)满二叉树一棵高度为h,且含有2h-1个结点的二叉树为满二叉树。对于编号为i的结点,若存在,其双亲的编号为[i/2]取下界,左孩子为2i,右孩子为2i+1。
2)完全二叉树:设一个高度为h、有n个结点的二叉树,当且仅当其每个结点都与高度为h的满二叉树中编号1~n的结点——对应时,称为完全二叉树。
3)二叉排序树:—棵二叉树,若树非空则具有如下性质:
对任意结点若存在左子树或右子树,则其左子树上所有结点的关键字均小于该结点,右子树上所有结点的关键字均大于该结点。
4)平衡二叉树:树上任意结点的左子树和右子树的深度只差不超过1。
二叉树的性质
1)非空二叉树上的叶子结点数等于度为2的结点数加1,即n0= n2+1
ni表示所有度为i的结点。n=n0+n1+n2,n=n1+2n2+1
2)非空二叉树上第k层上至多有2k-1个结点(k≥1)
3)高度为h的二叉树至多有2h-1个结点(h≥1)
4)结点i所在层次为[log2i]取下界+1。
5)具有n个(n>0)结点的完全二叉树的高度为[log 2n]取下界+1或[log 2(n+1)]取上界
二叉树的顺序存储
用一组连续的存储单元依次自上而下、自左至右存储完全二叉树上的结点元素。
在完全二叉树中依次编号,对于结点i:
若存在左孩子,则编号为2i;若存在右孩子,则编号为2i+1。
二叉树的链式存储
用链表来存放一棵二叉树,二叉树中每个结点用链表的一个链结点来存储。
typedef struct BiTNode{
ElemType data;
struct BiTNode *lchild,*rchild;
}BiTNode,*BiTree;
含有n个结点的二叉链表中,有n+1个空链域=2n-(n-1)
按某条搜索路径访问树中的每个结点,树的每个结点均被访问一次,而且只访问一次。
左子树 根 右子树
先序遍历 中序遍历 后序遍历
先序遍历:O(n)
若二叉树非空:
1)访问根结点
2)先序遍历左子树
3)先序遍历右子树
void PreOrder(BiTree T){
if(T!=NULL){
visit(T);
PreOrder(T->lchild);
PreOrder(T->rchild);
}
}
中序遍历:O(n)
若二叉树非空:
1)中序遍历左子树
2)访问根结点
3)中序遍历右子树
void InOrder(BiTree T){
if(T!=NULL){
InOrder(T->lchild);
visit(T);
InOrder(T->rchild);
}
}
后序遍历:O(n)
若二叉树非空:
1)后序遍历左子树
2)后序遍历右子树
3)访问根结点
void PostOrder(BiTree T){
if(T!=NULL){
PostOrder(T->lchild);
PostOrder(T->rchild);
visit(T);
}
}
中序遍历非递归算法:
借助栈算法思想:
1)初始时依次扫描根结点的所有左侧结点并将它们——进栈;
2)出栈一个结点,访问它;
3)扫描该结点的右孩子结点并将其进栈;
4)依次扫描右孩子结点的所有左侧结点并——进栈;
5)反复该过程直到栈空为止。
void InOrder2(BiTree T){
InistStack(S);
BiTree p=T;
while(p||IsEmpty(S)){
if(p){
Push(S,p);
p=p->lchild;
}
else{
Pop(S,p);
visit(p);
p=p->rchild;
}
}
}
层次遍历:
借助队列算法思想:
1)初始将根入队并访问根结点;
2)若有左子树,则将左子树的根入队;
3)若有右子树,则将右子树的根入队;
4)然后出队,访问该结点;
5)反复该过程直到队列空为止。
void levelOrder(BiTree T){
InitQueue(Q);
Bitree p;
EnQueue(Q,T);
while(!isEmpty(Q)){
DeQueue(Q,p);
visit(p);
if(p->lchild!=NULL)
EnQueue(Q,p->lchild);
if(p->rchild!=NULL)
EnQueue(Q,p->rchild);
}
}
由遍历序列构造二叉树
★(后)先序遍历序列和中序遍历序列可以确定一棵二叉树,而后序遍历序列和先序遍历序列不可以确定一棵二叉树。
中序遍历序列和先序遍历序列
1)在先序序列中,第一个节点是根结点;
2)根结点将中序遍历序列划分为两部分;
3)然后在先序序列中确定两部分的结点,并且两部分的第一个结点分别为左子树的根和右子树的根;
4)在子树中递归重复该过程,便能唯一确定一棵二叉树。
若无左子树,则将左指针指向其前驱结点;若无右子树,则将右指针指向其后继结点。
线索二叉树结点结构
ltag | lchild | data | rchild | rtag |
---|---|---|---|---|
标志域l®tag: 0,l®chlid域指示结点的左(右)孩子
1,l®child域指示结点的前(后)继
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild,*rchild;
int ltag,rtag;
}ThreadNode,*ThreadTree;
这种结点结构构成的二叉链表作为二叉树的存储结构,称为线索链表。
中序线索二叉树
前驱结点
若左指针为线索,则其指向结点为前驱结点
若左指针为左孩子,则其左子树的最右侧结点为前驱结点
后驱结点
若右指针为线索,则其指向结点为后驱结点
若右指针为右孩子,则其右子树的最左侧结点为后驱结点
void InThread(ThreadTree &p,ThreadTree &pre){
if(p!=NULL){
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 CreateThread(ThreadTree T){
ThreadTree pre=NULL;
if(T!=NULL){
InThread(T,pre);
pre->rchild=NULL;
pre->rtag=1;
}
}
存在两个空指针:
增加一个头结点:
中序线索二叉树的遍历:
ThreadNode *Firstnode(ThreadNode *p){
while(p->ltag==0)
p=p->lchild;
return p;
}
ThreadNode *Nextnode(ThreadNode *p){
if(p->rtag==0)
return Firstnode(p->rchild);
else
return p->rchild;
}
void Inorder(ThreadNode *T){
for(ThreadNode *p=Firstnode(T);p!=NULL;p=Nextnode(p))
visit(p);
}
二叉排序树BST,也称二叉查找树。
二叉排序树或者为空树,或者为非空树,当为非空树时有如下特点:
1)若左子树非空,则左子树上所有结点关键字值均小于根结点的关键字。
2)若右子树非空,则右子树上所有结点关键字值均大于根结点的关键字。
3)左、右子树本身也分别是一棵二叉排序树。
左子树结点值<根结点值<右子树结点值
查找
二叉树非空时,查找根结点,若相等则查找成功;
若不等,则当小于根结点值时,查找左子树;当大于根结点的值时,查找右子树。当查找到叶节点仍没查找到相应的值,则查找失败。
BSTNode *BST_Search(BiTree T,ElemType key,BSTNode *&p){
p=NULL;//保存查找结点的双亲结点
while(T!=NULL&&key!=T->data){
p==T;
if(key<T->data)
T=T->lchild;
else
T=T->rchild;
}
return T;
}
插入
若二叉排序树为空,则直接插入结点;
若二叉排序树非空,当值小于根结点时,插入左子树;当值大于根结点时,插入右子树;当值等于根结点时不进行插入。
int BST_Insert(BiTree &T,KeyType k){
if(T==NULL){
T=(BiTree)malloc(sizeof(BSTNode));
T->key=k;
T->lchild=T->rchild=NULL;
return 1;
}
else if(k==T->key)
return 0;
else if(k<T->key)
return BST_Insert(T->lchild,k);
else
return BST_Insert(T->rchild,k);
}
构造二叉排序树
读入一个元素并建立结点,若二叉树为空将其作为根结点;
若二叉排序树非空,当值小于根结点时,插入左子树;当值大于根结点时,插入右子树;当值等于根结点时不进行插入。
void Create_BST(BiTree &T,KeyType str[],int n){
T=NULL;
int i=0;
while(i<n){
BST_Insert(T,str[i]);
i++;
}
}
删除
1)若被删除结点z是叶结点,则直接删除;
2)若被删除结点z只有一棵子树,则让z的子树成为z父结点的子树,代替z结点。
3)若被删除结点z有两棵子树,则让z的中序序列直接后继代替z,并删去直接后继结点(要么没有子结点,直接删除;要么只有一个子结点,按第二种情况删除)。
查找效率
平均查找长度(ASL)取决于树的高度。
如果是完全二叉树,则为O(log2n);最坏情况为O(n)。
平衡二叉树AVL,任意结点的平衡因子(左子树高度-右子树高度)的绝对值不超过一。
高度为h的最小平衡二叉树的结点数Nh
Nh=Nh-1+Nh-2+1
N0=0
N1=1
平衡二叉树的判断
利用递归的后序遍历过程:
1)判断左子树是一棵平衡二叉树
2)判断右子树是一棵平衡二叉树
3)判断以该结点为根的二叉树为平衡二叉树
void Judge_AVL(BiTreebt,int &balance,int &h){
//balance表示平衡性,h表示高度
int bl=0,br=0,hl=0,hr=0;
if(bt==NULL){
h=0;
balance=1;
}
else if(bt->lchild==NULL&&bt->rchild==NULL){
h=1;
balance=1;
}
else{
Judge_AVL(bt->lchild,bl,hl);
Judge_AVL(bt->rchild,br,hr);
if(hl>hr)
h=hl+1;
else
h=hr+1;
if(abs(hl-hr)<2&&bl==1&&br==1)
balance=1;
else
balance=0;
}
}
平衡二叉树的插入
先插入再调整。
每次调整最小不平衡子树。
LL平衡旋转(右单旋转)
原因:在结点A的左孩子的左子树上插入了新结点
调整方法:右旋操作:将A的左孩子B代替A,将A结点作为B的右子树根结点,而B的原右子树则作为A的左子树。
RR平衡旋转(左单旋转)
原因:在结点A的右孩子的右子树上插入了新结点
调整方法:左旋操作:将A的右孩子B代替A,将A结点作为B的左子树根结点,而B的原左子树则作为A的右子树。
LR平衡旋转(先左后右双旋转)
原因:在结点A的左孩子的右子树上插入了新结点
调整方法:先左旋后右旋操作:将A的左孩子B的右孩子结点C代替B,然后再将C结点向上代替A的位置。
RL平衡旋转(先右后左双旋转)
原因:在结点A的右孩子的左子树上插入了新结点
调整方法:先右旋后左旋操作:将A的右孩子B的左孩子结点C代替B,然后再将C结点向上代替A的位置。
基本概念
带权路径长度:
路径长度:路径上所经历边的个数。
结点的权:结点被赋予的数值。
树的带权路径长度WPL:
树中所有叶节点的带权路径长度之和,记为,WPL=∑wili
哈夫曼树也称最优二叉树,含有n个带权叶子结点带权路径长度最小的二叉树
哈夫曼树的构造算法
1)将n个结点作为n棵仅含有一个根结点的二叉树,构成森林F;
2)生成一个新结点,并从F中找出根结点权值最小的两棵树作为它的左右子树,且新结点的权值为两棵子树根结点的权值之和;
3)从F中删除这两个树,并将新生成的树加入到F中;
4)重复2,3步骤,直到F中只有一棵树为止。
哈夫曼树的性质
1)每个初始结点都会成为叶节点,双支结点都为新生成的结点
2)权值越大离根结点越近,反之权值越小离根结点越远
3)哈夫曼树中没有结点的度为1
4)n个叶子结点的哈夫曼树的结点总数为2n-1,其中度为2的结点数为n-1。
5)哈夫曼树并不唯一,所以每个字符对应的哈夫曼编码也不唯一,但带权路径长度相同且最优。
二叉树的应用——编码
编码:对于一个字符串序列,用二进制来表示字符。
1)固定长度编码&可变长度编码
2)前缀编码没有一个编码是另一个编码的前缀
3)根据字母出现次数得到哈夫曼树然后进行编码:
图的定义:
图G由顶点集V和边集E组成,记为G=(V,E),
其中V(G)表示图G中顶点的有限非空集;E(G)表示图G中顶点之间的关系(边)集合。
|V|表示图G中顶点的个数,也称图G的阶;|E|表示图G中边的条数
无向图&有向图
无向边:无序对(v, w) = (w,v),v,w互为邻接点
有向边:有序对
简单图&多重图
简单图:无重复边,不存在结点到自身的边
多重图:存在重复边,或存在结点到自身的边
完全图
无向完全图:任意两个顶点之间都存在边,n个顶点有n(n-1)/2边
有向完全图:任意两个顶点之间都存在方向相反的弧,n个顶点有n(n-1)边
子图
设有两个图G=(V,E)和G’=(V,E’),若V是V的子集,且E’是E的子集,则称G’为G的子图,
且若V(G)= V(G’)则称G’为G的生成子图
连通
无向图 | 有向图 |
---|---|
连通 若从顶点v到顶点w有路径存在,则称v和w是连通。 |
强连通 若从顶点v到顶点w和顶点w到顶点v都有路径存在,则称v和w是强连通。 |
连通图:任意两个结点之间都是连通的 最少有n-1条边。 |
强连通图:任意两个结点之间都是强连通的 最少有n条边,构成一个环状结构 |
连通分量 极大连通子图 |
强连通分量 极大强连通子图 |
对于G的一个连通子图G’,如果不存在G的另一个连通子图G",使得G’⊆G”,则称G’为G的连通分量。 | 对于G的一个强连通子图G’,如果不存在G的另一个强连通子图G",使得G’⊆G”,则称G’为G的(强)连通分量。 |
1.连通图只有一个极大连通子图,就是它本身。(是唯一的) 2.非连通图有多个极大连通子图。(非连通图的极大连通子图叫做连通分量,每个分量都是一个连通图) 3.称为极大是因为如果此时加入任何一个不在图的点集中的点都会导致它不再连通。 |
1.强连通图的极大强连通子图为其本身。(是唯一的) 2.非强连通图有多个极大强连通子图。(非强连通图的极大强连通子图叫做强连通分量) |
极小连通子图 | 无这个概念 |
生成树、生成森林
生成树:
对连通图进行遍历,过程中所经过的边和顶点的组合可看做是一棵普通树。
连通图中的生成树必须满足以下 2 个条件:
1)包含连通图中所有的顶点;
2)任意两顶点之间有且仅有一条通路;
因此,连通图的生成树具有这样的特征,即生成树中边的数量 = 顶点数 - 1
。
生成森林
生成树是对应连通图来说,而生成森林是对应非连通图来说的。非连通图可分解为多个连通分量,而每个连通分量又各自对应多个生成树(至少是 1 棵),因此与整个非连通图相对应的,是由多棵生成树组成的生成森林。
极小连通子图:
1)一个连通图的生成树是该连通图顶点集确定的极小连通子图。(同一个连通图可以有不同的生成树,所以生成树不是唯一的)
(极小连通子图只存在于连通图中)
2)用边把极小连通子图中所有节点给连起来,若有n个节点,则有n-1条边。
3)之所以称为极小是因为此时如果删除一条边,就无法构成生成树,也就是说给极小连通子图的每个边都是不可少的。
4)如果在生成树上添加一条边,一定会构成一个环。
也就是说只要能连通图的所有顶点而又不产生回路的任何子图都是它的生成树。
顶点的度:以该顶点为一个端点的边的数目
无向图:顶点v的度为以v为端点的边的个数,记为TD(v)。
n顶点,e条边的无向图中度的总数为2e
有向图:顶点v的度为出度入度之和,TD(v) = OD(v)+ID(v)
出度:以v为起点的有向边的条数,记OD(v)
入度:以v为终点的有向边的条数,记ID(v)
n顶点,e条边的有向图中出度,入度为e
网:边有权值
稠密图&稀疏图
边多VS边少
稠疏稠密的界定:|E|<|V|log|V|
有向树:一个顶点的入度为0、其余顶点的入度均为1的有向图
路径:图中顶点v到顶点w的顶点序列,序列中顶点不重复的路径称为简单路径。
路径长度:路径上边的数目,若该路径最短则称其为距离。
回路:第一个顶点和最后一个顶点相同的路径
邻接矩阵法
结点数为n的图G = (V, E)的邻接矩阵A是n×n的。将G的顶点编号为v1,v2,…,vn(数组下标),若< vi,vj>∈E,则A[i][j]=1
,否则A[i][j]=0
。
当所存图边上有权值时:若< vi,vj>∈E,则A[i][j]=Wi,j
,否则A[i][j]=∞
。
#define MaxVertexNum 100
typedef char VertexType;
typedef int EdgeType;
typedef struct{
VertexType Vex[MaxVertexNum];//结点集
EdgeType Edge[MaxVertexNum][MaxVertexNum];//边集
int vexnum,arcnum;//结点数量,边的数量
}Mgraph;
性质
1)邻接矩阵法的空间复杂为O(n2),适用于稠密图
2)无向图的邻接矩阵为对称矩阵
3)无向图中第i行(或第i列)非0元素(非正无穷)的个数为第i个顶点的度;
有向图中第i行(第i列)非0元素(非正无穷)的个数为第i个顶点的出度(入度)
An的含义
An[ i ] [ j ] 表示从顶点vi到顶点vj长度为n的路径的条数
邻接表法:为每一个顶点建立一个单链表存放与它相邻的边
顶点表
采用顺序存储,每个数组元素存放顶点的数据和边表的头指针
边表(出边表)
采用链式存储,单链表中存放与一个顶点相邻的所有边,一个链表结点表示一条从该顶点到链表结点顶点的边
#define MaxVertexNum 100
typedef struct ArcNode{
int adjvex;//该边所连另一个结点
struct ArcNode *next;//下一条边
//InfoType info;//边权重
}ArcNode;//边表结点
typedef struct VNode{
VertexType data;
ArcNode *first;//边表头指针
}VNode;//顶点表结点
typedef struct{
VNode vertices[MaxVertexNum];//顶点表
int vexnum,acnum;//顶点数,边数
}ALGraph;//邻接表
特点
1)若G为无向图,存储空间为O (|V|+2|E|);若G为有向图,存储空间为O(|v|+|E|)
2)邻接表更加适用于稀疏图
3)若G为无向图,则结点的度为该结点边表的长度;
若G为有向图,则结点的出度为该结点边表的长度,计算入度则要遍历整个邻接表
4)邻接表不唯一,边表结点的顺序根据算法和输入的不同可能会不同。
邻接矩阵VS邻接表
邻接矩阵 | 邻接表 | |
---|---|---|
适用性 | 适用于稠密图 | 适用于稀疏图 |
存储方式 | 顺序存储 | 顺序存储+链式存储 |
判断两顶点间是否存在边 | 效率高 | 效率低 |
找出某顶点相邻的边 | 效率低 | 效率高 |
十字链表:有向图的一种链式存储
#define MaxVertexNum 100
typedef struct ArcNode{
int tailvex,headvex;//边的首位结点编号
struct ArcNode *hlink,*tlink;//下一条弧尾相同的边和下一条弧头相同的边
//InfoType info;
}ArcNode;//边
typedef struct VNode{
VertexType data;
ArcNode *firstin,*firstout;//第一条入边,第一条出边
}VNode;//顶点
typedef struct{
VNode xlist[MaxVertexNum];
int vexnum,arcnum;
}GLGraph;
邻接多重表:无向图的一种链式存储结构
#define MaxVertexNum 100
typedef struct ArcNode{
int ivex,jvex;//边的两个端点
struct ArcNode *ilink,*jlink;//两个端点所连接的下一条边
//InfoType info;//保存权重
//bool mask;//标志域,用于标记此节点是否被操作过,例如在对图中顶点做遍历操作时,为了防止多次操作同一节点,mark 域为 0 表示还未被遍历;mark 为 1 表示该节点已被遍历;
}ArcNode;//边
typedef struct VNode{
VertexType data;
ArcNode *firstedge;//顶点所连第一条边
}VNode;
typedef struct{
VNode adjmulist[MaxVertexNum];//顶点表
int vexnum,arcnum;
}AMLGraph;
十字链表&邻接多重表
十字链表 | 邻接多重表 |
---|---|
链式存储结构 | 链式存储结构 |
有向图 | 无向图 |
解决了无法快速查找某一顶点所有入边这一弊端 | 解决了每一条边都需要两个边结点来存放这一弊端 |
图的遍历从图中某一顶点出发,按照某种搜索方法沿着图中的边对图中的 所有顶点访问一次且仅访问一次。
广度优先搜索
·首先访问起始顶点v;
·接着由出发依次访问v的各个未被访问过的邻接顶点Wi,W2 ,…i;
·然后依次访问w,W……,的所有未被访问过的邻接顶点;
·在从这些访问过的顶点出发,访问它们所有未被访问过的邻接顶点;
·…,以此类推;
队列+辅助标记数组
bool visited[MAX_TREE_SIZE];
void BFSTraverse(Graph G){
for(int i=0;i<G.vexnum;++i){
visited[i]=FALSE;
}
InitQueue(Q);
for(int i;i<G.vexnum;++i)
if(!visited[i])
BFS(G,i);//每次只能遍历一个连通子图
}
void BFS(Graph G,int v){
visit(v);
visited[v]=TRUE;
EnQueue(Q,v);
while(!isEmpty(Q)){
DeQueue(Q,v);
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,u,w)){
if(!visited[w]){
visit(w);
visited[w]=TRUE;
EnQueue(Q,w);
}
}
}
}
BFS算法的性能分析
空间复杂度:O(|V|)(V是顶点数量)
时间复杂度:
无权图单源最短路径问题
定义从顶点u到顶点v的最短路径d(u,v)为从u到v的任何路径中最少的边数;
若从u到v没有通路,则d(u,v)=∞。
void BFS_MIN_Distance(Graph g,int u){
for(int i=0;i<G.vexnum;++i)
d[i]=MAX;
visited[u]=TRUE;
d[u]=0;
EnQueue(Q,u);
while(!isEmpty(Q)){
DeQueue(Q,u);
for(w=FirstNeighbor(G,u);w>=0;w=NextNeighbor(G,u,w))
if(!visited[w]){
visited[w]=TRUE;
d[w]=d[u]+1;
EnQueue(Q,w);
}
}
}
广度优先生成树
在广度遍历过程中,我们可以得到一棵遍历树,称为广度优先生成树(生成森林)。
邻接矩阵法的广度优先生成树唯一,邻接表法的不唯一。
深度优先搜索DFS
·首先访问起始顶点v;
·接着由v出发访问v的任意一个邻接且未被访问的邻接顶点w;
·然后再访问与w邻接且未被访问的任意顶点y;
·若w没有邻接且未被访问的顶点时,退回到它的上一层顶点v;
·重复上述过程,直到所有顶点被访问为止。
递归(栈)+辅助标记数组
bool visited[MAX_TREE_SIZE];
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])
DFS(G,i);
}
}
void DFS(Graph G,int v){
visir(v);
visited[v]=TRUE;
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w))
if(!visited[w])
DFS(G,w);
}
邻接矩阵法的DFS (BFS)序列唯一,邻接表法的不唯一
DFS算法的性能分析
深度优先生成树
在深度遍历过程中,我们可以得到一棵遍历树,称为深度优先生成树(生成森林)。
★邻接矩阵法的深度优先生成树唯一,邻接表法的不唯一
★在无向图当中,在任意结点出发进行一次遍历(调用一次BFS或DFS),若能访问全部结点,说明该无向图是连通的。
★在无向图中,调用遍历函数(BFS或DFS)的次数为连通分量的个数。
最小生成树
生成树:对连通图进行遍历,过程中所经过的边和顶点的组合可看做是一棵普通树。
最小生成树:对于带权无向连通图G=(V,E),G的所有生成树当中边的权值之和最小的生成树为G的最小生成树(MST)。
最小生成树的性质
1)最小生成树不一定唯一,即最小生成树的树形不一定唯一。当带权无向连通图G的各边权值不等时或G只有结点数减1条边时,MST唯一。
2)最小生成树的权值是唯一的,且是最小。
3)最小生成树的边数为顶点数减1
算法
GENRIC_MST(G){
T=NULL;
while T 未形成一棵生成树:
do 找到一条最小代价边(u,v)并且加入T后不会产生回路;
T=T∪(u,v);
}
Prim算法
初始化:向空的结果树T=(VT,ET)中添加图G=(V,E)的任一顶点u0,使VT={u0},E为空集;
循环(直到VT=V):从图G中选择满足{(u, v)[u∈VT,v∈V-VT}且具有最小权值的边(u,v),并置VT=VT∪{v}, ET=ET∪{(u,v)}。
void Prim(G,T){
T=空集;
U={w};
while((v-u)!=空集){
设(u,v)是使u∈U,,v∈V-U,且权值最小的边;
T=T∪{(u,v)};
U=U∪{v};
}
}
辅助数组:
void MST_Prim(Graph G){
int min_weight[G.vexnum];
int adjvex[G.vexnum];
for(int i=0;i<G.vexnum;i++){
min_weight[i]=G.Edge[0][i];
adjvex[i]=0;
}
int min_arc;//当前挑选边的权重
int min_vex;//当前挑选边的另一个端点
for(int i=1;i<G.vexnum;i++){
//寻找距离集合V最近的一个顶点
min_arc=MAX;
for(int j=1;j<G.vexnum;j++){
if(min_weight[j]!=0&&min_weight[j]<min_arc){
min_arc=min_weight[j];
min_vex=j;
}
}
//将该顶点加入到集合V,并更新集合V到其他顶点的最短距离
min_weight[min_vex]=0;
for(int j=0;j<G.vexnum;j++){
if(min_weight[j]!=0&&G.Edge[min_arc][j]<min_weight[j]){
min_weight[j]=G.Exdge[min_arc][j];
adjvex[j]=min_vex;
}
}
}
}
O(|V2|),适用于稠密图
Kruskal算法
初始化:点集V =VT,边集ET =空集。即是每个顶点构成一棵独立的树,T是一个仅含|V|个顶点的森林;
循环(直到T为树):按图G的边的权值递增的顺序依次从E-ET中选择一条边,若这条边加入后不构成回路,则将其加入E,否则舍弃。
void Kruskal(V,T){
T=v;
numS=n;
while(numS>1){
从E中取出权值最小的边(v,u);
if(v和u属于T中不同的连通分量){
T=T∪{(v,u)};
numS--;
}
}
}
先对边进行堆排序Sort()
然后初始化一个并查集,通过合并操作向V中加入新顶点,通过查找操作判断加入新顶点后是否产生回路。
typedef struct Edge{
int a,b;
int weight;
};
void MST_Kruskal(Graph G,Edge* edges,int* parent){
heap_sort(edges);//对边进行堆排序log|E|
Initial(parent);
for(int i=0;i<G.arcnum;i++){
int a_root=Find(parent,edges[i].a);
int b_root=Find(parent,edges[i].b);
if(a_root!=b_root)
Union(parent,a_root,b_root);
}
}
O(|E|log|E|),适用于稀疏图
最短路径:两个顶点之间带权路径长度最短的路径为最短路径。
在带权图当中,把从一个顶点v到另一个顶点u所经历的边的权值之和称为,路径的带权路径长度。
Dijkstra 带权图单源最短路径
辅助数组
s[]:标记已经计算完成的顶点。
数组中的值全部初始化为0。源点下标的值初始化为1。
dist[]:记录从源点v0到其他各顶点当前的最短路径长度。
数组中的值初始化为源点到各个顶点边的权值,即dist[i]=arcs[0][i]
path[]:记录从最短路径中顶点的前驱顶点,即path[i]为v到vi最短路径上vi的前驱顶点。
数组中的值初始化:
若源点v0到该顶点vi有一条有向边(无向边),则令path[i]=0;否则path[i]=-1;
算法思想
1)初始化数组,并将集合S初始为{0};
2)从顶点集合V-S中选出Vj,满足dist[j]=Min{dist[i]|[ vi∈V-S}, Vj就是当前求得的最短路径的终点,并令SU{ j };
3)修改此时从v0出发到集合V-S上任一顶点v最短路径的长度:
若dist[j]+arcs[j][k]
则令dist[k]=dist[j]+arcs[j][k]
; path[k]=j;
4)重复2、3操作n-1次,直到S中包含全部顶点;
void Dijkstra(Graph G,int v){
int s[G.vexnum];
int path[G.vexnum];
int dist[G.vexnum];
for(int i=0;i<G.vexnum;i++){
dist[i]=G.edge[v][i];
s[i]=0;
if(G.edge[v][i]<MAX)
path[i]=v;
else
path[i]=-1;
}
s[v]=1;
path[v]=-1;
//寻找距离最近的顶点u
for(i=0;i<G.vexnum;i++){
int min=MAX;
int u;
for(it j=0;j<G.vexnum;j++){
if(s[j]==0&&dist[j]<min){
min=dist[j];
u=j;
}
}
}
//将顶点u加入集合v,并更新距离
s[u]=1;
for(int j=0;j<G.vexnum;j++){
if(s[j]==0&&dist[u]+G.Edge[u][j]<dist[j]){
dist[j]=dist[u]+G.Edge[u][i];
path[j]=u;
}
}
}
时间复杂度:O(V2)
Dijkstra算法并不适用于含有负权边的图
Floyd 各顶点之间的最短路径
算法思想
递推产生一个n阶方阵序列A (-1),A (0),…,A(k),…,A(n-1)
A(k)[i][j]:顶点vi到vj的最短路径长度,且该路径经过的顶点编号不大于k
递推公式
初始化: A(-1)[i][j]=arcs[i] [j]
递推方法:A(k)[i] [j]=Min{A(k-1) [i] [j] , A(k-1)[i] [k]+A(k-1)[k] [j]}, k =0,1,…,n-1
void Floyd(Graph G){
int A[G.vexnum][G.vexnum];
for(int i=0;i<G.vexnum;i++)
for(int j=0;j<G.vexnum;j++)
A[i][j]=G.Edge[i][j];
for(int k=0;k<G.vexnum;k++)
for(int i=0;i<G.vexnum;i++)
for(int j=0;j<G.vexnum;j++)
if(A[i][j]>A[i][k]+A[k][j])
A[i][j]=A[i][k]+A[k][j];
}
时间复杂度:O(V3)
拓扑排序
算法思想
1)从DAG图中选择一个没有前驱的顶点并输出
2)从图中删除该顶点和所有以它为起点的有向边
3)重复1、2直到当前的DAG图为空或当前图中不存在无前驱的顶点为止。后一种情况说明图中有环。
性质
★算法结束时没有访问所有顶点,则存在以剩下顶点组成的环。
★拓扑排序的结果不一定唯一
代码实现
bool TopologicalSort(Graph G){
InitStack(S);
//将所有入度为0的顶点入栈
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;
//遍历该顶点的所有后继,将他们的入度减一,若入度变成了0则入栈
for(p=G.Vertices[i].firstarc;p;p=p->nextarc){
v=p->adjvex;
if(!(--indegree[v]))
Push(S,v);
}
}
if(count<G.vexnum)
return false;
else
returntrue;
}
时间复杂度:O(|V|+|E|)
特点
AOE网在有向带权图中,以顶点表示事件,以有向边表示活动,以边上权值表示完成该活动的开销(如完成活动所需要的时间),则称这种有向图为用边表示活动的网络,简称AOE网。
有且仅有一个顶点入度为0,叫源点;有且仅有一个顶点出度为0,叫汇点;
关键路径:从源点到汇点最大路径长度的路径称为关键路径,关键路径上的活动为关键活动。
关键路径求解算法
1)求事件Vk的最早发生时间Ve(k),按照拓扑排序顺序求解
Ve(源点)=0
Ve(k)=Max{Ve(j) + Weight (Vj, Vk)}
2)求事件Vk的最迟发生时间Vl(k),按照逆拓扑排序顺序求解
Vl(汇点)=Ve(汇点)
Vl(j)=Min{Ve(k) - Weight (Vj, Vk)}
3)活动ai的最早开始时间e(i)
若存在
4)活动ai的最迟开始时间l(i)
若存在
5)活动ai的差额d(i)=l(i)-e(i)
关键活动:d(i)为0的活动,关键路径:由部分活动构成
★缩短关键活动时间可以加快整个工程,但缩短到一定大小时关键路径会发生改变。
★当网中关键路径不唯一时,只有加快的关键活动或关键活动组合包括在所有的关键路径上才能缩短工期。
查找
在数据集合中寻找满足某种条件的数据元素的过程。查找结果分为查找成功和查找失败。
查找表
用于查找的数据集合,由同一种数据类型(或记录)的组成,可以是一个数组或链表等数据类型
操作:
1)查询某个特定的数据元素是否在查找表中
2)检索满足条件的某个特定的数据元素的各种属性
3)在查找表中插入一个数据元素
4)从查找表中删除一个数据元素
静态查找表:无插入删除操作
动态查找表:可以动态添加删除
关键字
数据元素中唯一标识该元素的某个数据项的值,使用基于关键字的查找,查找结果应该是唯一的。
平均查找长度
查找时,关键字比较次数的平均值:ASL=ΣPiCi
顺序查找又称线性查找,主要用于在线性表中进行查找。
无序查找
对无序线性表进行顺序查找,查找失败时要遍历整个线性表
typedef struct{
ElemType *elem;
int TableLen;
}SSTable;
int Search_Seq(SStable ST,ElemType key){
ST.elem[0]=key;//哨兵,找到哨兵时,返回的是0
for(int i=St.TableLen;ST.elem[i]!=key;i--);
return i;
}
平均查找长度:成功:(n+1)/2;失败:n+1
有序查找
对关键字有序线性表进行顺序查找,查找失败时不一定要遍历整个线性表
判定树:描述查找过程的二叉排序树
黄色为失败节点
二分查找 又称折半查找,仅适用于有序的顺序表
算法思想
1)首先将给定值key与表中中间位置元素的关键字比较,
若相等,则返回该元素的位置;
若不等,则在前半部分或者是后半部分进行查找。
2)查找序列升序时,
若key小于中间元素,则查找前半部分;
若key大于中间元素,则查找后半部分。
3)重复该过程,直到找到查找的元素为止;或查找失败。
代码实现
时间复杂度:O(log2n)
int Binary_Search(SeqList L,ElemType key){
int low=0,high=L.TableLen-1,mid;
while(low<=high){
min=(low+high)/2;
if(L.elem[mid]==key)
return mid;
else if(L.elem[mid]>key)
high=mid-1;
else
low=mid+1;
}
return -1;
}
判定树
顺序查找适用于顺序存储和链式存储,序列有序无序皆可;折半查找只适用于顺序存储,且要求序列一定有序。
分块查找又称索引顺序查找,它吸取了顺序查找和折半查找各自的优点,既有动态结构,又适于快速查找。
如何分块(块内无序块间有序)
1)将查找表分为若干子块。块内的元素可以无序,但块间是有序的,即对于所有块有第 i 块的最大关键字小于第i+1块的所有记录的关键字。
2)建立索引表,索引表中的每个元素含有各块的最大关键字和各块中的第一个元素的地址,索引表按关键字有序排列。
如何查找
1)在索引表中确定待查记录所在的块,可以顺序查找或折半查找索引表。
2)在块内进行顺序查找
**B-树(B树)**的基本概念
B-树又称多路平衡查找树,B-树中所有结点中孩子结点个数的最大值成为B-树的阶,通常用m表示,从查找效率考虑,一般要求m>=3。
一棵m阶B-树或者是一棵空树,或者是满足以下条件的m叉树。
1)每个结点最多有m个分支(子树);而最少分支数要看是否为根结点,如果是根结点且不是叶子结点,则至少要有两个分支,非根非叶结点至少有ceil(m/2)个分支,这里ceil代表向上取整。
2)如果一个结点有n-1个关键字,那么该结点有n个分支。这n-1个关键字按照递增顺序排列。
3)非叶结点的结构为:
n | k1 | k2 | … | kn |
---|---|---|---|---|
p0 | p1 | p2 | … | pn |
其中,n为该结点中关键字的个数;ki为该结点的关键字且满足ki 4)结点内各关键字互不相等且按从小到大排列。 5)叶子结点处于同一层;可以用空指针表示,是查找失败到达的位置。 注:平衡m叉查找树是指每个关键字的左侧子树与右侧子树的高度差的绝对值不超过1的查找树,其结点结构与上面提到的B-树结点结构相同,由此可见,B-树是平衡m叉查找树,但限制更强,要求所有叶结点都在同一层。
示例
上面的图片显示了一棵B-树,最底层的叶子结点没有显示。我们对上面提到的5条特点进行逐条解释:
1)结点的分支数等于关键字数+1,最大的分支数就是B-树的阶数,因此m阶的B-树中结点最多有m个分支,所以可以看到,上面的一棵树是一个5-阶B-树。
2)因为上面是一棵5阶B-树,所以非根非叶结点至少要有ceil(5/2)=3个分支。根结点可以不满足这个条件,图中的根结点有两个分支。
3)如果根结点中没有关键字就没有分支,此时B-树是空树,如果根结点有关键字,则其分支数比大于或等于2,因为分支数等于关键字数+1.
4)上图中除根结点外,结点中的关键字个数至少为2,因为分支数至少为3,分支数比关键字数多1,还可以看出结点内关键字都是有序的,并且在同一层中,左边结点内所有关键字均小于右边结点内的关键字,例如,第二层上的两个结点,左边结点内的关键字为15,26,他们均小于右边结点内的关键字39和45.
B-树一个很重要的特征是,下层结点内的关键字取值总是落在由上层结点关键字所划分的区间内,具体落在哪个区间内可以由指向它的指针看出。例如,第二层最左边的结点内的关键字划分了三个区间,小于15,15到26,大于26,可以看出其下层中最左边结点内的关键字都小于15,中间结点的关键字在15和26之间,右边结点的关键字大于26.
5)上图中叶子结点都在第四层上,代表查找不成功的位置。
查找
1)在B树中找结点(磁盘)
2)在结点中找关键字(内存)
插入
1)定位
查找插入该关键字的位置,即最底层中的某个非叶子结点(规定一定是插入在最底层的某个非叶子结点内)
2)插入
若插入后,不破会m阶B树的定义,即插入后结点关键字个数在属于区间[ ceil(m/2)-1, m-1],则直接插入;
若插入后,关键字数量大于m-1,则对插入后的结点进行分裂操作;
分裂:
插入后的结点中间位置(ceil(m/2))关键字并入父结点中,
中间结点左侧结点留在原先的结点中,右侧结点放入新的节点中,
若并入父节点后,父结点关键字数量超出范围,继续想上分裂,直到符合要求为止。(直到根节点,若依然超出范围,则继续分裂,创造一个新的根节点)
终端结点:最底层的非叶子结点。
1)直接删除
若被删除关键字所在结点关键字总数>ceil(m/2)-1,表明删除后仍满足B树定义,直接删除
2)兄弟够借
若被删除关键字所在结点关键字总数=ceil(m/2)-1,且与此结点邻近的兄弟结点的关键字个数>ceil(m/2),则需要从兄弟结点借一个关键字,此过程需要调整该结点、双亲结点和兄弟结点的关键字
3)兄弟不够借
若被删除关键字所在结点关键字总数=ceil(m/2)-1,且与此结点邻近的兄弟结点的关键字个数=ceil(m/2),则删除关键字,并与一个不够借的兄弟结点和双亲结点中两兄弟子树中间的关键字合并。
合并后若双亲结点因减少一个结点导致不符合定义,则继续执行2、3步骤。
非终端结点:终端结点和叶子结点之外的的结点。
1)若小于k的子树中关键字个数>ceil(m/2)-1,则找出k的前驱值k’,并用k’来取代k,再递归地删除k"即可。
2)若大于k的子树中关键字个数>ceil(m/2)-1,则找出k的后继值k’,并用k’来取代k,再递归地删除k’即可。
3)若前后两子树关键字个数均为ceil(m/2)-1,则直接两个子结点合并,然后删除k即可。
B+树
一棵m阶B+树满足如下特性:
1)每个分支结点最多有m棵子树(子结点)
2)若根结点不是终端结点,则至少有两棵子树
3)除根结点外的所有非叶结点至少有ceil(m/2)棵子树,子树和关键字个数相等
4)所有叶结点包含全部关键字及指向相应记录的指针,叶结点中将关键字按大小顺序排列,并且相邻结点按大小顺序连接起来
5)所有分支结点(可视为索引的索引)中仅包含他的各个子结点(下一级索引块)中关键字的最大值及指向其子结点的指针
示例
B+树 VS B树
1)在B+树中,具有n个关键字的结点值含有n棵子树,即每个关键字对应一棵子树;在B树中,具有n个关键字的结点含有n+1棵子树。
2)在B+树中,叶结点包含信息,所有非叶结点仅起索引作用,非叶结点中的每个索引项只含有对应子树的最大关键字和指向该子树关键字的指针,不含有该关键字对应记录的存储地址
3)在B+树中,叶结点包含全部关键字,即在非叶结点中出现的关键字也会出现在叶结点中;在B树中,叶结点包含的关键字和其他结点包含的关键字是不重复的
B+树中有两种查找方式:多路查找&顺序查找
★在B+树中查找时,无论查找成功还是失败一定是查找到叶结点当中的值为止。
散列函数:一个把查找表中的关键字映射成该关键字对应的地址的函数。Hash(key)=Addr
散列表:根据关键字而直接进行访问的数据结构。他建立了关键字与存储地址之间的一种直接映射关系。
冲突:散列函数可能会把多个不同的关键字映射到同一地址下的情况。
散列函数的构造方法
要求:
1)散列函数的定义域必须包含全部需要存储的关键字,而值域的范围则依赖于散列表的大小或地址范围。
2)散列函数计算出来的地址应该能等概率、均匀分布在整个地址空间中,从而减少冲突的发生。
3)散列函数应尽量简单,能够在较短时间内计算出任一关键字对应的散列地址。
a. 直接定址法:直接取关键字的某个线性函数值为散列地址。
Hash(key)=a*key + b,
其中a,b为常数
方法简单,不会产生冲突,若关键字分布不连续,则会浪费空间。
b. 除留取余法
Hash(key)=key % p,
假定散列表表长为m,取一个不大于m但最接近或等于m的质数p
选好p是关键,可以减少冲突的可能。
对于取模运算,由模数N决定了一个工作集合X={1,2,3,…,N-1},令g,x是X中的任意元素,令f(g,x)是定义在集合X上的一个函数(加法/乘法),则有,如果N为质数,f(g,x)的值域Y=X,**如果N不是质数,则对于符合某些条件的g元素,值域Y不等于X,而是X的一个真子集。**原本预期y=f(g,x)在集合X上具有平均分布特性,每个元素出现的机会均等,如果Y 比如N=12, g=4, f(g,x)=g*x mod N 4*1 mod N = 4 4*2 mod N = 8 4*3 mod N = 0 4*4 mod N = 4 4*5 mod N = 8 4*6 mod N = 0 4*7 mod N = 4 4*8 mod N = 8 4*9 mod N = 0 4*10 mod N = 4 4*11 mod N = 8
c. 数字分析法
分析数字关键字在各位上的变化情况,取比较随机的位作为散列地址
比如:取11位手机号码key
的后4位作为地址:
适用于关键字已知的集合,若更换关键字则需要重新构造散列函数。
d. 平方取中法
这种方法取关键字的平方值的中间几位作为散列地址
适用于关键字的每位取值不均匀或均小于散列地址所需要的位数。
521->平方:271441->取中:7144
e. 折叠法
将关键字分割成位数相同的几部分,然后取这几部分的叠加和作为散列地址
5211252 -> 521+125+2=648
适用于关键字的位数多,而且关键字中的每位上数字分布大致均匀
冲突处理
为产生冲突的关键字寻找下一个“空”的Hash地址
a. 开放定址法
是指可存放新表项的空闲地址既向它的同义词表项开放,又向它的非同义词表项开放。
Hi=(H(key)+di)%m, i=0,1,2…K(k <= m-1);
m为散列表表长,di为增量序列
如何计算增量序列:
线性探查法
即di=0,1,2,3…m-1
会产生堆积现象(大量相邻的关键字元素存放在相邻的位置)
堆积现象会大大降低查找效率
平方探测法
即di=02,12,-12,22,-22 … k2,-k2,其中k ≤m / 2
避免堆积问题,缺点是不能探测到散列表上的所有单元(至少可以探测到一般单元)
再散列法
即di=i*Hash2(key)
伪随机序列法
即di=伪随机序列
开放定址法的缺点
★在开放定址法中不能随便删除某个元素。(一旦删除,后面通过增量序列所存的元素将无法查找)
b. 拉链法
是指把所有同义词存放在一个线性链表中,这个线性链表由地址唯一标识,即散列表中每个单元存放该链表头指针。
★拉链法适用于经常进行插入和删除的情况
查找
初始化: Addr=Hash(key);
1)检测查找表中地址为Addr的位置上是否有记录,若无记录,则返回查找失败;若有记录,则比较它与key值,若相等则返回成功,否则执行步骤2
2)用给定的处理冲突方法计算“下一散列地址”,把Addr置为此地址,转入步骤1
查找效率
散列函数、处理冲突的方法和填装因子
填装因子一般记为α,表示表的装满程度
α=(表中记录数n) / (散列表长度m)
★散列表的平均查找长度依赖于散列表的填装因子
排序
重新排列表中的元素,使表中的元素满足按关键字递增或递减
排序算法的稳定性
若待排序表中有两个元素Ri和Rj,其对应的关键字ki=kj,且在排序前Ri在Rj前面,若使用某排序算法后,Ri仍然在Rj前面。则称这个排序算法是稳定的,否则称排序算法不稳定。
★算法的稳定性是算法的性质,并不能衡量一个算法的优劣
内部排序指在排序期间元素全部存放在内存中的排序
外部排序指在排序期间元素无法全部同时存放在内存中,必须在排序的过程中根据要求不断地在内、外存之间进行移动
时空复杂度决定内部排序算法的性能
插入排序:每次将一个待排序的序列插入到一个前面已排好序的子序列当中。
算法思想
·初始L[1]是一个已经排好序的子序列
·对于元素L(i)(L(2)~L(n))插入到前面已经排好序的子序列当中:
1)查找出L(i)在L[1…i-1]中的插入位置k
2)将L[k…i-1]中的所有元素全部后移一个位置
3)将L(i)复制到L(k)
时空复杂度:
空间复杂度:O(1)
时间复杂度:最坏:O(n2),最好:O(n)
代码实现:
void InsertSort(ElemType A[],int n){
int i,j;
for(i=2;i<=n;i++){
A[0]=A[i];
for(j=i-1;A[0].key<A[j].key;j--)
A[j+1]=A[j];
A[j+1]=A[0];
}
}
稳定!适用于顺序存储和链式存储。
代码实现
void BInsertSort(ElemType A[],int n){
init i,j;
int low,high,mid;
for(i=2;i<=n;i++){
A[0]=A[i];
/****折半查找O(logn)****/
low=1;high=i-1;
while(low<=high){
mid=(low+high)/2;
if(A[mid].key>A[0].key)
high=mid-1;
else
low=mid+1;
}
/****移动O(n)****/
//要插入的位置是high+1或者low
for(j=i-1;j>=high+1;j--)
A[j+1]=A[j];
A[high+1]=A[0];
}
}
时空复杂度:
空间复杂度:O(1)
时间复杂度:O(n2)
稳定的!只适用于顺序存储。
希尔排序:缩小增量排序
先将排序表分割成d个形如L[i, i+d, i+2d… i+kd]的“特殊”子表,分别进行直接插入排序,当整个表中的元素已呈“基本有序时”,再对全体记录进行一次直接插入排序。
Shell’s idea d1=取下界n/2, di+1=取下界di/2,直到最后一个dk=1
代码实现:
void ShellSort(ElemType A[],int n){
for(int dk=n/2;dk>=1;dk=dk/2){
//所有的组同时进行插入排序
for(int i=dk+1;i<=n;++i){
if(A[i].key<A[i-dk].key){
//将A[i]插入到自己所在组的对应位置:i-dk,i-2dk,...
A[0]=A[i];//哨兵
for(int j=i-dk;j>0&&A[0].key<A[j].key;j-=dk)
A[j+dk]=A[j];
A[j+dk]=A[0];
}
}
}
}
时空复杂度:
时间复杂度:最坏情况下:O(n2)无法证明,一般情况下效率比较高
空间复杂度:O(1)
不稳定!适用于顺序存储。
基本思想:
假设待排序表长为n,从后往前(从前往后)两两比较相邻元素的值,若为逆序(即A[i-1]>A[i]),则交换他们直到序列比较结束。
★一次冒泡会将一个元素放置到它最终的位置上
代码实现:
void BubbleSort(ElemType A[],int n){
for(int i=0;i<n-1;i++){
//i之前的元素是当前已经排好的元素
bool flag=false;
//从右往前,找出最小的元素沉底
for(int j=n-1;j>i;j--){
if(A[j-1].key>A[j].key){
swap(A[j-1],A[j]);
flag=true;
}
}
if(flag==false)//说明这波操作没发生交换
return;
}
}
时空复杂度:
时间复杂度:最好:O(n),最坏:O(n2)
空间复杂度:O(1)
稳定!适用于顺序存储和链式存储。
快速排序
在待排序表L[1…n]中任取一个元素pivot作为基准,通过一趟排序将待排序表划分为两部分,一部分比他大,一部分比他小。
★一次划分会将一个元素pivot放置到它最终的位置上
基本思路:
初始化标记low为划分部分第一个元素的位置, high为最后一个元素的位置,然后不断地移动两标记并交换元素:
1)high向前移动找到第一个比pivot小的元素
2)low向后移动找到第一个比pivot大的元素
3)交换当前两个位置的元素
4)继续移动标记,执行1) ,2),3)的过程,直到low大于等于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;
}
void QuickSort(ElemType A[],int low,int high){
if(low<high){
int pivotpos=Partition(A,low,high);
QuickSort(A,low,pivotpos-1);
QuickSort(A,pivotpos+1,high);
}
}
时空复杂度
最好、平均空间复杂度O(log2n )
最好、平均时间复杂度O(nlog2n)
初始基本有序或逆序:
最坏空间复杂度:O(n)
最坏时间复杂度:O(n2)
不稳定。顺序存储(链式存储)
选择排序:
每一趟在后面n-i+1 (i=1,2,n-1)个待排序元素中选取关键字最小的元素,作为有序子序列的第i个元素,直到n-1趟做完,待排序元素只剩下1个。
一趟排序会将一个元素放置在最终的位置上。
代码实现
void SelectSort(ElemType A[],int n){
for(int i=0;i<n-1;i++){
//找到i之后元素中的最小值
int min=i;
for(int j=i+1;j<n;j++){
if(A[j]<A[min])
min=j;
}
//不同于冒泡排序,选择排序每轮最多交换一个值。
if(min!=i)
swap(A[i],A[min]);
}
}
时空复杂度
时间复杂度:O(n2) (时间复杂度与初始序列无关)
空间复杂度:O(1)
不稳定!(有的值可能会被直接交换到后面),适用于顺序存储和链式存储。
堆
n个关键字序列L[1…n]称为堆,当且仅当该序列满足:
1)若L(i)<=L(2i)且L(i)<=L(2i+1),则称该堆为小根堆
2)若L(i)>=L(2i)且L(i)>=L(2i+1),则称该堆为大根堆
(1<=i<=取下界n/2)
在排序过程中将L[1…n]视为一棵完全二叉树的顺序存储结构。
堆的初始化——大根堆
对所有具有双亲结点按照编号从大到小(取下界(n/2)~1)做出如下调整:
1)若孩子结点皆小于双亲结点,则该结点的调整结束
2)若存在孩子结点大于双亲结点,则将最大的孩子结点与双亲结点交换,并对该孩子结点进行1)、2),直到出现1)或到叶结点为止
void BuildMaxHeap(ElemType A[],int len){
for(int i=len/2;i>0;i--){
AdjustDown(A,i,len)
}
}
void AdjustDown(ElemType A[],int k,int len){
A[0]=A[k];
//依次取出当前结点的子结点,孙子节点
for(int i=2*k;i<=len;i*=2){
//选出较大的一个子结点
if(i<len && A[i]<A[i+1])
i++;
if(A[0]>A[i])
break;
else{
//若子结点较大
A[k]=A[i];//交换
k=i;//继续检测子节点符号要求
}
}
A[k]=A[0];
}
经数学推导,初始建堆时间复杂度:O(n)
堆排序:不断地输出堆顶元素,并向下调整
void HeapSort(ElemType A[],int len){
BuildMaxHeap(A,len);
for (int i=len;i>1;i--){
Swap(A[i],A[1]);//将最大元素放在最后
AdjustDown(A,1,i-1);
}
}
时间复杂度:O(n*log2n)
空间复杂度:O(1)
不稳定!适用于顺序存储(链式存储)
堆的插入:将新结点放置在末端然后进行向上调整
void AdjustUp(ElemType A[],int len){
//向上调整用于堆的插入
A[0]=A[k];
int i=k/2;
while(i>0&&A[i]<A[0]){
A[k]=A[i];
k=i;
i=k/2;
}
A[k]=A[0];
}
分治的思想
归并排序的核心思想是分治,把一个复杂问题拆分成若干个子问题来求解。
归并排序的算法思想
把数组从中间划分成两个子数组;
一直递归地把子数组划分成更小的子数组,直到子数组里面只有一个元素;
依次按照递归的返回顺序,不断地合并排好序的子数组,直到最后把整个数组的顺序排好。
二路归并排序代码实现:
//合并两个有序线性表
ElemType *B=(ElemType *)malloc((n+1)*sizeof(ElemType));
void Merge(ElemType A[],int low,int mid,int high){
for(int k=low;k<=high;k++)
B[k]=A[k];
for(int i=low,j=mid+1,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 high){
if(low<high){
int mid=(low+high)/2;
MergeSort(A,low,mid);
MergeSort(A,mid+1,high);
Merge(A,low,mid,high);
}
}
时空复杂度:
时间复杂度:O(nlog2n)
空间复杂度:O(n)
稳定!适用于顺序存储和链式存储。
基数排序(不基于比较)
借助“分配”和“收集”两种操作对单逻辑关键字进行排序,
分为最高位优先(MSD)和最低位优先(LSD)。
以r为基数的最低位优先基数排序的过程:
假设线性表由结点序列a0,a1,…,an-1构成,
每个结点aj的关键字由d元组(kjd-1,kjd-2,…,kj1,kj0)组成
0<=kji<=r-1(0<=j 例如:324 768 270 121 962 666 857 503 768,n = 9, d = 3, r = 10
时空复杂度:
时间复杂度:O(d(n+r))
空间复杂度:O®
稳定!不基于比较
比较
排序算法 | 时间 | 复杂度 | 空间复杂度 | 稳定性 | |
---|---|---|---|---|---|
最好 | 平均 | 最坏 | |||
直接插入排序 | O(n) | O(n2) | O(n2) | O(1) | 稳定 |
冒泡排序 | O(n) | O(n2) | O(n2) | O(1) | 稳定 |
简单快速排序 | O(n2) | O(n2) | O(n2) | O(1) | 不稳定 |
希尔排序 | O(1) | 不稳定 | |||
快速排序 | O(nlog2n) | O(nlog2n) | O(n2) | O(log2n) | 不稳定 |
堆排序 | O(nlog2n) | O(nlog2n) | O(nlog2n) | O(1) | 不稳定 |
二路归并排序 | O(nlog2n) | O(nlog2n) | O(nlog2n) | O(n) | 稳定 |
基数排序 | O(d(n+r)) | O(d(n+r)) | O(d(n+r)) | O® | 稳定 |
应用
考虑因素:
元素数目、元素大小、关键字结构及分布、稳定性、存储结构、辅助空间等
1)若n较小时(n≤50),可采用直接插入排序或简单选择排序;若n较大时,则采用快排、堆排或归并排序。
2)若n很大,记录关键字位数较少且可分解,采用基数排序
3)当文件的n个关键字随机分布是,任何借助于“比较”的排序,至少需要O(nlog2n)的时间
4)若初始基本有序,则采用直接插入或冒泡排序
5)当记录元素比较大,应避免大量移动的排序算法,尽量采用链式存储
外部排序通常采用归并排序方法。
首先,根据缓冲区的大小将外存上含有n个记录的文件分成若干长度为h的子文件,依次读入内存并利用有限的内部排序算法对它们进行排序,并将排序后得到的有序子文件重新写回外存,通常称这些有序子文件为归并段或顺串
然后,对这些归并段进行逐趟归并,使归并段逐渐由小到大直至得到整个有序文件
外部排序的总时间=内部排序所需时间+外存信息读写时间+内部归并所需时间
tES = r * tIS + d * tIO +S(n-1)tmg
r表示初始划分归并段的个数,tIS 表示每个归并段排序时间;
d表示磁盘IO次数,tIO每次读写的时间
S表示归并趟数,每一趟需要n-1,tmg一个记录取出最小关键字的时间
20000个记录,初始归并段5000个记录:tES = 4* tIS + 3 * (4+4) * tIO +2 * 20000 * tmg
归并趟数=取上界(logmr)。增加归并路数可以减少磁盘读写次数。
S趟归并总共需要比较的次数:
S(n-1)(m-1)=取上界(logmr)*(n-1)(m-1)
n个关键字,m路归并段
失败树
树形选择排序的一种变体,可视为一棵完全二叉树。
每个叶结点存放各归并段在归并过程中当前参加比较的记录,内部结点用来记忆左右子树中的“失败者”,胜利者向上继续进行比较,直到根结点。
将归并时比较次数缩减成:取上界(log2m)
S(n-1)(m-1)=取上界(logmr) * (n-1) * 取上界(log2m)=取上界(log2r) * (n-1)
通过失败树进行比较时,增加归并路数可以减少磁盘读写次数,同时不影响比较次数。但m过大时,每个缓冲区会减少,读写外存次数会增多,因此m并非越大越好。
置换-选择排序
设初始待排序文件为FI,初始归并段文件为FO,内存工作区为WA,内存工作区可容纳w个记录。
1)从待排序文件FI输入w个记录到工作区WA;
2)从内存工作区WA中选出其中关键字取最小值的记录,记为MINIMAX;
3)将MINIMAX记录输出到FO中;
4)若FI未读完,则从FI输入下一个记录到WA中;
5)从WA中所有关键字比MINIMAX记录的关键字大的记录中选出最小的关键字记录,作为新的MINIMAX;
6)重复3)~5),直到WA中选不出新的MINIMAX记录位置,由此得到一个初始归并段,输出一个归并段的结束标志到FO中;
7)重复2) ~6),直到WA为空。由此得到全部初始归并段。
示例
m路归并排序可用一棵m叉树描述。
归并树用来描述m归并,并只有度为0和度为m的结点的严格m叉树。
带权路径长度之和为归并过程中的总读记录数
构造哈夫曼树:带权路径长度之和最小
当叶子节点数不够时,增加权值为0的结点用来构造哈夫曼树。
补充的虚段个数
设度为0的结点有n0个,度为m的结点有nm个,
则对严格m叉树有n0=(m-1) nm+1,即得nm=(n0-1)/(m-1)
·若(n0-1)%(m-1)==0,则说明对于这个n0个叶结点(初始归并段)可以构造m叉树归并树
·若(n0-1)%(m-1)=u≠0,则说明对于这个n0个叶结点(初始归并段)其中有u个叶结点是多余的
多出u个结点,需要补充m-u-1个结点。