绪论
数据结构基本概念
基本概念和术语
术语 | 定义 |
---|
数据 | 数据是信息的载体,是描述客观事物属性的数、字符及所有能输入到计算机中并被计算机程序识别和处理的符号的集合 | 数据元素 | 数据元素是数据的基本单位,通常作为一个整体进行考虑和处理,含有多个数据项 | 数据项 | 是构成数据元素的不可分割的最小单位 | 数据对象 | 具有相同性质的数据元素的集合,是数据的一个子集 | 数据类型 | 一个值的集合和定义在此集合上的一组操作的总称{原子类型、结构类型、抽象数据类型} | 数据结构 | 相互之间存在一种或多种特定关系的数据元素的集合 |
数据类型
数据类型 | 定义 |
---|
原子类型 | 其值不可再分的数据类型 | 结构类型 | 其值可以再分解为若干成分的数据类型 | 抽象数据类型ADT | 抽象数据组织及与之相关的操作 |
抽象数据类型的定义格式
ADT 抽象数据类型名{
数据对象:<数据对象的定义>
数据关系:<数据关系的定义>
基本操作:<基本操作的定义>
}ADT 抽象数据类型名;
基本操作名(参数表)
初始条件:<初始条件描述>
操作结果:<操作结果描述>
数据结构三要素
逻辑结构
定义:逻辑结构是指数据元素之间的逻辑关系,即从逻辑关系上描述数据。
分类:
- 线性结构:一般线性表、受限线性表(栈和队列)、线性表推广(数组)
- 非线性结构:集合结构、树结构、图结构
存储结构
定义:存储结构是指数据结构在计算机中的表示,也称物理结构
分类:
存储结构 | 定义 | 优点 | 缺点 |
---|
顺序存储 | 把逻辑上相邻的元素存储在物理位置上也相邻的存储单元中,元素之间的关系由存储单元的邻接关系来体现 | 随机存取,占用空间少 | 使用一整块相邻的存储单元,产生较多碎片 | 链式存储 | 不要求逻辑上相邻的元素在物理位置上也相邻,借助指示元素存储地址的指针来表示元素之间的逻辑关系 | 不会出现碎片,充分利用所有存储单元 | 需要额外空间,只能顺序存取 | 索引存储 | 在存储元素信息的同时,还建立附加的索引表。 | 检索速度快 | 附加的索引表需要额外空间。增删数据修改索引表时花费时间 | 散列存储 | 根据元素的关键字直接计算出该元素的存储地址,又称哈希(Hash)存储。 | 检索、增加和删除结点的操作很快 | 可能出现元素存储单元的冲突,解决冲突会增加时间和空间开销 |
数据的运算
定义:施加在数据上的运算包括运算的定义和实现。定义是针对逻辑结构的,指出运算的功能;运算的实现是针对存储结构的,指出运算的具体操作步骤。
算法和算法评价
算法的定义、特性和评价标准
- 定义:算法是针对特定问题求解步骤的一种描述,它是指令的有限序列,其中的每条指令表示一个或多个操作。
- 特性:输入、输出、确定性、有穷性、可行性
- 评价标准
- 正确性:正确结果
- 可读性
- 健壮性:输入数据非法时,能够适当的作出反应或相应处理,不会产生莫名其妙的输出结果
- 高效性:时间和空间
算法效率的度量
T
(
n
)
=
O
(
f
(
n
)
)
,
S
(
n
)
=
O
(
g
(
n
)
)
T(n)=O(f(n)),S(n)=O(g(n))
T(n)=O(f(n)),S(n)=O(g(n))
-
函数的渐进的界
O
(
g
(
n
)
)
O(g(n))
O(g(n)) (存在正数c和
n
0
n_0
n0?使得对于一切
n
≥
n
0
n\geq n_0
n≥n0?),
0
≤
f
(
n
)
≤
c
g
(
n
)
0\leq f(n)\leq cg(n)
0≤f(n)≤cg(n) -
算法复杂度分析步骤
- 确定表示输入规模的参数
- 找出算法的基本操作
- 检查基本操作的执行次数是否只依赖于输入规模。这决定是否需要考虑最差、平均以及最优情况下的复杂性
- 对于非递归算法,建立算法基本操作执行次数的求和表达式;对于递归算法,建立算法基本操作执行次数的递推关系及其初始条件
- 利用求和公式和法则建立一个操作次数的闭合公式,或者求解递推公式,确定增长的阶
-
递归算法两类复杂度分析
T
(
n
)
=
{
O
(
1
)
n
=
1
a
T
(
n
?
1
)
+
f
(
n
)
n
>
1
T(n)= \begin{cases} O(1) & n=1\\ aT(n-1)+f(n)& n>1 \end{cases}
T(n)={O(1)aT(n?1)+f(n)?n=1n>1?
T
(
n
)
=
a
n
?
1
T
(
1
)
+
∑
i
=
2
n
a
n
?
i
f
(
i
)
T(n)=a^{n-1}T(1)+\sum_{i=2}^n a^{n-i}f(i)
T(n)=an?1T(1)+i=2∑n?an?if(i)
T
(
n
)
=
{
O
(
1
)
n
=
1
a
T
(
n
b
)
+
f
(
n
)
n
>
1
T(n)= \begin{cases} O(1) & n=1\\ aT(\frac nb)+f(n)& n>1 \end{cases}
T(n)={O(1)aT(bn?)+f(n)?n=1n>1?
T
(
n
)
=
n
l
o
g
b
a
T
(
1
)
+
∑
j
=
0
l
o
g
b
n
?
1
a
j
f
(
n
b
j
)
T(n)=n^{log_b a}T(1)+\sum_{j=0}^{log_b n-1}a^jf(\frac n{b^j})
T(n)=nlogb?aT(1)+j=0∑logb?n?1?ajf(bjn?)
线性表
线性表的定义和基本操作
定义
线性表是具有相同数据类型的n个数据元素的有限序列。其中n为表长,当n=0时线性表是一个空表。若用L命名线性表,则其一般表示为
L
=
(
a
1
,
a
2
,
.
.
.
,
a
i
,
a
i
+
1
,
.
.
.
,
a
n
)
L=(a_1,a_2,...,a_i,a_{i+1},...,a_n)
L=(a1?,a2?,...,ai?,ai+1?,...,an?)。
特点
-
a
1
a_1
a1?是唯一的“第一个”数据元素,又称表头元素
-
a
n
a_n
an?是唯一的“最后一个”数据元素,又称表尾元素
- 除第一个元素外,每个元素有且仅有一个直接前驱。除最后一个元素外,每个元素有且仅有一个直接后继。
基本操作
InitList(&L):初始化表。构造一个空的线性表
Length(L):求表长
LocateElem(L,e):按值查找操作
GetElem(L,i):按位查找操作
ListInsert(&L,i,e):插入操作
ListDelete(&L,i,&e):删除操作,并用e返回删除元素的值
PrintList(L):输出操作
Empty(L):判断操作
DestoryList(&L):销毁操作
线性表的顺序表示
顺序表的定义
线性表的顺序存储又称顺序表。它是用一组地址连续的存储单元依次存储线性表中的数据元素,从而使得逻辑上相邻的两个元素在物理位置上也相邻。顺序表的特点是表中元素的逻辑顺序与物理顺序相同。
- 线性表A中第
i 个元素的内存地址:&(A[0])+i*sizeof(ElemType) - 一维数组可以是静态分配,也可以动态分配
- 静态分配时,数组的大小和空间事先已经固定,一旦空间占满,再加入新的数据就会产生溢出,进而导致程序崩溃
- 动态分配时,存储数组的空间是在程序执行过程中通过动态存储分配语句分配的,一旦数据空间占满,就另外开辟一块更大的存储空间,用以替换原来的存储空间。
静态分配的实现
#define MaxSize 50
typedef struct{
ElemType data[MaxSize];
int length;
}SqList;
动态分配的实现
#define InitSize 100
typedef struct{
ElemType *data;
int MaxSize,length;
}SqList;
L.data = (ElemType*)malloc(sizeof(ElemType)*InitSize);
free(L);
L.data = new ElemType[InitSize];
delete L;
特点
顺序表的实现
注意算法对i的描述是第i个元素,它是以1为起点的
bool ListInset(SqList &L,int i,ElemType e){
if(i<1||i>L.length+1) return false;
if(L.length>=MaxSize) return false;
for(int j=L.length;j>=i;j--) L.data[j] = L.data[j-1];
L.data[i-1] = e;
L.length++;
return true;
}
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;
}
int LocateElem(SqList L,ElemType e){
int i;
for(i=0;i<L.length;i++)
if(L.data[i] == e)
return i+1;
return 0;
}
线性表的链式表示
单链表
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;
}
LinkList List_TailInsert(LinkList &L){
int x;
L = (LinkList)malloc(sizeof(LNode));
LNode *s,*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 *LocateElem(LinkList L,ElemType e){
LNode *p = L->next;
while(p!=NULL&&p->data!=e)
p = p->next;
return p;
}
双链表
typedef struct DNode{
ElemType data;
struct DNode *prior,*next;
}DNode,*DLinkList;
循环链表
静态链表
借助数组来描述线性表的链式存储结构,结点也有数据域data 和指针域next ,这里的指针是节点的相对地址(数组下标),又称游标
#define MaxSize 50
typedef struct{
ElemType data;
int next;
}SLinkList[MaxSize];
栈和队列
栈
栈的基本概念
栈的定义
- 栈是只允许在一端进行插入或删除操作的线性表。后进先出LIFO
- 栈顶(Top):线性表允许进行插入删除的那一端。
- 栈底(Bottom):固定的,不允许进行插入和删除的另一端
- 空栈:不包含任何元素的空表
栈的基本操作
InitStack(&S):初始化一个空栈S
StackEmpty(S):判断一个栈是否为空,若栈S为空则返回true,否则返回false
Push(&S,x):进栈,若栈S未满,则将x加入使之成为新栈顶
Pop(&S,&x):出栈,若栈S非空,则弹出栈顶元素,并用x返回
GetTop(S,&x):读取栈顶元素,若栈S非空,则用x返回栈顶元素
DestroyStack(&S):销毁栈,并释放栈S占用的存储空间
栈的顺序存储结构
顺序栈的实现
利用一组地址连续的存储单元存放自栈底到栈顶的数据元素,并附设一个指针top 指示当前栈顶元素的位置
#define MaxSize 50
typedef struct{
Elemtype data[MaxSize];
int top;
}
- 栈顶指针:
S.top ,初始时设置S.top=-1 ;栈顶元素:S.data[S.top] - 进栈操作:栈不满时,栈顶指针先加1,再送值到栈顶元素
- 出栈操作:栈非空时,先取栈顶元素值,再将栈顶指针减1
- 栈空条件:
S.top==-1 ;栈满条件:S.top==MaxSize-1 ;栈长:S.top+1
共享栈
利用栈底位置相对不变的特性,可让两个顺序栈共享一个一维数组空间,将两个栈的栈底分别设置在共享空间的两端,两个栈顶共享空间的中间延伸。
栈的链式存储结构
采用链式存储的栈称为链栈,链栈的优点是便于多个栈共享存储空间和提高其效率,且不存在栈满上溢的情况。这里规定链栈没有头结点,Lhead 指向栈顶元素
typedef struct Linknode{
ElemType data;
struct Linknode *next;
} *LiStack;
队列
队列的基本概念
队列的定义
- 队列简称队,也是一种操作受限的线性表,只允许在表的一端进行插入,而在表的另一端进行删除。
- 向队列中插入元素称为入队或进队
- 删除元素称为出队或离队
- 操作的特性是先进先出
队列常见的基本操作
InitQueue(&Q):初始化队列,构造一个空队列Q
QueueEmpty(Q):判队列空
EnQueue(&Q,x):入队,若队列Q非满,将x加入,使之成为新的队尾
DeQueue(&Q,&x):出队,若队列Q非空,删除队头元素,并用x返回
GetHead(Q,&x):读队头元素,若队列Q非空,则将队头元素赋值给x
队列的顺序存储结构
队列的顺序存储
队列的顺序实现是指分配一块连续的存储单元存放队列中的元素,并附设两个指针:队头指针front 指向队头元素,队尾指针rear 指向队尾元素的下一个位置
#define MaxSize 50
typedef struct{
ElemType data[MaxSize];
int front,rear;
} SqQueue;
- 初始状态:
Q.front==Q.rear==0 - 进队操作:队不满时,先送值到队尾元素,再将队尾指针加1
- 出队操作:队不空时,先取队头元素值,再将队头指针加1
循环队列
将顺序队列臆造为一个环状的空间,即把存储队列元素的表从逻辑上视为一个环,称为循环队列。当队首指针Q.front=MaxSize-1 后,再前进一个位置就自动到0,这可以利用除法取余运算% 来实现
- 初始状态:
Q.front=Q.rear=0 - 队首指针进1:
Q.front=(Q.front+1)%MaxSize - 队尾指针进1:
Q.rear=(Q.rear+1)%MaxSize - 队列长度:
(Q.rear+MaxSize-Q.front)%MaxSize - 出队入队时:指针都按顺时针方向进1
判断循环队列队空或队满的三种方式
-
牺牲一个单元来区分队空和队满,入队时少用一个队列单元,约定以“队头指针在队尾指针的下一位置作为队满的标志”
- 队满条件:
(Q.rear+1)%MaxSize==Q.front - 队空条件:
Q.front=Q.rear - 队列中元素的个数:
(Q.rear-Q.front+MaxSize)%MaxSize -
类型中增设表示元素个数的数据成员。
- 队空条件:
Q.size==0 - 队满条件:
Q.size==MaxSize -
类型中增设tag 数据成员,以区分是队满还是队空。
-
tag=0 时,若因删除导致Q.front==Q.rear ,则为队空 -
tag=1 时,若因插入导致Q.front==Q.rear ,则为队满
队列的链式存储结构
队列的链式存储
队列的链式表示称为链队列,它实际是一个同时带有队头指针和队尾指针的单链表。头指针指向队头结点,尾指针指向队尾结点。
typedef struct{
ElemType data;
struct LinkNdoe *next;
}LinkNode;
typedef struct{
LinkNode *front,*rear;
}LinkQueue;
通常将链式队列设计成一个带头结点对的单链表,这样插入和删除就统一了
双端队列
双端队列是指允许两端都可进行入队和出队操作的队列,其元素的逻辑结构仍是线性结构。将队列的两端分别称为前端和后端。
- 输出受限的双端队列:允许在一端进行插入和删除,另一端只允许插入的双端队列
- 输入受限的双端队列:允许在一端进行插入和删除,另一端只允许删除的双端队列
栈和队列的应用
栈在括号匹配中的应用
- 初始设置一个空栈,顺序读入括号
- 若是右括号,则或者置于栈顶的最急迫期待得以消解,或者是不合法的情况
- 若是左括号,则作为一个新的更急迫的期待压入栈中
- 算法结束时,栈为空,否则括号序列不匹配
栈在表达式求值中的应用
后续表达式计算方式
顺序扫描表达式的每一项,然后根据它的类型作出如下相应操作:若该项是操作数,则将其压入栈中;若该项是操作符<op> ,则连续从栈中退出两个操作数Y 和X ,形成运算指令X<op>Y ,并将计算结果重新压入栈中。当表达式的所有项扫描并处理完毕后,栈顶存放的就是最后的结果
中缀表达式转换为前缀或后缀表达式的手工做法
- 按照运算符的优先级对所有的运算单位加括号
- 转换为前缀或后缀表达式。前缀把运算符移动到对应的括号前面,后缀把运算符移动到对应的括号后面
- 把括号去掉
中缀表达式转换为后缀表达式的算法思路
- 从左向右开始扫描中缀表达式
- 遇到数字时,加入后缀表达式
- 遇到运算符时
- 若为
( ,入栈 - 若为
) ,则依次把栈中的运算符加入后缀表达式,直到出现( ,从栈中删除( - 若为除括号外的其他运算符,当其优先级高于除
( 外的栈顶运算符时,直接入栈。否则从栈顶开始,依次弹出比当前处理的运算符优先级高和优先级相等的运算符,直到一个比它优先级低的或遇到一个左括号为止。
栈在递归中的应用
可以将递归算法转换为非递归算法。通常需要借助栈来实现这种转换
队列在层次遍历中的应用
- 根节点入队
- 若队空,则结束遍历;否则重复
3 操作 - 队列中第一个结点出队,并访问之。若其没有左孩子,则将左孩子入队,若其有左孩子,则将其右孩子入队,返回
2
队列在计算机系统中的应用
- 解决主机与外部设备之间速度不匹配的问题
- 解决由多用户引起的资源竞争问题
特殊矩阵的压缩存储
数组的定义
数组是由n个相同类型的数据元素构成的有限序列,每个数据元素成为一个数据元素,每个元素在n个线性关系中的序号称为该元素的下标,下标的取值范围称为数组的维界
数组是线性表的推广。一维数组可视为一个线性表;二维数组可视为其元素也是定长线性表的线性表。
数组的存储结构
多维数组的映方法:按行优先和按列优先
矩阵的压缩存储
指为多个值相同的元素只分配一个存储空间,对零元素不分配存储空间。其目的是节省存储空间
稀疏矩阵
矩阵中非零元素的个数远远小于矩阵元素的个数
使用三元组(行、列、值)或十字链表法存储,失去了随机存取特性
串
串的定义和实现
串的定义
串的存储结构
定长顺序存储表示
用一组地址连续的存储单元存储串值的字符序列
#define MAXLEN 255
typedef struct{
char ch[MAXLEN];
int length;
}SString;
堆分配存储表示
堆分配存储仍然以一组地址连续的存储单元存放串值的字符序列,但他们的存储空间是在程序执行过程中动态分配得到的
typedef struct{
char *ch;
int length;
}HString;
在C 语言中,存在一个称之为堆的自由存储区,并用malloc() 和free() 函数来完成动态存储管理
利用malloc() 为每个新产生的串分配一块实际串长所需要的存储空间,若分配成功,则返回一个指向起始地址的指针,称为串的基地址,这个串由ch 指针来指示;若分配失败,则返回NULL 。已分配的空间可用free() 释放掉
块链存储表示
类似于线性表的链式存储结构,也可采用链表方式存储串值。由于串的特殊性,在具体实现时,每个节点即可以存放一个字符,也可以存放多个字符,每个节点称为块,整个链表称为块链结构
串的基本操作
StrAssign(&T,chars):赋值操作。把串T赋值为chars
StrCopy(&T,S):复制操作。由串S复制得到串T
StrEmpty(S):判空操作。若S为空串,则返回TRUE,否则返回FALSE
StrCompare(S,T):比较操作,S>T则返回值>0;k若S=T,返回0,否则返回<0
StrLength(S):求串长
SubString(&Sub,S,pos,len):求子串。用Sub返回串S的第pos个字符起长度为len的子串
Concat(&T,S1,S2):串联接。用T返回S1和S2的联接
Index(S,T):定位操作
ClearString(&S):清空
DestroyString(&S):销毁串
串的模式匹配
简单的模式匹配算法BF
子串的定位操作通常称为串的模式匹配,它求的是子串在主串中的位置
KMP算法
基础概念
- 前缀:除最后一个字符以外,字符串的所有头部子串
- 后缀:除第一个字符外,字符串的所有尾部子串
- 部分匹配值
PM :字符串的前缀和后缀的最长相等前后缀长度
算法原理
编号 | 描述 | 1 | 2 | 3 | 4 | 5 |
---|
S | 字符 | a | b | c | a | c | PM | 子串右移位数=已匹配的字符数-对应的部分匹配值:Move=(j-1)-PM[j-1] | 0 | 0 | 0 | 1 | 0 | next (PM右移一位) | 子串右移位数:Move=(j-1)-next[j] ,子串的比较指针回退到:j=next[j]+1 | -1 | 0 | 0 | 0 | 1 | next=next+1 | 在子串的第j 个字符与主串发生失配时,则跳到子串的next[j]位置重新与主串当前位置进行比较 | 0 | 1 | 1 | 1 | 2 |
next[]推导方法
-
n
e
x
t
[
j
]
=
{
0
j
=
1
m
a
x
{
k
∣
1
<
k
<
j
且
′
p
1
.
.
.
p
k
?
1
′
=
′
p
j
?
k
+
1
.
.
.
p
j
?
1
′
}
当
此
集
合
不
空
时
1
其
他
情
况
(next[?]推导公式)
next[j]= \begin{cases} 0 & j=1\\ max\{k|1<k<j且'p_1...p_{k-1}'='p_{j-k+1}...p_{j-1}'\}& 当此集合不空时 \\ 1& 其他情况 \end{cases} \tag{next[ ]推导公式}
next[j]=??????0max{k∣1<k<j且′p1?...pk?1′?=′pj?k+1?...pj?1′?}1?j=1当此集合不空时其他情况?(next[?]推导公式) -
next的推导步骤,next[j]=k ,求next[j+1] next[j]=k 表明
p
1
.
.
.
p
k
?
1
=
p
j
?
k
+
1
.
.
.
p
j
?
1
p_1...p_{k-1}=p_{j-k+1}...p_{j-1}
p1?...pk?1?=pj?k+1?...pj?1?
- 若
p
k
=
p
j
p_k=p_j
pk?=pj?,则
next[j+1]=next[j]+1 - 若
p
k
≠
p
j
p_k \neq p_j
pk??=pj?。用前缀
p
1
.
.
.
p
k
p_1...p_k
p1?...pk?去跟后缀
p
j
?
k
+
1
.
.
.
p
j
p_{j-k+1}...p_j
pj?k+1?...pj?匹配,则当
p
k
≠
p
j
p_k \neq p_j
pk??=pj?是应将
p
1
.
.
.
p
k
p_1...p_k
p1?...pk?向右滑动至以第
next[k] 个字符与
p
j
p_j
pj?比较,如果
p
n
e
x
t
[
k
]
p_{next[k]}
pnext[k]?与
p
j
p_j
pj?还是不匹配,那么需要寻找长度更短的相等前后缀,下一步继续用
P
n
e
x
t
[
n
e
x
t
[
k
]
]
P_{next[next[k]]}
Pnext[next[k]]?与
p
j
p_j
pj?比较,直到找到k'=next[next...[k]] 满足条件
′
p
1
.
.
.
p
k
′
′
=
′
p
j
?
k
′
+
1
.
.
.
p
j
′
'p_1...p_{k'}'='p_{j-k'+1}...p_{j}'
′p1?...pk′′?=′pj?k′+1?...pj′?,则next[j+1]=k'+1
说明
- 为什么
next[1]=0 :当模式串中的第一个字符与主串的的当前字符比较不相等时,next[1]=0 ,表示模式串应该右移一位,主串当前指针后移一位,再和模式串的第一个字符进行比较 - 为什么要取
max{k} :当主串的第i 个字符与模式串的第j 个字符失配时,主串i 不回溯,则假定模式串的第k 个字符与主串的第i 个字符比较,k 应满足条件
1
<
k
<
j
且
′
p
1
.
.
.
p
k
?
1
′
=
′
p
j
?
k
+
1
.
.
.
p
j
?
1
′
1<k<j且'p_1...p_{k-1}'='p_{j-k+1}...p_{j-1}'
1<k<j且′p1?...pk?1′?=′pj?k+1?...pj?1′?。为了不使向右移动丢失可能的匹配,右移距离应该取最小,由于
j
?
k
j-k
j?k表示右移距离,所以取
m
a
x
{
k
}
max\{k\}
max{k}。
KMP算法的进一步优化
nextval[] ,如果出现了
p
j
=
p
n
e
x
t
[
j
]
p_j=p_{next[j]}
pj?=pnext[j]?,则将next[j] 修正为next[ next[j] ] ,直到两者不相等
树与二叉树
树
树的定义
- 有且仅有一个特定的称为根的结点
- 当
n
>
1
n>1
n>1时,其余结点可分为
m
m
m个互不相交的有限集
T
1
,
T
2
,
.
.
.
,
T
m
T_1,T_2,...,T_m
T1?,T2?,...,Tm?,其中每个集合本身又是一棵树,并且称为根的子树
基本术语
祖先、子孙、双亲、孩子、兄弟
结点的度、树的度
分支结点、叶子节点
节点的深度、高度、层次
有序树和无序树
路径和路径长度
森林
树的性质
- 树中的节点数等于所有结点的度数之和加1
- 总结点数=
n
0
+
n
1
+
n
2
+
.
.
.
+
n
m
n_0+n_1+n_2+...+n_m
n0?+n1?+n2?+...+nm?
- 总分支数=
1
n
1
+
2
n
2
+
.
.
.
+
m
n
m
1n_1+2n_2+...+mn_m
1n1?+2n2?+...+mnm?
- 总结点数=总分支数+1
二叉树
二叉树的定义及其主要特性
定义
每个节点至多只有两棵子树,并且二叉树的子树有左右之分,不能颠倒
几个特殊的二叉树
- 满二叉树:一棵高度为h,且含有
2
h
?
1
2^h-1
2h?1个结点的二叉树称为满二叉树,除叶子结点外每个结点度数为2
- 完全二叉树:每个结点都与同等高度的满二叉树有同样的编号
- 若
i
≤
?
n
/
2
?
i\leq \lfloor n/2 \rfloor
i≤?n/2?,则结点
i 为分支结点,否则为叶子结点 - 当
2
i
≤
n
2i\leq n
2i≤n时,结点
i 的左孩子编号为
2
i
2i
2i,否则无左孩子 - 当
2
i
+
1
≤
n
2i+1\leq n
2i+1≤n时,结点
i 的右孩子编号为
2
i
+
1
2i+1
2i+1,否则无右孩子 - 结点
i 所在的层次为
?
l
o
g
2
i
?
+
1
\lfloor log_2i \rfloor +1
?log2?i?+1 - 叶子结点只可能在层次最大的两层上出现,对于最大层次中的叶子结点,都依次排列在该层最左边的位置上
- 若有度为1的节点,则只可能有一个,且只有左孩子没有右孩子
- 若n为奇数,则每个分支结点都有左孩子和右孩子;若n为偶数,则编号最大的分支结点只有左孩子,没有右孩子
- 二叉排序树:左子树上所有节点的关键字小于根节点的关键字;右子树上的所有节点的关键字均大于根节点的关键字
- 平衡二叉树:树上任一节点的左子树和右子树的深度之差不超过1
二叉树的性质
- 非空二叉树上的叶子结点数等于度为2的结点数+1,即
n
0
=
n
2
+
1
n_0=n_2+1
n0?=n2?+1
二叉树的存储结构
顺序存储结构
将完全二叉树上编号为i 的结点元素存储在一维数组下标为i-1 的分量中
注意:这种存储结构建议从数组下标1开始存储树中的结点,若从数组下标0开始存储,则计算其孩子结点时与之前描述的计算公式不一致,在书写程序时需要注意。
链式存储结构
typedef struct BiTNode{
ElemType data;
struct BiTNode *lchild,*rchild;
}BiTNode,*BiTree;
n个结点的二叉链表中,含有n+1 个空链域
二叉树的遍历和线索二叉树
二叉树的遍历
二叉树的遍历是按照某条搜索路径访问树中每个结点,使得每个结点均被访问一次,而且仅被访问一次。共有先序遍历(NLR)、中序(LNR)、后续(LRN)三中遍历方法
递归遍历算法
void PreOrder(BiTree T){
if(T!=NULL){
visit(T);
PreOrder(T->lchild);
PreOrder(T->rchlid);
}
}
非递归遍历算法
void PreOrder2(BiTree T){
InitStack(S);BiTree p=T;
while(p||!IsEmpty(S)){
if(p){
visit(p);Push(S,p);
p = p->lchild;
}
else{
Pop(S,p);
p = p->rchild;
}
}
}
void Inorder2(BiTree T){
InitStack(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;
}
}
}
void PostOrder(BiTree T){
InitStack(S);
P = T;
r = NULL;
while(p||!IsEmpty(S)){
if(p){
push(S,p);
p = p->lchild;
}
else{
GetTop(S,p);
if(p->rchild&&r->rchild!=r)
p = p->rchild;
else{
pop(S,p);
cisit(p->data);
r = p;
p = NULL;
}
}
}
}
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);
}
}
由遍历序列构造二叉树
由二叉树中序遍历结果和前序、后序、层次中的一个组合,就可唯一确定一棵二叉树
线索二叉树
线索二叉树的基本概念
在含n 个结点的二叉树中,有n+1 个空指针。引入线索二叉树正是为了加快查找结点前驱和后继的速度。
规定:若无左子树,令lchild 指向其前驱结点;若无右子树,令rchild 指向其后继结点
l
c
h
i
l
d
=
{
0
,
l
c
h
i
l
d
域
指
示
结
点
的
左
孩
子
1
,
l
c
h
i
l
d
域
指
示
结
点
的
前
驱
r
c
h
i
l
d
=
{
0
,
r
c
h
i
l
d
域
指
示
结
点
的
右
孩
子
1
,
r
c
h
i
l
d
域
指
示
结
点
的
后
继
lchild= \begin{cases} 0,&lchild域指示结点的左孩子\\ 1,&lchild域指示结点的前驱 \end{cases}\\ rchild= \begin{cases} 0,&rchild域指示结点的右孩子\\ 1,&rchild域指示结点的后继 \end{cases}
lchild={0,1,?lchild域指示结点的左孩子lchild域指示结点的前驱?rchild={0,1,?rchild域指示结点的右孩子rchild域指示结点的后继?
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild,*rchild;
int ltag,rtag;
}ThreadNode,*ThreadTree;
中序线索二叉树的构造
线索化的实质就是遍历一次二叉树
中序线索二叉树的遍历
在对其进行遍历时,只要先找到序列中的第一个节点,然后依次找结点的后继,直至后继为空。在中序线索二叉树中找结点后继的规律是:若其右标志为“1”,则右链为线索,指示其后继,否则遍历右子树中第一个访问的结点为其后继。
先序线索二叉树和后序线索二叉树
后序线索二叉树上找后继时需要知道结点双亲,即需采用带标志域的三叉链表作为存储结构。
树、森林
树的存储结构
双亲表示法
采用一组连续空间来存储每个结点,同时在每个结点中增设一个伪指针,指示其双亲结点在数组中的位置。
根节点的下标为0,其伪指针域为-1
该存储结构利用了每个结点只有唯一双亲对的性质,可以很快的得到每个结点的双亲结点,但求结点的孩子需要遍历整个结构。
孩子表示法
将每个结点的孩子结点都用单链表链接起来形成一个线性结构
这种存储方式寻找子女的操作非常直接,而寻找双亲的操作需要遍历n个结点中孩子结点指针域指向的n个孩子链表
孩子兄弟表示法
又称二叉树表示法。孩子兄弟表示法使每个结点包括三部分内容:结点值、指向结点第一个孩子结点的指针,及指向结点下一个兄弟结点的指针。
data | firstchild | nextsibling |
---|
树、森林与二叉树的转化
-
树转换为二叉树
-
森林转换二叉树
- 规则:先将森林中的每棵树转换为二叉树;把第二棵树的根作为第一课树根的右兄弟,以此类推
- 画法:1.森林中的每棵树转换为相应的二叉树2.每棵树的根也可视为兄弟关系,在每棵树的根之间加一根连线;3.以第一棵树的根为轴心顺时针旋转45°
- 特点:森林中每棵树的根节点从第二个开始依次连接到前一棵树的根的右孩子,因此最后一棵树的根节点的右指针为空。另外,每个非终端节点,其所有孩子结点在转换后,最后一个孩子的右指针也为空。
-
二叉树转换为森林
- 若二叉树非空,则二叉树的根及其左子树为第一棵树的二叉树形式,故将根的右链断开。二叉树根的右子树又可视为由除第一棵树外的森林转换后的二叉树。
树和森林的遍历
树的遍历
森林的遍历
- 先序遍历森林
- 访问森林中第一棵树的根节点
- 先序遍历第一棵树的根节点的子树森林
- 先序遍历除去第一棵树后剩余的树构成的森林
- 中序遍历森林(又称后根遍历)
- 中序遍历森林中第一课树的根节点的子树森林
- 访问第一课树的根结点
- 中序遍历除去第一棵树后剩余的树构成的森林
树与二叉树的应用
二叉排序树BST
-
二叉排序树的定义
- 左子树上所有节点的关键字小于根节点的关键字;右子树上的所有节点的关键字均大于根节点的关键字
-
二叉排序树的查找、插入、构造 -
二叉排序树的剔除
- 若被删除结点
z 是叶子结点,则直接删除 - 若结点
z 只有一棵左子树或右子树,则让z 的子树成为z 父节点的子树,替代z 的位置 - 若结点
z 有左、右两棵子树,则令z 的直接后继替代z ,然后从二叉排序树中删去这个直接后继,这样就转换成了上面的两种情况 -
二叉排序树的查找效率分析
-
O
(
l
o
g
2
n
)
(
平
衡
二
叉
树
)
O(log_2n)(平衡二叉树)
O(log2?n)(平衡二叉树)~
O
(
n
)
O(n)
O(n)
-
A
S
L
a
ASL_a
ASLa?:平均查找长度
平衡二叉树
-
定义
- 任意结点的左右子树高度差的绝对值不超过1的二叉排序树
-
二叉排序树的插入
情况 | 具体 |
---|
LL平衡旋转(右单旋转) | | RR平衡旋转(左单旋转) | | LR平衡旋转(先左后右双旋转) | | RL平衡旋转(先右后左双旋转) | |
- 平衡二叉树的查找
- 含有n个结点的平衡二叉树的最大深度为
O
(
l
o
g
2
n
)
O(log_2n)
O(log2?n),平均查找长度为
O
(
l
o
g
2
n
)
O(log_2n)
O(log2?n)
- 平衡二叉树结点数的递推关系
n
h
=
1
+
n
h
?
1
+
n
h
?
2
,
n
0
=
0
,
n
1
=
1
,
n
2
=
2
n_h=1+n_{h-1}+n_{h-2},n_0=0,n_1=1,n_2=2
nh?=1+nh?1?+nh?2?,n0?=0,n1?=1,n2?=2,
n
h
n_h
nh?为构造此高度的平衡二叉树所需的最少结点数
哈夫曼树和哈夫曼编码
定义
哈夫曼树的构造
-
将n个结点分别作为n 棵仅含有一个结点的二叉树,构成森林F -
构造一个新结点,从F 中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和 -
从F 中删除刚才选出的两棵树,同时将新得到的树加入F 中 -
重复2-3步骤
哈夫曼树特点
- 每个初始节点最终都成为叶结点
- 构造过程中新建了
n-1 个结点,哈夫曼树中结点总数为2n-1 - 不存在度为
1 的节点
哈夫曼编码
图
图的基本概念
图的定义
图
G
G
G由顶点集
V
V
V和边集
E
E
E组成,记为
G
=
(
V
,
E
)
G=(V,E)
G=(V,E),其中
V
(
G
)
V(G)
V(G)表示图
G
G
G中顶点的有限非空集;
E
(
G
)
E(G)
E(G)表示图
G
G
G中顶点之间的关系集合。
V
=
{
v
1
,
v
2
,
.
.
.
,
v
n
}
V=\{v_1,v_2,...,v_n\}
V={v1?,v2?,...,vn?},
∣
V
∣
|V|
∣V∣表示顶点个数,
E
=
{
(
u
,
v
)
∣
u
∈
V
,
v
∈
V
}
E=\{(u,v)|u \in V,v\in V\}
E={(u,v)∣u∈V,v∈V},
∣
E
∣
|E|
∣E∣表示图
G
G
G中边的条数
基本术语
- 有向图
- 无向图
- 简单图、多重图
- 完全图
- 子图
- 连通、连通图和连通分量
- 强连通图、强连通分量
- 生成树、生成森林
- 顶点的度、入度和出度
- 边的权和网
- 稠密图、稀疏图
- 路径、路径长度和回路
- 简单路径、简单回路
- 距离
- 有向树
图的存储及基本操作
邻接矩阵法
- 一个一维数组存储图中顶点的信息
- 一个二维数组存储图中边的信息,称为邻接矩阵
- 设图
G
G
G的邻接矩阵为
A
A
A,
A
n
A^n
An的元素等于从顶点i到j的长度为n的路径数目
邻接表法
-
顶点表结点 边表的头指针和顶点的数据信息采用顺序存储
- 若存储的是无向图,空间复杂度为
O
(
∣
V
∣
+
2
∣
E
∣
)
O(|V|+2|E|)
O(∣V∣+2∣E∣);若为有向图,空间复杂度为
O
(
∣
V
∣
+
∣
E
∣
)
O(|V|+|E|)
O(∣V∣+∣E∣)
十字链表
- 有向图的一种链式存储结构
- 对应于有向图中的每条弧有一个结点,对应于每个顶点也有一个结点
- 弧结点
尾域 | 头域 | 链域->弧头相同的下一条弧 | 链域->弧尾相同的下一条弧 | 弧信息 |
---|
tailvex | headvex | hlink | tlink | info |
数据 | 第一条出弧 | 第一条入弧 |
---|
data | firstin | firstout |
邻接多重表
- 无向图的链式存储结构
- 与邻接表的区别是,同一条边在邻接多重表中只有一个结点
- 边结点
标志域 | 依附的结点 | 下一条依附于ivex 的边 | 依附的结点 | 下一条依附于jvex 的边 | 边信息 |
---|
mark | ivex | ilink | jvex | jlink | info |
数据 | 第一条依附于该顶点的边 |
---|
data | firstedge |
图的基本操作
Adjacent(G,x,y):判断图G是否存在边<x,y>
Neighbors(G,x):列出图G中与结点x邻接对的边
InsertVertex(G,x):在图G中插入顶点x
DeleteVertex(G,x):从图G中删除顶点x
AddEdge(G,x,y):若边<x,y>不存在,则向图G中添加该边
RemoveEdge(G,x,y):若边<x,y>存在,则从图G中删除该边
FirstNeighbor(G,x,y):求图G中顶点x的第一个邻接点,若有则返回顶点号。否则返回-1
NextNeighbor(G,x,y):假设图G中顶点y是顶点x的一个邻接点,返回除y外顶点x的下一个邻接点的顶点号,若没有返回-1
Get_edge_value(G,x,y):获取图G中边<x,y>的权值
Set_edge_value(G,x,y,v):设置图G中边<x,y>对应的权值为v
图的遍历
- 图的遍历是从图中某一顶点出发,按照某种搜索方法沿着图中的对边对图中所有顶点访问且只访问一次。
广度优先算法
Breadth-Frist-Search,BFS
-
基本思想 首先访问起始顶点
v
v
v,接着由
v
v
v出发,依次访问v的各个未访问过的邻接顶点
w
1
,
w
2
,
.
.
.
,
w
i
w_1,w_2,...,w_i
w1?,w2?,...,wi?,然后一次访问
w
1
,
w
2
,
.
.
.
,
w
i
w_1,w_2,...,w_i
w1?,w2?,...,wi?的所有未被访问的邻接顶点。 换句话说,BFS 是以v 为起始点,由近及远依次访问和v 有路径相通且路径长度为1,2,…的顶点
bool visited[MAX_VERTEX_NUM];
void BFSTraverse(Graph G){
for(i=0;i<G.vexnum;i++)
visited[i] = FALSE;
InitQueue(Q);
for(i=0;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=Neighbor(G,v,w))
if(!visited[w]){
visit(w);
visited[w] = TRUE;
EnQueue(Q,w);
}
}
}
深度优先搜索
Depth-First-Search,DFS
bool visited[MAX_VERTEX_NUM];
void DFSTraverse(Graph G){
for(v=0;v<G.vexnum;++v)
visited[v] = FALSE;
for(v=0;v<G.vexnum;++v)
if(!visited[v])
DFS(G,v);
}
void DFS(Graph G,int v){
visit(v);
visited[v] = TRUE;
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w))
if(!visited[w]){
DFS(G,w);
}
}
性能
性能 | 广度优先搜索/深度优先搜索 |
---|
空间复杂度 | $O( | 时间复杂度-邻接矩阵 | $O( | 时间复杂度-邻接表 | $O( | 生成树 | 广度深度优先生成树,邻接表不唯一,邻接矩阵唯一 |
图的应用
最小生成树
求一个带权连通图的最小生成树Minimum-Spanning-Tree,MST
Prim算法
Kruskal算法
- 基本思想
- 初始时为只有n个顶点而无边的非连通图T,每个顶点自成一个连通分量
- 按照边的权值由小到大,加入到非连通图T中,不能形成环
- 重复直到加满
- 时间复杂度:
O
(
∣
E
∣
l
o
g
∣
E
∣
)
O(|E|log|E|)
O(∣E∣log∣E∣)
最短路径
Dijkstra算法求单源最短路径
Floyd算法求个定点之间最短路径
有向无环图描述表达式
有向无环图DAG
有向无环图是描述含有公共子式的表达式的有效工具,可实现对相同子式的共享,从而节省存储空间
拓扑排序
-
AOV网 :若用AVG 表示一个工程,其顶点表示活动,用有向边
<
V
i
,
V
j
>
<V_i,V_j>
<Vi?,Vj?>表示活动
V
i
V_i
Vi?必须先于活动
V
j
V_j
Vj?进行的这样一种关系,则将这种有向图称为顶点表示活动的网络 -
拓扑排序:在图论中,由一个有向无环图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑排序
- 每个顶点出现且只出现一次
- 若顶点
A 在序列中排在顶点B的前面,则在图中不存在从顶点B到顶点A的路径 -
拓扑排序算法
- 从
AOV 网中选择一个没有前驱的顶点并输出 - 从网中删除该顶点和所有以它为起点的有向边
- 重复1-2,直到当前
AOV 网为空 -
时间复杂度
O
(
∣
V
∣
+
∣
E
∣
)
O(|V|+|E|)
O(∣V∣+∣E∣) -
逆拓扑排序
- 从
AOV 网中选择一个没有后继的顶点并输出 - 从网中删除该顶点和所有以它为重点的有向边
- 重复1-2直到
AOV 为空
关键路径
-
在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销,称之为用边表示活动的网络,简称AOE网 -
AOE网 中仅有一个入度为0的顶点,称为开始顶点(源点);只存在一个出度为0的顶点,称之为结束顶点(汇点) -
具有最大路径长度的路径称为关键路径,而把关键路径上的活动称为关键活动 -
关键路径并不唯一,只提高其中一条关键路径上的关键活动速度不能缩短整个工程的工期。 -
计算参数
-
事件
v
k
v_k
vk?的最早发生时间
v
e
(
k
)
ve(k)
ve(k)
-
v
e
(
源
点
)
=
0
ve(源点)=0
ve(源点)=0
-
v
e
(
k
)
=
M
a
x
{
v
e
(
j
)
+
W
e
i
g
h
t
(
v
j
,
v
k
}
ve(k)=Max\{ve(j)+Weight(v_j,v_k\}
ve(k)=Max{ve(j)+Weight(vj?,vk?}
- 事件
v
k
v_k
vk?的最迟发生时间
v
l
(
k
)
vl(k)
vl(k)
-
v
l
(
汇
点
)
=
v
e
(
汇
点
)
vl(汇点)=ve(汇点)
vl(汇点)=ve(汇点)
-
v
l
(
k
)
=
M
i
n
{
v
l
(
j
)
?
W
e
i
g
h
t
(
v
k
,
v
j
}
vl(k)=Min\{vl(j)-Weight(v_k,v_j\}
vl(k)=Min{vl(j)?Weight(vk?,vj?}
- 按逆拓扑排序依次计算
- 活动
a
i
a_i
ai?的最早开始时间
e
(
i
)
e(i)
e(i)
- 它是指该活动弧的起点所表示的事件最早发生时间。
- 若边
<
v
k
,
v
j
>
<v_k,v_j>
<vk?,vj?>表示活动
a
i
a_i
ai?,则有
e
(
i
)
=
v
e
(
k
)
e(i)=ve(k)
e(i)=ve(k)
-
活动
a
i
a_i
ai?的最迟开始时间
l
(
i
)
l(i)
l(i)
- 它是指该活动弧的终点所表示事件的最迟发生时间与该活动所需时间之差
- 若边
<
v
k
,
v
j
>
<v_k,v_j>
<vk?,vj?>表示活动
a
i
a_i
ai?,则有
l
(
i
)
=
v
l
(
j
)
?
W
e
i
g
h
t
(
v
k
,
v
j
)
l(i)=vl(j)-Weight(v_k,v_j)
l(i)=vl(j)?Weight(vk?,vj?)
-
一个活动
a
i
a_i
ai?的最迟开始时间
l
(
i
)
l(i)
l(i)和其最早开始时间
e
(
i
)
e(i)
e(i)的差额
d
(
i
)
=
l
(
i
)
?
e
(
i
)
d(i)=l(i)-e(i)
d(i)=l(i)?e(i)
-
d
(
i
)
=
0
d(i)=0
d(i)=0++的活动
a
i
a_i
ai?是关键活动
查找
查找的概念
- 查找:在数据集合中寻找满足某种条件的数据元素的过程称为查找。查找的结果分为成功和失败
- 查找表:用于查找的数据集合称为查找表。对查找表进行的操作一般有四种
- 静态查找表:不涉及插入和删除的查找表
- 关键字:数据元素中唯一标识该元素的某个数据项的值
- 平均查找长度:
A
S
L
=
∑
i
=
1
n
P
i
C
i
ASL=\sum_{i=1}^nP_iC_i
ASL=∑i=1n?Pi?Ci?,
P
i
P_i
Pi?是概率,
C
i
C_i
Ci?是比较次数
顺序查找和折半查找
顺序查找
一般线性表的顺序查找
typedef struct{
ElemType *elem;
int TableLen;
}SSTable;
int Search_Seq(SSTable ST,ElemType key){
ST.elem[0] = key;
for(i=ST>TableLen;ST.elem[i]!=key;--i);
return i;
}
- 哨兵:引入它的目的是可以不必判断数组是否会越界。引入哨兵可以避免很多不必要的判断语句,从而提高程序效率
有序表的顺序查找
- 由于表的关键字是有序的,查找失败时可以不用比较到表的另一端就能返回失败信息
折半查找
int Binary_Search(SeqList L,ElemType key){
int low=0,high=L.TableLen-1,mid;
while(low<=high){
mid = (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;
}
分块查找
-
又称索引顺序查找,它吸取了顺序查找和折半查找各自的优点,既有动态结构,又适合快速查找 -
基本思想 将查找表分成若干块。块内的元素可以是无序的。但块之间按照每个块的最大关键字进行排序 建立一个索引表,索引表中的每个元素含有各块的最大关键字和各块的第一个元素的地址,索引表按关键字有序排列 -
分块查找的过程
- 在索引表中确定待查记录所在的块,可以顺序查找或折半查找索引表
- 在块内顺序查找
-
索引查找的平均查找长度
L
I
L_I
LI?,块内查找的平均查找长度
L
S
L_S
LS?
时间复杂度评价
查找算法 |
A
S
L
成
功
ASL_{成功}
ASL成功? |
A
S
L
失
败
ASL_{失败}
ASL失败? |
---|
顺序查找-无序表 |
n
+
1
2
\frac{n+1}{2}
2n+1? |
n
+
1
n+1
n+1 | 顺序查找-有序表 |
n
+
1
2
\frac{n+1}{2}
2n+1? |
n
2
+
n
n
+
1
\frac n2+\frac n{n+1}
2n?+n+1n? | 折半查找 |
s
u
m
(
圆
形
结
点
?
对
应
层
数
)
/
n
sum(圆形结点*对应层数)/n
sum(圆形结点?对应层数)/n |
s
u
m
(
方
结
点
?
对
应
层
数
?
1
)
/
(
n
+
1
)
sum(方结点*对应层数-1)/(n+1)
sum(方结点?对应层数?1)/(n+1) | 分块查找 |
A
S
L
=
L
I
+
L
S
ASL=L_I+L_S
ASL=LI?+LS? | |
B树和B+树
B树及其基本操作
B树又称多路平衡查找树,B树中所有结点的孩子个数的最大值称为B树的阶,通常用m 表示。
定义
- 树中每个结点至多有m棵子树,即至多含有m-1个关键字
- 若根结点不是终端节点,则至少有两棵子树
- 除根结点外的所有非叶结点至少有
?
m
/
2
?
\lceil m/2 \rceil
?m/2?棵子树,即至少含有
?
m
/
2
?
?
1
\lceil m/2 \rceil-1
?m/2??1个关键字
- 所有非叶子结点的结构如下
n |
P
0
P_0
P0? |
K
1
K_1
K1? |
P
1
P_1
P1? |
K
2
K_2
K2? |
P
2
P_2
P2? |
.
.
.
...
... |
K
n
K_n
Kn? |
P
n
P_n
Pn? |
---|
? 其中,
K
i
K_i
Ki?为结点的关键字,
P
i
P_i
Pi?为指向子树根结点的指针,且指针
P
k
?
1
P_{k-1}
Pk?1?所指子树中所有结点的关键字均小于
K
i
K_i
Ki?,
P
i
P_i
Pi?所指子树中所有结点的关键字均大于
K
i
K_i
Ki?
? 结点的孩子个数等于该节点中关键字个数加1
- 所有的叶节点都出现在同一层次上,并且不带信息,称为外部结点
B树是所有结点的平衡因子都等于0的多路平衡查找树
B树的高度
B树的高度不包括最后的外部结点那一层
log
?
m
(
n
+
1
)
≤
h
≤
log
?
?
m
/
2
?
(
(
n
+
1
)
/
2
)
+
1
\log_m(n+1) \leq h \leq \log_{\lceil m/2\rceil}((n+1)/2)+1
logm?(n+1)≤h≤log?m/2??((n+1)/2)+1
B树的查找
- 在B树中找结点,在磁盘中进行
- 在结点在找关键字,在内存中进行
B树的插入
- 定位:利用B树的查找算法,找出插入该关键字的最低层中的某个非叶结点
- 插入:在B树中,每个失败结点的关键字个数都在区间
[
?
?
m
/
2
?
?
1.
m
?
1
?
]
[\ \lceil m/2 \rceil-1.m-1\ ]
[??m/2??1.m?1?]内。插入后的结点关键字个数小于m,可以直接插入。如果插入后关键字个数大于
m-1 ,必须进行分裂
- 分裂方法是
- 取一个新结点,在插入key后的原结点,从中间位置将其中的关键字分为两部分
- 左部分包含的关键字放在原结点中,右部分包含的关键字放到新结点中
- 中间位置1的节点插入原节点的父节点。
- 若此时导致父节点也超过了上限,则对父节点继续分裂
B树的删除
B+树的基本概念
- 每个分支结点最多有m棵子树
- 非叶根结点至少有两棵子树,其他每个分支结点至少有
?
m
/
2
?
\lceil m/2 \rceil
?m/2?棵子树
- 结点的子树个数与关键字个数相等
- 所有叶结点包含全部关键字及指向相应记录的指针,叶节点中将关键字按大小顺序排雷,并且相邻叶节点按大小顺序相互链接起来
- 所有分支结点中仅包含它的各个子节点中关键字的最大值及指向其子结点的指针
在B+树中查找时,非叶结点上的关键字值等于查找值时并不停止,而是继续往下找,直到叶结点上的该关键字为止。无论成功与否,每次查找都是一条从根结点到叶结点的路径
B树VSB+树
| B树 | B+树 |
---|
关键字个数为n的结点的子树个数 | n-1 | n | 结点关键字个数n范围 |
?
m
/
2
?
≤
n
≤
m
\lceil m/2 \rceil \leq n \leq m
?m/2?≤n≤m |
?
m
/
2
?
?
1
≤
n
≤
m
?
1
\lceil m/2 \rceil -1\leq n \leq m -1
?m/2??1≤n≤m?1 | | | 叶结点包含信息,所有非叶结点仅起索引作用,非叶结点中的每个索引项只含有对应子树的最大关键字和指向该子树的指针,不含有该关键字对应记录的存储地址 | | 叶结点包含的关键字和其他结点包含的关键字是不重复的 | 叶节点包含了全部关键字,即在非叶结点中出现的关键字也会出现在叶结点中 |
散列表
散列表的基本概念
- 散列函数:一个把查找表中关键字映射成该关键字对应的地址的函数,记为
Hash(key)=Addr - 冲突:散列函数把两个或两个以上的不同关键字映射到同一地址的现象
- 同义词:引起冲突的关键字
- 散列表:根据关键字而直接进行访问的数据结构。散列表建立了关键字和存储地址之间的一种直接映射关系
散列函数的构造方法
散列函数的要求
- 定义域包含全部关键字,值域依赖于散列表的大小或地址范围
- 散列函数计算出的地址应该能等概率、均匀的分布在整个地址空间中,减少冲突发生
- 尽可能简单,能够快速计算出散列地址
常见构造函数 | 公式 | 评价 |
---|
直接定地法 |
H
(
k
e
y
)
=
k
e
y
H(key)=key
H(key)=key或
H
(
k
e
y
)
=
a
×
k
e
y
+
b
H(key)=a \times key+b
H(key)=a×key+b | 最简单,不会产生冲突。适合关键字的分布基本连续的情况 | 除留余数法 |
H
(
k
e
y
)
=
k
e
y
H(key)=key%p
H(key)=key,
p
p
p为不大于散列表表长
m
m
m但最接近或等于
m
m
m的质数 | | 数字分析法 | 设关键字是r进制数,选取数码分布比较均匀的若干位作为散列地址 | 适合于一直的关键字集合,若更换了关键字,则需要重新构造新的散列函数 | 平方取中法 | 取关键字对的平方值的中间几位作为散列值 | 适用于关键字的每位取值都不均匀或均小于散列地址所需的位数 |
处理冲突的方法
- 开放定址法,
H
i
=
(
H
(
k
e
y
)
+
d
i
)
%
m
H_i=(H(key)+d_i)\%m
Hi?=(H(key)+di?)%m,
m
m
m表示散列表表长,
d
i
d_i
di?为增量序列
开放定址法 |
d
i
d_i
di? | 补充说明 |
---|
线性探测法 |
0
,
1
,
2
,
.
.
.
,
m
?
1
0,1,2,...,m-1
0,1,2,...,m?1 | 可能出现大量元素在相邻地址上聚集,降低查找效率 | 平方探测法 |
0
2
,
1
2
,
?
1
2
,
2
2
,
?
2
2
,
.
.
.
,
k
2
,
?
k
2
0^2,1^2,-1^2,2^2,-2^2,...,k^2,-k^2
02,12,?12,22,?22,...,k2,?k2 | 散列表长度m必须是一个可以表示成4k+3 的素数 | 再散列法 |
i
?
H
a
s
h
2
(
k
e
y
)
i*Hash_2(key)
i?Hash2?(key) |
i
i
i是冲突的次数 | 伪随机序列法 |
d
i
=
d_i=
di?=随机序列 | |
散列查找及性能分析
查找过程
- 初始化
Addr=Hash(key) - 检测查找表中地址为
Addr 的位置上是否有记录,若无记录,返回查找失败;若有记录。比较它与key的值,若相等,则返回查找成功的标志,否则执行步骤3 - 用给定的处理冲突方法计算“下一个散列地址”,并将
Addr 置为此地址,转入步骤2
散列表的查找效率取决于散列函数、处理冲突的方法和装填因子
- 装填因子
α
\alpha
α定义为一个表的装满程度
α
=
表
中
记
录
数
n
散
列
表
长
度
m
\alpha = \frac{表中记录数n}{散列表长度m}
α=散列表长度m表中记录数n?
排序
排序的基本概念
- 排序:就是重新排列表中的元素,使表中的元素满足按关键字有序的过程
- 算法的稳定性:在排序之前关键字相同的元素,在排序后相对位置不变的排序算法是稳定的
- 内部排序:排序期间元素全部存放在内存中的排序
- 外部排序:排序期间元素无法全部同时同放在内存中,必须在排序的过程中根据要求不断的在内、外存之间移动的排序
- 可将排序算法分为:插入排序、交换排序、选择排序、归并排序和基数排序五大类
插入排序
基本思想:每次讲一个待排序的记录按其关键字大小插入前面已排好序的子序列,直到全部记录插入完成
直接插入排序
要将L(i) 插入已有序的子序列L[1...i-1] ,需要执行以下操作
void InsertSort(ElemType A[],int n){
int i,j;
for(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];
}
}
折半插入排序
- 查找有序子表时用折半查找来实现
- 确定待插入位置后,同意以地向后移动元素
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 = (low+high)/2;
if(A[min]>A[0]) high = mid-1;
else low = mid+1;
}
for(j=i-1;j>=high+1;--j)
A[j+1] = A[j];
A[high+1] = A[0];
}
}
希尔排序
基本思想:先将待排序表分割成若干形如L[i,i+d,i+2d,...,i+kd] 的特殊子表,即把相隔某个“增量”的记录组成一个子表,对每个子表分别进行直接插入排序,当整个表中的元素已呈“毕本有序”时,再对全体记录进行一次直接插入排序
过程
- 先取一个小于n的步长
d
1
d_1
d1?,把表中的全部记录分成
d
1
d_1
d1?组,所有距离为
d
1
d_1
d1?的倍数的记录放在同一组,在各组内进行直接插入排序
- 然后取第二个步长
d
2
<
d
1
d_2<d_1
d2?<d1?.
- 重复上述过程,直到所取到的
d
t
=
1
d_t=1
dt?=1,即所有记录已放在同一组,再进行直接插入排序
增量序列:
d
1
=
n
/
2
,
d
i
+
1
=
?
d
i
/
2
?
d_1=n/2,d_{i+1}=\lfloor d_i/2 \rfloor
d1?=n/2,di+1?=?di?/2?,最后一个增量等于1
void ShellSort(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];
}
}
交换排序
冒泡排序
基本思想:从后往前(或从前往后)两两比较相邻元素的值,若为逆序则交换,指导序列比较完。
void BubbleSort(ElemType A[],int n){
for(i=0;i<n-1;i++){
flag = false;
for(j=n-1;j>i;j--)
if(A[j-1]>A[j]){
swap(A[j-1],A[j]);
flag = true;
}
if(flag==false)
return;
}
}
注意:冒泡排序所产生的有序子序列是全局有序的。每一趟排序都会将一个元素放置到其最终的位置上
快速排序
基本思想:在待排序表L[1...n] 中任取一个元素pivot 作为枢轴,通过一趟排序将待排序表划分为独立的两个部分L[1...k-1] 和L[k+1...n] ,使得L[1...k-1] 中的所有元素小于pivot ,L[k+1...n] 中的所有元素大于等于pivot ,则pivot 放在了其最终位置L(k) 上,这个过程称为一趟快速排序。然后分别对左右两部分重复上述过程,直到每个部分只有一个元素。
void QuickSort(ElemType A[],int low,int high){
if(low<high){
int pivotpos=Partition(A,low,high);
QuickSort(A,low,pivotpos-1);
QuickSort(A,pivots+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;
reutrn low;
}
快速排序是所有内部排序算法中平均性能最优的排序算法
选择排序
简单选择排序
基本思想:假设排序表为L[1...n] ,第i 趟排序即从L[i...n] 中选择关键字最小的元素与L(i) 交换,每一趟排序可以确定一个元素的最终位置,经过n-1趟排序可以使整个排序表有序
void SelectSort(ElemType A[],int n){
for(i=0;i<n-1;i++){
min = i;
for(j=i+1;j<n;j++)
if(A[j]<A[min]) min = j;
if(min!=j) swap(A[i],A[min]);
}
}
堆排序
-
大根堆:L(i)>=L(2i) & L(i)>=L(2i+1) ,最大元素在根结点 -
小根堆:L(i)<=L(2i) & L(i)<=L(2i+1) ,最小元素在根结点 -
堆的插入:把新结点放到堆的末端,后进行向上调整 -
构造初始堆:
- n个结点的完全二叉树,最后一个结点是第
?
n
/
2
?
\lfloor n/2 \rfloor
?n/2?个结点的孩子。对第
?
n
/
2
?
\lfloor n/2 \rfloor
?n/2?个结点为根的子树筛选(对于的大根堆,若根结点的关键字小于左右孩子中关键字较大者,则交换),使该子树成为堆。
- 之后向前依次对各节点
?
n
/
2
?
?
1
~
1
\lfloor n/2 \rfloor-1 \sim 1
?n/2??1~1为根的子树进行筛选,看该结点值是否大于其左右子节点的值,不大于的话进行交换
- 交换后可能会破坏下一级的堆,使用上述办法继续构造下一级的堆,直到以根结点形成堆为止
-
输出堆顶元素,重新构建堆,重复这一过程
void BuildMaxHead(ElemType A[],int len){
for(int i=len/2;i>0;i--)
HeadAdjust(A,i,len);
}
void HeadAdjust(ElemType A[],int k,int len){
A[0] = A[k];
for(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];
}
void HeapSort(ElemType A[],int len){
BuildMAxHeap(A,len);
for(i=len;i>1;i--){
Swap(A[i],A[1]);
HeadAdjust(A,1,i-1);
}
}
归并排序和基数排序
归并排序
假定待排序表含有n个记录,则可将其视为n个有序的子表,每个子表的长度为1,然后两两合并,得到
?
n
/
2
?
\lceil n/2 \rceil
?n/2?个长度为2或1的有序表,继续两两合并。这种排序方法称为2路归并排序。
一趟归并排序的操作是,调用
?
n
/
2
h
?
\lceil n/2h \rceil
?n/2h?次算法merge() ,将L[1...n] 中前后相邻且长度为h的有序段进行两两归并,得到前后相邻、长度为2h的有序段进行两两归并,得到前后相邻、长度为2h的有序段,整个归并排序需要进行
?
l
o
g
2
n
?
\lceil log_2n\rceil
?log2?n?趟
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(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);
}
}
基数排序
- 最高位优先法
MSD :将关键字位权重递减一次逐层划分成若干更小的子序列,最后将所有子序列依次连接成一个有序序列 - 最低位优先法
LSD :将关键字权重递增一次进行排序,最后形成一个有序序列
排序过程:
-
在排序中,使用r个队列
Q
0
,
Q
1
,
.
.
.
,
Q
r
?
1
Q_0,Q_1,...,Q_{r-1}
Q0?,Q1?,...,Qr?1? -
对
i
=
0
,
1
,
.
.
.
,
d
?
1
i=0,1,...,d-1
i=0,1,...,d?1,依次做一次分配和收集,每个关键字结点
a
j
a_j
aj?由d元组组成 -
分配:开始时,把
Q
0
,
Q
1
,
.
.
.
,
Q
r
?
1
Q_0,Q_1,...,Q_{r-1}
Q0?,Q1?,...,Qr?1?各个队列置成空队列,然后依次考察线性表中的每个结点
a
j
a_j
aj?,若
a
j
a_j
aj?的关键字
k
j
i
=
k
k_j^i=k
kji?=k,就把
a
j
a_j
aj?放进
Q
k
Q_k
Qk?队列中 -
收集:把
Q
0
,
Q
1
,
.
.
.
,
Q
r
?
1
Q_0,Q_1,...,Q_{r-1}
Q0?,Q1?,...,Qr?1?各个队列中的结点依次首尾相连,得到新的结点序列,从而组成新的线性表
各种内部排序算法比较及应用
内部排序算法的比较
算法种类 | 时间复杂度-最好 | 时间复杂度-平均 | 时间复杂度-最坏 | 空间复杂度 | 是否稳定 |
---|
直接插入排序 |
O
(
n
)
O(n)
O(n) |
O
(
n
2
)
O(n^2)
O(n2) |
O
(
n
2
)
O(n^2)
O(n2) |
O
(
1
)
O(1)
O(1) | 是 | 冒泡排序 |
O
(
n
)
O(n)
O(n) |
O
(
n
2
)
O(n^2)
O(n2) |
O
(
n
2
)
O(n^2)
O(n2) |
O
(
1
)
O(1)
O(1) | 是 | 简单选择排序 |
O
(
n
2
)
O(n^2)
O(n2) |
O
(
n
2
)
O(n^2)
O(n2) |
O
(
n
2
)
O(n^2)
O(n2) |
O
(
1
)
O(1)
O(1) | 否 | 希尔排序 | | | |
O
(
1
)
O(1)
O(1) | 否 | 快速排序 |
O
(
n
log
?
2
n
)
O(n\log_2n)
O(nlog2?n) |
O
(
n
log
?
2
n
)
O(n\log_2n)
O(nlog2?n) |
O
(
n
2
)
O(n^2)
O(n2) |
O
(
log
?
2
n
)
O(\log_2n)
O(log2?n) | 否 | 堆排序 |
O
(
n
log
?
2
n
)
O(n\log_2n)
O(nlog2?n) |
O
(
n
log
?
2
n
)
O(n\log_2n)
O(nlog2?n) |
O
(
n
log
?
2
n
)
O(n\log_2n)
O(nlog2?n) |
O
(
1
)
O(1)
O(1) | 否 | 2路归并排序 |
O
(
n
log
?
2
n
)
O(n\log_2n)
O(nlog2?n) |
O
(
n
log
?
2
n
)
O(n\log_2n)
O(nlog2?n) |
O
(
n
log
?
2
n
)
O(n\log_2n)
O(nlog2?n) |
O
(
n
)
O(n)
O(n) | 是 | 基数排序 |
O
(
d
(
n
+
r
)
)
O(d(n+r))
O(d(n+r)) |
O
(
d
(
n
+
r
)
)
O(d(n+r))
O(d(n+r)) |
O
(
d
(
n
+
r
)
)
O(d(n+r))
O(d(n+r)) |
O
(
r
)
O(r)
O(r) | 是 |
应用
选取排序方法需要考虑的因素
- 待排序的元素数目n:较小考虑直接插入和简单选择排序,较大考虑快排】堆排序、归并排序
- 元素本身信息量的大小:是否选取移动量较少的排序方法
- 关键字的结构及其分布情况:如已经有序,则选取直接插入或冒泡排序
- 稳定性的要求
- 语言工具的要求,存储结构及辅助空间的大小等
外部排序
- 外部排序指待排序文件较大,内存一次放不下,需存放在外存的文件的排序
- 为减少平衡归并中外存读写次数所采取的方法:增大归并路数和减少归并段个数
- 利用败者树增大归并路数
- 利用置换-选择排序增大归并段长度来减少归并段的个数
外部排序的基本概念
在许多应用中,经常需要对大文件进行排序,因为文件中的记录很多、信息量庞大,无法将整个文件复制进内存中进行排序。因此,需要将待排序的记录存储在外存之上,排序时再把数据一部分一部分地调进内存进行排序,在排序过程中需要多次进行内存和外存之间的交换。这种排序方法就称为外部排序
外部排序的方法
- 根据内存缓冲区的大小,将外存上的文件分成若干长度为
l
l
l的子文件,依次读入内存并利用内部排序方法对它们进行排序,并将排序后得到的有序子文件重新写回外存,称这些有序子文件称为归并段或顺串
- 对这些归并段进行逐趟归并,使归并段逐渐由小到大,直至得到整个有序文件为止
外部排序的总时间=内部排序所需时间+外存信息读写的时间+内部归并所需的时间 - 在进行归并的时候,需要使用输入缓冲区和输出缓冲区,在内存和外存中传输数据
- 对
r 个初始段归并,做k 路平衡归并,归并树可用严格k叉树来表示,树的高度=
?
log
?
k
r
?
\lceil \log_kr\rceil
?logk?r?=归并趟数S
多路平衡归并与败者树
-
做内部归并时,在k个元素中选择关键字最小的记录需要比较k-1次,S趟归并总需的比较次数是
S
(
n
?
1
)
(
k
?
1
)
=
?
log
?
k
r
?
(
n
?
1
)
(
k
?
1
)
=
?
log
?
2
r
?
(
n
?
1
)
(
k
?
1
)
?
log
?
2
k
?
S(n-1)(k-1)=\lceil \log_kr\rceil(n-1)(k-1)=\lceil \log_2r \rceil (n-1)(k-1)\lceil \log_2k \rceil
S(n?1)(k?1)=?logk?r?(n?1)(k?1)=?log2?r?(n?1)(k?1)?log2?k? -
引入败者树后,在k个元素中选择关键字最小的记录需要比较
?
log
?
2
k
?
\lceil \log_2k \rceil
?log2?k?次,内部归并的比较次数与k无关。因此只要内存允许,增大归并路数k将有效减少归并树的高度,提高外部排序饿的速度 -
败者树
- k个叶结点分别存放k个归并段在归并过程中当前参加比较的记录
- 内存结点用来记忆左右子树中的失败者,而让胜者往上继续比较,一直到根结点
- 根结点记录胜者
- 叶结点进行编号
b
0
~
b
k
b0 \sim bk
b0~bk,内存结点编号
l
s
[
0
]
~
l
s
[
k
]
ls[0] \sim ls[k]
ls[0]~ls[k],
l
s
[
0
]
ls[0]
ls[0]为根结点
置换-选择排序(生成初始归并段)
初始待排文件FI ,初始归并段输出文件为FO ,内存工作区为WA ,FO 与WA 的初始状态为空,WA 可容纳
w
w
w个记录
- 从
FI 输入w个记录到工作区WA - 从
WA 中选出其中关键字取最小值的记录,记为MINIMAX - 将
MINIMAX 就输出到FO 中去 - 若
FI 不为空,则从FI输入下一个记录到WA 中 - 从
WA 中鄋关键字比MINIMAX 记录的关键字大的记录中选出最小关键字记录,作为新的MINIMAX - 重复
3-5 ,直至在WA 中选不出新的MINIMAX 记录为止,由此得到一个初始归并段,输出一个归并段的结束标志至FO 中去 - 重复
2-6 ,直至WA 为空,由此得到全部初始归并段
最佳归并树
把归并段的长度作为权值,进行严格k叉树的哈夫曼树思想,构造最佳归并树
-
(
n
0
?
1
)
%
(
k
?
1
)
=
0
(n_0-1)\%(k-1)=0
(n0??1)%(k?1)=0,不需要添加
-
(
n
0
?
1
)
%
(
k
?
1
)
=
u
≠
0
(n_0-1)\%(k-1)=u\neq0
(n0??1)%(k?1)=u?=0,需要添加
k
?
1
?
u
k-1-u
k?1?u个长度为0的虚段
|