图
第一章 算法
1.1 算法的时间复杂度
-
时间复杂度的定义:
(1)时间复杂度表示为O(f(n)),随问题规模n的逐渐增大,算法时间的增长率和f(n)相同
(2)O(1):常数阶
(3)O(n):线性阶
(4)O(\(n^2\)):平方介
(5)O(\(log_2n\)):对数阶while(count < n){ //每次循环,count都会离n进一步,需要循环对数次 count = count*2; }
-
推到O阶的方法
(1)用常数1取代运行时的所有加法
(2)修改后的运行次数函数中,只保留最高阶
(3)如果最高阶存在且不是1,则去除这个项相乘的常数。
上面三部得到的结果就是O的阶
(4)eg:如下例子的复杂度
当i=0时,内循环执行了n次;当n=1时,执行了n-1次,所以总次数为n+(n-1)+(n-2)+...+1 = \(\frac{1}{2}n^2+\frac{1}{2}n\) 。 因此时间复杂度为O(\(n^2\))for(int i=0;i<n;i++){ for(j=i;j<n;j++){ /* 事件复杂度为O(1)的程序步骤 */ } }
(5)又一个例子
操作总次数=1+1+\(\frac{n(n+1)}{2}\),所以时间复杂度为O(\(n^2\))
c n++; /* 执行次数为1 */ fun(n); /* 执行次数为1 */ for(int i=0;i<n;i++){ /* 执行次数为如上面的例子 */ for(int j=i;j<n;j++){ /*时间复杂度为O(1)的操作*/ } }
1.2 常用事件复杂度的比较
- 时间复杂度公式
\(O(1) < O(logn) < O(n) < O(nlogn) < O(n^2) < O(n^3) < O(n!) < O(n^n)\)
第四章 树
三. 二叉树的存储结构
- 二叉链表
(1)左右孩子指针为空的,途中用^表示#include <stdio.h> #include <stdlib.h> typedef char TElemType ; #define MAXTREESIZE 1000 typedef struct BiTnode{ TElemType data; struct BiTnode *lchild,* rchild; }BiTnode,* BiTree;
- 遍历二叉树
(1)前序遍历:先根节点,在左右子树
(2)中序遍历:先左子树,根节点,右子树
(3)后序遍历:先左右子树,最后根节点/* 前序遍历 */ void preOrderTraverse(BiTree tree){ if(tree == NULL) return; printf("%c\n", tree->data); preOrderTraverse(tree->lchild); preOrderTraverse(tree->rchild); } /* 中序遍历 */ void inOrderTraverse(BiTree tree){ if(tree == NULL) return; inOrderTraverse(tree->lchild); printf("%c\n", tree->data); inOrderTraverse(tree->rchild); } /* 后序遍历 */ void postOrderTraverse(BiTree tree){ if(tree == NULL) return; postOrderTraverse(tree->lchild); postOrderTraverse(tree->rchild); printf("%c\n", tree->data); }
(4)层次遍历:树的第一层->第二层->第n层
-
推导遍历结果
有一种题型是给出前续+中续或后续+中续,求后续和前序遍历
这种题型现根据前序和后序找出根节点(第一个和最后一个打印),然后用根节点划分中序遍历为两部分,再去试 -
建立二叉树
(1)让用户输入每个二叉树节点的data值。为了确定每个节点是否有左右孩子,我们规定将二叉树中每个节点的空左右孩子指针引出一个虚节点,该虚节点有特定值"#"。使得原二叉树变成扩展二叉树。
(2)此时,若要形成下图的二叉树,其前序遍历的序列就为AB#D##C##
/* 以先序遍历的节点顺序输入 */ void createBiTree(BiTree *tree){ TElemType ch; scanf("%c",&ch); getchar(); printf("%d\n",ch); if(ch == '#'){ *tree = NULL; return; } *tree = (BiTree)malloc(sizeof(BiTnode)); (*tree)->data = ch; createBiTree(& (*tree)->lchild); createBiTree(& (*tree)->rchild); }
-
线索二叉树
(1)普通二叉树的结点分为数据域和指针域,指针域指向左右孩子结点。但是没有左右孩子的结点,其指针域指向空,也就浪费了这个指针。
(2)线索二叉树,对于没有左右孩子的节点,其lchild指针指向前驱结点,其rchild指针,指向后继结点。此时,加上线索的二叉链表成为线索链表。线索化的二叉树便于查找某些结点的前驱和后继结点。
(3)为了区分指针志向的时孩子结点还是前驱后继结点,引入ltag和rtag标记。标记为0,指向左右孩子;标记为1,指向前驱后继
(4)线索二叉树的定义#include <stdio.h> typedef char TElemType ; #define MAXTREESIZE 1000 typedef enum { // Link=0,指向孩子结点。Thread=1,指向前驱后继 Link,Thread; } PointerTag; typedef struct BiThreadNode{ TElemType data; struct BiThreadNode *lchild,*rchild; PointerTag ltag; PointerTag rtag; }BiThreadNode,*BiThrTree;
(5)对二叉树进行中序遍历线索化
c /* 中序遍历对二叉树中序线索化 */ BiThreadNode *pre; // 全局变量,指向刚刚访问过的节点 void inThreading(BiThreadNode *node){ if(node == NULL) return; inThreading(node->lchild); if(node->lchild == NULL){ // 没有左孩子 node->ltag = Thread; // 线索 node->lchild = pre; } if(pre->rchild == NULL){ // 前驱没有右孩子 pre->rtag = Thread; pre->rchild = node; } pre = node; inThreading(node->rchild); }
-
树,森林转化为二叉树
(1)树转化为二叉树
1)在同层所有相邻的兄弟结点之间,加上一条线
2)只保留结点的第一个孩子结点的连线,删除其余所有孩子结点的连线
3)此时,二叉树建立完毕,最后调整为正常的二叉树图形形式。
(2)森林转化为二叉树
1)把森林中的每个树都转换成二叉树
2)依次把后一棵树的根节点,作为前一棵二叉树的根节点的右孩子连接上去,形成一个大的二叉树。(由树转化而成的二叉树,根节点没有右孩子)
(3)二叉树转化为树
1)加线:把每个结点的左孩子的右孩子结点,左孩子的右孩子的右孩子结点 。。。左孩子所有右孩结点的右孩子结点与该结点连线连接
2)去线:删除原二叉树中所有结点与其右孩子结点的连线。
(4)二叉树转化为森林
判断一棵树原来是森林还是树,只要看这个二叉树的根节点有没有右孩子即可。有右孩子,则是森林
1)从根节点开始,把每个右孩子结点的连线断掉
2)对形成的多个树转换成二叉树
-
压缩编码的始祖:霍夫曼编码
(1)霍夫曼树:
霍夫曼树解决了当存在大量输入需要进行条件分支选择时,总输入的条件判断次数最少的解法。
比如如下成绩判断代码:if(a>60) b="不及格"; else if(a<70) b="及格"; else if(a<80) b="中等"; else if(a<90) b="优秀";
该代码粗看下没什么问题,但是输入量很大时就会有问题。发现不及格的人数非常少,中等的人最多,但是要判断成中等,需要经过是否大于60,大于70,大于80的三次判断才能出结果。如果把大于80小于90作为第一个条件判断,效率会大大提升。
(2)霍夫曼树的定义:
带权路径长度WPL最小的二叉树称为霍夫曼树。WPL=每个结点的路径长度 * 节点权值
WPL=53 + 153 + 402 + 302 + 10*2 = 220
(3)霍夫曼树的构建过程
选择权值最小的两个节点组成一个二叉树,该二叉树的根节点的权值为2个孩子结点权值之和,将该形成的二叉树加入备选结点。不断重复这个过程,直到点全部加入到二叉树。
(4)霍夫曼编码
比如一段消息包含ABCDEF几个字母,进行压缩发送。再给每个字母进行01编码时,通过字母出现次数作为权值,构建霍夫曼树,形成的每个字母的编码就是霍夫曼编码。霍夫曼编码使得编码后的总码字长最小。
eg:一段报文中,A出现27次,B出现8次,C出现15次,D出现15次,E出现30次,F出现5次,进行霍夫曼编码。
第四章 查找
4.1 查找概论
- 查找表:同一类型的数据构成的集合
- 关键字:数据元素种,某个数据项的值
- 静态查找表:只做查找操作
- 动态查找表:在查找的同时,做新增或删除操作
4.2 折半查找
-
折半查找有前提:
(1)线性表中的记录必须是关键字有序
(2)线性表必须采用顺序存储结构(不能是链表)/** 二分查找 * @param arr : 带查找数组 * @param n : arr[0]不做存储,待查数据产品那个arr[1]开始 * @param key :待查关键字 * @return :返回角标 */ int binerySearch(int *arr,int n,int key){ int low = 1; //从arr[1]开始查找 int high = n; int mid; while(low <= high){ mid = (low + high)/2; if(key < arr[mid]){ high = mid-1; }else if(key > arr[mid]){ low = mid + 1; }else{ return mid; } } return -1; }
4.3 线性索引查找
-
索引:把关键字与对应记录相关联的过程
(1)现行索引就是将索引项集合组织为现行结构
(2)现行索引结构称作索引表
(3)下面介绍3种索引表:稠密索引,分块索引,倒排索引 -
稠密索引
对数据表中的i每一项都做索引记录,使得索引表中的记录条数和数据表中的记录条数一样。 -
分块索引:
对数据项分块,使得快内数据无序,块间数据有序。索引表只对每块进行索引。 -
倒排索引:
搜索引擎的索引。
4.4 二叉排序树
-
二叉查找树:
又称二叉排序树,它是一个二叉树,具有如下性质:左子树结点上的值均小于根节点的值。右子树的值均大于根节点的值。
-
二叉排序树的递归查找
typedef struct BiTNode{ struct BiTNode *lchild, * rvhild; int data; }BiTNode,*BiTree; /** * 二叉排序树查找操作 * @param node : node所链接的树 * @param key :关键字值 * @param f : 指针f指向node结点的双亲。初始调用值为NULL * @param p :查找成功时,p指向该数据结点;查找失败时,指向查找路径上最后访问的节点。初始时,p是NULL * @return */ int serachBST(BiTree node,int key,BiTNode *f,BiTree *p){ if(node == NULL){ * p =f; return -1; }else if(key == node->data){ *p = node; return 0; }else if(key < node->data){ return serachBST(node->lchild,key,node,p); }else{ return serachBST(node->rvhild,key,node,p); } } /** * insertBST(tree,93) * @param tree * @param key * @return */ int insertBST(BiTree *tree,int key){ BiTree p, s; if(! serachBST(*tree,key,NULL,&p)){ // 查找不成功就开始插入将诶点 s = (BiTree) malloc(sizeof(BiTNode)); s->data = key; s->lchild = s->rvhild = NULL; if(p == NULL) // 开始没有根节点,创建s结点作为根节点 * tree = s; else if (key < p->data) p->lchild = s; // s作为做结点插入 else p->rvhild = s; // s作为右结点插入 return 1; }else{ return -1; // 树中已经有关键字的结点,无需插入 } } int main() { int a[10] = {1,2,45,234,12,6,78,123,43,111}; BiTree tree ; for (int i = 0; i < 10; i++) { insertBST(&tree ,a[i]); } }
-
二叉排序树的节点删除
(1)要删除的节点只有左子树/右子树
直接删除结点,并把其左子树/右子树代替删除结点的位置
(2)要删除的结点既有左子树,又有右子树
1)用二叉排序树书中该结点的直接前驱和直接后继元素代替需要删除的结点的位置
2)把替代元素的位置用其子树代替(此时替代元素只有一个子树,因为替代元素是最右结点或最左结点)
eg:要删除结点47,用他的前驱和后继结点
int deleteNode(BiTree *node); /** * 删除二叉排序树的结点 * @param tree * @param key * @return */ int deleteBST(BiTree *tree,int key){ if(* tree == NULL) return -1; if (key == (*tree)->data) return deleteNode(tree); else if(key < (*tree)->data) return deleteBST(&(*tree)->lchild,key); else return deleteBST(&(*tree)->rvhild,key); } /** * 链接断链 * @param node * @return */ int deleteNode(BiTree *node){ BiTree tmp,s; // tmp记录被删除的结点 if((*node)->rvhild == NULL) { // 右子树为空,只要链接左子树 tmp = *node; *node = (*node)->lchild; free(tmp); }else if((*node)->lchild == NULL){ // 左子树为空,只要链接右子树 tmp = *node; *node = (*node)->rvhild; free(tmp); }else{ // 被删除结点既有左子树又有右子树 tmp = *node; s = (*node)->lchild; while(s->rvhild != NULL){ // 找到左子树中的最右结点,就是被删除结点的前驱结点,把该结点代替删除结点的位置 tmp = s; // tmp指向s的前驱结点 s = s->rvhild; // s指向被删除结点左子树的最右结点 } (*node)->data = s->data; if(tmp != *node) tmp->rvhild = s->lchild; else tmp->lchild = s->lchild; free(s); } return 1; }
(3)二叉排序树的出现,是为了查找某个结点的时候,比较的次数很少。但是设想一种极端情况,带查找的数据是一个从小到大的数组,构建成二叉排序树后,是一个极端的右斜树。此时,虽然是一个二叉排序树,但是却不够“平衡”,层次太深。我们希望一个二叉排序树的深度像一个完全二叉树一样,为\(log_2n\),那么其查找的时间复杂度就为\(O(logn)\)。下面就开始介绍如何把一个二叉树变成一个平衡的二叉排序树
4.5 平衡二叉树(AVL树)
- 平衡二叉树:一种二叉排序树结构,其左右子树的深度最多相差1.所以,判断一个二叉树是否是平衡二叉树,首先看其是否是二叉排序树,其次看他的左右子树高度差。
- 左子树高度-右子树高度的值 = 平衡因子(平衡因子的取值只能是-1,1,0)
4.6 B树与B+树
-
B树
(1)B树和B+树都是完全平衡查找树,这种平衡,要求树种的每个结点的平衡因子都是0
(2)m阶B树(也称m路B树)的性质
a)所谓m阶或者m路,是说每个结点最多有m个孩子结点。(含有m个分支的意思)
b)为了增加B树查找的速度,他规定了每个结点的关键字个数要多,多个关键字之间不是相邻的整数,要有大小间隔。
这些间隔就指向了不同范围的孩子结点。(如下图,65与67不相邻)。
c)B数的节点结构:由三部分组成,开头是节点中关键字个数,然后依次交替的是指向孩子节点的指针与关键字的值
其中,关键字ki的值 > 指针p(i-1)所指向孩子节点的所有关键字的值。
d)B树规定:m路分支的B树,是说节点最多有m个分支,最少也要有\(\frac{m}{2}\)向上取整个分支。
也就意味着:m路分支的B树,其节点中的关键字最少有\(\frac{m}{2}\)向上取证-1个,最多有m-1个关键字
e)根节点例外于d中的条件,根节点最少只要2个分支即可,也就意味着,根节点可以只有一个关键字
(3)B树的查找过程
B树是查找硬盘数据的数据结构,他只能只有一个节点在内存中。
当要查找的数不在内存节点的记录中时,会找到节点中指向该范围的孩子节点的硬盘地址。
然后把这个地址上的节点加载到内存中进行查找。
(4)B树的最底层叶子节点,是NULL指针。他没有任何关键字。只是作为查找失败的标志:
只要查找到B树的叶子节点,证明记录不在B树中
最底层非叶子结点,有关键字,称作终端节点
(5)B树的高度范围:
若B树中存储的总关键字个数为n,且最多有m个分支(m阶)。则其高度满足公式:
\(【log_m(n+1)】 \leq h \leq 【log_\frac{m}{2}(\frac{(n+1)}{2})+1】\)
eg:3阶B树,共有8个关键字,其高度为\(log_39\leq h \leq 【log_1.5(\frac{9}{2})+1】\)
(6)B树的插入:(底层插,提中间点为父母)
a)B树的关键字插入,均在最底层的非叶子结点中
b)根据大小,找到位置插入。若发现插入后,节点的关键字个数 > m-1个。则把节点中中间的关键字,提到父节点中。然后拆分左右记录,挂到新增父节点关键字的两边。
(7)B树关键字删除
a)过删除的是非终端节点(非底层),删除关键字后,把两边指针指向的孩子节点合并
b)若删除的是终端节点:先直接删除,若删除后发现关键字个数太少,则向兄弟节点借关键字。若兄弟节点关键字不够借,则把双亲结点的关键字下落到终端节点上,再把下落后形成的节点挂在原节点的兄弟双亲结点上。一层层的双亲结点下落,重新调整树形。
(8)B树索引文件的例子:
1)文件的每一行称作文件的每一个记录。B树索引文件,就是对这些行进行索引
2)文件中的每一行会压缩成一个key(用来排序)和一个指向指针(指向文件记录真实内容)的关键字,存放在B树的节点中。
3)B树索引后形成的节点,最大只能占一个内存页。(这样,从磁盘拿节点时不必换入多个页)
4)B树节点开头的关键字个数字段,占2字节 -
B+树
(1)m个分支的B+树节点,有m个关键字
(2)m阶B+树:B+树的所有节点最多有m个分支。最少有\(\frac{m}{2}\)个分支
(3)B+树的叶子节点中,包含所有关键字。叶子节点的一层用指针链接形成一个链表,便于顺序查找所有关键字
4.7 散列表查找‘
-
散列表的引出
(1)我们一开始对无序列表进行查找的时候,需要挨个遍历比较。后来对有序的线性表查找时,采用折半查找的方式,比较待查找值与“中点”值大小进行查找。后来,我们为了进一步减少比较的次数,构建了二叉排序树,形成树形结构。接着,对二叉树形结构优化成平衡排序树,使得所有结点的比较次数都差不多相同。在后来,对于大量查找的数据,我们用B树和B+树对外存的数据建立索引进行查找。
(2)在折半查找顺序表结构时,我们可以进一步优化,直接通过待查找的值,计算出他可能存在的线性表位置,做到一次查找,就能判断是否命中。这种线性表就叫做散列表(Hash Table),而通过元素值计算元素位置的映射函数就是散列函数。
(3)因此,散列表即是存储方法,也是查找方法。
(4)装填因子:是散列表中装填的数据个数占表长的百分比 -
散列函数的构建
设计原则:计算简单,三列地址分布均匀
(1)直接定址法:$f(key) = a*key + b \((a,b均为常数) a)直接定址法是key的线性函数 b)这种方法不会产生位置冲突,但只适合表小且连续的情况。 (2)数字分析法 a)当存储的每个元素是一个大长串数字时,可以截取一小段作为key,然后对key进行一个函数的映射 b)数字分析法的关键在于截取长串数字中的一小段 (3)平方取中法 a)平方取中法,先计算key平方的值,然后取这个值的中间n位作为散列地址。 b)eg:key=1234,key方=1522756,中间3位就是227,作为散列地址 c)平方取中法适合不知道关键字分布,且关键字位数不是很大的情况。 (4)折叠法 a)折叠法把关键字柘城等长的几段,然后进行相加的和作为散列地址 b)eg:9876543210:987+654+321+000 = 1962散列地址 c)适合不知道关键字分布,但关键字位数不很大的情况。 (5)除数留余法 a)\)F(key) = key (mod) p\(。用关键字对p取模作为散列地址。此时,散列地址为(0~p-1) b)一般情况,p选择小于表长m的最大质数 (6) 随机数法:\)F(key) = random(Key)$ -
处理散列冲突的方法
(1)开放地址法
当发现f(key1)计算出的位置已经有元素的时候,就采用下式重新计算位置:
\(f_i(key) = (f(key) + d_i) MOD\) m (\(d_i\)=1,2,3 ... m-1)
(2)再散列法:
比如当采用数字分析法进行散列映射出现冲突后,改用平方取中法重新计算散列位置
(3)链地址法:
当出现散列冲突后,把该元素加在改为制的链表上 -
代码实现散列表查找
#define MAXSIZE 12 #define NULLKEY -32768 typedef struct{ int *elem; // 数组元素存储基址,动态分配数组 int count; // 当前数组元素的个数 }HashTable; /* 初始化散列表 */ int initHashTable(HashTable *hash){ hash->count = MAXSIZE; hash->elem = (int *)malloc(sizeof(int)); for (int i = 0; i < MAXSIZE; ++i) { hash->elem[i] = NULLKEY; } return 1; } /* 哈希映射函数:除数求余法 */ int hashFun(int key){ return key % MAXSIZE; } /* 插入散列表, 冲突解决采用开放定址法 */ void insertHashTable(HashTable *hash,int key){ int addr = hashFun(key); while(hash->elem[addr] != NULLKEY){ addr = (addr + 1) %MAXSIZE; } hash->elem[addr] = key; }
第七章 图
7.1 概念
- 连通图:如果图中任意两点都有路径,则该图是连通图
- 若一个有向图恰有一个顶点的入度为0,其与定点入度为1,则是一颗有向树
7.2 图的物理存储结构
因为图的节点度数相差很大,按照度数最大的顶点设计节点结构会造成存储单元浪费;如果按照每个顶点自己的度数设计不同结构,又会带来操作的不便
一、邻接矩阵
-
邻接矩阵存储使用2个数组存储图的信息:1个以为数组存储顶点,一个二维数组存储边的信息
(1)二维数组中的对角线为0,以为不存在顶点到自身的边
(2)要知道某个点的出度,就是顶点vi在第i行的元素之和,入度就是该顶点所在列的元素之和
(3)顶点vi的所有邻接点就是吧矩阵中第i行元素扫描一遍
(4)对于有权值的网,二维数组中的元素不再是0,1表示是否存在边,而是把元素值表示为权值。不存在的边,权值记录为\(\infty\);对角线上的权值为0.
-
邻接矩阵定义图
#include <stdio.h> typedef char VertexType; typedef int EdgeType; #define MAXVEX 100 #define IUNFINITY 65535 typedef struct { VertexType vexs[MAXVEX]; /* 顶点表*/ EdgeType arc[MAXVEX][MAXVEX]; /* 邻接矩阵 */ int vnum,edgenum; /*定点的个数和边的个数*/ }MGraphy; void createGraphy(MGraphy *g){ printf("input vetex num and edge num\n"); scanf("%d,%d",&g->vnum,&g->edgenum); for (int i = 0; i < g->vnum ; i++) { // 输入顶点字符 printf("input %d vetex:",(i+1)); setbuf(stdin, NULL); scanf("%c",&g->vexs[i]); } for(int i=0;i<g->vnum;i++){ // 初始化数组元素 Infonity for(int j=0;j<g->vnum;j++){ g->arc[i][j] = IUNFINITY; } } printf("input a,b,c represent corner mark and weight\n"); for(int i=0;i<g->edgenum;i++){ int a,b,c=0; printf("%d edge:",(i+1)); setbuf(stdin,NULL); scanf("%d,%d,%d",&a,&b,&c); g->arc[a][b] = c; g->arc[b][a] = c; // 无向图增加这个 } } int main() { MGraphy g ; createGraphy(&g); }
二. 邻接表
-
邻接矩阵对于顶点多而边数少的稀疏图造成存储空间的大量浪费。正如线性表的预先分配可能造成存储空间浪费,因此引入链式存储结构。同样可以考虑用链表存储边或弧。
-
邻接表:数组 + 链表
(1)用的数组存储每个节点
(2)数组中的每个节点的所有邻接点组成一个链表(因为邻接点的个数不确定)。这个邻接表就是顶点的出度表
(3)邻接表的图形表示
(4)邻接表关心了出度,但是查找入度就需要遍历整个图 -
创建邻接表
#include <stdio.h> #include <malloc.h> typedef char VertexType; typedef int EdgeType; #define MAXVEX 100 #define IUNFINITY 65535 typedef struct EdgeNode{ int adjvex; /* 邻接点域,该顶点对应的下标 */ EdgeType weight; EdgeNode *next; /* 链,指向下一个邻接点 */ }EdgeNode; typedef struct VertexNode{ /* 顶点表结点 */ VertexType data; /* 节点名字 */ EdgeNode *firstedge; /* 边表头节点 */ }VertexNode; typedef struct{ VertexNode adjList[MAXVEX]; /* 顶点表是一个结构体数组,数组中的元素是Vertex节点 */ int vnum,enumber; /* 图中当前顶点和边数 */ }GraphyAdjList; /* 建立邻接表结构 */ void createGraphy(GraphyAdjList *g){ EdgeNode *e; printf("input vertexNum and edgeNum:\n"); setbuf(stdin,NULL); scanf("%d,%d",&g->vnum,&g->enumber); for (int i = 0; i < g->vnum; i++) { printf("int %d vertex",(i+1)); setbuf(stdin,NULL); scanf("%c",&g->adjList[i].data); g->adjList[i].firstedge = NULL; /* 将边表设为空 */ } /* 建立边表 */ for (int k = 0; k < g->enumber; k++) { printf("input edge serialize num (i,j):\n"); int i,j; setbuf(stdin,NULL); scanf("%d,%d",&i,&j); e = (EdgeNode *) malloc (sizeof(EdgeNode)); } }
7.3 图的遍历
一. 基本思路
-
图的遍历:从图中某一个顶点出发遍历途中其余顶点,每一个顶点仅被访问一次
-
基本思路
(1)树有四种遍历方式,因为根节点只有一个。而图的复杂情况是的顺着一个点向下寻找,极有可能最后又找到自己,形成回路导致死循环。
(2)所以要设置一个数组voisited[n],n是图中顶点个数,初值为0,当该顶点被遍历后,修改数组元素的值为1
(3)基于此,形成了2中遍历方案:深度优先遍历和广度优先遍历
二. 深度优先遍历(DFS)
-
如下图所示,我们进行深度遍历,一个原则就是,每当我们发现有多个出度时,选择右手边的出度作为下一个遍历的顶点路径。
(1)从A出发,发现出度为B,F。选择右手边的B。A->B
(2)从B出发,出度为C,I,G,选择右手边的C
(3)从C出发,出度为I,D,选择右手边的D
(4)从D出发,出度为I,G,H,E,选择右手边的E
(5)从E出发,出度为H,F,选择右手边的F
(6)从F出发,出度为A,G,选择右手边的A,但发现A已经被遍历过,所以选择G
(7)从G出发,出度为B,D,H,B,D访问过了,选择H
(8)从H出发,出度为D,F,均被访问过了。但此时图中的节点并没有遍历完全,因此我们要按原路返回,去找没走过的路
(9)回退到G,发现所连接的BDFH均被访问;
(10)回退到F,没有通道;回退到E,没有通道,回退到D,发现一个点I,进行标记(若此时与D相邻的还有其他顶点,则在此时一起进行标记);然后继续回退到A,走完整个路 -
邻接矩阵下的深度遍历
int visited[MAXVEX] = {0}; void DFS(MGraphy g,int i){ visited[i] = 1; printf("%c,\t",g.vexs[i]); for (int j = 0; j < g.vnum; j++) { if(g.arc[i][j]!=0 && g.arc[i][j]!=IUNFINITY && !visited[j]){ DFS(g,j); } } } void DFSTraverse(MGraphy g){ printf("deep first search begin.\n"); for (int i = 0; i < g.vnum; i++) { if(!visited[i]){ DFS(g,i); } } } int main() { MGraphy g ; createGraphy(&g); printf("graphy create success ! ! !\n"); DFSTraverse(g); }
-
邻接表下的深度遍历
int visited[MAXVEX] = {0}; void DFS(Graph g, int i){ printf("%c",g.vset[i].name); visited[i] = 1; EdgeNode *edgeNode = g.vset[i].firstedgeNode; while(edgeNode!=NULL){ if(!visited[edgeNode->index]) DFS(g,edgeNode->index); edgeNode = edgeNode->next; } } void DFStraverse(Graph g){ for (int i = 0; i < g.vNum; i++) { // 用于不同连通分量 if(!visited[i]) DFS(g,i); } } int main() { Graph g; createGraphy(&g); printf("create graphy success ! ! !\n"); DFStraverse(g); }
三. 广度优先遍历
-
广度优先遍历类似输的层次遍历
(1)先入队列一个元素
(2)弹出队列顶端的1个元素打印,并把它连接的顶点入队
(3)重复以上过程,直到队列为空 -
BFS的过程
-
BFS的实现
7.4 最小生成树
- 应用场景
设想有9个村庄,这些村庄构成如下图所示的地理位置,每个村庄的直线距离都不一样。若要在每个村庄间架设网络线缆,若要保证成本最小,则需要选择一条能够联通9个村庄,且长度最小的路线
二. 最小生成树
-
最小生成树的概念
(1)一个带权值的图:网。所谓最小成本,就是用n-1条边把n个顶点连接起来,且连接起来的权值最小。
(2)我们把构造联通网的最小代价生成树称为最小生成树
(3)普里姆算法和克鲁斯卡尔算法 -
普里姆算法
如下图,普利姆的最小生成树过程为:用Vs存储已经遍历的点,用Es存储已经遍历的边
(1)选择D为起点,加入Vs,与D连接的边中,权值最小的边为5,连接的点为A,因此将A加入到Vs,路径DA加入到Es。
(2)此时Vs中存在D和A。与DA连接的边中,权值最小的为6,连接的点为F,因此F加入到Vs,边DF加入到Es。
(3)此时Vs中存在DAF,与DAF连接的边中最小权值为7,连接的点为B,因此B加入Vs,路径AB加入Es
(4)重复以上过程,知道Vs中加入了所有的点 -
克鲁斯卡尔算法
克鲁斯卡尔算法从边的集合中挑选权值最小的,加入到选择的边集合中。如果这条边,予以选择的边构成了回路,则舍弃这条边。
如下图所示,克鲁斯卡尔的方法为:
(1)选择权值最小为7的边V7-V4
(2)选择权值最小为8的边V2-V8
(3)选择权值最小为10的边V1-V0
(4)选择权值最小为11的边V0-V5
(5)选择全职最小为12的边V1-V8,但是发现V1和V8全部是已经访问的点,所以会构成回路,舍弃
(6)选择权值最小为16的边V1-V6
(7)选择权值最小为16的边V3-V7
(8)。。。。
7.5 最短路径
最短路径问题:求出图中,有多条路径连接的两个点的最短路径。
- 迪杰斯特拉算法
第九章 排序
9.1 基本概念
- 排序是线性表的一种操作
- 每个元素叫做记录,输入是一个元素集合,输出是一个元素集合
- 内排序吧整个数据ui放在内存中进行排序,外排序由于记录个数太多,不能全部放在内存中
- 7种排序算法分为两大类:
(1)简单算法:冒泡排序,简单选择排序,直接插入排序
(2)改进算法:希尔排序,堆排序,归并排序,快速排序 - 排序用到的数据结构
(1)线性表
(2)数组元素交换操作#include <stdio.h> #define MAXSIZE 10 typedef struct{ int r[MAXSIZE+1] ; // r[0]用于存储哨兵或临时变量 int length; }SqList; void swap(SqList *l,int i,int j){ int temp = l->r[i]; l -> r[i] = l->r[j]; l->r[j] = temp; }
(3)数组打印
c void printSqlList(SqList *l){ for (int i = 0; i < l-> length+1; i++) { printf("%d , ",l-> r[i]); } }
9.2 冒泡排序
- 冒泡排序的基本思想:
两两比较相邻记录的关键字,如果反序则交换记录,直到没有反序为止。 - 算法实现:
(1)冒泡排序每次循环都把一个最小的元素放到无序表的最顶端
(2)这个比较要从线性表的最后一位开始,逐个比较,把小的交换到前面,不断比较到线性表的开头,此时,最小的元素被放到了最开始的位置。
(3)当发现某一次循环中,未发生元素交换,就是说每个相邻元素,都是左小右大的,此时,线性表已经全具有序,不需要再次比较。引入flag比较为
/* 冒泡排序 */
void bubbleSort(SqList *list){
int flag = 1;
for (int i = 1; i < list->length && flag ; i++) { // 数组0位置不是元素
flag = 0;
for (int j = list->length-1; j >= i; j--) {
if(list->r[j+1] < list->r[j]){
flag = 1; // 产生交换操作,下一次还需要进行比较交换
swap(list,j,j+1);
}
}
}
}
(4)冒泡排序的时间复杂度
最好情况:线性表已经有序,需要比较n-1次
最坏情况:比较次数(n-1) + (n-2) + (n-3)+ ... +3 + 2 + 1 = \(\frac{n(n-1)}{2}\)
所以总时间复杂度:O(n^2)
9.3 简单选择排序
-
冒泡排序不停的进行比较和交换,确保局部为左小右大。而简单选择排序,是一种不断进行比较,却很少次数交换的排序算法
-
简单选择排序:选择无需列表中最小的元素,用一个min标记记录该元素位置。然后把该元素和无序列表的第一个元素交换
/* 简单选择排序 */ void simpleSelest(SqList *list){ for (int i = 1; i < list->length; i++) { for (int j = i+1; j <= list->length; j++) { int min = i; if(list->r[min]>list->r[j]) min = j; if(min != i) swap(list,i,min); } } }
-
简单选择排序的时间复杂度
所有的比较次数:最后一趟比较1次,倒数第二趟比较2次,所以一共比较1+2+3+..+(n-1)=\(\frac{n(n-1)}{2}\)
最好的时候,交换次数为0,最坏的情况下,交换次数为O(n),所以比较次数+交换次数形成算法的时间复杂度O(\(n^2\)),
9.4 直接插入排序
-
直接插入排序的思想
(1)直接插入排序是把无序列表中的每个元素,插入到已经排好序的有序列表中
(2)角标为0的位置,存放每次待插入的元素值。 -
时间复杂度
直接插入排序的时间复杂度要好于冒泡排序和简单选择排序,他的平均比较和移动次数约为\(\frac{n^2}{4}\) -
算法
/* 直接插入排序 */ void insertSort(SqList *list){ for (int i = 2; i <= list->length ; i++) { // 初始情况下,认为第一个数据是有序列表 if(list->r[i] < list->r[i-1]){ list->r[0] = list->r[i]; // 哨兵 int j; for (j = i-1; list->r[j] > list->r[0] ; j--) { list->r[j+1] = list->r[j]; } list->r[j+1] = list->r[0]; } printSqlList(list); printf("\n"); } }
9.5 希尔排序
- 冒泡排序,简单选择排序,直接插入排序一个比一个节省时间。但是都没有突破O(\(n^2\))的限制。长期以来,算法界甚至觉得 一切排序算法都无法超越O(\(n^2\))。
直到希尔排序出现,把时间复杂度提高到O(n*logn)。之后出现了一批超越O(\(n^2\))的算法 - 直接插入排序在某些情况下的效率很高:比如记录本身基本有序,只需少量插入操作就可以完成排序。还有就是纪录很少,插入的次数也就不多。但是这两个条件很苛刻。所以希尔排序出现了
。他的思路就是把无序列表,向着记录数少,基本有序靠拢。 - 希尔排序不是稳定的排序
- 希尔排序的思想:
希尔排序是分组内的直接插入排序。每趟保证分组内有序,最后increment为1,进行一次全部的直接插入排序
/* 希尔排序 */
void shellSort(SqList *list){
int increament = list->length ;
do{
increament = increament/3 + 1; // 增量序列
printf("increament is %d :",increament);
for (int i = increament+1; i <= list->length ; i++) {
if(list->r[i] < list->r[i-increament]){ // 初始时r[0]与r[increment]比较
list->r[0] = list->r[i]; // 暂存
int j = 0;
for (j = i-increament; j>0 && list->r[0] < list->r[j] ; j-=increament) {
list->r[j+increament] = list->r[j];
}
list->r[j+increament] = list->r[0];
}
}
printSqlList(list);
}while (increament > 1);
}
9.6 堆排序
-
节点大于等于左右孩子结点的完全二叉树称为大顶堆
节点小于等于左右孩子节点的完全二叉树称为小顶堆 -
堆排序的思想:
(1)形成大顶堆,使得根节点为待排数中最大的数
(2)将这个堆顶与最后一个孩子节点交换,使得最大的元素在最后
9.6 快速排序
- 对冒泡排序的改进