本文基本按照《数据结构》(陈越)课本目录顺序,外加大量的复杂算法实现和例题
数据结构是数据对象,以及存在于该对象的实例和组成实例的数据元素之间的各种联系
——SartajSahni《数据结构、算法与应用》
维基百科:数据结构是计算机中存储、组织数据的方式
关于数据对象在计算机中的组织方式,还包含两个概念:
一)是数据对象集的逻辑结构(线性或非线性);
数据类型描述两方面的内容:一是数据对象集;二是与数据集合相关联的操作集
数据的基本单位是数据元素
数据项是构成数据元素的不可分割的最小单位
一般而言,算法是一个有限指令集,它接受一些输入(有些情况下不需要输入),产生输出,并一定在有限步骤之后终止。
算法不是程序
一个显然的区别是:程序可以无限运行(操作系统)但算法必须在有限步后终止。
时间复杂度的计算
大O渐进表示法
大O符号(Big O notation):是用于描述函数渐进行为的数学符号。推导大O阶方法:
1、用常数1取代运行时间中的所有加法常数。
2、在修改后的运行次数函数中,只保留最高阶项。
3、如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶。
另外有些算法的时间复杂度存在最好、平均和最坏情况:
例1:计算一下这个算法的时间复杂度
// 请计算一下Func1基本操作执行了多少次?
void Func1(int N) {
int count = 0;
//两层循环嵌套外循环执行n次,内循环执行n次,整体计算就是N*N的执行次数
for (int i = 0; i < N ; ++ i)
{
for (int j = 0; j < N ; ++ j)
{
++count;
}
}
//2 * N的执行次数
for (int k = 0; k < 2 * N ; ++ k)
{
++count;
}
//常数项10
int M = 10;
while (M--)
{
++count;
}
printf("%d\n", count);
}
精确的时间复杂度是N ^ 2 + 2 * N + 10
大O的渐进表示法时间复杂度是O(N ^ 2)
分析:
1、两层循环嵌套外循环执行n次,内循环执行n次,整体计算就是N*N 的执行次数
2、2 * N的执行次数
3、常数项10
根据前面的大o渐进表示法规则,最后只保留那项对执行次数影响最大的那一项,时间复杂度就是O(N ^ 2)
例2:计算一下这个算法的时间复杂度
// 计算Func3的时间复杂度?
void Func3(int N, int M)
{
//执行M次
int count = 0;
for (int k = 0; k < M; ++ k)
{
++count;
}
//执行N次
for (int k = 0; k < N ; ++ k)
{
++count;
}
printf("%d\n", count);
}
时间复杂度:O(M + N)
假设:
M远大于N --> O(M)
N远大于M --> O(N)
M和N一样大 --> O(M) / O(N)
还是取影响最大的那一项,如果并没有说明M和N的大小关系,那么时间复杂度就是O(M + N)
例3:计算一下这个算法的时间复杂度(二分查找)
// 计算BinarySearch的时间复杂度?
int BinarySearch(int* a, int n, int x)
{
assert(a);
int begin = 0;
int end = n-1; .
while (begin < end)
{
int mid = begin + ((end-begin)>>1);//(end-begin)>>1相当于取中位数,向下取整
if (a[mid] < x)
begin = mid+1;
else if (a[mid] > x)
end = mid;
else
return mid;
}
return -1;
}
二分查找的思想:
二分查找是通过下标来搜索对应的元素值,前提是这个数组是有序的,通过确定中间位置划分左右区间,如果val小于mid那么目标值排除了出现在右边的情况,继续再左边区间查找,确定中间下标,通过中间下标划分左右区间,直到找到,否则一直重复规则直到循环结束,理解了二分查找的思想我们能想到的几种情况
每次确定中间下标,划分左右区间的时候都会除以2,那么
N / 2
N / 4
N / 8
.
.
.
1 //等于1的时候找到了
当找到了,要想知道它的执行次数的时候,通过它的展开就能知道
1 * 2 * 2 * 2 * 2 = N
2 ^ x = N
x = log N (以2为底,N的对数)
最长连续递增子序列
递归算法详解
数据组织的基本实现方式:数组、链表、结构体等
(很复杂的数据结构,图,树等也不外乎用数组和链表来实现)
相信大家对数组这种数据类型已经比较了解了,这里我放一个用二级指针动态创建二维数组的例子
int** data = (int**)malloc(sizeof(int*) * Maxsize);
for (int i = 0; i < 100; ++i)
{
data[i] = (int*)malloc(sizeof(int) * Maxsize);
}
//全部赋值为0
for (int i = 0; i <= T->mu; i++) {
for (int j = 0; j <= T->nu; j++) {
data[i][j] = 0;
}
}
typedef 原有数据类型名 新类型名
例:typedef int ElementType;
相当于给int起了一个别名叫ElementType
类型名 * 指针变量名
例:float *p;
指针与数组
struct 结构名{
类型名 结构成员名 1;
类型名 结构成员名 2;
类型名 结构成员名 3;
…
类型名 结构成员名 4;
};
放一个用两个结构体嵌套实现的矩阵的三元组形式的数据结构的表示的例子:
typedef struct {
int i, j; //该三元组元素的行下标和列下标
ElemType e; //数据域
}Triple;
typedef struct {
Triple data[MAXSIZE]; //非零元三元组表,data[0]使用了
int Rows, Cols, num; //矩阵的行数,列数和非零元个数
}TSMatrix;
结构指针:
typedef struct SNode* PtrToSNode;
struct SNode {
int* Data;
int MaxSize;
};
typedef PtrToSNode Stack;
Stack S;S是什么?
所以Stack S定义了一个名为S的指针,指向这个结构体类型;
S中的元素可以通过结构指针调用:S->Data、S->MaxSize;
单向链表,双向链表以及循环链表;
通常使用结构的嵌套来定义单向链表结点的数据类型:
typedef struct Node * PtrToNode;
struct Node{
int Data;//存储结点数据
PtrToNode Next;//指向下一个结点的指针
};
链表是一种动态的数据结构,所以也需要动态申请内存空间:
PtrToNode P = (PtrToNode)malloc(sizeof(struct Node));
单向链表的一些基本操作:
(1)插入结点
将t插入到p结点后面:
t->Next=p->Next;
p->Next=t;
在链表的头上插入一个结点t:
t->Next=head;
head=t;
(2)删除结点
从单向链表head中删除一个结点t:
首先找到被删除结点的前面一个结点p,然后删除p后面的那个结点;
t=p->Next;
p->Next=t->Next;
free(t)
如果删除的是链表的第一个结点:
t=head;
head=head->Next;
free(t);
(3)单向链表的遍历
p=head;
while(p!=NULL){
//基本操作
…
p=p->Next;
}
(4)链表的建立:
建立链表的过程其实就是不断在链表中插入结点的过程,常用的有头插法和尾插法,原理都一样,可以参考文章:尾插法创建单链表
创建和单链表很相似,只是多了一个前驱单元指针
typedef struct DNode * PtrToNode;
struct DNode{
int Data;//存储结点数据
PtrToNode Next;//指向下一个结点的指针
PtrToNode Previous;//指向前一个结点的指针
};
如果将双向链表的最后一个元素的Next指针指向链表的第一个单元,第一个单元的Previous指针指向链表的最后一个单元,就构成了一个双向循环链表。
双向链表的插入、删除和遍历基本思路和单链表相同,但需要同时考虑前后两个指针。
插入:
t->Previous=p;
t->Next=p->Next;
p->Next->Previous=t;
p->Next=t;
注:之前有人问过一个问题:在链表中加头节点和不加头节点有什么区别?
其实从上面对链表的基本操作就可以看出来,如果没有头结点,在链表中对第一个结点的操作的结构和其它结点是不相同的。
可以想象一下,比如有个题目是单链表的逆转,需要利用循环,从表头开始逐个进行处理,核心的要点是如何把握住循环不变式,很多类似的问题都是一样的道理,如果不加头节点是需要对第一个元素进行单独的判断的。
switch(表达式){
case 常量表达式 1:语句段1;break;
case 常量表达式 2:语句段2;break;
…
case 常量表达式 3:语句段3;break;
}
do{
循环体语句
}while(表达式)
break是直接强制结束循环 continue是跳过循环体中continue后面的语句,继续下一次循环
大家对函数应该都比较熟悉就不做赘述
对递归的算法理解可以参考:
递归算法详解
定义:线性表的顺序存储;
特点:(随机存取;物理相邻)
初始化:
List MakeEmpty(){
List L;
L = (List)malloc(sizeof(struct LNode));
L->Last = -1;
return L;
}
查找:
int Find(List L, int X) {
int i = 0;
while (i <= L->Last && L->Data[i]!= X) {
i++;
}
if (i > L->Last)return -1;
else return i;//找到的存储位置
}
插入:
bool ListInSert(List L, int i, int X) {//在L的第i个元素后插入X
if (i<1 || i>L->Last)return false;
for (int j = L->Last; j >= i; j--) {
L->Data[j] = L->Data[j - 1];//第i个元素及之后元素后移
}
L->Data[i - 1] = X;
L->Last++;
}
(1)顺序栈实现
typedef struct {
int data[MaxSize];//存放栈中元素
int top;//栈顶指针
};
void InitStack(Stack &S){
S->top=-1;
}
(3)进栈
bool Push(Stack &S, int X) {
if (S.top == MaxSize - 1)return false;//栈满
S.data[++S.top] = X;//指针先加一,再入栈
return true;
}
(4)出栈
bool Pop(Stack& S) {
if (S.top == -1) return false;
S.top--;
return true;
}
(1)顺序存储:
#define MaxSize 50
typedef struct {
int Data[MaxSize];
int front, rear;
}Queue;
struct QNode{
int *Data;
int front, rear;
int MaxSize;
};
typedef struct QNode* Queue;
创建:
Queue CreatQueue(int MaxSize) {
Queue Q = (Queue)malloc(sizeof(struct QNode));
Q->Data = (int*)malloc(MaxSize * sizeof(int));
Q->front = Q->rear = 0;
return Q;
}
判断是否队列已满:
bool IsFull(Queue Q) {
return (Q->rear + 1) % Q->MaxSize == Q->front;
}
判断队列是否为空:
bool IsEmpty(Queue Q) {
return (Q->front == Q->rear);
}
入队:
bool AddQ(Queue Q,int X) {
if (IsFull(Q))return false;
else {
Q->rear = (Q->rear + 1) % Q->MaxSize;
Q->Data[Q->rear] = X;
return true;
}
}
出队:
int DeleteQ(Queue Q) {
if (IsEmpty(Q))return -1;
else {
Q->front = (Q->front + 1) % Q->MaxSize;
return Q->Data[Q->front];
}
}
通常采用递归定义方法,左右子树有顺序之分。
二叉树有五种基本形态
a:空二叉树
b:只有根节点的二叉树
c:只有根节点和左子树的二叉树
d:只有根节点和右子树的二叉树
e:有根节点,左子树和右子树的二叉树
性质
1)一个二叉树的第i 层的最大节点数为 2^(i-1)
2)根据等比数列求和公式,深度为K的二叉树有最大节点总数为 2^k-1
3)对任何非空的二叉树T,若n0
左子树上的所有结点的关键字均小于根节点的关键字,右子树上所有结点的关键字均大于根节点的关键字
树上任一结点的左子树和右子树的深度之差绝对值不超过1;
由于树是非线性结构,创建一颗二叉树必须首先确定树中节点的输入顺序,常有的方法是先序创建和层序创建
层序生成二叉树:
typedef int ElementType;
#define NoInfo 0
BinTree CreatBinTree(){
ElementType Data;
BinTree BT,T;
Queue Q =CreatQueue();//创建空队列
//建立第一个结点(根节点)
scanf("%d",&Data);
if (Data!=NoInfo){
//分配根节点单元,并将结点地址入队
BT=(BinTree)malloc(sizeof(struct TNode));
BT->Data=Data;
BT->Left=BT->Right=NULL;
AddQ(Q,BT);
}
else return NULL;//第一个数据为0,返回空树
while(!IsEmpty(Q)){
T=DeleteQ(Q);//从队列中取出一结点的地址
scanf("%d",&Data);//读入T的左孩子
if(Data==NoInfo)T->Left=NULL;
else{
//分配新结点,作为出队结点的左孩子;新结点入队
T->Left=(BinTree)malloc(sizeof(struct TNode));
T->Left->Data=Data;
T->Left->Left=T->Left->Right=NULL;
AddQ(Q,T->Left);
}
scanf("%d",&Data);//读入T的右孩子
if(Data==NoInfo)T->Right=NULL;
else{
//分配新结点,作为出队结点的左孩子;新结点入队
T->Right=(BinTree)malloc(sizeof(struct TNode));
T->Right->Data=Data;
T->Right->Left=T->Right->Right=NULL;
AddQ(Q,T->Right);
}
return BT;
}
遍历方式有先序遍历,中序遍历,后序遍历,非递归遍历,层序遍历
对于先序遍历,中序遍历,后序遍历,其实很好区分,”先、中、后“都是对于根节点来说的
先序遍历就是根节点、左子树、右子树;中序遍历就是左子树、根节点、右子树;后序遍历就是左子树、右子树、根节点的顺序;
中序遍历:
void InorderTraversal(BinTree BT){
if(BT){
InorderTraversal(BT->Left);
printf("%d",BT->Data);//这部分在这里是输出,也可以看作是其它的操作
InorderTraversal(BT->Right);
}
}
先序遍历:
void InorderTraversal(BinTree BT){
if(BT){
printf("%d",BT->Data);//这部分在这里是输出,也可以看作是其它的操作
InorderTraversal(BT->Left);
InorderTraversal(BT->Right);
}
}
后序遍历:
void InorderTraversal(BinTree BT){
if(BT){
InorderTraversal(BT->Left);
InorderTraversal(BT->Right);
printf("%d",BT->Data);//这部分在这里是输出,也可以看作是其它的操作
}
}
非递归遍历:
前三种都是递归算法,但是并非所有程序设计语言都支持递归,另一方面,递归程序虽然简洁,但执行效率并不高
实现遍历的非递归算法需要用到堆栈,先序遍历是遇到节点就访问,中序遍历是在从左子树返回时遇到节点就访问,后序遍历是从右子树返回时遇到节点访问。
这里以非递归的中序遍历为例:
void InorderTraversal(BinTree BT){
BinTree T;
Stack S=CreateStack();//创建空堆栈
T=BT;
while(T||!IsEmpty(S)){
while(T){
Push(S,T);
T=T->Left;
}
T=Pop(S);
printf("%d",T->Data);
T=T->Right;
}
}
层序遍历:
层序遍历是按照树的层次,从第一层的根节点开始向下逐层遍历访问每个节点,对同一层中的节点是按照从左到右的顺序访问。
算法实现:设置一个队列,遍历从根节点开始,首先将根节点指针入队,然后执行下面三个操作:
1)从队列中取出一个元素
2)访问该元素所指节点
3)若该元素所指节点的左、右孩子非空,则将左右孩子的指针顺序入队
不断执行这三步操作,直到队列为空。
void LevelorderTraversal(BinTree BT){
Queue Q;
BinTree T;
if(!BT)return ;//若空树则直接返回
Q=CreatQueue();
AddQ(Q,BT);
while(!IsEmpty(Q)){
T=deleteQ(Q);
printf("%d",T->Data);
if(T->Left) AddQ(Q,T->Left);
if(T->Right) AddQ(Q,T->Right);
}
}
常规求法:由于要获得根节点的高度,首先要获得其左右子树的高度,所以通过后序遍历的方式,递归求解。
int GetHeight(BinTree BT){
int HL,HR,MaxH;
if(BT){
HL=GetHeight(BT->Left);
HR=GetHeight(BT->Right);
MaxH=HL>HR?HL:HR;
return (MaxH+1);
}
else return 0;//空树高度为0
}
定义:一个二叉搜索树是一棵二叉树,它可以为空。如果不为空,它将满足以下性质:
非空左子树的所有键值小于其根结点的键值。
非空右子树的所有键值大于其根结点的键值。
左、右子树都是二叉搜索树。
Position Find(BinTree BST,ElementType X){
if (!BST) return NULL;
if (X>BST->Data)
return Find(BST->Left,X);
else if(X<BST->Data)
return Find(BST->Right,X);
else
return BST;
}
非递归:
Position Find(BinTree BST,ElementType X){
while(BST){
if(X>BST->Data)
BST=BST->Right;
else if(X<BST->Data)
BST=BST->Left;
else
break;
}
return BST;
}
Position FindMin(BinTree BST){
if(!BST) return NULL;
else if(!BST->Left)return BST;//找到最左端点然后返回
else return FindMin(BST->Left);
}
Position FindMax(BinTree BST){
if(!BST)
whlie(BST->Right){
BST=BST->Right;
}
return BST;
}
将元素X插入到二叉搜索树BST中,关键是要找到元素应该插入的位置,如果Find到了X,说明要插入的元素存在,可放弃插入,如果不存在,查找终止的地方就是X应插入的位置。
BinTree Insert(BinTree BST ,ElementType X){
if (!BST){//原树为空,
BST=(BinTree)malloc(sizeof(struct TNode));
BST->Data=X;
BST->Left=BST->Right=NULL;
}
else{
if(X<BST->Data)
BST->Left=Insert(BST->Left,X);//递归插入到左子树
else if(X>BST->Data)
BST->Right=Insert(BST->Right,X);//递归插入到右子树
}
return BST;
}
二叉搜索树的删除比其它操作较为复杂,要删除结点的位置决定了所采取的操作,有三种情况
1)删除叶节点:可以直接删除,修改其父节点的指针
2)删除的结点只有一个子节点,修改其父指针,指向删除结点的子节点
3)删除结点有左右两颗子树,在保持二叉搜索树有序的条件下,要用哪棵子树来填充删除结点的位置?有两种选择,一是取右子树中的最小元素;二是取左子树中的最大元素;
BinTree Delete(BinTree BST,ElementType X){
int Tmp;
if (!BST)
printf("要删除的元素未找到");
else{
if(X<BST->Data){
BST->Left=Delete(BST->Left,X);
else if(X>BST->Data)
BST->Right=Delete(BST->Right,X);
else{//BST就是要被删除的结点
if(BST->Left&&BST->Right){
//从右子树中找到最小元素填充删除结点
Tmp=FindMin(BST->Right);
BST->Data=Tmp;
BST->Right=Delete(BST->Right,BST->Data);
else{//被删除的结点只有一个子节点或没有子节点
Tmp=BST;
if(!BST->Left)//只有右孩子或者无子节点
BST=BST->Right;
else
BST=BST->Left;
free(Tmp);
}
}
}
return BST;
}
定义:带权路径长度最小的二叉树(最优二叉树)
图的存储i结构有四种:邻接矩阵法,邻接表法,十字链表法,邻接多重表法
简单来说,就是用矩阵表示各顶点之间的邻接关系,放几个表示的例子:
无向图
有向图
特点:
1)无向图的邻接矩阵一定是对称矩阵
2)无向图邻接矩阵的第i行(或i列)非0元素的个数为第i个顶点的度
3)有向图邻接矩阵的第i行非0元素的个数是第i个顶点的出度;第i列非0元素的个数是第i个顶点的入度
4)用邻接矩阵存储图,很容易确定图中两个顶点是否有边相连,确定一个顶点的所有邻接点,也只需要对一行或一列进行检索;但是要确定图中有多少条边,必须按照行或列对所有元素进行检索;
1)若G为无向图,所需存储空间O(V+2E);
若G有向图,所需存储空间O(V+E);
2)图的邻接表表示不唯一
3)有向图的邻接表中,求一个给定顶点的出度,只需要计算其邻接表中的结点个数
邻接矩阵:
void Visit(Vertex V)
{
printf(" %d", V);
}
void DFS(MGraph Graph, Vertex V, void (*Visit)(Vertex))
{
Visit(V);//输出该节点的值
Visited[V] = true;//在Visit数组中进行标记该节点已经遍历过
for (int i = 0; i < Graph->Nv; i++)
{
if (Graph->G[V][i] == 1 && Visited[i] == false)DFS(Graph, i, Visit);//如果两节点之间有通路并且还没被访问过,访问i节点
}
return;
}
邻接表:
void Visit(Vertex V)
{
printf("访问结点: %d", V);
}
void DFS(MGraph Graph, Vertex V, void (*Visit)(Vertex))
{
PtrToAdjVNode w;
Visit(V);//输出该节点的值
Visited[V] = true;//在Visit数组中进行标记该节点已经遍历过
for (w=Graph->G[V].FirstEdge;w;w=w->Next)
{
if (!Visited[w->AdjV])
DFS(Graph, w->AdjV, Visit);//如果两节点之间有通路并且还没被访问过,访问该节点
}
}
邻接表:
void BFS ( LGraph Graph, Vertex S, void (*Visit)(Vertex) ){
int p[10001];
Visit(S);
int cnt=0,x=0;
Visited[S]=true;
p[cnt++]=S;
while(x!=cnt){
PtrToAdjVNode t=Graph->G[p[x++]].FirstEdge;
while(t){
int tt=t->AdjV;
if(!Visited[tt]){
p[cnt++]=tt;
Visit(tt);
Visited[tt]=true;
}
t=t->Next;
}
}
}
邻接矩阵:
bool IsEdge(MGraph Graph,Vertex V,Vertex W){//判断w是V的邻接点
return Graph->G[V][W]<INFINITY?True:False;
}
void BFS(MGraph Graph,Vertex S,void (*Visit)(Vertex)){
//以s为出发点对邻接矩阵存储的图进行BFS遍历
Queue Q;
Vertex V,W;
Q=CreateQueue(MaxSize);
Visit(S);//访问S,可以根据具体要求改写具体操作
Visited[S]=True;//标记S以访问
AddQ(Q,S);//入队列
while(!IsEmpty){
V=DeleteQ(Q);
for(W=0;W<Graph->Nv;W++){//图中的每个顶点
if(!Visited[W]&&IsEdge(Graph,V,W)){//W是V的邻接点并且未被访问
Visit(W);
Visited[W]=True;
AddQ(Q,W);
}
}
}
对于有n个结点的无向连通图,无论生成树的形态如何,只要是树,就都仅有n-1条边
如果一个无向连通图是一个网图,那么它的所有生成树中必有一棵边的权值总和最小的生成树,称为最小生成树
(未必唯一;一定没有环;边数=顶点数-1)
定义:在一个有向无环图中,满足以下条件的称为该图的一个拓扑序列:
1)每个顶点出现且只出现一次。
2)若存在一条从顶点A到顶点B的路径,在排序中顶点B出现在顶点A的后面。
可以判断一个有向图是否有环
定义:从开始顶点到结束顶点的所有路径中,具有最大路径长度的路径
关键活动:关键路径上的活动
可以看一下这位博主的,都是用c语言实现,而且有动图,非常好理解:排序算法