1 绪论
程序设计 = 数据结构 + 算法
数据是描述客观事物的符号,是计算机中可以操作的对象,是能被计算机识别,并输入计算机处理的符号集合。
数据元素是组成数据的、有一定意义的基本单位,在计算机中通常作为整体处理,也被称作记录。一个数据元素可以由若干个数据项组成。
数据项是数据不可分割的最小单位。
数据结构是相互之间存在一种或多种特定关系的数据元素的集合。
按照视点的不同,数据结构可分为逻辑结构和物理结构(存储结构)。逻辑结构分为集合结构、线性结构(一对一)、树形结构(一对多)、图形结构(多对多)四种;物理结构分为顺序存储和链式存储两种。
数据类型是指一组性质相同的值的集合及定义在此集合上的一些操作的总称。在C语言中,按照取值的不同,数据类型可以分为两类:
原子类型:是不可以再分解的基本类型,包括整型、实型、字符型等。
结构类型:由若干个类型组合而成,是可以再分解的,例如,数组等。
2 算法
算法是解决特定问题求解步骤的描述,在计算中表现为指令的有限序列,并且每条指令表示一个或多个操作。
算法的五个基本特性:输入、输出、有穷性、确定性和可行性。
算法的设计要求:正确性、可读性、健壮性、高效率和低存储量。
常用的时间复杂度:
O(1)<O(logN)<O(N)<O(NlogN)<O(N^2)<O(N^3)<O(2^N)<O(N!)<O(N^N)
3 线性表(List)
线性表是零个或多个具有相同配型的数据元素的有限序列。
线性表有顺序存储和链式存储两种结构。
线性表的链式存储结构的特点是用一组任意的存储单元存储线性表的数据元素,这组存储单元可以是连续的,也可以是不连续的。
线性表链式存储结构中的头指针与头结点:
头指针是指向链表第一个结点的指针,若链表有头结点,则是指向头结点的指针。无论链表是否为空,头指针均不为空,头指针是链表的必要元素。头指针有标识作用,所以常以头指针冠以链表的名字。
头结点是为了操作的统一和方便而设立的,放在单链表的第一个元素结点之前,其数据一般无意义(也可存放链表的长度)。头结点不是链表必须要素。
3.1 单链表
单链表的存储结构:
/*线性表的单链表存储结构*/
typedef struct Node
{
ElemType data;
struct Node *next;
} Node;
typedef struct Node *LinkList; /*定义LinkList*/
3.1.1 头插法
在单链表的表头插入元素,只需将新建元素的next指向表头的next,然后将新建元素赋值给表头的next即可。即:newNode->next = Header->next; Header->next = newNode;。
/* 随机产生n个元素的值,建立带表头结点的单链线性表L(头插法)*/
void CreateListHead(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; /*插入到表头*/
}
}
3.1.2 尾插法
在单链表的末尾插入一个新元素,需要一个临时变量tail保存当前链表的最后一个元素,然后将新建的元素赋值给原最后元素的next,此时最后的元素是新建的元素,所以需要更新临时变量。创建结束时,要为最后变量的next赋空值。即:tail->next = newNode; tail = newNode;。循环结束后 tail->next = NULL;。
/* 随机产生n个元素的值,建立带表头结点的单链线性表L(尾插法)*/
void CreateListTail(LinkList *L, int n)
{
LinkList p,r;
int i;
srand(time(0)); /*初始化随机数种子*/
*L = (LinkList)malloc(sizeof(Node));/*为整个线性表*/
r=*L; /*r为指向尾部的结点*/
for (i=0; i<n; i++)
{
p = (Node *)malloc(sizeof(Node)); /*生成新结点*/
p->data = rand()%100+1; /*随机生成100以内的数字*/
r->next=p; /*将表尾终端结点的指针指向新结点*/
r = p; /*将当前的新结点定义为表尾终端结点*/
}
r->next = NULL; /*表示当前链表结束*/
}
3.1.3 链表删除
/*初始条件:顺序线性表L已存在,操作结果:将L重置为空表*/
Status ClearList(LinkList *L)
{
LinkList p,q;
p=(*L)->next; /*p指向第一个结点*/
while(p) /*没到表尾*/
{
q=p->next;
free(p);
p=q;
}
(*L)->next=NULL; /*头结点指针域为空*/
return OK;
}
3.2 循环链表(circular linked list)
将单链表中终端结点的指针端由空指针改为指向头结点,就使整个单链表形成一个环,这种头尾相接的单链表称为单循环链表,简称循环链表(circular linked list)。
其实循环链表和单链表的主要差异就在于循环的判断条件上,原来是判断p->next是否为空,现在则是p -> next不等于头结点,则循环未结束。
3.3 双向链表
双向链表(double linked list)是在单链表的每个结点中,再设置一个指向其前驱结点的指针域。
/*线性表的双向链表存储结构*/
typedef struct DulNode
{
ElemType data;
struct DuLNode *prior; /*直接前驱指针*/
struct DuLNode *next; /*直接后继指针*/
} DulNode, *DuLinkList;
3.3.1 双向链表的插入
修改顺序是先修改插入节点的前驱和后继,再修改后继结点的前驱,最后修改前驱结点的后继。必须是最后修改前驱结点的后继next,否则插入的就不对,因为后结点的前驱prior修改需要使用到它。
/* 在结点p后插入s */
s->next = p->next;
s->prior = p;
p->next->prior = s;
p->next = s; /* p->next必须最后修改 */
3.3.2 双向链表的删除
/* 删除p结点 */
p->prior->next = p->next;
p->next->prior = p->prior;
4 栈
栈(stack)是限定仅在表尾进行插入和删除操作的线性表。
4.1 栈的顺序存储结构
typedef int SElemType; /* SElemType类型根据实际情况而定,这里假设为int */
typedef struct
{
SElemType data[MAXSIZE];
int top; /* 用于栈顶指针 */
}SqStack;
/* 插入元素e为新的栈顶元素 */
Status Push(SqStack *S, SElemType e)
{
if(S->top == MAXSIZE -1) /* 栈满 */
{
return ERROR;
}
S->top++; /* 栈顶指针增加一 */
S->data[S->top]=e; /* 将新插入元素赋值给栈顶空间 */
return OK;
}
/* 若栈不空,则删除S的栈顶元素,用e返回其值,并返回OK;否则返回ERROR */
Status Pop(SqStack *S, SElemType *e)
{
if(S->top==-1)
return ERROR;
*e=S->data[S->top]; /* 将要删除的栈顶元素赋值给e */
S->top--; /* 栈顶指针减一 */
return OK;
}
4.2 栈的链式存储结构
typedef struct StackNode
{
SElemType data;
struct StackNode *next;
}StackNode,*LinkStackPtr;
typedef struct LinkStack
{
LinkStackPtr top;
int count;
}LinkStack;
/* 插入元素e为新的栈顶元素 */
Status Push(LinkStack *S, SElemType e)
{
LinkStackPtr p=(LinkStackPtr)malloc(sizeof(StackNode));
p->data=e;
p->next=S->top;/* 把当前的栈顶元素赋值给新结点的直接后继,如图中① */
S->top=p; /* 将新的结点s赋值给栈顶指针,如图中② */
S->count++;
return OK;
}
/* 若栈不空,则删除S的栈顶元素,用e返回其值,并返回OK;否则返回ERROR */
Status Pop(LinkStack *S,SElemType *e)
{
LinkStackPtr p;
if(StackEmpty(*S))
return ERROR;
*e=S->top->data;
p=S->top; /* 将栈顶结点赋值给p,如图③ */
S->top=S->top->next; /* 使得栈顶指针下移一位,指向后一结点,如图④*/
free(p); /* 释放结点p */
S->count--;
return OK;
}
4.3 栈的应用--四则运算表达式求值
中缀表达式: 9 + ( 3 - 1 ) * 7 + 10 / 5
后缀表达式(所有符号都在数字后面出现): 9 3 1 - 7 * + 10 5 / +
后缀表达式的计算规则:
从左到右遍历后缀表达式的每个数字和符号,遇到是数字就进栈,遇到是符号,就将处于栈顶两个数字出栈,进行运算,运算结果进栈,一直到最终获得结果。
中缀表达式转后缀表达式规则:
从左到右遍历中缀表达式的每个数字和符号,若是数字就输出,即成为后缀表达式的一部分;若是符号,则判断其与栈顶符号的优先级,是右括号或优先级低于栈顶符号(乘除优先加减)则栈顶元素依次出栈并输出,并将当前符号进栈,一直到最终输出后缀表达式为止。
5 队列
队列(queue)是只允许在一端进行插入操作,在另一端进行删除操作的线性表。
5.1 循环队列的顺序存储
typedef int QElemType; /* QElemType类型根据实际情况而定,这里假设为int */
/* 循环队列的顺序存储结构 */
typedef struct
{
QElemType data[MAXSIZE];
int front; /* 头指针 */
int rear; /* 尾指针,若队列不空,指向队列尾元素的下一个位置 */
}SqQueue;
/* 初始化一个空队列Q */
Status InitQueue(SqQueue *Q)
{
Q->front=0;
Q->rear=0;
return OK;
}
/* 返回Q的元素个数,也就是队列的当前长度 */
int QueueLength(SqQueue Q)
{
return (Q.rear-Q.front+MAXSIZE)%MAXSIZE;
}
5.2 队列链式存储
typedef int QElemType; /* QElemType类型根据实际情况而定,这里假设为int */
typedef struct QNode /* 结点结构 */
{
QElemType data;
struct QNode *next;
}QNode,*QueuePtr;
typedef struct /* 队列的链表结构 */
{
QueuePtr front,rear; /* 队头、队尾指针 */
}LinkQueue;
6 串
串(string)是由零个或多个字符组成的有限序列,又名字符串。
KMP模式匹配算法
改进后的KMP算法
7 树
树是n(n>=0)个结点的有限集。n=0时称为空树。
结点拥有的子树数称为结点的度(Degree)。度为0的结点称为叶节点(Leaf)或者终端结点;度不为0的结点称为非终端结点或者分支节点。除根节点外,分支结点也称为内部结点。
树的度是树内部各结点的度的最大值。树中结点的最大层次称为树的深度(Depth)或高度。
森林(Forest)是m(m>=0)棵互不相交的树的集合。对树中每个结点而言,其子树的集合即为森林。
7.1 树的表示法
树有三种不同的表示法:双亲表示法、孩子表示法和孩子兄弟表示法。
7.1.1 双亲表示法
/* 树顺序存储结构的双亲表示法结点结构定义 */
#define MAX_TREE_SIZE 100
typedef int TElemType; /* 树结点的数据类型,目前暂定为整型 */
typedef struct PTNode /* 结点结构 */
{
TElemType data; /* 结点数据 */
int parent; /* 双亲位置 */
} PTNode;
typedef struct /* 树结构 */
{
PTNode nodes[MAX_TREE_SIZE]; /* 结点数组 */
int r,n; /* 根的位置和结点数 */
} PTree;
7.1.2 孩子表示法
/* 树的顺序存储结构的孩子表示法结构定义 */
#define MAX_TREE_SIZE 100
typedef struct CTNode /* 孩子结点 */
{
int child;
struct CTNode *next;
} *ChildPtr;
typedef struct /* 表头结构 */
{
TElemType data;
ChildPtr firstchild;
} CTBox;
typedef struct /* 树结构 */
{
CTBox nodes[MAX_TREE_SIZE]; /* 结点数组 */
int r,n; /* 根的位置和结点数 */
} CTree;
7.1.3 孩子兄弟表示法
/* 树的孩子兄弟表示法结构定义 */
typedef struct CSNode
{
TElemType data;
struct CSNode *firstchild,*rightsib;
} CSNode,*CSTree;
7.2 二叉树
二叉树(Binary Tree)是n(n≥0)个结点的有限集合,该集合或者为空集(称为空二叉树),或者由一个根结点和两棵互不相交的、分别称为根结点的左子树和右子树的二叉树组成。
二叉树的特点:
(1)每个结点最多有两棵子树,所以二叉树中不存在度大于2的结点;
(2)左子树和右子树是有顺序的,次序不能任意颠倒;
(3)即使树中某结点只有一棵子树,也要区分它是左子树还是右子树。
7.2.1 特殊的二叉树
斜树:
所有的结点都只有左子树的二叉树叫左斜树。
所有结点都是只有右子树的二叉树叫右斜树。
其实线性表结构就可以理解为是树的一种极其特殊的表现形式。
满二叉树:
在一棵二叉树中,如果所有分支节点都存在左子树和右子树,并且所有叶子都在同一层上,这样的二叉树称为满二叉树。
满二叉树有如下特点:
(1)叶子只能出现在最下一层。
(2)非叶子结点的度一定是2。
(3)在同样深度的二叉树中,满二叉树的结点个数最多,叶子数最多。
完全二叉树:
对一棵具有n个结点的二叉树按层序编号,如果编号为i(1<=i<=n)的结点与同样深度的满二叉树中编号i的结点在二叉树中位置完全相同,则这棵二叉树称为完全二叉树。
满二叉树一定是一棵完全二叉树,但完全二叉树不一定是满的。
完全二叉树有如下特点:
(1)叶子结点只能出现在最下两层。
(2)最下层的叶子一定集中在左部连续位置。
(3)倒数二层,若有叶子结点,一定都在右部连续位置。
(4)如果结点度为1,则该结点只有左孩子,即不存在只有右子树的情况。
(5)同样结点数的二叉树,完全二叉树的深度最小。
7.2.2 二叉树的性质
1.在二叉树的第i层上至多有2^(i-1)个结点(i>=1)。
2.深度为k的二叉树至多有2^k-1个结点(k>=1)。
3.对于任何一棵二叉树T,如果其叶子结点数为n0,度为2的结点数为n2,则n0 = n2+1。
4.具有n个结点的完全二叉树的深度为log2n的最大整数+1。由性质2推知。
5.如果对一棵有n个结点的完全二叉树的结点按层序编号,对任一结点i(1<=i<<n)有:
(1)如果i=1则结点i是根,无双亲;否则,其双亲是结点」i/2」(i/2的最大整数)。
(2)如果2i>n,则结点i无左孩子,是叶子结点;否则其左孩子是结点2i。
(3)如果2i+1>n,则结点i无右孩子;否则其右孩子是结点2i+1。
7.2.3 二叉树的存储结构
二叉树既可以是顺序存储结构也可以是链式存储结构,但一般选用链式存储结构,因为顺序存储结构会有很大的空间浪费。顺序存储结构一般只用于完全二叉树。
/* 二叉树的二叉链表结点结构定义 */
typedef struct BiTNode /* 结点结构 */
{
TElemType data; /* 结点数据 */
struct BiTNode *lchild, *rchild; /* 左右孩子指针 */
} BiTNode, *BiTree;
7.3 遍历二叉树
二叉树的遍历(traversing binary tree)是指从根结点出发,按照某种次序依次访问二叉树中所有结点,使得每个结点被访问一次且仅被访问一次。二叉树的遍历共分四种:前序遍历、中序遍历、后序遍历和层序遍历。
前序遍历:先访问根结点,然后前序遍历左子树,再前序遍历右子树。
/* 二叉树的前序遍历递归算法 */
void PreOrderTraverse(BiTree T)
{
if(T==NULL)
return;
printf("%c",T->data);/* 显示结点数据,可以更改为其他对结点操作 */
PreOrderTraverse(T->lchild); /* 再先序遍历左子树 */
PreOrderTraverse(T->rchild); /* 最后先序遍历右子树 */
}
中序遍历:从根结点开始(注意并不是先访问根结点),中序遍历根结点的左子树,然后是访问根结点,最后中序遍历右子树。
/* 二叉树的中序遍历递归算法 */
void InOrderTraverse(BiTree T)
{
if(T==NULL)
return;
InOrderTraverse(T->lchild); /* 中序遍历左子树 */
printf("%c",T->data);/* 显示结点数据,可以更改为其他对结点操作 */
InOrderTraverse(T->rchild); /* 最后中序遍历右子树 */
}
后序遍历:从左到右先叶子后结点的方式遍历访问左右子树,最后是访问根结点。
/* 二叉树的后序遍历递归算法 */
void PostOrderTraverse(BiTree T)
{
if(T==NULL)
return;
PostOrderTraverse(T->lchild); /* 先后序遍历左子树 */
PostOrderTraverse(T->rchild); /* 再后序遍历右子树 */
printf("%c",T->data);/* 显示结点数据,可以更改为其他对结点操作 */
}
层序遍历:从树的第一层,也就是根结点开始访问,从上而下逐层遍历,在同一层中,按从左到右的顺序对结点逐个访问。
已知前序和中序遍历序列,可以唯一确认一棵二叉树;已知后序和中序遍历序列,也可以唯一确认一棵二叉树;但已知前序和后序遍历序列,是不能确定一棵二叉树的。
7.4 线索二叉树
7.5 树、森林、二叉树的转换
树转换为二叉树:
(1)加线:在所有兄弟结点之间加一条连线。
(2)去线:对树中每个结点,只保留它与第一个孩子结点的连线,删除它与其他孩子结点之间的连线。
(3)层次调整:以树的根结点为轴心,将整棵树顺时针旋转一定的角度,使之结构层次分明。注意第一个孩子是二叉树结点的左孩子,兄弟转换过来的孩子是结点的右孩子。
森林转化为二叉树:
(1)把每个树转换为二叉树。
(2)第一棵二叉树不动,从第二棵二叉树开始,依次把后一棵二叉树的根结点作为前一棵二叉树的根结点的右孩子,用线连接起来。当所有的二叉树连接起来后就得到了由森林转换来的二叉树。
二叉树转换为树:
二叉树转换为树是树转换为二叉树的逆过程。
(1)加线:若某结点的左孩子结点存在,则将这个左孩子的右孩子结点、右孩子的右孩子结点、右孩子的右孩子的右孩子结点……哈,反正就是左孩子的n个右孩子结点都作为此结点的孩子。将该结点与这些右孩子结点用线连接起来。
(2)去线:删除原二叉树中所有结点与其右孩子结点的连线。
(3)层次调整:使之结构层次分明。
二叉树转换为森林:
判断一棵二叉树能够转换成一棵树还是森林,标准很简单,那就是只要看这棵二叉树的根结点有没有右孩子,有就是森林,没有就是一棵树。
(1)从根结点开始,若右孩子存在,则把与右孩子结点的连线删除,再查看分离后的二叉树,若右孩子存在,则连线删除……,直到所有右孩子连线都删除为止,得到分离的二叉树。
(2)再将每棵分离后的二叉树转换为树即可。
7.6 赫夫曼树
从树中一个结点到另一个结点之间的分支构成两个结点之间的路径,路径上的分支数目称做路径长度。树的路径长度就是从树根到每一结点的路径长度之和。
如果考虑到带权的结点,结点的带权的路径长度为从该结点到树根之间的路径长度与结点上权的乘积。树的带权路径长度为树中所有叶子结点的带权路径长度之和。
假设有n个权值{w1,w2,…,wn},构造一棵有n个叶子结点的二叉树,每个叶子结点带权wk,每个叶子的路径长度为lk,我们通常记作,则其中带权路径长度WPL最小的二叉树称做赫夫曼树。也叫最优二叉树。
赫夫曼树的构造:
(1)将有权值的叶子结点按从小到大的顺序排列;
(2)取两个最小权值的叶子结点作为新节点N1的子结点,注意,较小的是左孩子。新结点的权值是两个叶子结点的权值和;
(3)用新结点N1替换两个叶子结点插入有序序列,并保持从小到大的排序;
(4)重复上述步骤,直到完成赫夫曼树的构造。
赫夫曼编码:
一般地,设需要编码的字符集为{d1,d2,…,dn},各个字符在电文中出现的次数或频率集合为{w1,w2,…,wn},以d1,d2,…,dn作为叶子结点,以w1,w2,…,wn作为相应叶子结点的权值来构造一棵赫夫曼树。规定赫夫曼树的左分支代表0,右分支代表1,则从根结点到叶子结点所经过的路径分支组成的0和1的序列便为该结点对应字符的编码,这就是赫夫曼编码。
8 图
8.1 图的定义
图(Graph)是由顶点的有穷非空集合和顶点之间边的集合组成,通常表示为:G(V,E),其中,G表示一个图,V是图G中顶点的集合,E是图G中边的集合。
线性表中把数据元素叫元素,树中将数据元素叫结点,在图中数据元素则称为顶点(Vertex)。线性表中没有元素称为空表,树中没有结点称为空树,而图不允许没有顶点,定义中已强调顶点集合为有穷非空。图中任意两个顶点之间都可能有关系,顶点之间的逻辑关系用边来表示,边集可以是空的。
无向边(Edge):若顶点vi到vj之间的边没有方向,则称这条边为无向边,用无序偶对(vi,vj)来表示。如果图中任意两个顶点之间的边都是无向边,则称该图为无向图(Undirected graphs)。
有向边(Arc,也称弧):若从顶点vi到vj的边有方向,则称这条边为有向边。用有序偶<vi, vj>来表示,vi称为弧尾(Tail),vj称为弧头(Head)。如果图中任意两个顶点之间的边都是有向边,则称该图为有向图(Directed graphs)。
在图中,若不存在顶点到其自身的边,且同一条边不重复出现,则称这样的图为简单图。
在无向图中,如果任意两顶点之间都存在边,则称该图为无向完全图。含有n个顶点的无向完全图有n*(n-1)/2条边。
在有向图中,如果任意两顶点之间都存在方向互为相反的两条弧,则称该图为有向完全图。含有n个顶点的有向完全图有n*(n-1)条边。
有很少条边或弧的图称为稀疏图,反之称为稠密图。
带权(Weight)的图通常称为网(Network)。
假设有两个图G=(V,{E})和G’=(V’,{E’}),如果V’⊆V且E’⊆E,则称G’为G的子图(Subgraph)。
8.1.1 图的顶点与边的关系
对于无向图G=(V,{E}),如果边(v,v’)∈E,则称顶点v和v’互为邻接点(Adjacent),即v和v’相邻接。边(v,v’)依附(incident)于顶点v和v’,或者说(v,v’)与顶点v和v’相关联。顶点v的度(Degree)是和v相关联的边的数目,记为TD(v)。
对于有向图G=(V,{E}),如果弧<v,v’>∈E,则称顶点v邻接到顶点v’,顶点v’邻接自顶点v。弧<v,v’>和顶点v,v’相关联。以顶点v为头的弧的数目称为v的入度(InDegree),记为ID(v);以v为尾的弧的数目称为v的出度(OutDegree),记为OD(v);顶点v的度为TD(v)=ID(v)+OD(v)。
路径的长度是路径上的边或弧的数目。
8.1.2 连通图
在无向图G中,如果从顶点v到顶点v’有路径,则称v和v’是连通的。如果对于图中任意两个顶点vi、vj∈E,vi和vj都是连通的,则称G是连通图(Connected Graph)。无向图中的极大连通子图称为连通分量。
在有向图G中,如果对于每一对vi、vj∈V、vi≠vj,从vi到vj和从vj到vi都存在路径,则称G是强连通图。有向图中的极大强连通子图称做有向图的强连通分量。
一个连通图的生成树是一个极小的连通子图,它含有图中全部的n个顶点,但只有足以构成一棵树的n-1条边。
如果一个有向图恰有一个顶点的入度为0,其余顶点的入度均为1,则是一棵有向树。
一个有向图的生成森林由若干棵有向树组成,含有图中全部顶点,但只有足以构成若干棵不相交的有向树的弧。
8.2 图的存储结构
8.2.1 邻接矩阵
图的邻接矩阵(Adjacency Matrix)存储方式是用两个数组来表示图。一个一维数组存储图中顶点信息,一个二维数组(称为邻接矩阵)存储图中的边或弧的信息。
无向图的边数组是一个对称矩阵。
typedef char VertexType; /* 顶点类型应由用户定义 */
typedef int EdgeType; /* 边上的权值类型应由用户定义 */
#define MAXVEX 100 /* 最大顶点数,应由用户定义 */
typedef struct
{
VertexType vexs[MAXVEX]; /* 顶点表 */
EdgeType arc[MAXVEX][MAXVEX]; /* 邻接矩阵,可看作边表 */
int numVertexes, numEdges; /* 图中当前的顶点数和边数 */
}MGraph;
8.2.2 邻接表
把数组与链表相结合的存储方法称为邻接表(Adjacency List):图中顶点用一维数组存储,每个数据元素还需要存储指向第一个邻接点的指针,便于查找该顶点的边信息;图中每个顶点的所有邻接点构成一个线性表,用单链表存储。
8.2.3 十字链表
对有向图来说,邻接表是有缺陷的:出度和入度只能关心到一个,不能两者兼顾。要解决这个问题就是采用十字链表。
8.2.4 邻接多重表
8.2.5 边集数组
边集数组是由两个一维数组构成。一个是存储顶点的信息;另一个是存储边的信息,这个边数组每个数据元素由一条边的起点下标(begin)、终点下标(end)和权(weight)组成。
显然边集数组关注的是边的集合,在边集数组中要查找一个顶点的度需要扫描整个边数组,效率并不高。因此它更适合对边依次进行处理的操作,而不适合对顶点相关的操作。
8.3 图的遍历
图的遍历是和树的遍历类似,我们希望从图中某一顶点出发访遍图中其余顶点,且使每一个顶点仅被访问一次,这一过程就叫做图的遍历(Traversing Graph)。对于图的遍历通常有两种遍历次序方案:深度优先遍历(DFS Depth_First_Search)和广度优先遍历(BFS Breadth_First_Search)。
图的遍历需要设置一个访问数组visited[n]来标记已经访问过的顶点,避免重复访问。
8.3.1 深度优先遍历(DFS)
深度优先遍历就像是对一棵树的前序遍历。
/* 邻接矩阵的深度优先递归算法 */
void DFS( MGraph G, int i)
{
visited[i] = true;
for (int j = 0; j < G.numVertexes; j++)
{
/* 边存在,且顶点j未被访问过 */
if (G.arc[i][j] == 1 && !visited[j])
{
DFS(G, j);
}
}
return;
}
/* 邻接矩阵的深度优先遍历 */
void DFSTraverse(MGraph G)
{
for (int i = 0; i < G.numVertexes; i++)
{
visited[i] = false;
}
for (int i = 0; i < G.numVertexes; i++)
{
if (!visited[i])
{
DFS(G, i);
}
}
return;
}
8.3.2 广度优先遍历(BFS)
广度优先遍历就像是对树的层序遍历。
/* 邻接矩阵的广度遍历 */
void BFSTraverse(MGraph G)
{
for (int i = 0; i < G.numVertexes; i++)
{
visited[i] = false;
}
queue<int> q;
for (int i = 0; i < G.numVertexes; i++)
{
if (!visited[i])
{
visited[i] = true;
q.push_back(i);
while(!q.empty())
{
i = q.pop_front();
for (int j = 0; j < G.numVertexes; j++)
{
/* 边存在,且顶点j未被访问过 */
if(G.arc[i][j] == 1 && !visited[j] )
{
visited[j] = true;
q.push_back(j)
}
}
}
}
}
}
深度优先和广度优先在时间复杂度上是一样的,没有优劣之。具体选择什么算法,视不同的情况选择:深度优先更适合目标比较明确,以找到目标为主要目的的情况,而广度优先更适合在不断扩大遍历范围时找到相对最优解的情况。
8.4 最小生成树
把构造连通网的最小代价生成树称为最小生成树(Minimum Cost Spanning Tree)。找连通网的最小生成树,经典的有两种算法,普里姆(Prim)算法和克鲁斯卡尔(Kruskal)算法。
8.4.1 普里姆(Prim)算法
/*
普里姆算法是贪心算法:从剩余的点中找出离选出的点距离最近的一个。
dist[]用来表示剩余的点到选中的点的距离,第dist[i] = 0表示i点已被选中,
每次选中一个点,则更新该点到剩余点的距离,然后继续选择距离最近的点,如此重复。
select[]用来标注dist中最的选中的弧的一端的顶点,另一端就是当前的循环变量j.
*/
void MiniSpanTreePrim(const AdjMatrixGraph &graph, const BYTE first)
{
BYTE dist[graph.verNum] = {};
BYTE select[graph.verNum] = {};
cout<<"first one is "<<graph.vertexs[first]<<endl;
for(BYTE i = 0; i< graph.verNum; i++)
{
dist[i] = graph.arc[first][i];
select[i] = first;
}
BYTE next = first;
for (BYTE i = 0; i < graph.verNum; i++)
{
BYTE minWeight = MAX_BYTE;
for(BYTE j = 0; j < graph.verNum; j++)
{
if (dist[j] != 0 && dist[j] < minWeight)
{
minWeight = dist[j];
next = j;
}
}
cout <<"next one is "<<graph.vertexs[next]<<endl;
cout<<"the edge is ("<<graph.vertexs[select[next]]<<", "<<graph.vertexs[next]<<"), edge weight: "<< (int)dist[next]<<endl;
cout<<endl;
dist[next] = 0;
for (BYTE j = 0; j < graph.verNum; j++)
{
if (dist[j] != 0 && graph.arc[next][j] < dist[j])
{
dist[j] = graph.arc[next][j];
select[j] = next;
}
}
}
return;
}
8.4.2 克鲁斯卡尔(Kruskal)算法
/*
克鲁斯卡尔算法,从边出发,先对边按权值从小到大排序,然后从最小边开始取边,只要不构成回路就行,一定可以取到n-1条边构成最小生成树。
其中的groupNum数组是用来标记索取顶点的集合的,防止形成回路。
groupNum的维护规则是:用边的小顶点为记号,标注该顶点所属的集合。
*/
BYTE GetVertexGroupNum(BYTE *groupNum, BYTE index)
{
while(groupNum[index]>0)
{
index = groupNum[index];
}
return index;
}
void MiniSpanTree_Kruskal(EdgeGraph &graph)
{
const BYTE vertexsNUM = graph.verNum;
BYTE groupNum[vertexsNUM] = {};
vector<Edge> select;
for (auto &edge : graph.edges)
{
BYTE b = GetVertexGroupNum(groupNum, edge.begin);
BYTE e = GetVertexGroupNum(groupNum, edge.end);
if (b != e)
{
groupNum[e] = b;
select.push_back(edge);
cout<< "select edge("<<edge.begin<<", "<<edge.end<<") weight is "<<(int)edge.weight<<endl;
}
if (select.size() == vertexsNUM - 1)
{
break;
}
}
return;
}
对比两个算法,克鲁斯卡尔算法主要是针对边来展开,边数少时效率会非常高,所以对于稀疏图有很大的优势;而普里姆算法对于稠密图,即边数非常多的情况会更好一些。
8.5 最短路径
8.5.1 迪杰斯特拉(Dijkstra)算法
8.5.2 弗洛伊德(Floyd)算法
8.6 拓扑排序
在一个表示工程的有向图中,用顶点表示活动,用弧表示活动之间的优先关系,这样的有向图为顶点表示活动的网,我们称为AOV网(Activity On Vertex Network)。AOV网中的弧表示活动之间存在的某种制约关系。
设G=(V,E)是一个具有n个顶点的有向图,V中的顶点序列v1,v2,……,vn,满足若从顶点vi到vj有一条路径,则在顶点序列中顶点vi必在顶点vj之前。则我们称这样的顶点序列为一个拓扑序列。
对AOV网进行拓扑排序的基本思路是:从AOV网中选择一个入度为0的顶点输出,然后删去此顶点,并删除以此顶点为尾的弧,继续重复此步骤,直到输出全部顶点或者AOV网中不存在入度为0的顶点为止。
8.7 关键路径
在一个表示工程的带权有向图中,用顶点表示事件,用有向边表示活动,用边上的权值表示活动的持续时间,这种有向图的边表示活动的网,我们称之为AOE网(Activity On Edge Network)。我们把AOE网中没有入边的顶点称为始点或源点,没有出边的顶点称为终点或汇点。由于一个工程,总有一个开始,一个结束,所以正常情况下,AOE网只有一个源点一个汇点。
尽管AOE网与AOV网都是用来对工程建模的,但它们还是有很大的不同,主要体现在AOV网是顶点表示活动的网,,它只描述活动之间的制约关系,而AOE网是用边表示活动的网,边上的权值表示活动持续的时间。
把路径上各个活动所持续的时间之和称为路径长度,从源点到汇点具有最大长度的路径叫关键路径,在关键路径上的活动叫关键活动。
9 查找(Search)
查找表(Search Table)是由同一类型的数据元素(或记录)构成的集合。查找表按照操作方式来分有两大种:静态查找表和动态查找表。
静态查找表(Static Search Table):只作查找操作的查找表。
动态查找表(Dynamic Search Table):在查找过程中同时插入查找表中不存在的数据元素,或者从查找表中删除已经存在的某个数据元素。
9.1 顺序查找O(n)
顺序查找的优化:有哨兵的顺序查找,在顺序表的开头或者结尾增加一个哨兵(arr[0] = key 或arr[n+1] = key),使用while循环,省去for循环时i<n的越界判断。在数据很多时可以提高效率。
9.2 有序表查找
9.2.1 二分查找(Binary Search)O(logn)
折半查找又称二分查找,要求线性表中的记录必须是关键码有序的,且线性表采用顺序存储。
int BinarySearch(vector<int> &arr, int key)
{
int low = 0;
int high = arr.size();
while (low <= high)
{
int mid = (low + high) / 2;
if (key < arr[mid])
{
high = mid - 1;
}
else if (key > arr[mid])
{
low = mid + 1;
}
else
{
return mid;
}
}
return -1;
}
9.2.2 插值查找(Interpolation Search)
插值查找是根据要查找的关键字key与查找表中最大最小记录的关键字比较后的查找方法,其核心就在于插值的计算公式:(key -a[low]) / (a[high]-a[low])
int mid = low+ (high-low)*(key-a[low])/(a[high]-a[low]); /* 插值 */
9.2.3 斐波拉契查找(Fibonacci Search)
斐波那契查找是利用了黄金分割原理来实现的,斐波那契查找算法的核心在于:
(1)当key=a[mid]时,查找就成功;
(2)当key<a[mid]时,新范围是第low个到第mid-1个,此时范围个数为F[k-1] -1个;
(3)当key>a[mid]时,新范围是第m+1个到第high个,此时范围个数为F[k-2] -1个。
9.3 线性索引查找
索引就是把一个关键字与它对应的记录相关联的过程,一个索引由若干个索引项构成,每个索引项至少应包含关键字和其对应的记录在存储器中的位置等信息。索引技术是组织大型数据库以及磁盘文件的一种重要技术。
索引按照结构可以分为线性索引、树形索引和多级索引。所谓线性索引就是将索引项集合组织为线性结构,也称为索引表。
稠密索引
分块索引
倒排索引
10 排序
10.1 冒泡排序(Bubble Sort)
冒泡排序一种交换排序,它的基本思想是:两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止。
void BubbleSort(vector<int> &vec)
{
int length = vec.size();
for (int i = 0; i < length; i++)
{
for (int j = length-1; j > 0; j--)
{
if (vec[j] < vec[j-1])
{
swapVector(vec, j, j-1);
}
}
}
return;
}
10.2 简单选择排序(Simple Selection Sort)
简单选择排序法就是通过n-i次关键字间的比较,从n-i+1个记录中选出关键字最小的记录,并和第i(1≤i≤n)个记录交换之。
void SelectSort(vector<int> &vec)
{
int length = vec.size();
for (int i = 0; i < length; i++)
{
int min = i;
for (int j = i+1; j < length; j++)
{
if (vec[min] > vec[j])
min = j;
}
if (min != i)
{
swapVector(vec, i, min);
}
}
return;
}
尽管简单选择排序的时间复杂度与冒泡排序同为O(n^2),但简单选择排序的性能上还是要略优于冒泡排序。
10.3 直接插入排序(Straight Insertion Sort)
直接插入排序的基本操作是将一个记录插入到已经排好序的有序表中,从而得到一个新的、记录数增1的有序表。
void InsertSort(vector<int> &vec)
{
for (int i = 1; i < vec.size(); i++)
{
int k = vec[i];
int j;
for (j = i-1; j >= 0 && vec[j] > k; j--)
{
vec[j+1] = vec[j];
}
vec[j+1] = k;
}
return;
}
直接插入排序法的时间复杂度也为O(n^2),但性能比冒泡和简单选择排序的要好一些。
10.4 希尔排序(Shell Sort)
希尔排序是对直接插入排序算法的改进,其基本思想是将待排序的数组按照一定的间隔分组,对每组进行插入排序,然后逐渐缩小间隔,直到间隔为1,此时整个数组已经基本有序,再进行一次插入排序即可。
希尔排序是一种不稳定的排序算法。
void ShellSort(vector<int> &vec)
{
int length = vec.size();
for (int gap = length / 2; gap > 0; gap /= 2)
{
for (int i = gap; i < length; i++)
{
int tmp = vec[i];
int j;
for (j = i; j >= gap && vec[j-gap] > tmp; j -= gap)
{
vec[j] = vec[j-gap];
}
vec[j] = tmp;
}
}
}
希尔排序的时间复杂度突破了O(n^2),是第一批突破O(n^2)的排序算法。
10.5 快速排序(Quick Sort)
快速排序是冒泡排序的升级,属于交换类排序。
快速排序的基本思想是:通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。
int partition(vector<int> &vec, int low, int high)
{
int pivot = vec[low];
while (low < high)
{
while (low < high && vec[high] >= pivot)
{
high--;
}
vec[low] = vec[high];
while (low < high && vec[low] <= pivot)
{
low++;
}
vec[high] = vec[low];
}
vec[low] = pivot;
return low;
}
void QuickSort(vector<int> &vec, int low, int high)
{
if (low < high) {
int pivotIndex = partition(vec, low, high);
QuickSort(vec, low, pivotIndex - 1);
QuickSort(vec, pivotIndex + 1, high);
}
return;
}
快速排序算法的时间复杂度 在最优的情况下为O(nlogn),在最坏的情况下为O(n^2)。由数学归纳法可证明,其数量级为O(nlogn)。
快速排序是一种不稳定的排序方法。
10.6 堆排序(Heap Sort)
堆排序是对简单选择排序的改进,是利用堆(假设利用大顶堆)进行排序的方法。它的基本思想是,将待排序的序列构造成一个大顶堆。此时,整个序列的最大值就是堆顶的根结点。将它移走(其实就是将其与堆数组的末尾元素交换,此时末尾元素就是最大值),然后将剩余的n-1个序列重新构造成一个堆,这样就会得到n个元素中的次小值。如此反复执行,便能得到一个有序序列了。
堆是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。
堆排序的时间复杂度为O(nlogn)。
堆排序也是一种不稳定的排序方法。
10.7 归并排序(Merging Sort)
归并排序就是利用归并的思想实现的排序方法。它的原理是假设初始序列含有n个记录,则可以看成是n个有序的子序列,每个子序列的长度为1,然后两两归并,得到⌈n/2⌉(⌈x⌉表示不小于x的最小整数)个长度为2或1的有序子序列;再两两归并,……,如此重复,直至得到一个长度为n的有序序列为止,这种排序方法称为2路归并排序。
void Merge(vector<int> &vec, int low, int mid, int high)
{
int i = low;
int j = mid + 1;
int k = 0;
int length = high - low + 1;
vector<int> temp(length);
while (i <= mid && j <= high)
{
if (vec[i] <= vec[j])
{
temp[k++] = vec[i++];
} else
{
temp[k++] = vec[j++];
}
}
while (i <= mid)
{
temp[k++] = vec[i++];
}
while (j <= high)
{
temp[k++] = vec[j++];
}
for (i = low, k = 0; i <= high;)
{
vec[i++] = temp[k++];
}
}
void MergeSort(vector<int> &vec, int low, int high)
{
if (low < high)
{
int mid = (low + high) / 2;
MergeSort(vec, low, mid);
MergeSort(vec, mid + 1, high);
Merge(vec, low, mid, high);
}
return;
}
归并排序的时间复杂度也为O(nlogn)。归并排序是一种比较占用内存,但却效率高且稳定的算法。使用归并排序时,尽量考虑用非递归方法。