数据结构(data structure)的简单解释:相互之间存在一种或多种特定关系的数据元素(data element)的集合。
首先计算机的应用场景是解决实际问题,步骤为:
其中,寻找数学模型的实质是:
数据(data):所有能输入到计算机中并被计算机程序处理的符号的总称。
数据元素(data element):数据的基本单位。
数据项(data item):是数据不可分割的最小单位。一个 data element 可由多个 data item 组成。
数据对象(data object):性质相同的 data element 集合。
数据类型:一个值的集合和定义在这个值集上的一组操作的总称。
C语言中,数据类型可以分为两类:
抽象:抽取出事物具有的普遍性的本质。抽取特征忽略细节,是对具体事物的概括。
对已有的数据类型进行抽象,就有了抽象数据类型 。
抽象数据类型(Abstract Data Type,ADT):一个数学模型及定义在该模型上的一组操作。
第一章介绍了一些术语的概念,例如:data、data element、data item、data subject。
并介绍了数据结构分为两大类:逻辑结构和物理结构,向下又可以更细的划分类型。
算法(algorithm):是对特定问题求解步骤的一种描述,它是指令的有限序列,其中每一条指令代表一个或多个操作。
好的算法有以下要求 :
事后统计方法:设计好程序后,利用计算机计时器对不同算法编制的程序的运行时间进行比较。
这种方法有很大缺陷:
事前分析估算方法:在程序编制前,依据统计方法对算法进行估算。
抛开与计算机软、硬件相关的因素,一个程序的运行时间依赖于算法的好坏和问题的输入规模。
测定运行时间最可靠的方法就是计算对运行时间有消耗的基本操作的执行次数。
判断一个算法的效率时,函数中的常数和其他次要项常常可以忽略,而更因该关注最高阶项的次数。
时间复杂度随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,记作:
T(n)= O(f(n))
随着n的增大,T(n)增长最慢的算法为最优算法。
推导大O阶的方法:
O(1) < O(logn) < O(n) < O(nlogn) < O(n2) < O(n3) < O(2n) < O(n!) < O(nn)
一般没有特殊说明的情况下,时间复杂度指最坏时间复杂度。
算法执行所需辅助空间相对于输入数据量而言如果是常数,则S(n)=O(1)。
线性表(linear list):零个或多个 data element 的有限序列。
线性表的逻辑结构是线性结构
ADT 线性表(List)
Data
线性表的数据对象集合为{a1,a2,……,an},每个元素的类型为DataType,除第一个元素,每个元素都有一个直接前驱元素,除最后一个元素外,每个元素都有一个直接后继元素。data element 之间的关系是一对一的。
Operation
InitList(*L):初始化操作,建立一个空的线性表L。
ListEmpty(L):若线性表为空,返回true,否则返回false。
ClearList(*L):将线性表清空。
GetElem(L,i,*e):将线性表L中第i个位置元素返回给e。
LocateElem(L,e):在线性表L中查找与给定值e相等的元素,如果查找成功,返回该元素在表中序号表示成功,否则返回0表示失败。
ListInsert(*L,i,e):在线性表L中第i个位置插入新元素e。
ListDelete(*L,i,*e):删除L中第i个位置的元素,并用e返回其值。
ListLength(L):返回L的元素个数。
endADT
定义:用一段地址连续的存储单元依次存储线性表的数据元素。
#define MAXSIZE 20
typrdef int ElemType;
typredef struct
{
ElemType data[MAXSIZE];
int length;
}SqList;
可以用C语言的一维数组来实现顺序存储结构,数组的存取时间复杂度为O(1),把具有这一特点的存储结构称为随机存取结构。
#define OK 1
#define ERROR 0
#define TRUE 1
#define FALESE 0
typrdef int Status;
Status GetElem (SqList L; int i ; ElemType *e)
{
if(L.length == o || i < 1 || i > L.length)
return ERROR;
*e = L.data[i-1];
return OK;
}
Status ListInsert(SqList *L,int i ,ElemType e)
{
int k;
if(L->length == MAXSIZE)
return ERROR;
if(i<1 || i>L->length+1)
return ERROR;
if(i <= L->length) //注意要判断插入数据位置是否在表尾。
{
for(k = L->length-1; k >= i-1; k--)
{
L->data[k+1] = L->data[k];
}
}
L-.data[i-1] = e; //
L->length++; //notice
return OK;
}
Status ListDelete(SqList *L, int i , ElemType e)
{
int k;
if(L->length == 0)
return ERROR;
if(i < 1 || i > L->length)
return ERROR;
if(i < L->length)
{
for(k = i; k < L->length; k++)
L->data[k-1] = L->data[k];
}
L->length --;
return OK;
}
优点:
缺点:
每个 element 除了存储数据信息外(数据域),还需存储其直接后继的位置信息(指针域)。这两部分信息组成 data element 的存储映像,称为结点(Node)。
头指针:存储链表中第一个 node 的地址。
头结点:单链表第一个 node 前附设一个 node,头结点数据域通常不存储信息,也可以存链表的长度等附加信息。
typedef struct Node
{
ElemType data;
struct Node * next;
}Node;
typedef struct Node * LinkList;
Status GetElem( LinkLst L, int i , ElemTypre * e)
{
int j;
LinkList p;
p = L->next;
j = 1;
while(p && j < i)
{
p = p->next;
j++;
}
if(!p || j>i)
return ERROR;
*e = p->data;
return OK;
}
Status ListInsert(LinkList * L, int i , ElemType e)
{
int j ;
LinkList p,s;
p = *L;
j = 1;
while(p && jnext;
j++;
}
if(!p || j>i)
return ERROR;
s = (LinkList) malloc ( sizeof(Node) );
s->data = e;
s->next = p->next;
p->next = s;
return OK;
}
Status ListInsert(LinkList L, int i , ElemType * e)
{
int j ;
LinkList p,q;
p = *L;
j = 1;
while(p && jnext;
j++;
}
if(!p || j>i)
return ERROR;
s = (LinkList) malloc ( sizeof(Node) );
s->data = e;
s->next = p->next;
p->next = s;
return OK;
}
void CreatListHead(LinkList *L,int n)
{
LinkList p;
int i;
srand( time(0) );
*L = (LinkList)malloc(sizeof(Node));
(*L)->next = NULL;
for(i= 0;i < n;i++)
{
p = (LinkList)malloc(sizeof(Node));
p->data = rand()%100+1;//随机生成100以内的数;
p-next = (*L)->next;
(*L)->next = p;
}
}
尾插法:
void CreatListTail(LinkList *L,int n)
{
LinkList p,r;
int i;
srand(time(0));
*L = (LinkList)malloc(sizeof(Node));
r = *L;
for(i = 0;i < n;i++)
{
p = (Node *)malloc(sizeof(Node));
p->data = rand()%100+1;
r->next = p;
r = p;
}
r->next = NULL;
}
Status ClearList(LinkList *L)
{
LinkLIst p,q;
p = (*L)->next;
while(p)
{
q = p->next;
free(p);
p = q;
}
(*L)->next = NULL;
return OK;
}
静态链表是为了没有指针的高级语言设计的一种实现单链表能力的方法,这种思考方式颇为巧妙,理解其思想,以备不时之需。
循环链表的定义:将单链表中终端结点的指针端由空指针改为指向头结点。整个链表首尾相接,形成了一个“环”。
循环链表若想访问尾结点,时间复杂度为O(n),当对链表进行改造后,增加一个尾指针(LinkList rear),则访问尾结点时间复杂度为O(1),如此访问存放首个 element 的结点时间复杂度也为O(1)。
合并两个循环链表的操作:
链表1的尾指针为 rearA
链表2的尾指针为 rearB
p = rearA->next;
rearA->next = rearB->next->next;
q = rearB->next;
rearB->next = p;
free(q);
定义:双向链表(double linked list)是在单链表的每个结点中,在设置一个指向其前驱结点的指针域。
typedef struct DulNode
{
ElementType data;
struct DulNode * prior;
suruct DulNode * next;
}DulNode, * DuLinkList;
双向链表部分操作和单链表相同,例如 ListLength,GetElem,LocateElem等,而插入、删除操作需要更改两个指针变量。
插入操作(在 p 结点后插入 s 结点);
s->prior = p;
s-> next = p->next;
p->next = s;
s->next->prior = s;
删除操作(删除 p 结点):
p->prior = p->next;
p->next->prior = p->prior;
free(p);
线性表分顺序存储、链式存储,其中链式存储又可以细分为单链表、静态链表、循环链表、双向链表。
掌握链表的定义、初始化、插入、删除等操作。
栈:限定仅在表尾进行插入或删除操作的线性表。
队列:只允许在一端进行插入操作,另一端进行删除操作的线性表。
栈顶(Top):允许插入和删除操作的一端。
栈底(Bottom):另一端。
空栈:不含任何数据元素的栈。
栈又称为后进先出(Last In First Out)结构,LIFO结构。
栈是一个线性表,栈元素具有线性关系。
栈元素的插入又称入栈,元素的删除又称出栈
栈的出战次序要保证始终是栈顶元素出栈。
ADT 栈(stack)
Data
同线性表。元素具有相同类型,相邻元素具有前驱和后继关系。
Opration
InitStack(*S):初始化操作,建了一个空栈。
DestoryStack(*S):若栈存在,则销毁它。
ClearStack(*S):将栈清空。
StackEmpty(S):若栈为空,返回true,否则返回false。
GetTop(S,*e):若栈S存在且非空,用e返回S的栈顶元素。
Push(*s,e):若栈S存在,插入新元素e到栈S中并成为栈顶元素。
Pop(*S,*e):删除栈S中的栈顶元素,并用e返回其值。
StackLength(S):返回栈S的元素个数。
endADT
结构定义:
typedef int SElemType;
typedef struct
{
SElemType data[MAXSIZE];
int top;
}SqStack;
空栈:top = -1;
站内有元素:1 <= top <= MAXSIZE-1;
Status Push(SqStack * S,SELemType e)
{
if(S->top == MAXSIZE-1)
return ERROR;
S->top++;
S->data[S->top] = e;
return OK;
}
Status Pop(SqStack *S,SElemType)
{
if(S->top == -1)
return ERROR;
*e = S->data[S->top];
S->top --;
return OK;
}
结构代码
typrdef struct
{
SElemType data[MAXSIZE];
int top1;
int top2;
}SqDoubleStack;
两个相同类型的栈共存在一个数组中,一个栈的 Bottom 在数组头,另一个的 Bottom 在数组尾,栈顶都向数组中间靠拢,当 top1 + 1 == top2 时,栈满。top1=-1&&top2=MAXSIZE
时,为空栈。
Push操作代码:
Status Push (SqDouble *S,SelemType e,int StackNumber)
{
if(s->top1 + 1 ==S->top2)
return ERROR;
if(StackNumber == 1)
S->data[++S->top1] = e;
else if(StackNumber == 2)
S->data[--S->top2] = e;
else
return ERROR;
return OK;
}
Pop操作代码:
Status Pop(SqDoubleStack * S,SElemType * e, int StackNumber)
{
if(StackNumber == 1)
{
if(S->top1 = -1)
return ERROR;
*e = S->data[S->top1--];
}
else if(StackNumber == 2)
{
if(S->top2 == MAXSIZE)
return ERROR;
*e = S->data[s->top2++];
}
}
这样的数据结构的应用场景:两个栈对空间的需求有相反关系。
链式存储结构需要栈顶指针,通常和头指针合二为一,同时不需要头结点了。
typedef struct StackNode
{
SElemType data;
struct StackNode * next;
}StackNode,*LinkStackPtr;
typedef struct LinkStack
{
LInkSTtackPtr top;
int count;
}LinkStack;
Status Push(LinkStack * S,SElemType e)
{
LinkStackPtr s = (LinkStackPtr)malloc(sizeof(StackNode));
s->data = e;
s->next = S->top;
S->top = s;
S->count--;
return OK;
}
Status Pop(LinkStack * S.SElemType * e)
{
LinkStackPtr p;
if(StackEmpty(*S))
return ERROR;
*e = S->top->data;
p = S->top;
S->top = S->top->next;
free(p);
S->count --;
return OK;
}
递归函数:直接调用自己或通过一系列的调用语句间接地调用自己的函数。
每个递归必须得有终止条件,即不在引用自身而是返回值推出。
递归的优点:能使程序的结构更清晰简洁,增强可读性。
缺点:量的递归调用会建立函数的副本,会耗费大量的时间和内存。
定义:所有的符号都在要运算的数字的后面出现。
规则:从左到右遍历表达式的每个数字和符号,遇到数字就进栈,遇到是符号,就将处于栈顶两个数字出栈,进行运算,一直到最终获得计算结果。
9 3 1 - 3 * + 10 2 / + 结果是20。
规则:从左到右遍历,遇到数字输出,需要第一个符号进栈,后续符号的优先级若小于等于前一个符号,则输出前符号,后符号进栈,直至优先级大于前一个符号。遇到右括号,则依次输出符号至左括号。
队列(Queue)是只允许在一端进行插入操作,另一端进行删除操作的线性表。
是一种先进先出(First In First Out)的线性表,简称FIFO结构。
队尾:允许插入的一端。
队头:允许删除的一端。
ADT Queue
Data
同线性表,元素具有相同的性质,相邻元素具有前驱和后继关系。
Operation
InitQueue(*Q):初始化操作,建立一个空队列Q。
DestoryQueue(*Q):若队列Q存在,则销毁它。
ClearQueue(*Q):将队列Q清空。
QueueEmpty(Q):若队列Q为空,返回TRUE,否则返回FALSE。
GetHead(Q,*e):若队列Q存在且非空,用e返回队列Q的队头元素。
EnQueue(*Q,e):若队列Q存在,插入新元素e到队列Q中并成为队尾元素。
DeQueue(*Q,*e):删除队列Q的队头元素,并用e返回其值。
QueueLength(Q):返回队列Q的元素个数。
endADT
引入两个指针,front 指向队头元素,rear 指向队尾元素的下一个位置,当 front == rear 时,队列为空队列。
执行 n 次出队操作后,front 一直后移,队头元素前变成空闲空间了,但之后进队操作就可能产生数组越界的错误,这种现象称为“假溢出”。
头尾相接的顺序存储结构。
队列状态判断条件:
计算队列长度公式:(rear-front+QueueSize)%QueueSize
循环队列顺序存储结构代码:
typedef int QElemType;
typedef struct
{
QElemType data[MAXSIZE];
int front;
int rear;
}SqQueue;
循环队列的初始化代码:
Status InitQueue(SqQueue * Q)
{
Q->front = 0;
Q->rear = 0;
return OK;
}
循环队列求队列长度代码:
int QueueLength(SqQueue Q)
{
return (Q.rear-Q.front+MAXSIZE)%MAXSIZE;
}
循环队列的入队操作代码:
Status EnQueue(SqQueue *Q,QElemType e)
{
if( (Q->rear+1)%MAXSIZE == Q->front )
return ERROR;
Q->data[Q->rear] = e;
Q->rear = (Q->rear+1)%MAXSIZE;
return OK;
}
循环队列的出队操作代码:
Status DeQueue(SqQueue *Q,QElemType *e)
{
if(Q->front == Q->rear)
return ERROR;
*e = Q->data[Q->front];
Q->front = (Q->front+1)%MAXSIZE;
return OK;
}
对头指针指向链队列的头结点,队尾指针指向终端结点。
空队列:front 和 rear 都指向头结点。
结构代码:
typedef int QElemType;
typedef struct QNode
{
QElemType data;
struct QNode * next;
}QNode,*QueuePtr;
typedef struct
{
QueuePtr front,rear;
}LInkQueue;
Status EnQueue(LinkQueue * Q,QElemType e)
{
QueuePtr s = (QueuePtr)maaloc(sizeof(QNode));
if(!s)
exit(OVERFLOW);//是否有必要
s->data = e;
s->next = NULL;
Q->rear->next = s;
Q->rear = s;
return OK;
}
Status DeQueue(LinkQueue * Q,QElemType *e)
{
QueuePtr p;
if(Q->front == Q->rear)
returf ERROR;
p = Q->front->next;
*e = p->data;
Q->front->next = p->next;
if(Q->rear == p)
Q->rear = Q->front;
free(p);
reutnr OK;
}
串(string)是由零个或多个字符组成的有限序列,又称字符串。
一般记为 s = "a1 a2 a3 ……an ",注意:
空格串:只包含空格的串,有长度有内容,区别于空串。
字串与主串:串中任意个数的连续字符组成的子序列称为该串的子串,包含子串的串称为主串。
子串在主串的位置就是子串的第一个字符在主串中的序号(1 ≤ i ≤ n)。
定义:给定两个串,s = “a1 a2 a3 ……an”,t = “b1 b2 b3 ……bm”,当满足以下条件之一,s<t:
串的逻辑结构和线性表类似,线性表关注的是单个元素的操作,串更多关注的是查找子串位置、得到指定位置子串、替换字串等操作
ADT string
Data
串中元素因有一个字符组成,相邻元素具有前驱和后记关系。
Operation
StrAssign(T,*chars):生成一个其值等于字符串常量chars的串T。
StrCopy(T,S):串S存在,由串S复制得串T。
ClearString(S):若S存在,将串清空。
StringEmpty(S):若串S为空,返回true,否则返回false。
StrLength(S):返回串元素个数,即串的长度。
StrCompare(S,T):若S>T,返回值>0,若S=T,返回值=0,若S<T,返回值<0.
Concat(T,S1,S2):用T返回 S1 和S2 联接而成的新串。
SunString(Sun,S,pos,len):串S存在,1 ≤ pos ≤ StrLength(S),且0< len <StrLength(S)-pos+1,用 sub 返回串 S 的第 pos 个字符起长度为 len 的子串。
Index(S,T,pos):串S和T存在,T非空,1≤ pos ≤StrLength(S)。若主串S中存在和串T相等的子串,则返回它在主串中pos个字符后第一次出现的位置,否则返回0.
Replace(S,T,V):串S、T、V存在,T非空,用V替换主串S中出现的所有和T相等的不重叠的子串
StrInsert(S,pos,T):串S和T存在,1≤pos≤StrLength(S)+1。在串S的第pos个字符前插入串T。
StrDelete(S,pos,len):串S存在,1≤pos≤StrLength-len+1.从串S中删除第pos个字符起长度为len的子串。
endADT
Index算法实现:
int Index(String S,String T,int pos)
{
int n,m,i;
String sub;
if(pos >0)
{
n = StrLength(S);
m = StrLength(T);
i = pos;
while(i <= n-m+1)
{
SubString(sub,S,i,m);
if(StrCompare(sub,T) != 0)
++i;
else
return i;
}
}
return 0;
}
顺序存储这种方式可能会使串丢失元素。
串结构每个元素是一个字符,如果每个结点存放一个元素会造成浪费,因此一个结点可以存放多个字符,最后一个结点若是未被占满,可以用“#”或其他非串值字符补全。
链式存储除了在连接串和串操作时有一定方便之处,总的来说不如顺序存储灵活,性能也不如顺序存储结构好。
【算法暂时有点懵懵的,只能看懂代码,跳过去】
int Index(String S,String T,int pos)
{
int i = pos;
int i = 1;
while (i<=S[0] && j<=T[0])
{
if(S[i] == T[i])
{
++i;
++j;
}
else
{
i = i-j+2;
j = 1;
}
}
if(j > T[0])
return i-T[0];
else
return 0;
}
时间复杂度较高
【最坏时间复杂度为O((n-m+1)*m)】
三位算法发明者,克努特-莫里斯-普拉特算法,简称KMP算法。
例子1:
主串 T=“abcdef”,附串 S=“abcx”,以朴素算法匹配时:
事实上,用KMP模式,可以省去2、3步,【以下是个人理解部分,如果发现有缺陷及时改正】在第1步时,机器已知 S 中 a ≠ b/c,而 T 中b、c一一对应,则知 S ≠ T 中的 b/c,于是跳过2、3步。
例子2
主串 T=“abcabcd”,附串 S=“abcabx”,以KM算法匹配时:
例子3
主串T=“abcd”,附串S=“aaa”【?】:如果只是KMP模式算法,会拿b匹配三个a的,这种冗余的操作再KMP模式匹配算法改进后会优化。
第三步的跳过,是因为,机器对S知根知底,知道4、5号位的字符与1、2号位一致,而第一步匹配结果可知“abcab”对应,因此得知T的4、5号位也是a和b。但只知道T的6号位≠x,而S中的x≠c,所以不确定T的6号位是否=c,因此需要匹配
通过这两个例子,可以发现主串当前位置的下标(i)不像朴素算法一样一直回溯。
j值的变化和T有关,j = 当前字符位置前的前后缀(我原先理解为字串,事实上是前后缀!)相似字符个数+1。
例子1
中S的当前位置是4,x之前没有相似的子串,j = 1;
例子2
中S的当前位置是6,x之前相似字串为“ab”,j=3。
【i、j始终是串的位置指标!!!妈的,差点被这一串文字搞蒙】
树(Tree):树是n个结点的有限集。n=0时称为空树。在任意一颗非空树中:(1)有且仅有一个特定的根(Root)的结点;(2)当n>1时,其余结点可分为m(m>0)个互不相交的有限集T1、T2、……、Tn,其中每一个集合本身又是一棵树,并且称为根的子树(SunTree)。
树的结点包含一个数据元素和若干指向其子树的分支。结点拥有的子树称为结点的度(Degree)。
度为0的结点称为叶结点(Leaf)或终端结点。
度不为0的结点称为非终端结点或分支结点。除根结点外的分支结点称为内部结点。
数的Degree是各结点的度的最大值。
结点的子树的根称为该结点的孩子(child);
相反,该结点称为孩子的双亲(parent);
同一个双亲的孩子互称兄弟(sibling);
结点的祖先是从根节点到该结点所经分支上所有的结点。
以某结点为根的子树中的任一结点都称为该结点的子孙。
结点的层次从根开始定义,根在第一层,根的孩子在第二层。
双亲在同一层的结点互为堂兄弟。
树中结点的最大层次称为树的深度(deep)或高度。
有序树:
无序树:
森林:m(m≥0)棵互不相交的树。
ADT 树
Data
树是由一个根结点和若干棵子树构成。树中结点具有相同数据类型及层次关系。
Peration
InitTree(*T)
DestoyrTree(*T)
CreatTree(*T,definition)
ClearTree(*T)
TreeEmpty(T)
TreeDepth(T)
Root(T)
vALUE(t,cur_e)
Assign(T,cur_e,value)
Parent(T,cur_e)
LeftChild(T,cur_e)
RightSibling(T,cur_e)
InsertChild(*T,*p,i,c)
DeleteChild(*T,*p,i)
endADT
二叉树(Binary Tree)是n(n≥0)个结点的有限集合,该集合或者为空集(称为空二叉树),或者由一个根结点和两棵互不相交的、分别成为根结点的左子树和右子树的二叉树组成。
二叉树的特点有:每个结点最多有两棵子树,所以二叉树不存在Degree大于2的结点。
左子树和右子树是有顺序的,次序不能颠倒。
即使某结点只有一棵子树,也要区分它是左子树还是右子树。
一、斜树
所有结点都只有左子树的二叉树叫左斜树,所有结点只有右子树的二叉树叫右斜树。
斜树很明显的特点:结点个数=深度。
二、满二叉树
特点
三、完全二叉树
对一棵具有n个结点的二叉树按层序变好,如果编号为i的i(1≤i≤n)的结点与同样深度的满二叉树编号为i的结点在二叉树中位置完全相同,则这棵二叉树称为完全二叉树。
特点:
性质1
在二叉树的第 i 层上至多有2i-1个结点(i≥1)。
性质2
深度为k的二叉树至多有2k-1个结点(k≥1)。
性质3
对任意一棵二叉树T,其终端结点数为n0,度为2的结点数为n2 ,则n0=n2+1。
推导过程:
以二叉树的连线数作为等式等价条件
自下而上:连线树=N-1(N为结点总数)
自上而下:连线树=2 × n2 + n1(n1为度=1的结点个数)
而N = n0+n1+n2
性质4
具有n个结点的完全二叉树的深度为[log2n]+1( [x]代表≤x的最大整数。)
性质5
如果对一颗有n的结点的完全二叉树(其深度为[log2n]+1)的结点按层序编号(从第一层到[log2n]+1层,每层从左到右),对任一结点 i(0≤i≤n)有:
二叉树的顺序存储结构适用一维数组存储结点。
不存在的结点设置为“^”。
考虑极端情况,深度为k的树只有k个结点,却需要分配2k-1个存储单元空间,因此顺序存储结构一般用于完全二叉树。
因为二叉树最多有两个孩子,因此为它设计一个数据域、两个指针域。
typedef struct BiTNode
{
TElemType data;
struct BiTNode *lchild,*rchild;
}BiTNode,*BiTree;
如果有需要,可以增加一个指向双亲的指针域,如此一来就成了三叉链表。
二叉树的遍历:从根结点出发,按照某种次序访问树中每个结点,使每个结点被访问且仅被访问一次。
树的遍历次序有别于线性结构,树没有唯一的前驱或后继关系,在访问一个结点后,下一个被访问的结点面临着不同的选择。
四种遍历方法,都是在把树中的结点变成某种意义的线性序列
一、前序遍历
规则:若二叉树为空,则空操作返回。否则先访问根结点,然后前序遍历左子树,再前序遍历右子树。
二、中序遍历
规则:若二叉树为空,则空操作返回。否则从根结点开始(不是访问root)先中序遍历左子树,再访问根结点,最后中序遍历右子树。
三、后序遍历
规则:若二叉树为空,则空操作返回。否则从根结开始(非访问)先后序遍历左子树,再后序遍历右子树,最后访问根结点。
四、层序遍历
规则:若树为空,则空操作返回,否则从树的第一层(根结点)开始访问,从上到下逐层遍历,同一层中,按从左到右的顺序对结点逐个访问。
void PreOrderTraverse(BiTree T)
{
if(T==NULL)
return;
printf("%c",T->data);
PreOrderTraverse(T->lchild);
PreOrderTraverse(T->rchilr);
}
void InOrderTraverse(BiTree T)
{
if(T==NULL)
return;
InOrderTraverse(T->lchild);
printf("%d",T->data);
InOrderTraverse(T->rchild);
}
void PostOrderTraverse(BiTree T)
{
if(T==NULL)
return;
PostOrderTraverse(T->lchild);
PostOrderTraverse(T->rchild);
printf("%c",T->data;);
}
问题:已知中序遍历和前/后序遍历,算后/前序遍历。
前提条件:必须已知中序遍历,确定哪些结点是左子树的和右子树的。
第一步:建立前需要把二叉树变成拓展二叉树。
拓展二叉树:为了能让每个结点确认是否有左右孩子,对原二叉树进行拓展,补充每个结点的左右孩子,变为拓展二叉树,拓展出的结点为虚结点,其值为“#”。
第二步:把拟创建的树的前序遍历用键盘挨个输入。(例如AB#D##C##)
第三步:实现算法。
void CreatBiTree(BiTree * T)
{
TElemType ch;
scanf("%c",&ch);
if(ch == '#')
*T=NULL;
else
{
*T=(BiTree)malloc(sizeof(BiTNode));
if(!*T)
exit(OVERLOW);
(*T)->data = ch;
CreatBiTree(& (*T)->lchild);
CreatBiTree(& (*T)->rchild;
}
}
代码和前序遍历的代码相像,只是在打印结点的地方,改成了生成结点。
中序遍历/后序遍历【代码我需要完整的!自己想没想出来。】
例如中序遍历得到每个结点的前驱、后继
指向前驱和后继的指针称为线索。
加上线索的二叉链表为线索链表,相应的二叉树就称为线索二叉树。
把空闲的指针域指向前驱和后继?
线索化:对二叉树以某种次序遍历使其变为线索二叉树的过程。
意义:把一棵二叉树变成一个双向链表。
【并没有变成双向链表啊】
【线索二叉树前驱后继会因为遍历方式不同而不同?】
【】
目前还有一个问题:lchild可能是左孩子或者前驱、rchild可能是有孩子或后继,所以要引入两个标志域:ltag和rtag,这两个标志域只存放0和1。
ltag==0,代表左孩子;1,代表前驱。
rtag0,代表右孩子;==1,代表后继。
typedef enum {Link,Tread} PointerTag;
typedef struct BiThrNode
{
TElemType data;
struct BiThrNode * lchild, * rchild;
PointerTag LTag,RTag;
}
中序遍历线索化的代码实现:
BiThrTree pre;//全局变量,始终指向刚刚访问过的结点。
void InThreading(BiThrTree p)
{
if(p)
{
InThreading(p->lchild);
if(!p->lchild)
{
p->LTag = Thread;
p->lchild = pre;
}
if(!pre->rchild)
{
pre->RTag = Thread;
pre->rchild = p;
}
pre = p;
InThrTree(p->rchild);
}
}
【代码有点晕,两个判断针对了p和pre!】
双向链表通常会加一个头结点。作用:方便从头开始遍历,或者从最后一个结点遍历。
遍历的代码:
Status InOrderTraverser_Thr(BiThrTree T)
{
BiThrTree p;
p = T->lchild;
while(p != T)
{
while(p->LTag == Link)
{
p = p->lchild;
}
printf("%c",p->data );
while(p->RTag == Thread && p->rchild != T)
{
p = p->rchild;
printf("%c",p->data);
}
p = p->rchild;
}
return OK;
}
步骤:
步骤:
步骤:
判断条件:
根结点有右子树的话。
步骤:
【一定要转成普通树吗?】
树的遍历:
森林的遍历:
3. 第一种:前序遍历依次对每棵树进行先根遍历。
4. 第二种:后序遍历依次对每棵树进行后根遍历。
森林树的前序遍历和二叉树的前序遍历结果相同,森林的后序遍历和二叉树的后序遍历结果相同。
启示:对树/森林进行遍历可以用二叉树遍历来替代。
路径长度:从根结点到目的结点的距离,相邻两个结点距离为1。
树的路径长度:根到每个结点的路径长度之和。
带权路径长度:路径长度×结点的带权
赫夫曼树:带权路径长度WPL最小的二叉树。(又名最优二叉树)
构建赫夫曼树的过程:
一般地,设需要编码的字符集为{d1,d2,……,dn},各个字符在电文中出现的次数或频率集合为{w1,w2,……,wn}。以 w1,w2,……,wn作为权值构造一棵赫夫曼树。规定左分支代表0,右分支代表1,从根结点到叶子的01序列为字符的赫夫曼编码。
图(Graph)是由顶点的有穷非空集合和顶点之间边的集合组成,通常表示为G(V,E),其中,G表示一个图,V是图G中顶点的集合,E是边的集合。
顶点(Vertex):图中数据元素。有穷非空
边:顶点之间的逻辑关系,边集可以为空。
无向边Edge:顶点vi到vj的没有方向的边。用(vi,vj)表示。
有向边:顶点vi到vj的有方向的边,又称弧(Arc)。用
弧尾(Tail):vi
弧头(Head):vj
简单图:1、不存在Vertex到自身的边;2、同一条边不重复出现。
无向完全图:无向图中,任意两个顶点都存在边。
有向完全图:有向图中,任意两个顶点之间存在互为相反的两条弧。
n个结点,无向图的边的数量为[0,Cn2],有向图[0,An2]。
稀疏图:有很少条边或弧的图,反之为稠密图。这两个概念是相对而言,没有明确的标准判断稀疏/稠密。
权(Weight):与图的边或弧相关的数字。
网(Network):带权的图。
子图(Subgraph):假设两个图G=(V,{E})、G’=(V’,{E’}),且V⊆V’,E⊆E’。则称G’是G的子图。
对于无向图G=(V,{E}),如果边(v,v’)∈E,则称顶点v和v’互为邻接点(Adjacent)。边(v,v’)与顶点v和v’相关联。
顶点v的度等于和v关联的边的数量,记为TD(v)。
图的边数等于各顶点度的和除以2,简记为e=(1/2)∑ni=1TD(vi)。
对于有向图G=(V,{E}),如果弧
以顶点v为头的弧的数目称为v的入度(InDegree),记为ID(v);以顶点v尾头的弧的数目称为v的出度(OutDegree),记为OD(v)。
顶点v的度为TD(v)=ID(v)+OD(v)。
图的狐数等于各顶点入度+出度,简记为e=∑ni=1ID(vi)+∑ni=1OD(vi)。
无向图G=(V,{E})中从顶点v到v’的路径(Path)是一个顶点序列,任两个邻接点的边∈E。
如果G是有向图,则路径也是有向的。
路径的长度是路径上边或弧的数目。
回路或环(Cycle):第一个顶点到最后一个顶点相同的路径。
简单路径:序列中顶点不重复出现的路径。
简单回路/简单环:除第一个顶点和最后一个顶点外,其他顶点不重复出现的Cycle。
在无向图中,从顶点v到v’有路径,则称v和v’是连通的。如果对于图中任意两个顶点都是连通的,则称图是连通图。
无向图中的极大连通子图称为连通分量。它强调:
对于有向图,每一对节点之间都存在Path,则称为强连通图。有向图的极大强连通子图称作强连通分量。
连通图的生成树:针对无向图,他的极小连通子图(n个结点,只有n-1条边)
有向图恰有一个顶点入度为0,其余顶点入度为1,则是一颗有向树。
一个有向图的生成森林是由若干棵有向树组成,含有图的所有顶点,但只有构成若干棵有向树不相交的弧。
ADT 图(Graph)
Data
顶点的有穷非空集合和边的集合
Operation
CreateGraph(*G,V,VR):按照顶点集V和边弧集VR的定义构造图G。
DestoyGraph(*G):图G若存在则销毁。
LocateVex(G,u):若图G中存在顶点v,则返回图中的位置。
GetVex(G,v):返回图G中顶点v的值。
PutVex(G,v,value):将图G中v顶点赋值value。
FirstAdjVex(G,*v):返回顶点v的第一个邻接顶点。若顶点在G中无邻接顶点返回空。
NextAdjVex(G,v,*w):返回顶点v相对于顶点w的下一个邻接顶点,若w是v的最后一个邻接顶点,则返回空。
InsertVex(*G,v):在图G中添加新顶点v。
DeleteVex(*G,v):删除图G中顶点v及其相关的弧。
InsertArc(*G,v,w):在图中增添弧,如果是无向图,则再增添。
DeleteArc(*G,v,w):删除弧,如果是无向图,则再删除。
DFSTraverse(G):深度优先遍历每个顶点。
HFSTraverse(G):广度优先遍历。
endADT
图不能用简单的顺序存储结构表示;而多重链表的方式,需要按度数最大的顶点设计结点结构,会造成很大的存储单元浪费。
前辈们提供了5中存储结构:
用两个数组表示图。一个一维数组存储图中顶点信息,一个二维数组(称为邻接矩阵)存放图中边或弧的信息。
设图G有n个顶点,则邻接矩阵是一个n×n的方阵。
分析1:无向图的邻接矩阵是对称的;
分析2:顶点i的度=第i行(列)中1的个数;
特别:完全图的邻接矩阵中,对角元素为0,其余1。
第i行含义:以结点vi为尾的弧(即出度边);
第i列含义:以结点vi为头的弧(即入度边)。
分析1:有向图的邻接矩阵可能是不对称的;
分析2:顶点的出度 = 第 i 行元素之和
顶点的入度 = 第 i 列元素之和
顶点的度 = 第 i 行元素之和 + 第 i 列元素之和
分析3:要求vi的所有邻接点就是把矩阵第i行元素扫描一遍,查找arc[i][j]为1的顶点。
对于网Network,需要对矩阵进行改造:
Wij表示权重,∞表示计算机允许的、大于所有边上权值的值。(因为边的权值有时是0或者负数,用∞表示可以规避歧义)
图的邻接矩阵存储的结构:
typedef char VertexType;
typedef int EdgeType;
#define MAXVEX 100
#define INFINITY 65535
typedef struct
{
VertexType vexs[MAXVEX];
EdgeType arc[MAXVEX][MAXVEX];
int numVertexes,numEdges;
}MGrapg;
建立无向网图的邻接矩阵表示:
void CreateMGraph(MGraph *G)
{
int i.j.k.w;
printf("输入顶点数和边数:\n");
scanf("%d,%d",&G->numVertexes,G->numEdges);
for(i = 0;i < G->numVertexes;i++)
{
scanf(&G->vexs[i]);
}
for(i = 0;i < G->numVertexes;i++)
{
for(j = 0; j < G->numVertexes; j++)
{
G->arc[i][j] = INFINITY;
}
}
for(k = 0;k < G->numVertexes;k++)
{
printf("输入边(v~i~,v~j~j)上的下表i、j和权w";
scanf("%d,%d,%d",&i,&j,&w);
G->arc[i][j] = w;
G->arc[j][i] = G->arc[i][j];
}
}
时间复杂度为O(n2)
缺点:边数相对顶点较少的图,对存储空间造成较大的浪费。
类似树的孩子表示法,数组和链表相结合的存储方式称为邻接表(Adjacency List)。
typedef char VertexType;
typedef int EdgrType;
typedef struct EdgeNode
{
int adjavex;
EdgeType weight;
struct EdgeNode *next;
}EdgeNode;
typedef struct VertexNode
{
VertexType data;
EdgeNode *firstedege;
}VertexNode,AdjList[MAXVEX];
typedef struct
{
AdjList adjList;
int numVertexes,numEdges;
}GraphAdjList;
无向图邻接表创建代码:
void CreateALGraph(GraphAdjList *G)
{
int i,j,k;
EdgeNode *e;
prntf("输入顶点数和边数:\n");
scanf("%d,%d",&G->numVertexes,&G->numEdges);
for( i = 0; i < G->numVertexes; i++)
{
scanf(&G->adjList[i].data);
G->adjList[i].firstedge = NULL;
}
for(k = 0;k < G->numEdges;k++)
{
printf("输入边(vi,vj)上的顶点序号:\n");
scanf("%d,%d",&i,&j);
e = (EdgeNode *)malloc(sizeof(EdgeNode));
e->adjvex = j;
e->next = G->adjList[i].firstedge;
G->adjList[i].firstedge = e;
e = (EdgeNode *)malloc(sizeof(EdgeNode));
e->adjvex = i;
e->next = G->adjList[j].firstedge;
G->adjList[j].firstedge = e;
}
}
n个结点,e条边来说,时间复杂度为O(n+e)
对于有向图,邻接表只能了解出度,若想了解入度就需要逆邻接表,二者结合起来,就是另一种存储方式:十字链表Orthogonal List。
重新定义了顶点表的顶点结构:
其中:firstin表示入边表头指针【入边表是顶点作为弧头?】,firstout表示出边表头指针。
重新定义边表结点结构:
tailvex是指弧起点在顶点表的下标。
headvex是指弧终点在顶点表的下标。
taillink指入边表指针域,指向终点相同的下一条边。
headlink是指出边表指针域,指向起点相同的下一条边。
如果是网,还可以增加一个weight域存放权。
无向图的了邻接表,涉及到对边操作时,过程会比较繁琐。因此,仿照十字链表的方式,对边表结点的结构进行一些改造,可以避免这些问题。
重新定义便表结构结构:
ivex和jvex是与某条边依附的两个顶点在顶点表中的下标。ilink指向依附顶点ivex的下一条边,jlink指向依附顶点jvex的下一条边。
和邻接表相比,邻接多重表同一条边只用一个结点进行标表示。
边集数组是由两个一维数组构成。一个是存储顶点信息,另一个是存储边的信息,边数组每个数据元素由一条边的起点下标(begin)、终点下标(end)和权(weight)组成。
边集数组结构代码:
typedef struct
{
int begin;
int ehd;
int weigth;
}Edge;
边集数组要查找一个顶点的度需要扫描整个边数组,效率不高,因此适合对边依次进行处理的操作,不适合对顶点相关的操作。
从图中某一顶点出发访遍图中其余顶点,且使每个顶点仅被访问一次。
Depth_First_Search,简称DFS。
如果用的是邻接矩阵方式,则代码如下:
typedef int Boolean;
Boolean visited[MAX];
void DFS(MGraph G,int i)
{
int j;
visited[i] = TRUE;
printf("%c",G.vexs[i]);
for(j = 0; j < G.numVertexes; j++)
{
if(G.arc[i][j]) == 1 && !visited[j])
{
DFS(G,j);
}
}
}
void DFSTraverser(MGraph G)
{
int i;
for(i = 0;i < G.numVertexes;i++)
{
visited [i] = FALSE;
}
for(i = 0;i < G.numVertexes;i++)
{
if(!visited[i])
{
DFS(G,i);
}
}
}
如果图结构是邻接表结构,其DFSTraverse函数几乎相同,只是递归函数将数组换成了链表而有所不同。
typedef int Boolean;
Boolean visited[MAX];
void DFS(GraphAdjList GL,int i)
{
EdgeNode *p;
visited[i] = TRUE;
printf("%c",GL->adjList[i].data);
p = GL->adjList[i].firstedge;
while(p)
{
if(!visited[p->adjList])
{
DFS(GL,p->adjvex);
}
p = p->next;
}
}
void DFSTraverser(GraphAdjList GL)
{
int i;
for(i = 0;i < GL->numVertexes;i++)
{
visited [i] = FALSE;
}
for(i = 0;i < GL->numVertexes;i++)
{
if(!visited[i])
{
DFS(GL,i);
}
}
}
对比不同存储结构的深度优先遍历算法,邻接矩阵由于是二维数组,要查找顶点的邻接点需要访问矩阵中所有的元素,时间复杂度为O(n2),邻接表找邻接点所需时间取决于顶点和边的数目,时间复杂度为O(n+e)。
查找表(Search Table):是由同一类型的数据元素(或记录)构成的集合。
关键字 / 键值(Key):数据元素中某个数据项的值。Key对应的数据项称为关键码。
查找:根据给定的某个值,在查找表中确定一个其关键词等于给定值的数据元素。
如果查找表中不存在关键字等于给定值的情况,则查找不成功,查找的结果可给出一个“空”记录或“空”指针。
查找表按操作方式来分有两大种:
面向查找操作的数据结构称为查找结构。【查找结构具体分类?】
对于静态查找表来说,不妨应用线性表结构组织数据,如果在对主关键字排序,则可以用折半查找法等技术进行高校查找。
如果需要动态查找,可以考虑二叉排序树的查找技术。
顺序查找(Sequential Search):又叫线性查找,从表中第一个(或最后一个)记录开始,逐个进行记录的关键字和给定值比较,若某个记录的关键字和给定值相等,则查找成功,找到所查的记录;如果直至最后一个(或第一个)记录,其关键字和给定值比较都不等时,则表中没有所查的记录,查找不成功。
int Sequential_Search ( int * a, int n, int key )
{
int i;
for( i=0 ; i
以上是我写的代码,有不合适的代码:
第4行改成 for( i=1; i<=n; i++ )
修改后的代码,需注意数组a是从下标1开始和key匹配的,这样就要求把定义数组a的时候要从下表1开始存放数据元素。
8.3.2 顺序表查找优化
8.3.1的顺序表叉腰,每次循环都需要对i是否越界进行判断,可以优化代码减少该步骤。
思想:设置哨兵。例如使a[0]=key,即在下标0的位置设置了一个哨兵。
int Sequential_Search2 ( int * a, int n, int key )
{
int i;
a[0] = key;
i = n;
while ( a[i] != key )
{
i--;
}
return i;
}
时间复杂度:O(n)
优点:算法简单;对静态查找表的记录没有任何要求。
缺点:n很大时,查找效率低下。
8.4 有序表查找
查找表的记录按照某种规则顺序排列。
8.4.1 折半查找
折半查找 / 二分查找(Binary Search)
前提是线性表中的记录必须是关键码有序,线性表必须采用顺序存储。
基本思想:取中间记录作为比较对象,若给定值==中间记录的关键字,则查找成功;若给定值<中间记录的关键字,则在中间记录的左半区继续查找;若给定值>中间记录的关键字,则在中间记录的右半区查找。不断重复以上操作,直至查找成功,或所有记录均≠给定值,查找失败。
int Binary_Search ( int *a, int n, int key )
{
int low,high,mid;
low = 1;
high = n;
while ( low < high )
{
mid = (low + high)/2;
if ( a[mid] == key )
{
return mid;
}
else if ( a[mid] < key )
{
low = mid+1;
}
else
{
high = mid-1;
}
}
return 0;
}
时间复杂度:O(logn)
缺点:前提是查找表必须是有序的,对于需要频繁执行插入或删除操作的数据集而言,维持有序的排序会带来不小的工作量。
8.4.2 插值查找
折半查找是对半查找,系数是1/2,这个系数可以根据实际情况变化,例如在英语词典中查找apple,可以把系数调成1/10。
插值查找(Interpolation Search)的核心是插值的计算。
另外,插值查表和折半查找,代码区别是:
优点:对于表长较长、分布比较均匀的查找表来说,平均性能比折半好。
缺点:对于分布极不均匀的查找表,使用起来性能较差。
8.4.3 斐波那契查找
斐波那契查找:利用黄金分割原理实现的。
int Fibonacci_Search ( int *a, int n, int key )
{
int low,high,mid,i,k;
low = 1;
high = n;
k = 0;
while ( n > F[k]-1) 【判断,并随后会把数组a扩充到F[k]-1,这是难点之一】
{
k++;
}
for ( i=n; i a[mid] )
{
low = mid + 1;
k = k - 2;
}
else
{
if ( mid <= n )
{
return mid;
}
else
{
return n;
}
}
}
return 0;
}
- F[k]-1刚好等于F[k-1]-1加F[k-2]-1加1,最后那个1即mid,每次比较完key和a[mid],会在mid左边区域或右边区域递归操作。
如果查找的记录在右侧,左侧的数据就不用再判断了,时间复杂度是O(n),但性能优于折半查找(右侧区域小于左侧);但是最坏的情况,key=1,始终在左侧长半区查找,性能低于折半查找。
还有一点,折半查找进行了加法、除法的运算,而插值查找运用了更复杂的运算,斐波那契【我还老忘记它的名字】查找只运用了加减法,这可能会影响查找效率。
折半、插值、斐波那契小结:三种查找方式本质区别是分隔点的选择不同。
8.5 线性索引查找
以上查找是基于查找表有序的前提下,大部分实际问题存在数据增减的情形,如果是中确保Table是有序的,会增加很多工作量。
索引就是把一个关键字与它对应的记录相关联的过程。一个索引由若干个索引项构成,每个索引项至少包含关键字和其对应的记录在存储器中的位置等信息。
索引按照结构可以分为:
- 线性索引【只接触】:将索引项集合组织为线性结构,也称索引表。重点介绍稠密索引、分块索引、倒排索引。
- 树形索引
- 多级索引
8.5.1 稠密索引
你可能感兴趣的:(学习笔记,数据结构)