数据结构:线性表、队列、栈、串、树、图
1. 简介
数据结构是一门研究非数值计算的程序设计问题中的操作对象,以及它们之间的关系和操作等相关问题的学科。
数据:是描述客观事物的符号,是计算机中可以操作的对象,是能被计算机识别,并输入给计算机处理的符号集合。
数据元素:是组成数据的、有一定意义的基本单位,在计算机中通常作为整体处理,也被称为记录。
数据项:一个数据元素可以由若干个数据项组成。数据项是数据不可分割的最小单位。
数据对象:是性质相同的数据元素的集合,是数据的子集。
结构:各个组成部分相互搭配和排列的方式。在现实世界中,不同数据元素之间不是独立的,而是存在特定的关系,我们将这些关系称为结构。
数据结构:是相互之间存在一种或多种特定关系的数据元素的集合。
逻辑结构:指数据对象中数据元素之间的相互关系。
逻辑结构分为四种:
- 集合结构:集合结构中的数据元素除了同属于一个集合外,他们之间没有关系。
- 线性结构:线性结构中的数据元素之间是一对一的关系。
- 树形结构:树形结构的元素之间存在一种一对多的层次关系。
- 图形结构:图形结构的数据元素是多对多的关系。
物理结构:是指数据的逻辑结构在计算机中的存储形式。数据的存储结构应正确的反应数据元素之间的逻辑关系。其基本目标就是将数据及其逻辑关系存储到计算机的内存中。
数据元素的存储结构形式有两种:
- 顺序存储结构:是把数据元素存放在地址连续的存储单元里,其数据间的逻辑关系和物理关系是一致的。数组就是这样的。
- 链式存储结构:是把数据元素存放在任意的存储单元里,这组存储单元可以是连续的,也可以是不连续的。链表。
逻辑结构是面向问题的,物理结构是面向计算机的。
数据类型:是指一组性质相同的值的集合,以及定义在此集合上的一些操作的总称。
抽象是指抽取事物具有的普遍的本质。抽象是一种思考方式,它隐藏了繁杂的细节,只保留实现目标所必须的信息。
抽象数据类型(Abstract Data Type, ADT):一个数字模型以及定义在该模型上的一组操作。实际上,抽象数据类型体现了程序设计中问题分解、抽象和信息隐藏的特性。
2. 算法
算法:是解决特定问题求解步骤的描述,在计算机中表现为指令的有限序列,并且每条指令表示一个或多个操作。
算法具有五个基本特性:输入、输出、有穷性、确定性、可行性。
- 输入:算法可以存在零个或多个输入。
- 输出:算法一定存在至少一个输出。
- 有穷性:指算法在执行有限的步骤之后,自动结束而不会出现无线循环,并且每个步骤在一个可以接受的时间内完成。
- 确定性:算法的每一步骤都有确定的意义,不会出现二义性。
- 可行性:算法的每一步都必须是可行的,也就是说,每一步都能够通过执行有限次数完成。
算法设计的要求:
- 正确性:算法的正确性是指算法至少应该具有输入、输出和加工处理无歧义性,能正确反应问题的需求,能够得到问题的正确答案。
- 可读性:算法的另一种目的是为了便于阅读、理解、交流。
- 健壮性:当输入数据不合法时,算法也能做出相关处理,而不是产生异常和莫名其妙的结果。
- 时间效率高:指算法的执行时间。
- 储存量低:指算法程序运行时所占用的内存或外部硬盘存储空间。
算法效率的度量方法:
- 事后统计方法:这种方法主要是通过设计好的测试程序和数据,利用计算机计时器对不通苏纳法编制的程序的运行时间进行比较,从而确定算法效率的高低。
- 事前分析估算方法:在计算机程序编制前,依据统计方法对算法进行估算。
一个程序的运行时间,依赖于算法的好坏和问题的输入规模。所谓问题输入规模是指的输入量的多少。最终,在分析程序的运行时间是,最重要的是把程序看成是独立于程序设计语言的算法或一系列步骤。
函数的渐近增长:输入规模n在没有限制的情况下,只要超过一个数值N,这个函数就总是大于另一个函数,我们称函数是渐近增长的。
函数的渐近增长:给定两个函数f(n和g(n),如果存在一个整数N,使得对于所有的n>N,f(n)总是大于g(n),那么,我们说f(n)的增长渐近快于g(n)。
我们通常忽略加号后面的常数,最高项相乘的常数也不重要,最高次项的指数大的,函数随着n增长,结果也会增长更快。从而可以得出一个结论:
- 判断一个算法的效率时,函数中的常数和其他次要项常常可以忽略,而更应该关注主项(最高阶项)的阶数。
算法时间复杂度:在进行算法分析时,语句总的执行次数T(n)是关于问题规模n的函数,进而分析T(n)随n的变化情况并确定T(n)的数量级。算法的时间复杂度,也就是算法的时间量度,记作T(n)= O( f(n) )。它表示随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,乘坐算法的渐近时间复杂度。简称为时间复杂度。其中f(n)是问题规模n的某个函数。
这样用大写O()来体现算法时间复杂度,我们称之为大O记法。
目前可公开情报:O(1)叫常数阶,O(n)叫线性阶,O(n2)是平方阶
推导大O阶:
- 用常数1取代运行时间中的所有加法常数。
- 在修改后的运行次数函数中,只保留最高阶项。
- 如果最高阶项存在且其系数不是1,则去除与这个项相乘的系数。
分析算法的复杂度,关键就是分析循环结构的运行情况。
常见的时间复杂度:
执行次数函数 | 阶 | 非正式术语 |
---|---|---|
12 | O(1) | 常数阶 |
2n+3 | O(n) | 线性阶 |
3n2+2n+1 | O(n2) | 平方阶 |
5log2n+20 | O(log n) | 对数阶 |
2n+3nlog2n+19 | O(nlogn) | n logn阶 |
6n3+2n2+3n+4 | O(n3) | 立方阶 |
2n | O(2n) | 指数阶 |
常用的时间复杂度所消耗的时间按从小到大依次是:
-
O (1)<O (log n)<O (n)<O (nlog n)<O (n2)<O (n3)< O (2n)<O (n!)<O (nn)
空间复杂度:算法的空间复杂度通过计算算法所需的存储空间实现,算法空间复杂度的计算公式记作:S(n) = O(f(n)),其中,n为问题的规模,f(n)为语句关于n所占存储空间的函数。
3. 线性表
线性表:零个或多个数据元素的有限序列。
- 线性表是一个序列,元素之间是由次序的,若存在多个元素,则第一个元素无前驱,最后一个元素无后继,其它的每一个元素都有且只有一个前驱和后继。
- 线性表强调元素有限。元素的个数是有限的。
如果将线性表记为(A,B,C,····),则表中A领先于B,B领先于C,称A为B的直接前驱元素,C为B的直接后继元素。
线性表的长度&空表:线性表中元素的个数n(n≥0)定义为线性表的长度,当n=0时,称为空表。
线性表的位序:在非空表中,每一个数据元素都有一个确定的位置,这个位置i称为数据元素ai在线性表中的位序。
在较复杂的线性表中,一个数据元素可以由若干个数据项组成。
当你传递一个参数给函数的时候,这个参数会不会改动决定了使用什么样的参数形式:
- 如果需要被改动,则需要传递指向这个参数的指针。
- 如果不用被改动,可以直接传递这个参数。
线性表的顺序存储结构,指的是用一段地址连续的存储单元依次存储线性表的数据元素。
一维数组来实现顺序存储结构。
存储器中的每一个存储单元都有自己的编号,这个编号称为地址。
随机存取结构:存取时间性能为O(1)的存储结构,例如线性表。
线性表顺序存储结构:
- 优点
- 无须为表示表中元素之间的逻辑关系增加个额外的空间
- 可以快速的存取任意位置的元素
- 缺点
- 插入何删除操作需要移动大量元素
- 当线性表长度拜年话较大时,难以确保存储空间的容量
- 造成存储空间的碎片
在链式结构中,出了要存储数据元素信息外,还要存储它的后继元素的存储地址。因此,为了表示每个数据元素ai与其直接后继元素数据元素ai+1之间的逻辑关系,对于数据元素ai来说,除了存储自身的信息之外,还需要存储一个指示其直接后继的信息(即直接后继的存储位置)。我们把存储数据元素信息的域称为数据域,把存储直接后继位置的域称为指针域。指针域中存储的信息称为指针或链。这两部分信息组成数据元素ai的存储映像,称为结点(Node)。
线性表的链式存储结构:n个结点(ai的存储映像)链结成一个链表,即为线性表(a1,a2,···,an)的链式结构。
每个结点中只含一个指针域,叫做单链表。链表中第一个节点的存储位置叫做头指针。
有时候,为了更方便的对链表进行操作,会在单链表的第一个结点前附设一个结点,称为头结点。
头指针和头结点的异同:
- 头指针:
- 头指针是指链表指向第一个结点的指针,若链表有头结点,则是指向头结点的指针。
- 头指针具有标志作用,所以常用头指针冠以链表的名字。
- 无论链表是否为空,头指针均不为空,头指针是链表的必要元素。
- 头结点:
- 头结点是为了操作方便统一设立的,放在第一元素的结点之前,其数据域一版无意义(也可以存放链表的长度)
- 有了头结点,对在第一元素结点前插入结点和删除第一结点,其操作与其他结点就统一了。
- 头结点不一定是链表的必要要素。
对于插入或删除数据越频繁的操作,单链表的效率优势越明显。
头插法:始终让新结点处在链表的第一的位置。
尾插法:始终让新结点处在链表的最后终端结点后面。
单链表结构&顺序存储结构的优缺点:
存储结构 | 存储分配方式 | 时间性能 | 空间性能 |
---|---|---|---|
顺序存储结构 | 用一段连续存储单元依次存储线性表的数据元素 | 查找:O(1) 插入和删除:需要平均移动表长一半的元素,时间复杂度为O(n) |
需要预留存储空间,分大了浪费,分小了容易出现溢出 |
单链表结构 | 采用链式存储结构,用一组任意的存储单元存放线性表的元素 | 查找:O(n) 插入和删除:在拿到需要操作位置的指针后,时间复杂度为O(1) |
不需要分配储存空间,只要有就可以分配,元素个数也不受限制 |
- 若线性表需要频繁的查找,很少进行插入和删除操作时,宜采用顺序存储结构。
- 当线性表中的元素个数变化较大或者根本不知道有多大时,最好用单链表结构。
静态链表:用数组描述的链表称为静态链表,这种描述方法还有起名叫做游标实现法,是为了没有指针描述的语言准备的一种单链表。在静态链表中,每一个元素都由两个数据域构成,data和cur。也就是说,每个数组的下表都对应一个data和一个cur。数据域是用来存放数据元素的,即通常要进行处理的数据,而cur相当于单链表中的next指针,存放元素的后继在数组中的下标,我们将cur叫做游标。
数据结构 | 优点 | 缺点 |
---|---|---|
静态链表 | 在插入和删除操作时,只需要修改游标,不需要移动元素,从而改进了在顺序存储结构中插入和删除操作需要移动大量元素的缺点。 | 没有解决连续存储分配带来的表长难以确认的问题,失去了链式存储结构随机存取的特性。 |
循环链表:将单链表中的终端节点的指针端由空指针改为指向头结点,就使整个单链表形成一个环,这种头尾相连的单链表称为单循环链表,简称循环链表。
循环链表遍历时,就需要判断当前遍历结点非头结点,否则循环结束。
单链表中,我们有了next指针,这使得我们查找下一节点的时间复杂度为o(1),可如果查找上一结点,时间复杂度最坏就是O(n)。
双向链表:是在单链表的每个结点中,再设置一个指向其前驱节点的指针域。所以双向链表中每个结点都有两个指针域,一个指向直接后继,一个指向直接前驱,从而克服单向性的缺点。
总结:
- 线性表
- 顺序存储结构
- 链式存储结构
- 单链表
- 静态链表
- 循环链表
- 双向链表
4. 栈和队列
- 栈:是限定仅在表尾(栈顶)进行插入和删除操作的线性表。
- 队列:是指允许在一段进行插入操作,而在另一端进行删除操作的线性表。
LIFO结构:我们把允许插入和删除的一段称为栈顶(top),另一端称为栈底(bottom),不含任何数据元素的栈称为空栈。栈又称为后进先出(Last In First Out)的线性表,简称LIFO结构。
栈的插入操作,叫做进栈,也称压栈(push)、入栈。
栈的删除操作,叫做出栈,也称弹栈(pop)。
栈对线性表的插入和删除的位置做了限制,并没对元素进出的时间做限制。在不是所有元素都进栈的情况下,事先进去的元素可以出栈,只要保证是栈顶元素出栈就可以。
ADT 栈(stack)
Data
同线性表。元素具有相同的类型,相邻元素具有前驱和后继的关系。
Operation
InitStack( S ):建立一个空栈S。
DestroyStack( S ):若栈S存在,则销毁它。
ClearStack( S ):将栈S清空。
StackEmpty( S ):返回栈S是否为空。
GetTop( S ):若栈存在且非空,返回栈顶元素。
Push( S , e ):若栈存在且非空,则插入元素e成为栈顶元素。
Pop( S ):删除栈S中栈顶元素,并返回栈顶元素值。
StackLength( S ):返回栈S中元素个数。
endADT
栈的顺序存储结构:栈作为线性表的特例,那么栈的顺序存储其实也是线性表存储的简化,简称为顺序栈。
两个栈共享空间:
此时满栈条件为top1 + 1 = top2 。
使用这样的数据结构往往是因为两个栈的空间需求具有相反的关系,否则两个同时增长,很快就会栈满溢出。
栈的链式存储结构,简称为链栈。
如果栈的使用过程中元素变化不可预料,有时很小,有时非常大,那么做好用链栈,反之,如果它的变化在可控范围内,建议使用顺序栈 会好很多。
栈的引入,简化了程序设计问题,划分了不同关注层次,使得思考范围缩小,更加聚焦我们要解决的问题核心。
递归函数:我们将一个直接调用自己或通过一系列的调用语句间接地调用自己的函数,称为递归函数。
每个递归函数必须有至少一个退出递归的条件,即不在引用自身,而是返回值退出。
迭代和递归的区别:
- 迭代使用的是循环结构,不需要重估调用函数和占用额外内存。
- 递归使用的是选择结构,能使程序的结构更清晰更简洁更容易让人理解,减少代码的阅读时间,但是大量的递归会建立函数副本,会消耗大量的时间和内存。
FIFO结构:队列是一种先进先出(First In First Out)的线性表,简称FIFO。允许插入的一端称为队尾,允许删除的一端称为队头。
ADT 队列(Queue)
Data
同线性表。元素具有相同的类型,相邻元素具有前驱和后继的关系。
Operation
InitQueue( Q ):建立一个空队列Q。
DestroyQueue( Q ):若队列Q存在,则销毁它。
ClearQueue( Q ):将队列Q清空。
QueueEmpty( Q ):返回队列Q是否为空。
GetHead( Q ):若队列Q存在且非空,返回队头元素。
EndQueue( Q ,e ):若队列Q存在且非空,插入元素e作为队尾元素。
DeQueue( S , e ):若队列Q存在且非空,删除队列Q中队头元素。
QueueLength( Q ):返回队列Q中元素个数。
endADT
顺序存储的队列:
为了避免队头和队尾重合的情况,引入了两个指针,front指针指向队头元素,rear指针指向队尾元素的下一个位置。这样当 front = rear 的时候,队列中不是还剩一个元素,而是空队列。
循环队列:循环队列是为了解决顺序存储队列的假溢出问题(后边满了,但是前边空着,可是队列却判定溢出了),后边满了,就从头开始,即头尾相接的顺序存储结构称为循环队列。
此时,当队列满时:front = rear ,当队列空时:front = rear,怎么知道队列是否满呢?
- 设置一个变量flag,当front = rear && flag = 0时,队列为空,当front = rear && flag = 1时,队列为满。
- 当队列空时,条件为front = rear,当队列满时,此时空出一个元素空间,修改队列满时的条件。此时rear和front整整差了一圈,若队列的最大尺寸为QueueSize,那么队列满的条件为(rear+1)% QueueSize == front。因此,通用的计算队列长度的通式为:**(rear - front + QueueSize)% QueueSize **
队列的链式存储结构:其实就是线性表的单链表,只不过它只能尾进头出而已,我们把它简称为链队列。
在可以确定队列长度的最大值的情况下,建议使用循环队列,如果无法预估队列的长度,则使用链队列。
5. 串
串:串String是由零个或多个字符组成的有序序列,又叫字符串。
一般记为s = "a1a2...an"(n > 0),串中的字符数目n为串的长度。零个字符的串称为空串,长度为0。所谓的序列,说明串的相邻字符之间具有前驱和后继的关系。
ASCII编码:计算机使用的常用字符是标砖的ASCII编码,由7个二进制数表示一个字符,最多可以表示128个。后来由于特殊字符的出现,扩展ASCII码表由8个二进制数表示一个字符实现,此时共可表示256个字符。
Unicode编码:由于需要兼容全世界的语言字符,出现了Unicode编码,由一个16位的二进制编码表示一个字符,这样共可表述216个字符,约为6.5万个字符。
为了与ASCII编码兼容,Unicode编码的前256个字符与ASCII码相同。
串的模式匹配:像字串的定位操作通常称作串的模式匹配。
朴素模式匹配算法:一个一个的对比,最坏情况下的时间复杂度为O(((n-m)+1)× m)
KMP模式匹配算法:一个模式匹配算法,可以大大避免重复遍历的情况,我们将它称为克努特-莫里斯-普拉特算法,简称KMP算法。在需要查找字符串前,先对要查找的字符串做一个分析,这样可以大大减少我们查找的难度,提高查找的速度。主要是消除了主串的指针回溯,加速了查找速率。
KMP模式匹配算法:
6. 树
树:树(Tree)是n(n ≥ 0)个结点的有限集。n = 0时称为空树。
在任意一颗非空树中:
- 有且仅有一个特定的称为根(Root)的结点
- 当n > 1 时,其余结点可分为m(m > 0)个互补相交的有限集T1、T2、T3、····、Tm,其中每一个集合本身又是一棵树,称为根的子树(SubTree)。子树之间一定是互不相交的。
树的结点包含一个数据元素及若干指向其子树的分支。
- 结点拥有的子树数称为结点的度(Degree)。
- 度为0的结点称为叶节点或终端结点。
- 度不为0的结点称为分支节点或非终端结点。
- 除根节点以外,分支结点也被称为内部节点。
- 树的度是树内各结点的度的最大值。
- 结点的子树的根称为该结点的孩子(Child)
- 该结点称为孩子的双亲(Parent),因为对于孩子结点来说,其父母同体,所以称为双亲。
- 同一个双亲结点的孩子结点之间互称兄弟(Sibling).
- 结点的祖先是从根结点到该结点上所经历分支上的所有结点。
- 以某一结点为根的子树中的任一结点都称为该结点的子孙。
- 结点的层次(Level)从根开始定义起,根为第一层,根的孩子为第二层。
- 双亲再同一层的结点,互为堂兄弟。
- 树中结点的最大层次称为树的深度(Depth)或高度。
- 如果树中结点的各子树看成从左至右是有次序的,不能互换的,则称该树为有序树,否则称为无序树。
- 森林(Forest)是m(m ≥ 0)棵互不相交的树的集合。对书中每个结点而言,其子树的集合即为森林。
线性结构 | 树结构 |
---|---|
第一个数据元素:无前驱 最后一个数据元素:无后继 中间元素:一个前驱,一个后继 |
根节点:无双亲,唯一 叶节点:无孩子,可以多个 中间结点:一个双亲多个孩子 |
ADT 树(tree)
Data
树是有一个根节点和若干棵子树构成的。树中结点具有相同数据类型及层次关系。
Operation
InitTree(T):构造空树T。
DestroyTree(T):销毁树T。
CreateTree(T , defintion):按defintion中给定的规则来构造树T。
CleanTree(T):若树T存在,则清空树。
TreeEmpty(T):返回树T是否为空树。
TreeDepth(T):返回树T的深度。
Root(T):返回树T的根节点。
Value(T , cur_e):返回树T中非根结点cur_e的值。
Assign(T , cur_e , value):将树T中的结点cur_e赋值为value。
Parent(T , cur_e):若cur_e是树T中的非根结点,返回它的双亲结点,否则返回空。
LeftChild(T , cur_e):若cur_e是树T的非叶节点,返回它的最左孩子,否则返回空。
RightSibling(T , cur_e):若cur_e有右兄弟,则返回它的右兄弟,否则返回空。
InsertChild(T , p , i , c):其中,p指向树T的某个结点,i为所指结点p的度上加1,非空树c与T不相交,操作结果为插入c为树T中p所指结点的第i棵子树。
DeleteChild(T , p , i):其中p指向树T的某个结点,i为所指结点p的度,操作结果为删除T中p所指结点的第i棵子树。
endADT
树的存储结构
双亲表示法:在每个结点中,附设一个指示器指示其双亲结点在数组中的位置。
-
结点结构:
-
data:数据域
-
parent:指针域,存储结点双亲在数组中的下标(用数组存储树结构)
多重链表表示法:每个结点有多个指针域,其中每个指针指向一棵子树的根节点,我们把这种方法叫做多重链表表示法。
-
结点结构:
-
data:数据域
-
child1-childd:是指针域,指向该结点的孩子结点(每棵子树的根节点)。
-
d的大小为树的度:当树的各结点度相差很大时,开辟的空间会浪费很多
- 实现如图:
-
d的大小为该结点的度:需要额外维护一个位置来存储结点指针域的个数。
-
degree为度域。
-
实现如图:
孩子表示法:把每个结点的孩子结点排列起来,以单链表做存储结构,则n个结点有n个孩子链表,如果是叶子结点则此单链表为空。然后n个头指针又组成一个线性表,采用顺序存储结构,存放进一个一维数组中。
-
孩子结点结构:
- child:孩子结点的数据域,存放某个结点在表头数组中的下标。
- next:指针域,用来存储指向某节点的下个孩子结点的指针。
-
表头结构:
- data:数据域,保存某结点的数据信息。
- firstchild:头指针域,保存孩子链表们的头指针。
双亲孩子表示法:算是孩子表示法的改进,综合双亲表示法和孩子表示法,可以方便遍历孩子结点,兄弟结点,双亲结点。
孩子兄弟表示法:我们发现,对于任意一棵树,它的结点的第一个孩子,如果存在就是唯一的,它的右兄弟如果存在也是唯一的。因此,我们设置两个指针,分别指向第一个孩子和此结点的右兄弟。
-
结点结构:
-
data:数据域
-
firstchild:指针域
-
rightsib:指针域,右兄弟结点的存储地址
这个表示法最大的好处是把一棵复杂的树,变成了一棵二叉树。因此就可以利用二叉树的特性和算法来处理这棵树了。
存储结构的设计是一个非常灵活的过程,一个存储结构设计的是否合理,取决于基于该存储结构的运算是否合适、是否方便,时间复杂度好不好等。
二叉树
二叉树(Binary Tree):是n(n≥0)个结点的有限集合,该集合或者为空集(称为空二叉树),或者由一个根节点和两颗互不相交的、分别称为根节点的左子树和右子树的二叉树组成。
二叉树的特点
- 每个结点最多存在两棵子树,所以二叉树中不存在度大于2的结点。
- 左子树和右子树是有顺序的,次序不可以任意颠倒。
- 即使树中的某结点只有一棵子树,也要区分它是左子树还是右子树。
二叉树的五种基本形态
- 空二叉树
- 只有一个根节点
- 根节点只有左子树
- 根节点只有右子树
- 根节点既有左子树又有右子树
对于一个存在三个结点的二叉树,存在如下5种形态:
特殊二叉树
-
斜树:所有的结点都只有左子树的二叉树叫左斜树。所有结点都只有右子树的二叉树叫右斜树。两者统称为斜树。
- 斜树的特点很明显,就是每层都只有一个结点,结点的个数与二叉树的深度相同。如上图的树、树5。
-
满二叉树:在一颗二叉树中,如果所有分支结点都存在左子树和右子树,并且所有的叶子节点都在同一层上,那么这样的二叉树称为满二叉树。
-
满二叉树的特点有:
- 叶子只能出现在最下面一层,出现在其他层就不能达到平衡。
- 非叶子结点的度一定为2。
- 在同样深度的二叉树中,满二叉树的结点个数最多,叶子数最多。
-
完全二叉树:对一棵具有n个结点的二叉树按层序编号,如果编号为i (1 ≤ i ≤ n)的结点与同样深度的满二叉树中编号为i的结点在二叉树中位置完全相同,则这颗二叉树称为完全二叉树。
满二叉树一定是完全二叉树,但是完全二叉树不一定是满二叉树。
完全二叉树的所有结点与同样深度的满二叉树,他们按层序编号相同的结点,是一一对应的。下图的5号结点没有左子树,却有又子树,导致按层编号的10号空档了,就不是完全二叉树。
下图的树由于结点3没有子树,使得编号6、7编号的位置空挡了。就不是完全二叉树。
- 完全二叉树的特点:
- 叶子节点指能出现在最下面的两层。
- 最下层的叶子一定集中在左部连续的位置。
- 倒数两层,如果存在叶子节点,一定都在右部连续位置。
- 如果结点度为1,则该结点只有左孩子,即不存在只有右子树的情况。
- 同样结点数的二叉树,完全二叉树的深度最小。
- 完全二叉树的特点:
二叉树的性质
- 在二叉树的第i层,至多存在2i-1个结点(i ≥ 1)。
- 深度为K的二叉树之多有2K-1个结点(K ≥ 1)。
- 对任何一棵二叉树T,如果终端结点数为N0,度为2的结点数为N2,则N0=N2+1
- 具有n个结点的完全二叉树的深度K为⌊ log2n ⌋ +1 ( ⌊x⌋ 表示不大于x的最大整数,取绝对值向下取整)
- 如果对一棵有n个结点的完全二叉树(其深度为⌊ log2n ⌋+1 )的结构按层序编号(从第一层到第⌊ log2n ⌋+1 层,每层从左到右),对任一结点j( 1 ≤ i ≤ n )有:
- 如果i = 1,则结点i是二叉树的根,无双亲;如果i>1,则其双亲是结点 ⌊ i/2 ⌋。
- 如果2i>n,则结点i无左孩子(结点i为叶子结点),否则,其左孩子是结点2i。
- 如果2i+1>n,则结点i无右孩子,否则,其右孩子是结点2i+1。
二叉树的顺序存储结构
- 顺序存储结构
由于完全二叉树严格的定义,顺序结构也能很好的表现出来完全二叉树的结构。
对于一般的二叉树,特别是斜树这种极端情况,它只存在K个结点,但是需要分配2K-1个内存单元,造成空间的浪费。
所以,顺序存储结构一般只用于完全二叉树。
- 二叉链表
二叉树每个结点最多有两个孩子,所以为他们设计一个数据域和两个指针域,这样的链表就叫二叉链表。
结构示意图如下:
二叉树的遍历
二叉树的遍历(traversing binary tree)是指从根节点出发,按某种次序,依次访问二叉树中的结点,使得每个结点被访问一次且仅被访问一次。
- 前序遍历
规则:如果二叉树为空,则空操作返回,否则先访问根结点,然后前序遍历左子树,再前序遍历右子树。下图顺序为:ABDGHCEIF
- 中序遍历
规则:若树为空,则空操作返回,否则从根结点开始(注意并不是先访问根节点),中序遍历根节点的左子树,然后访问根结点,最后中序遍历右子树。下图遍历顺序为:GDHBAEICF
- 后序遍历
规则是若树为空,则空操作返回,否则从左到右先叶子后结点的方式遍历访问左右子树,最后访问根节点。下图遍历顺序为:GHDBIEFCA
- 层序遍历
规则:若树为空,则空操作返回,否则从树的第一层,也就是根节点开始访问,从上而下逐层遍历,在同一层中,按从左到右的顺序对结点逐个访问。下图遍历顺序为:ABCDEFGHI
两条二叉树遍历的性质:
- 已知前序遍历序列和后序遍历序列,可以唯一确定一颗二叉树
- 已知后续遍历序列和中序遍历序列,可以唯一确定一颗二叉树
!已知前序和后序遍历,是不能确定一颗二叉树的!比如前序序列ABC,后序序列CBA,只能确定A是根节点,无法确定那个结点时左子树,哪个是右子树,会存在四种可能。
二叉树的建立
扩展二叉树:将二叉树中每个结点的空指针,引出一个虚结点,其值为一特定值“#”,这种处理过后的二叉树,称为扩展二叉树。
对于扩展二叉树,可以做到一个遍历序列确定一个二叉树,如上图的前序遍历序列为: AB#D##C##
线索二叉树
我们将指向前驱和后继的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树称为线索二叉树。
(空心箭头实线为前驱,虚线黑箭头为后继,此时的遍历次序为中序遍历,故前驱后继为中序序列中的前驱后继)
其实线索二叉树,等于是把一颗二叉树转变成了一个双向链表,这样就为插入删除结点、查找某个结点带来了方便。
对二叉树以某种次序遍历使其变为线索二叉树的过程称为线索化。
为了区分普通二叉树和线索二叉树,在结点的结构中我们新增两个字段:
- Ltag(boolean):为0时,指向该结点的左孩子,为1时,指向该结点的前驱。(Lchild)
- Rtag(boolean):为0时,指向该结点的右孩子,为1时,指向该结点的后继。(Rchild)
因此左方的二叉树可以被修改成右方的样子。
如果所用的二叉树需经常遍历或查找结点时,需要某种遍历序列中的前驱和后继,那么采用线索二叉链表的存储结构就是不错的选择。
树,森林,转化为二叉树
利用孩子兄弟表示法可以将任意一棵树表示为一棵二叉树。
如图的树,将其转化为二叉树:
- 加线,在所有兄弟结点之间加一条线。
- 去线。对树的每个结点,值保留它的第一个孩子
(左孩子)结点的连线,删除它与其他孩子结点的连线。
- 层次调整,以树的根节点为轴心,将整棵树进行一定角度的旋转,使之结构分明。注意第一个孩子一定是左孩子,兄弟转换过来的孩子是结点的右孩子。
森林由若干棵树组成,每一棵树之间都是兄弟,可以按兄弟的办法处理来操作。
二叉树转换为树,是树转化为二叉树的逆向过程。
二叉树转换为森林,需要看这个二叉树的根节点有没有右孩子,有就是森林,没有就只是一棵二叉树。
哈夫曼树(最优二叉树)
树结点间的边相关的数叫做权(Weight)。
情景:给学生成绩赋分,通过百分制换算为等级制,构造一棵二叉树。
按学生对应所占比例构造一棵带权二叉数:
上图树a为直接构造,树b为通过哈夫曼树优化后的二叉树。
哈夫曼定义:
- 从树的一个结点到另一个结点之间的分支构成两个结点之间的路径,路径上的分支数目称为路径的长度。
- 树的路径长度就是从树根到每一结点的路径之和。
- 结点的带权的路径长度为从该结点到树根之间的路径长度与结点上权的乘积。
- 树的带权的路径长度为树中所有叶子结点的带权路径长度之和。
假设有n个权值{w1, w2, …, wn}构造一棵有n个叶子结点的二叉树,每个叶子结点带权wk,每个
叶子结点的路径长度为Lk,其中带权路径长度WPL最小的二叉树称做哈夫曼树。也称为最优二叉树。
哈夫曼算法构造哈夫曼树:
- 根据给定的n个权值{w1, w2, …, wn},构成n个二叉树的集合F ={T1, T2, …, Tn},其中每棵二叉树Ti中只有一个带权为wi的根节点,其左右子树均为空。
- 在F中选取两颗根节点权值最小的树作为左右子树构造一棵新的二叉树,且置新的二叉树的根节点的权值为其左右子树上根节点的权值之和。
- 在F中删除这两棵树,同时将新得到的二叉树加入F中。
- 重复步骤2,3,直到F中只剩下一棵树为止,这棵树便是哈夫曼树。
由哈夫曼树构造出的哈夫曼编码:
采用哈夫曼树转换出来的二进制串要小于原编码转换出的二进制字符串,从而达到文件压缩的目的,解压缩时同样采用哈夫曼树进行反编码,从而还原文件。这就是数据压缩的原理,只要在编码和解码时,约定好使用同一颗哈夫曼树,就可以做到无损编码和无错解码。
7. 图
图:图(Graph)是由顶点的有穷非空集合和顶点之间边的集合组成的,通常表示为G( V, E),其中,G表示一个图,V是图G中顶点的集合,E是图G中边的集合。
- 线性表中,我们将数据元素叫元素,树中将数据元素叫结点,在图中数据元素,称为顶点(Vertex)
- 在线性表中,没有元素我们称为空表。树种没有结点我们称为空树。在图结构中,不允许没有顶点。在定义中,若V是顶点的集合,则强调了顶点集合V有穷,非空。
- 线性表中,相邻的数据元素之间具有线性关系,树结构中,相邻两层的结构具有层次关系。在图中,任意两个顶点之间都可能有关系,顶点之间的逻辑关系用边来表示,边集可以是空的。
图中的各种定义:
- 无向边:若顶点vi到vj之间的边没有方向,则称这条边为无向边(Edge),用无序偶对(vi , vj)来表示。
- 无向图:若图中任意两个顶点之间的边都是无向边,则称该图为无向图(undirected graphs)。
- 有向边:若顶点vi到vj之间的边有方向,则称这条边为有向边,也成为弧(Arc),用有序偶对<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)。
- 邻接点:对于无向图G=(V , {E}),如果边(v, v')∈ E,则称顶点v和v'互为邻接点(Adjacent),即v和v‘相邻接。边(v, v')依附(incident)于顶点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) 。
- 有向图顶点的度:TD(v) = ID (v) + OD (v) 。
- 路径:无向图G= (V,{E})中从顶点v到顶点v'的路径(Path)是一个顶点序列,其中(Vi,j+1,Vi,j) ∈ E,1≤j≤m。
- 路径的长度:路径上的边或弧的数目。
- 回路:第一个顶点和最后一个顶点相同的路径称为回路或环(Cycle)。
- 简单路径:序列中顶点不重复出现的路径称为简答路径。
- 简单回路:除了第一个订单和最后一个顶点外,其余顶点不重复出现的回路,称为简单回路或简单环。
下图两个都是环,左图为简单环,有图不是简单环。
- 连通图:在无向图G中,如果从顶点v到顶点v‘有路径,则称v和v’是连通的。如果对于图中任意两个顶点vi、vj ∈ V,vi和vj都是连通的,则称G是连通图(Connected Graph)。
- 连通分量:无向图中的极大连通子图称为连通分量。
- 要是子图
- 子图要是连通的
- 连通子图含有极大顶点数
- 具有极大顶点数的连通子图包含依附于这些顶点的所有边
- 强连通图:在有向图G中,如果对于每对vi、 vj ∈ V、 vi ≠ vj,从vi到vj和从vj到vi都存在路径,
则称G是强连通图。 - 强连通分量:有向图中的极大强连通子图称做有向图的强连通分量。
- 连通图的生成树:一个连通图的生成树是一个极小的连通子图,它含有图中全部的n个顶点,但只有足以构成一棵树的n-1条边。
图1为一个普通连通图,图二图三为一个连通图的生成树,图四不是生成树。
由上图可以知道:
- 如果一个图由n个顶点和小于n-1条边,则是非连通图。
- 如果一个图由n个顶点和大于n-1条边,则必然构成环。
- 如果一个图由n个顶点和n-1条边,不一定是生成树。
- 如果一个有向图恰有一个顶点的入度为0(相当于根节点),其余顶点的入度为1(即非根结点的双亲有且仅有一个),则是一个有向树。
- 一个有向图的生成森林由若干棵有向树构成,含有图中全部顶点,但只有足以构成若干棵不相交的有向树的弧。
图一为一个有向图,图2图3为图1的生成森林。
概念总结
图按有无方向分为有向图和无向图,有向图由顶点和弧构成,无向图由顶点和边构成。弧存在弧头和弧尾之分。
图按边或弧的多少分为稀疏图和稠密图。如果任意两个顶点之间都存在边叫完全图,有向的叫有向完全图。若没有重复边或顶点到自身的边则叫简单图。
图中顶点之间有邻接点、依附的概念。无向图的边数叫做度,有向图顶点分为入度和出度。
图上的边或弧上带权则称为网。
图中顶点间存在路径,两顶点存在路径则说明是连通的,如果路径最终回到了起始点则称为环,当中不重复叫简单路径。若任意两顶点都是连通的,则称为连通图,有向则称为强连通图。图中存在子图,若子图极大连通则就是连通分量,有向的则称为强连通分量。
无向图中连通且n个顶点n-1条边叫生成树。有向图中一顶点入度为0其余顶点入度为1则称为有向树。一个有向图由若干棵有向树构成生成森林。
图的抽象数据模型
ADT 图(Graph)
Data
顶点的有穷非空集合、边的集合。
Operation
CreateGraph (*G, V, VR) :按照顶点集V和边弧集VR的定义构造图G。
DestroyGraph (*G) :图G存在则销毁。
LocateVex (G, u) :若图G中存在顶点u,则返回图中的位置。
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):在图G中增添弧<v,w>,若G是无向图,还需要增添对称弧<w, v>。
DeleteArc (*G,v,w):在图G中删除弧<v,w>,若G是无向图,则还删除对称弧 <w, v>。
DFSTraverse (G) :对图G中进行深度优先遍历,在遍历过程中对每个顶点调用。
HFSTraverse (G) :对图G中进行广度优先遍历,在遍历过程中对每个顶点调用。
endADT
图的存储结构
- 图的邻接矩阵(Adjacency Matrix):存储方式是用两个数组来表示图。
- 顶点信息:一个一维数组存储图中顶点信息。
- 边或弧的信息:一个二维数组(称为邻接矩阵)存储图中的边或弧的信息。
无向图的边数组是一个对称矩阵。即对于n阶矩阵而言,它的元满足aij = aji(0 ≤ i ,j ≤ n)。从矩阵的左上角到右下角的主对角线为轴,右上角的元与左下角相对应的元全是相等的。
有向图的弧数组因为存在有向,并不对称,且对于顶点来说,讲究入度和出度,对于任意一个顶点vi,入度为Vi列各数之和,出度为Vi行各数之和。
对于网来说,需要在邻接矩阵上进一步保存权值信息,此时我们用一个计算机允许的、大于所有边上权值的值,来表示”不存在“。因为权值在大多数情况下为正值,但也存在负值或0。
- 邻接表(Adjacency List):数组与链表相结合的存储方式称为邻接表。
- 顶点信息:用一个一维数组存储,也可以用单链表,但是数组可以比较容易的读取定点信息。在数据元素中我们还需要存储指向第一个邻接点的指针。
- 边或弧的信息:图中每个顶点Vi的所有邻接点构成一个线性表,由于邻接表的个数不确定,所以用单链表存储,无向图称为顶点Vi的边表,有向图则称为顶点Vi作为弧尾的出边表。
无向图的邻接表结构
有向表的邻接表结构(出边表)
有时为了方便确定顶点的入度或以顶点为弧头的弧,也会建立一个有向图的逆邻接表,即对每个顶点都建立一个链接为该顶点为弧头的表。
带权值信息的有向图的邻接表结构。
顶点数组中保存的顶点信息结构:
- data:数据域
- firstedge:指针域,指向邻接表的第一个元素
邻接表中保存的边或弧信息结构:
- adjvex:数据域,保存邻接点在顶点数组中的数组下标
- next:指针域,下一个邻接点的指针
- weight:数据域,用来保存权值信息(如果存在权值)
- 十字链表(Orthogonal List):把邻接表和逆邻接表结合起来的一种有向图的存储方式。
本质上和邻接表没有区别,只不过修改了顶点表结构和边表结构,额外保存了入度信息。
- 顶点表结构
-
data:数据域
-
firstin:指针域,表示入边表的头指针,指向该顶点的入边表中第一个结点
-
firstout:指针域,表示出边表的头指针,指向该顶点的出边表中第一个结点
- 边表结点结构
-
tailvex:指弧起点在顶点表中的下标
-
taillink:出边表指针域,指向起点相同的下一条边
-
headvex:指弧终点在顶点表中的下标
-
headlink:入边表指针域,指向终点相同的下一条边
-
weight:数据域,用来保存权值信息(如果存在权值)
该种数据模型可以很容易的找到以当前结点为尾的弧,也可以很容易找到以当前结点为头的弧,所以容易求得顶点的出度和入度。而且除了结构较为复杂外,创建图的算法的时间复杂度和邻接表是相同的。
-
邻接多重表
-
边集数组:边集数组是由两个一维数组构成的。一个是储存顶点的信息,另一个是存储边的信息,这个边数组每个数据元素由一条边的起点下标(begin)、终点下标(end)和权(weight)组成。
显然边集数组结构更关心的是边的集合,如果要查找某个顶点的度就需要遍历整个边集合,更适合对边一次进行处理的操作,不适合对顶点的相关操作。
- begin:存储起点下标
- end:存储终点下标
- weight:存储权值
图的遍历
从图的某一顶点出发,遍历图中其余顶点,且使每个顶点仅被访问一次,这种过程就叫做图的遍历(Traversing Graph)。
在图中,每一个顶点都可能和其他顶点相邻接,极有可能沿某条路径搜索后,又回到原点,有些顶点却还没遍历到的情况。所以我们需要在遍历过程中,把访问过的顶点打赏标记,避免多次访问而不自知的情况。
设置一个访问数组vidited[n],n为该图的顶点数,初始值为0,访问过设置为1。
通常有两种遍历方案:深度有限遍历和广度优先遍历
深度优先遍历:深度优先遍历(Depth First Search),也成为深度优先搜索,简称为DFS。
它从一个顶点v出发,访问此顶点,然后从v的未被访问的邻接点出发深度优先遍历图,直至图中所有和v有路径相同的顶点都被访问到。若图中尚有顶点未被访问,则另选图中一个未曾被访问的顶点作起始点,重复上述过程,直至图中所有顶点都被访问为止。
邻接矩阵结构遍历的是数组,邻接表遍历的是链表。
深度优先遍历其实就是一个递归的过程,甚至像是一棵树的前序遍历。
因此,对于n个顶点e条边的图来说,邻接矩阵由于是二维数组,要访问每个顶点的邻接点,都需要遍历矩阵中的所有元素,时间复杂度为O(n2)。邻接表找邻接点所需时间取决于顶点和边的数量,所以是O(n+e)。
所以,对于点多边少的稀疏图来说,邻接表结构使得算法在时间效率上大大提高。
广度优先遍历:广度优先遍历(Breadth First Search),又称广度优先搜索,简称BFS。
- 从图中某个顶点V0出发,并访问此顶点;
- 从V0出发,访问V0的各个未曾访问的邻接点W1,W2,…,Wk;然后,依次从W1,W2,…,Wk出发访问各自未被访问的邻接点;
- 重复步骤2,直到全部顶点都被访问为止。
需要一个队列和辅助数组:
- 队列:
- 辅助数组:记录当前结点是否已经遍历过,即入队结点都需要注册到辅助数组中
类似于树的层序遍历
- 先访问顶点A,并将结点A入队
- 将顶点A的邻接点入队,访问A的邻接点,后将顶点A弹出
- 依次访问顶点B,并将顶点B的邻接点入队,后将顶点B弹出
- 重复以上步骤,直到遍历完成。
对于深度优先遍历和广度优先遍历的算法,它们的时间复杂度上是一样的,不同之处仅在于对顶点访问的顺寻不同。
深度优先更适合目标比较明确,以找到目标为主要目的的情况。
广度优先适合在不断扩大遍历范围时,找到最优解的情况。
最小生成树
我们把构造连通网的最下代价生成树称为最小生成树(Minimun Cost Spanning Tree)。
找连通树的最小生成树,经典有两种算法,普里姆算法和克鲁斯卡尔算法。
普利姆(Prim)算法
假设N=(V,{E})是连通网,TE是N上最小生成树中边的集合。算法从U={u}(u,∈V),TE={}开始。
重复执行下述操作:
- 在所有u∈U,v∈V-U的边(u,v)EE中找一条代价最小的边(u0,v0)并入集合TE,同时v0并入U, 直至U=V为止。
此时TE中必有n-1条边,则T=(V,{TE})为N的最小生成树。
由算法代码中的循环嵌套可得知此算法的时间复杂度为O(n2)。
普利姆算法是以某顶点为起点,逐步找个顶点最小权值的边来构建最小生成树的。
克鲁斯卡尔(Kruskal)算法
克鲁斯卡尔算法是以边为目标去构建,因为权值都在边上,直接去找边构建最小生成树,但是要考虑是否会形成环路。此时就用到了图的存储结构中的边集数据结构。
假设N=(V,{E})是连通网,则令最小生成树的初始状态为只有n个顶点而无边的非连通图T={V,{}},图中每个顶点自成一个连通分量。在E中选择代价最小的边,若该边依附的顶点落在T中不同的连通分量上,则将此边加入到T中,否则舍去比边而选择下一条代价最小的边。
以此类推,直到T中所有顶点都在同一通分量上为止。
此算法的时间复杂度为O(eloge)。
对于以上两个算法,克鲁斯卡尔算法蛀牙是针对边来展开,变数少时,效率会很高,所以针对稀疏图会有很大优势,而普利姆算法对于稠密图,即边数非常多的情况会好一些。
最短路径
对于非网图来说,最短路径实际上就是两顶点之间经过的最少的路径。
对于网图来说,最短路径,是指两顶点之间经过的边上的权值之和最少的路径,并且我们称路径上的第一个顶点是源点,最后一个顶点是终点。
迪杰斯特拉(Dijkstra)算法
这是一个按路径长度递增的次序产生最短路径的算法。
也就是说,通过迪杰斯特拉算法解决了从某个源点到其余各顶点的最短路径问题。其时间复杂度为O(n2),如果要求任意顶点到其他顶点的最短路径,办法就是把每个顶点都当作源点循环一次迪杰斯特拉算法,此时时间复杂度就成了O(n3)。
弗洛伊德(Floyd)算法
如果面临需求所有顶点至所有顶点的最短路径问题时,弗洛伊德算法应该是不错的选择。
拓扑排序
在一个表示工程的有向图中,用顶点表示活动,用弧表示活动之间的优先关系,这样的有向图为顶点表示活动的图,我们称为AOV网(Activity On Vertex Network)。
设G = (V, E)是一个具有n个顶点的有向图,V中的顶点序列v1,v2,v3,····,vn,满足若从顶点vi到vj有一条路径,则在顶点序列中顶点vi必在顶点vj之前。则我们称这样的顶点序列为一个拓扑序列。
所谓拓扑排序,就是对一个有向图构造拓扑序列的过程。构造过程会产生两个结果:
- 此网的全部结点被输出,说明它不存在环是一个AOV网。
- 输出的顶点少了,哪怕少了一个,说明这个网存在回路,不是AOV网。
一个不存在回路的AOV网,我们可以将它应用在各种工程或项目的流程图中。
对一个AOV网进行拓扑排序的基本思路是:
- 从AOV网中选择一个入度为0的顶点输出,然后删去此顶点,并输出以此顶点为尾的弧,继续重复此步骤,直到输出全部顶点或者AOV网中不存在入度为0的顶点为止。
关键路径
拓扑排序主要是为了解决一个工程能否顺利进行的问题,但有时也需要解决工程完成需要的最短时间问题。
在一个表示工程的带权有向图中,用顶点表示事件,用有向边表示活动,用边上的权值表示活动的持续时间,这种有向图的边表示活动的网,我们称之为AOE网(Activity On Edge Network)。
AOE网中,没有入边的顶点称为始点或源点,没有出边的顶点称为终点或汇点。
尽管AOE网和AOV网都是用来对工程建模的,但仍存在很大不通,主要体现在AOV是顶点表示活动的网,它表示活动那个之间的制约关系。AOE是边表示活动的网,边上的权值表示活动持续的时间。
因此,AOE网是要建立在活动之间制约关系没有矛盾的基础上。再来分析完成整个工程需要多少时间,或者为缩短完成工程所需时间,应当加快哪些活动等问题。
路径上各个活动所持续的时间之和称为路径长度,从源点到汇点具有最大长度的路径叫关键路径,在关键路径上的活动叫关键活动。
关键路径算法
我们只需要找到活动的最早开始时间和最晚开始时间,并且比较它们,如果相等,则意味着此活动就是关键活动,活动间的路径就是关键路径。如果不等,则不是。
为此,需要定义几个参数:
-
事件的最早发生时间etv(earliest time of vertex):即顶点Vk的最早发生时间。
-
事件的最晚发生时间ltv(latest time of vertex):即顶点Vk的最晚发生时间。也就是每个顶点对应的事件最晚需要开始的时间,超出此时间将会延误整个工期。
-
活动的最早开工时间ete(earliest time of edge):即弧ak的最早发生时间。
-
活动的最晚开工时间lte(latest time of edge):即弧ak的最晚发生时间。也就是不推迟工期的最晚开工时间。
我们由1.2.可以求得3.4.,然后再根据ete[k]是否与lte[k]相等来判断ak是否是关键活动。
求时间的最早发生时间etv的过程,就是我们从头至尾找拓扑路径的过程,因此再求关键路径之前,需要先调用一次拓扑排序算法来计算etv和拓扑序列列表。
对于多条关键路径的有向无环图,单是提高一条关键路径上的关键活动的有向无环图。如果是多条关键路几个,则单是提高一条关键路径上的关键活动的速度并不能导致整个工程缩短工期,而必须同时提高几条关键路径上的活动的速度。
事实证明,这样的算法有利于工程的前期估算和中期的计划调整都有很大帮助。
总结回顾
图是计算机科学中非常常用的数据结构,有许许多多的计算问题都是用图来定义的。图也是最复杂的数据结构,对图的探究过程中,涉及到数组、链表、栈、队列、树等数据结构。
图的存储结构一共介绍了五种:
- 邻接矩阵
- 邻接表
- 边集数组
- 十字链表
- 邻接多重表
其中比较重要的是邻接矩阵和邻接表。它们分别代表着边集是用数组还是链表的方式存储。
十字链表是针对有向图邻接表结构的优化。
邻接多重表是针对无向图邻接表结构的优化。
边集数组更多考虑的是对边的关注。
通常情况下,稠密图或存储数据较多、结构修改较少的图,用邻接矩阵要合适,反之则应该考虑邻接表。
图的遍历分为深度和广度两种,各有优缺点。
图的应用一共介绍了三种:最小生成树、最短路径、有向无环图的应用。
- 最小生成树:普利姆算法和克鲁斯卡尔算法。
- 普利姆算法像是走一步看一步的思维方式,逐步生成最小生成树
- 克鲁斯卡尔的算法则更有全局意识,直接从图中的最短权值的边入手,寻找最后答案。
- 最短路径:迪杰斯特拉算法和弗洛伊德算法
- 迪杰斯特拉算法更强调单源点顶点查找路径的方式,思路容易理解,但是算法代码相对复杂。
- 弗洛伊德算法完全抛开了单点的局限思维方式,巧妙地应用矩阵地变换,用最清爽地代码实现了多顶点间最短路径求解的方案,原理理解有难度,但代码简洁。
- 有向无环图常应用于工程规划中
- 对整个工程或系统来说,我们一方面关注工程能否顺利进行的问题,通过拓扑排序的方式,我们可以有效地分析出一个有向图是否存在环,如果不存在,那么它的拓扑序列是什么。
- 另一方面我们关注整个工程完成的最短必须时间,利用关键路径的算法,可以得到最短完成工程的工期以及关键的活动有哪些。