《大话数据结构》学习笔记

文章目录

第1章 数据结构绪论

数据结构是一门研究非数值计算的程序设计问题中的操作对象,以及它们之间的关系和操作等相关问题的学科。

1.4 基本概念和术语

数据:是描述客观事物的符号,是计算机中可以操作的对象,是能被计算机识别,并输入给计算机处理的符号集合。

数据元素:是组成数据的、有一定意义的基本单位。

数据项:一个数据元素可以由若干个数据项组成。数据项是数据不可分割的最小单位。

数据对象:是性质相同的数据元素的集合,是数据的子集。

不同数据元素之间不是独立的,而是存在特定的关系。

数据结构:是相互之间存在一种或多种特定关系的数据元素的集合。

1.5 逻辑结构与物理结构

数据结构分为逻辑结构和物理结构。

1.5.1 逻辑结构

逻辑结构:是指数据对象中数据元素之间的相互关系。

逻辑结构分为以下四种:

  1. 集合结构

    集合结构中的数据元素除了同属于一个集合外,它们之间没有其他关系。各个数据元素是“平等”的,它们的共同属性是“同属于一个集合”。
    IphfzT.jpg

  2. 线性结构

    线性结构中的数据元素之间是一对一的关系。
    Iphcon.jpg

  3. 树形结构

    树形结构中的数据元素之间存在一种一对多的层次关系。
    Iph2iq.jpg

  4. 图形结构

    图形结构的数据元素是多对多的关系。
    IphWWV.jpg

逻辑结构是针对具体问题的,是为了解决某个问题。

1.5.2 物理结构(存储结构)

物理结构:是指数据的逻辑结构在计算中的存储形式。

  1. 顺序存储结构

    是把数据元素存放在地址连续的存储单元里,其数据间的逻辑关系和物理关系是一致的。

  2. 链式存储结构

    把数据元素存放在任意的存储单元里,这组存储单元可以是连续的,也可以是不连续的。

    数据元素的存储关系需要用一个指针存放数据元素的地址,这样通过地址就可以找到相关联数据元素的位置。

1.6 抽象数据类型

数据类型:是指一组性质相同的值的集合及定义在此集合上的一些操作的总称。

数据类型是按照值的不同进行划分的。

抽象是指抽取出事物具有的普遍性的本质。它是抽出问题的特征而忽视非本质的细节,是对具体事物的一个概括。

抽象数据类型:是指一个数据模型及定义在该模型上的一组操作。

5O295d.jpg

第2章 算法

算法:算法是解决特定问题求解步骤的描述,在计算机中表现为指令的有限序列,并且每条指令表示一个或多个操作。

2.5 算法的特性

算法有五个特性:输入、输出、有穷性、确定性和可行性。

2.5.1 输入输出

算法具有零个或多个输入,算法至少有一个或多个输出。

2.5.2 有穷性

算法在执行有限的步骤之后,自动结束而不会出现无限循环,并且每一个步骤都在可接受的时间内完成。

2.5.3 确定性

算法的每一步骤都具有确定的含义,不会出现二义性,相同的输入只能有唯一的输出结果。

2.5.3 可行性

算法的每一步都必须是可行的,每一步都能够通过执行有限次数完成。

2.6 算法设计的要求

2.6.1 正确性

算法的正确性是指算法至少应该具有输入、输出和加工处理无歧义性、能正确反映问题的需求、能够得到问题的正确答案。

算法的“正确”通常在用法上有很大的差别,大体分为以下四个层次:

  1. 算法程序没有语法错误。
  2. 算法程序对于合法的输入数据能够产生满足要求的输出结果。
  3. 算法程序对于非法的输入数据能够得出满足规格说明的结果(标准)。
  4. 算法程序对于精心选择的,甚至刁难的测试数据都有满足要求的输出结果。

2.6.2 可读性

算法设计的另一目的是为了便于阅读、理解和交流。

2.6.3 健壮性

当输入数据不合法时,算法也能做出相关处理,而不是产生异常或莫名其妙的结果。

2.6.4 时间效率高和存储量低

存储量指算法在执行过程中需要的最大存储空间,主要指算法程序运行时所占用的内存或外部硬盘存储空间。

2.7 算法效率的度量方法

测定运行时间最可靠的方法就是计算对运行时间有消耗的基本操作的执行次数。

分析一个算法的运行时间,要把基本操作的数量与输入规模关联起来,即基本操作的数量表示成输入规模的函数。

2.8 函数的渐近增长

给定两个函数f(n)和g(n),如果存在一个整数N,使得对于所有的n>N,f(n)总是比g(n)大,那么,我们说f(n)的增长渐近快于g(n)。

判断一个算法的效率时,函数中的常数和其他次要项常常可以忽略,而更应该关注主项的阶数

2.9 算法时间复杂度

2.9.1 时间复杂度定义

算法的时间复杂度,也就是算法的时间量度,记作:T(n) = O(f(n))。它表示随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐近时间复杂度,简称时间复杂度。

算法时间复杂度用O()来表示,称为大O记法。

O(n)、O(1)、O(n²)也称为常数阶、线性阶和平方阶。

2.9.2 推导大O阶方法

  1. 用常数1取代运算时间中的所有加法常数。
  2. 在修改后的运行次数函数中,只保留最高阶项。
  3. 如果最高阶项存在且不说1,则去除与这个项相乘的常数。

得到的结果就是大O阶。

2.10 常见的时间复杂度

常见的时间复杂度所耗费的时间从小到大依次是:

O(1) < O(㏒n) < O(n) < O(n㏒n) < O(n²) < O(n³) < O(2^n) < O(n!) < O(n^n)

2.11 最坏情况与平均情况

最坏情况运行时间是一种保证,那就是运行时间将不会再坏了,我们提到的时间都是最坏情况的运行时间。

平均运行时间是期望的运行时间,但是很难通过分析得到,一般是通过实验数据估算出来。

2.12 算法空间复杂度

算法空间复杂度的计算公式记作:S(n) = O(f(n)),n为问题的规模,f(n)为语句关于n所占存储空间的函数。

第3章 线性表

线性表(List):零个或多个数据元素的有限序列。

3.2 线性表的定义

元素之间是有顺序的,若元素存在多个,则第一个元素无前驱,最后一个元素无后续,其他元素都有且只有一个前驱和后续。

线性表元素的个数n(n>=0)定义为线性表的长度,当n=0时,称为空表。

3.3 线性表的抽象数据类型

  • 建立一个空的线性表。
  • 判断线性表是否为空。
  • 将线性表清空。
  • 将线性表的第i个位置元素返回。
  • 查找给定的值是否在线性表中。
  • 在线性表的第i个位置插入新元素。
  • 删除线性表的第i个位置元素。
  • 返回线性表的元素个数。

3.4 线性表的顺序存储结构

3.4.1 顺序存储定义

线性表的顺序存储结构,指的是用一段地址连续的存储单元依次存储线性表的数据元素。

3.4.2 顺序存储方式

线性表存储会在内存中找一块空地,这块地的第一个位置就是存储空间的起始位置。

描述存储结构需要三个属性:

  • 存储空间的起始位置。
  • 线性表的最大存储容量。
  • 线性表的当前长度。

3.4.4 地址计算方法

存储器中的每个存储单元都有自己的编号,这个编号称为地址。

线性表的存取时间性能为O(1),通常把具有这一特点的存储结构称为随机存取结构。

3.5 顺序存储结构的插入与删除

3.5.2 插入操作

思路

  • 如果插入位置不合理,抛出异常;
  • 如果线性表长度大于等于数组长度,则抛出异常或动态增加容量;
  • 从最后一个元素开始向前遍历到第i个位置,分别将它们都向后移动一个位置;
  • 将要插入元素填入位置i处;
  • 表长加1;

3.5.3 删除操作

思路:

  • 如果删除位置不合理,抛出异常;
  • 取出删除元素;
  • 从删除元素位置开始遍历到最后一个元素位置,分别将它们都向前移动一个位置;
  • 表长减1;

线性表的存取数据的时间复杂度是O(1),而插入或删除的时间复杂度是O(n)。

3.5.4 线性表顺序存储结构的优缺点

优点:

  • 无须为表示表中元素之间的逻辑关系而增加额外的存储空间。
  • 可以快速地存取表中任一位置的元素。

缺点:

  • 插入和删除操作需要移动大量元素。
  • 当线性表长度变化较大时,难以确定存储空间的容量。
  • 造成存储空间的“碎片”。

3.6 线性表的链式存储结构

3.6.1 顺序存储结构不足

线性表的顺序存储结构的最大缺点就是插入和删除操作需要移动大量元素,这显然就需要耗费时间。

3.6.2 线性表链式存储结构定义

线性表的链式存储结构的特点是用一组任意的存储单元存储线性表的数据元素,这组存储单元可以是连续的,也可以是不连续的。

为了表示每个数据元素ai与其直接后续数据元素ai+1之间的逻辑关系,对数据元素ai来说,除了存储其本身的信息之外,还需存储一个指示其直接后继的信息(直接后继的存储位置)。我们把存储数据元素信息的域称为数据域,把存储直接后继位置的域称为指针域。指针域中存储的信息称做指针或链。这两部分信息组成数据元素ai的存储映像,称为结点(Node)。

n个结点链结成一个链表,即为线性表的链式存储结构,此链表的每个结点中只包含一个指针域,所以叫做单链表。

IksYz8.jpg

IksRL4.jpg

3.6.3 头指针与头结点的异同

头指针

  • 头指针是指链表指向第一个结点的指针,若链表有头结点,则是指向头结点的指针。
  • 头指针具有标识作用,所以常用头指针冠以链表的名字。
  • 无论链表是否为空,头指针均不为空,头指针是链表的必要元素。

头结点

  • 头结点是为了操作的统一和方便而是设立的,放在第一元素的结点之前,其数据域一般无意义(也可存放链表的长度)。
  • 有了头结点,对在第一元素结点前插入结点和删除第一结点,其操作与其它节点的操作就统一了。
  • 头结点不一定是链表必须要素。

Ik6YDg.jpg

3.7 单链表的读取

获取链表第i个数据的算法思路:

  1. 声明一个指针p指向链表第一个结点,初始化j从1开始;
  2. 当j<i时,就遍历链表,让p的指针向后移动,不断指向下一结点,j累加1;
  3. 若到链表末尾p为空,则说明第i个结点不存在;
  4. 否则查找成功,返回结点p的数据。

时间复杂度为O(n)。

3.8 单链表的插入与删除

3.8.1 单链表的插入

第i个数据插入结点的算法思路

  1. 声明一指针p指向链表头结点,初始化j从1开始;
  2. 当j<i时,让p的指针向后移动,不断指向下一结点,j累加1;
  3. 若到链表末尾p为空,则说明第i个结点不存在;
  4. 否则查找成功,在系统中生成一个空节点s;
  5. 将数据元素e赋值给s->data;
  6. 单链表的插入标准语句s->next=p->next;p->next=s;
  7. 返回成功。

3.8.2 单链表的删除

第i个数据删除结点的算法思路:

  1. 声明一指针p指向链表头指针,初始化j从1开始;
  2. 当j<i时,就遍历链表,让p的指针向后移动,不断指向下一个结点,j累加i;
  3. 若到链表末尾p为空,则说明第i个结点不存在;
  4. 否则查找成功,将欲删除的结点p->next赋值给q;
  5. 单链表的删除标准语句p->next=q->next;
  6. 将q结点中的数据赋值给e,作为返回;
  7. 释放q结点;
  8. 返回成功。

从第i个位置,插入10个结点,对于顺序存储结构意味着,每一次插入都需要移动n-i个结点,每次都是O(n)。而单链表,我们只需要在第一次查找时,找到第i个位置的指针,接下来只是简单地通过赋值移动指针而已,时间复杂度都是O(1)。

对于插入或删除数据越频繁的操作,单链表的效率优势就越是明显。

3.9 单链表的整表创建

算法思路:

  1. 声明一指针p和计数器变量i;
  2. 初始化一空链表L;
  3. 让L的头结点的指针指向NULL,即建立一个带头结点的单链表;
  4. 循环:
    • 生成一新结点赋值给p;
    • 随机生成一数字赋值给p的数据域p->data;
    • 将p插入到头结点与前一新结点之间。

这个算法始终让新结点在第一的位置,叫做头插法。

3.10 单链表的整表删除

算法思路:

  1. 声明一结点p和q;
  2. 将第一个结点赋值给p;
  3. 循环:
    • 将下一结点赋值给q;
    • 释放p;
    • 将q赋值给p。

3.11 单链表结构与顺序存储结构优缺点

存储分配方式

  • 顺序存储结构用一段连续的存储单元依次存储线性表的数据元素。
  • 单链表采用链式存储结构,用一组任意的存储单元存放线性表的元素。

时间性能

  • 查找
    • 顺序存储结构O(1)。
    • 单链表O(n)。
  • 插入和删除
    • 顺序存储结构需要平均移动表长一半的元素,时间为O(n)。
    • 单链表在线出某位置的指针后,插入和删除时间仅为O(1)。

空间性能

  • 顺序存储结构需要预分配存储空间,分大了,浪费,分小了易发生上溢。
  • 单链表不需要分配存储空间,只要有就可以分配,元素个数也不受限制。

结论

  • 若线性表需要频繁查找,很少进行插入和删除,宜采用顺序存储结构。若需要频繁插入和删除时,宜采用单链表结构。
  • 当线性表中的元素个数变化较大或者根本不知道有多大时,最好用单链表结构,这样可以不需要考虑存储空间的大小问题。

3.12 静态链表

C语言具有指针能力,使得它可以非常容易地操作内存中的地址和数据。有些面向对象语言如Java、C#不使用指针,而是启用了对象引用机制,从某种角度也实现了指针的某些作用。

数组描述的链表叫做静态链表,也叫游标实现法。

数组的每个下标都对应一个data(存放数据元素)和一个cur(相当于单链表中的next指针)。

I3a7LR.jpg

3.12.1 静态链表的插入操作

I3wrbn.jpg

3.12.2 静态链表的删除操作

I30pVI.jpg

3.12.3 静态链表优缺点

优点

在插入和删除操作时,只需要修改游标,不需要移动元素,从而改进了在顺序存储结构中的插入和删除操作需要移动大量元素的缺点。

缺点

没有解决连续存储分配带来的表长难以确定的问题。

失去了顺序存储结果随机存取的特性。

静态链表是为了给没有指针的高级语言设计的一种实现单链表能力的方法。

3.13 循环链表

将单链表中终端结点的指针端由空指针改为指向头结点,就使整个单链表形成一个环,这种头尾相接的单链表称为单循环链表,简称循环链表。

I30TyQ.jpg

将尾指针作为头结点,就可以在O(1)访问到开始结点和终端结点。

3.14 双向链表

单链表中,查找下一个结点的时间复杂度是O(1),而查找上一个结点的时间复杂度是O(n),因为我们每次都要从头开始遍历查找。

双向链表是在单链表的每个结点中,再设置一个指向其前驱结点的指针域。在双向链表中的结点都有两个指针域,一个指向直接后继,另一个指向直接前驱。

IGOVOO.jpg

p->next->prior = p = p->prior->next

双向链表的插入操作

IGXeg0.jpg

双向链表的删除操作

IGX72q.jpg

双向链表属于空间换取时间的结构。

IGj1L8.jpg

第4章 栈与队列

4.2 栈的定义

后进先出。

栈(stack)是限定仅在表尾进行插入和删除操作的线性表。

允许插入和删除的一端称为栈顶(top),另一端称为栈底(bottom),不含任何数据元素的栈称为空栈。简称LIFO结构。

栈的插入操作,叫做进栈,也称压栈、入栈。

栈的删除操作,叫做出栈,也叫弹栈。

4.4 栈的顺序存储结构及实现

IGzVdU.jpg

进栈和出栈的操作的时间复杂度都是O(1)。

4.5 两栈共享空间

数组有两个端点,两个栈有两个栈底,让一个栈的栈底为数组的始端,另一个栈为数组的末端,两个栈如果增加元素,就是两端点向中间延伸。

IJCw4S.jpg

当top1 + 1 = top2时栈满。

这种栈适用于同种数据类型的栈。

4.6 栈的链式存储结构及实现

4.6.1 栈的链式存储结构

栈的链式存储结构也称为链栈。

把栈顶放在单链表的头部。

IN9Iat.jpg

对于链栈来说,基本不存在栈满的情况,除非内存已经没有可以使用的空间。

4.6.2 栈的链式存储结构——进栈操作

INC4lF.jpg

4.6.3 栈的链式存储结构——出栈操作

INPZ6g.jpg

时间复杂度均为O(1)。

空间性能上,顺序栈会存在内存空间浪费的问题,但它的优势是存取取定位很方便,而链栈则要求每个元素都有指针域,同时增加了一些内存开销,但对于栈的长度无限制。如果栈的使用过程中元素变化不可预料,有时很小,有时非常大,那么最好是用链栈,反之,如果它的变化在可控范围内,建议使用顺序栈会更好一些。

4.8 栈的应用——递归

4.8.1 斐波那契数列实现

前面相邻两项之和,构成了后一项。

当n=1时,F(n) = 1,当n>1时,F(n-1)+F(n-2)。

function fbi(i) {
  if (i < 2 && i >= 0) return i;
  return fbi(i - 1) + fbi(i - 2);
}

console.log(fbi(10)); // 55

每个递归定义必须至少有一个条件,满足时递归不再进行,即不再引用自身而是返回值退出。

4.9 栈的应用——四则运算表达式求值

4.9.1 后缀(逆波兰)表示法定义

一种不需要括号的后缀表示法,我们把它称为逆波兰(RPN)表示。

4.9.2 后缀表达式计算结果

后缀表达式: 9 3 1 - 3 * + 10 2 / +

结果为20。

规则:从左到右遍历表达式的每个数字和符号,遇到是数字就进栈,遇到是符号,就将处于栈顶两个数字出栈,进行运算,运算结果出栈,一直到最终获得结果。

4.9.3 中缀表达式转后缀表达式

“9+(3-1)×3+10÷2”称为中缀表达式,输出9 3 1 - 3 * + 10 2 / +;

规则:从左到右遍历中缀表达式的每个数字和符号,若是数字就输出,即成为后缀表达式的一部分;若是符号,则判断其与栈顶符号的优先级,是右括号或优先级不高于栈顶符号(乘除优先加减)则栈顶元素依次出栈并输出,并将当前符号进栈,一直到最终输出后缀表达式位置。

4.10 队列的定义

队列(queue)是只允许在一端进行插入操作,而在另一端进行删除操作的线性表。队列是一种先进先出的线性表,简称FIFO。

I073mq.jpg

4.11 队列的抽象数据类型

队列插入数据只能在队尾,而删除数据只能在对头。

4.12 循环队列

4.12.1 队列顺序存储的不足

入队列操作,就是在队尾追加一个元素,不需要移动任何元素,时间复杂度为O(1)。

由于队列元素的出列是在对头,即下标为0的位置,意味着队列中的所有元素都得向前移动,以保证队列的对头不为空,此时时间复杂度为O(n)。

I0OFd1.jpg

如果不去限制队列的元素必须存储在数组的前n个单元这一条件,出队的性能就会大增,对头不需要一定在下标为0的位置。

为了避免当只有一个元素时,队列和队尾重合使得处理变得麻烦,引入front指针指向队头元素,rear指针指向队尾元素的下一个位置,当front=rear时,是空队列。

这个会造成“假溢出”的现象,就是当队头的元素出队列,而有元素在队尾入队时,rear指针会指向数组越界,造成假溢出。

I0OvTI.jpg

4.12.2 循环队列定义

把队列头尾相接的顺序存储结构称为循环队列,用来解决假溢出的问题。

rear改为指向下标为0的位置,避免造成指针指向不明。

IcIfoR.jpg

之前说front == rear是队列空,为了解决这个问题,有两种办法,第一种是需要再添加一个变量flag,当front == rear 且 flag = 0时队列空,办法二是保留一个元素空间,也就是当队列满时,数组中还有一个空闲单元,当(rear+1)%queueSize == front时,队列满。

IcTXGt.jpg

循环队列也面临着数组可能会溢出的问题。

4.13 队列的链式存储结果及实现

队列的链式存储结构,其实就是线性表的单链表,只不过它只能尾进头出而已,简称为链队列。

为了方便操作,将对头指针指向链队列的头结点,而队尾指针指向终点结点。

IcbkvV.jpg

4.13.1 队列的链式存储结构——入队操作

入队操作,就是在链表尾部插入结点。

Icbu59.jpg

Q->rear->next=s;Q->rear=s;

4.13.2 队列的链式存储结构——出队操作

出队操作,就是头结点的后续结点出队,将头结点的后续改为它后面的结点,若链表除头结点只剩一个元素时,则需将rear指向头结点。

IcqDY9.jpg

循环队列与链队列的比较,它们的操作都是常数时间O(1),不过循环队列需要事先申请好空间,期间不释放,而链队列每次申请和释放结点也会存在一些时间开销,对于空间上来说链队列会比循环队列更好更灵活。

在可以确定队列长度最大值的情况下,建议用循环队列,如果你无法预估队列的长度,则用链队列。

第5章 串

串(string)是由零个或多个字符组成的有限序列,又名叫字符串。

一般记为s=“a1a2…an”(n>=0)。零个字符的串称为空串(null string)。

5.3 串的比较

串的大小取决于他们挨个字母的前后顺序。比如"silly" < “stupid”。

串的比较是通过组成串的字符之间的编码来进行的,计算机中常用字符是ASCII编码,由8位二进制表示一个字符,总共可以表示256个字符。

由于全世界有成百上千中语言和文字,显然256个字符是不够的,因此就有了Unicode编码,常用的是16位的二进制数表示一个字符,约是6.5万多个字符。为了和ASCII码兼容,Unicode前256个字符与ASCII码完全相同。

在C语言中判断两个串是否相等,必须是它们串的长度以及他们各个对应位置的字符都相等。

5.4 串的抽象数据类型

串的逻辑结构和线性表很相似,不同之处在于串针对的是字符集。

线性表更关注的是单个元素的操作,比如查找一个元素,插入或删除一个元素,但串中更多的是查找子串位置、得到指定位置子串、替换子串等操作。

5.5 串的存储结构

5.5.1 串的顺序存储结构

用一组地址连续的存储单元来存储串中的字符序列的。按照预定义的大小,为每个定义的串变量分配一个固定长度的存储区。一般是用定长数组来定义。

串的顺序存储方式其实是有问题的,因为字符串的操作,都有可能使得串序列的长度超过了数组的长度Maxize。

于是对于串的顺序存储,有一些变化,串值的存储空间可在程序执行过程中动态分配而得。比如在计算机中存在一个自由存储区,叫做“堆”。

5.5.2 串的链式存储结构

串的链式存储结构,与线性表相似,不过由于串结构的特殊性,一个结点对应一个字符,就会存在很大的空间浪费,因此,一个结点可以存放一个字符,也可以考虑存放多个字符,最后一个结点是未被占满,可以用其他非串值字符补全,比如“#”。

IRtxeg.jpg

串的链式存储结构除了在连接串与串操作时有一定方便之外,总的来说不如顺序存储灵活,性能也不如顺序存储结构好。

5.6 朴素的模式匹配算法

从主串中查找子串的时间复杂度为O(n+m),n和m分别是子串和主串的长度。

朴素模式匹配算法比较低效。

5.7 KMP模式匹配算法

克努特-莫里斯-普拉特算法,简称KMP算法,是一个模式匹配算法,可以大大避免重复遍历的情况。

5.7.1 KMP模式匹配算法原理

KMP算法的核心就是为了让没必要的回溯不发生。

第6章 树

6.2 树的定义

树(Tree)是n(n>=0)个结点的有限集。n=0时称为空树。在任意一颗非空树中:(1)有且仅有一个特定的称为根(Root)的结点;(2)当n>1时,其余结点可分为m(m>0)个互不相交的有限集T1、T2、…、Tm,其中每一个集合本身又是一棵树,并且称为根的子树(SubTree)。

  1. n>0时根结点是唯一的,只有一个。
  2. m>0时,子树的个数没有限制,但它们一定是互不相交的。
IIB8AI.jpg

6.2.1 结点分类

树的节点包含一个数据元素及若干指向其子树的分支。结点拥有的子树数称为结点的(Degree)。度为0的结点称为叶结点(Leaf)或终端结点;度不为0的结点称为非终端结点或分支结点。除根结点之外,分支结点也称为内部结点。树的度是树内各结点的度的最大值

IIBfu4.png

6.2.2 结点间关系

结点的子树的根称为该结点的孩子(Child),相应地,该结点称为孩子的双亲(Parent)。同一个双亲的孩子之间互称兄弟(Sibling)。结点的祖先是从根到该结点所经分支上的所有结点。

6.2.3 树的其他相关概念

结点的层次(Level)从根开始定义起,根为第一层,根的孩子为第二层。若某结点在第i层,则其子树的根就在第i+1层。双亲在同一层的结点互为堂兄弟。树中结点的最大层次称为树的深度(Depth)或高度。

I7VPsg.jpg

如果将树中结点的各子树看成从左至右是有次序的,不能互换的,则称该树为有序树,否则称为无序树

森林(Forest)是m(m>=0)棵互不相交的树的集合。

线性表与树的区别

线性结构:

  • 第一个数据元素:无前驱。
  • 最后一个数据元素:无后继。
  • 中间元素:一个前驱一个后继。

树结构:

  • 根结点:无双亲,唯一。
  • 叶子结点:无孩子,可以多个。
  • 中间结点:一个双亲多个孩子。

6.4 树的存储结构

双亲表示法、孩子表示法、孩子兄弟表示法。

6.4.1 双亲表示法

假设以一组连续空间存储树的结点,同时在每个结点中,附设一个指示器指示其双亲结点在数组中的位置。

用data来表示数据域,用parent来表示指针域。data-parent。

可以通过拓展结构为双亲域(parent)、长子域(firstchild)、右兄弟域(rightsib),可以减少时间复杂度。

存储结构的设计是一个非常灵活的过程。一个存储结构设计得是否合理,取决于基于该存储结构的运算是否适合、是否方便,时间复杂度好不好等。

6.4.2 孩子表示法

每个结点有多个指针域,其中每个指针指向一颗子树的根结点,这种方法称为多重链表表示法

方案一

指针域的个数等于树的度。data-child1-child2…childd。

这种方法缺点是比较浪费空间,因为有很多结点的指针域都是空的。

方案二

每个结点指针域的个数等于该结点的度。取一个位置来存储结点指针域的个数。data-degree-child1-child2…childd。data为数据域,degree为度域,child为指针域。

这种方法克服了空间上的浪费,但由于结点的链表是不相同的结构,加上要维护结点的度的数值,在运算上就会带来时间上的损耗。

孩子表示法,把每个结点的孩子结点排列起来,以单链表作存储结构,则n个结点有n个孩子链表,如果是叶子结点则此单链表为空,然后n个头指针又组成一个线性表,采用顺序存储结构,存放进一个一维数组中。

I70bZQ.jpg

对于整个结构我们要查找某个结点的某个孩子,或者找某个结点的兄弟,只需要查找这个结点的孩子单链表即可。

但是我们不知道某个结点的双亲,所以将双亲表示法和孩子表示法结合起来,称为双亲孩子表示法。

I70LIs.jpg

6.4.3 孩子兄弟表示法

任意一棵树,它的结点的第一个孩子如果存在就是唯一的,它的右兄弟如果存在也是唯一的。因此,我们设置两个指针,分别指向该结点的第一个孩子和此结点的右兄弟。

I7BHl6.jpg

这种表示法,只需要通过firstchild找到此结点的长子,然后再通过长子结点的rightsib找到它的二弟,借着一直下去,直到找到具体的孩子。缺点的找到某个结点的双亲有缺陷,当然只要再增加一个parent指针域就能解决这个问题了。

这个表示法的最大好处是它把一颗复杂的变成了一颗二叉树。

6.5 二叉树的定义

对于这种在某个阶段都是两种结果的情形,比如真和假、0和1、对与错,都适合用树状结构来建模,这种树叫做二叉树。

二叉树(Binary Tree)是n(n≥0)个结点的有限集合,该集合或者为空集(称为空二叉树),或者由一个根结点和两颗互不相交的、分别称为根结点的左子树和右子树的二叉树组成。

6.5.1 二叉树特点

  • 每个结点最多有两颗子树,所以二叉树中不存在度大于2的结点。
  • 左子树和右子树是有顺序的,次序不能任意颠倒。
  • 即使树中某结点只有一颗子树,也要区分它是左子树还是右子树。

二叉树五种基本形态:

  1. 空二叉树。
  2. 只有一个根结点。
  3. 根结点只有左子树。
  4. 根结点只有右子树。
  5. 根结点既有左子树又有右子树。

6.5.2 特殊二叉树

  1. 斜树

    所有结点都只有左子树的二叉树叫左斜树,所有结点都是只有右子树的二叉树叫做右斜树。斜树的特点就是一层只有一个结点,结点的个数与二叉树的深度相同。

  2. 满二叉树

    在一颗二叉树中,如果所有分支结点都存在左子树和右子树,并且所有叶子都在同一层上,这样的二叉树称为满二叉树。

    特点:

    1. 叶子只能出现在最下一层 。
    2. 非叶子结点的度一定是2。
    3. 在同样深度的二叉树中,满二叉树的结点个数最多,叶子数最多。
  3. 完全二叉树

    对一颗具有n个结点的二叉树按层序编号,如果编号为i(1≤i≤n)的结点与同样深度的满二叉树中编号为i的结点在二叉树中位置完全相同,则这棵二叉树称为完全二叉树。

    IvMdbj.jpg

    满二叉树一定是一颗完全二叉树,但完全二叉树不一定是满的。

    特点:

    1. 叶子结点只能出现在最下两层。
    2. 最下层的叶子一定集中在左部连续位置。
    3. 倒数两层,若有叶子结点,一定都在右部连续位置。
    4. 如果结点度为1,则该结点只有左孩子,即不存在只有右子树的情况。
    5. 同样结点的二叉树,完全二叉树的深度最小。

6.6 二叉树的性质

6.6.1 二叉树性质1

在二叉树的第i层上至多有2^i-1个结点(i≥1)。

6.6.2 二叉树性质2

深度为k的二叉树至多有(2^k)-1个结点(k≥1)。

6.6.3 二叉树性质3

对任何一颗二叉树T,如果其终端结点树为n0,度为2的结点树为n2,则n0=n2 + 1。

假设n1为度是1的结点数,则树T结点总数n=n1+n2+n0。

分支线总数为结点树总数减去1。

6.6.4 二叉树性质4

具有n个结点的完全二叉树的深度为㏒2^(n+1)。

6.6.5 二叉树性质5

如果对一个有n个结点的完全二叉树的结点按层序编号,对任一结点i有:

  1. 如果i=1,则结点i是二叉树的根,无双亲;如果i>1,则其双亲是结点[i/2]。
  2. 如果2i>n,则结点i无左孩子(结点i为叶子结点);否则其左孩子是结点2i。
  3. 如果2i+1>n,则结点无右孩子;否则其右孩子是结点2i+1。

6.7 二叉树的存储结构

6.7.1 二叉树顺序存储结构

由于二叉树的特殊性,所以用一维数组存储二叉树的结点。

oQ61zj.jpg

一般用于完全二叉树,而对其他的二叉树则不会适合,因为会造成空间的浪费,比如一棵右斜树,它只有k个结点,却需要分配2^k-1个存储单元空间。

6.7.2 二叉链表

设计一个数据域和两个指针域(存放左孩子和右孩子的指针)。

const tree = {
  data: 'A',
  lchild: {
    data: 'B',
    lchild: {
      data: 'D',
      lchild: {},
      rchild: {}
    },
    rchild: {
      data: 'E',
      lchild: {},
      rchild: null
    }
  },
  rchild: {
    data: 'C',
    lchild: {},
    rchild: {}
  }
}
oQcdBt.jpg

6.8 遍历二叉树

6.8.1 二叉树遍历原理

二叉树的遍历是指从根结点触发,按照某种次序依次访问二叉树中所有结点,使得每个结点被访问依次且仅被访问一次。

6.8.2 二叉树遍历方法

  1. 前序遍历

    若二叉树为空,则空操作返回,否则先访问根节点,然后前序遍历左子树,再前序遍历右子树。

    oQgTdP.jpg
  2. 中序遍历

    规则是若树为空,则空操作返回,否则从根结点开始,中序遍历根结点的左子树,然后是访问根结点,最后中序遍历右子树。

    oQ2FWF.jpg
  3. 后序遍历

    先叶子后结点的方式遍历访问左右子树,最后是访问根结点。

    oQ2HmR.jpg
  4. 层序遍历

    从根结点开始访问,从上而下逐层遍历,在同一层中,按从左到右的顺序对结点逐个访问。

    oQRi7t.jpg

6.8.3 前序遍历算法

const preOrderTraverse = (tree) => {
  if (tree === undefined) return
  console.log(tree.data);
  preOrderTraverse(tree.left);
  preOrderTraverse(tree.right);
}

6.8.4 中序遍历算法

const inOrderTraverse = (tree) => {
  if (tree === undefined) return
  inOrderTraverse(tree.left);
  console.log(tree.data);
  inOrderTraverse(tree.right);
}

6.8.5 后序遍历算法

const postOrderTraverse = (tree) => {
  if (tree === undefined) return
  postOrderTraverse(tree.left);
  postOrderTraverse(tree.right);
  console.log(data);
}

6.8.6 推导遍历结果

已知前序遍历和中序遍历序列,可以唯一确定一颗二叉树。

已知后序遍历和中序遍历序列,可以唯一确定一颗二叉树。

已知后序和前序,不能确定一颗二叉树。

6.9 二叉树的建立

将二叉树变成扩展二叉树,扩展二叉树就可以做到一个遍历序列确定一颗二叉树了。

o3REz8.jpg

可以通过遍历递归的方式生成一颗二叉树结构。

const preOrderTraverse = (tree) => {
  if (tree === undefined) return
  // 生成树的逻辑,比如定义一个对象,将data属性赋值给树的结点
  preOrderTraverse(tree.left);
  preOrderTraverse(tree.right);
}

6.10 线索二叉树

6.10.1 线索二叉树原理

二叉树的每个结点都有两个指针,一个指向左孩子,一个指向右孩子。对于一个有n个结点的二叉链表,一共有2n个指针域,而n个结点的二叉树一共有n-1条分支线树,也就是说,存在2n-(n-1)=n+1个空指针域。

可以利用这些空指针域,存放指向结点在某种遍历次序下的前驱和后继结点的地址。我们把这种指向前驱和后继的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树称为线索二叉树。

o3hMod.jpg o34CX8.jpg

我们对二叉树以某种次序遍历使其变为线索二叉树的过程称为线索化

o34jDU.jpg

6.10.2 线索二叉树结构实现

const BiThrNode = {
  data: 'A',
  lchild: {
    data: 'B',
    lchild: {...},
    rchild: {...},
    lTag: 0,
    rTag: 0
  },
  rchild: {...},
  lTag: 0,
  rTag: 0
}

中序遍历线索化的递归函数

let pre = null;
const inThreading = (tree) => {
  if (tree) {
    tree.lTag = 0;
    tree.rTag = 0;
    inThreading(tree.lchild);
    if (!tree.lchild) {
      tree.lTag = 1;
      tree.lchild = pre;
    }
    if (!pre ? !pre : !pre.rchild) {
      pre ? (pre.rTag = 1) : "";
      pre ? (pre.rchild = tree) : "";
    }
    pre = tree;
    inThreading(tree.rchild);
  }
};

遍历双向链表,使用一个头结点,将头结点的lchild指向根结点,将中序遍历时访问的最后一个结点指向rchild。

// 中序遍历双向链表线索二叉树
// 时间复杂度O(n)
const inOrderTraverseThr = (t) => {
  let p;
  p = t.lchild;
  // 如果p不为null说明遍历还没结束
  while (p !== t && p !== null) {
    // 如果存在左孩子则往下查找
    while (p.lTag === 0) {
      p = p.lchild;
    }
    // 当没有左孩子则打印
    console.log(p.data);
    while (p.rTag === 1 && p.rchild !== t) {
      p = p.rchild;
      console.log(p.data);
    }
    p = p.rchild;
  }
  return "OK";
};

inOrderTraverseThr({
  lchild: tree,
  rchild: null
});

如果所用的二叉树需经常遍历或查找结点时需要某种遍历序列中的前驱和后继,那么采用线索二叉树链表的存储结构是个非常不错的选择。

6.11 树、森林与二叉树的转换

6.11.1 树转换为二叉树

  1. 加线。在所有兄弟结点之间加一条连线。
  2. 去线。对树中每个结点,只保留它与第一个孩子结点的连线,删除它与其他孩子结点之间的连线。
  3. 层次调整。以树的根结点为轴心,将整棵树顺时针旋转一定的角度,使之结构层次分明。
oYtpcR.jpg

6.11.2 森林转换为二叉树

  1. 把每一颗树转换为二叉树。
  2. 第一颗二叉树不动,从第二颗二叉树开始,依次把后一棵二叉树的根结点作为前一棵二叉树的根结点的右孩子,用线连接起来。
oYtAAO.jpg

6.11.3 二叉树转换为树

  1. 加线。若某结点的左孩子结点存在,则将这个左孩子的右孩子结点、右孩子的右孩子…反正就是左孩子的n个右孩子结点都作为此结点的孩子。将该结点与这些右孩子结点用线连接起来。
  2. 去线。删除原二叉树中所有结点与其右孩子结点结点的连线。
  3. 层次调整。使之结构层次分明。
oNThes.jpg

6.11.4 二叉树转换为森林

判断一颗二叉树能够转换成一颗树还是森林,看这棵二叉树的根结点有没有右孩子,有就是森林,没有就是一颗树。

  1. 从根结点开始,若右孩子存在,则把与右孩子结点的连线删除,再查看分离后的二叉树,若右孩子存在,则连线删除,直到所有右孩子连线都删除为止,得到分离的二叉树。
  2. 再将每科树分离后的二叉树转换为树即可。
oNHkCV.jpg

6.11.5 树与森林的遍历

树的遍历分为两种方式:

  1. 一种是先根遍历树,即先访问树的根结点,然后依次先根遍历根的每颗子树。
  2. 另一种是后根遍历,即先依次后根遍历每颗子树,然后再访问根结点。

森林的遍历分为两种方式:

  1. 前序遍历:先访问森林中第一棵树的根结点,然后再依次先根遍历根的每颗子树,再依次用同样方式遍历除去第一颗树的剩余树构成的森林。
  2. 后序遍历:是先访问森林中第一棵树,后根遍历的方式每颗子树,然后再访问根结点,再依次同样方式遍历除去第一棵树的剩余树构成的森林。

6.12 赫夫曼树及其应用

赫夫曼编码用于压缩文件,平时所用的压缩和解压缩技术也都是基于赫夫曼的研究之上发展而来。

6.12.2 赫夫曼树定义与原理

IMG_5515

从树中一个结点到另一个结点之间的分支构成两个结点之间的路径,路径上的分支数目称做路径长度。路的路径长度就是从树根到每一个结点的路径长度之和

a树的树路径长度为1+1+2+2+3+3+4+4=20,b树为1+1+2+2+2+2+3+3=16。

假设有n个权值{w1,w2…wn},构成一颗有n个叶子结点的二叉树,每个叶子结点带权wk,每个叶子的路径长度为lk,带权路径长度WPL最小的二叉树称为赫夫曼树(最优二叉树)。

二叉树a的WPL=5×1+15×2+40×3+30×4+10×4=315,b树的WPL=40×2+30×2+10×2+5×3+15×3=220。

构造赫夫曼树步骤
  1. 先把有权值的叶子结点按从小到大排序成一个有序序列,即A5,E10,B14,D30,C40。
  2. 取头两个最小权值的结点作为一个新结点N1的两个子节点,相对较小的是左孩子。新结点的权值为两个叶子权值的和5+10=15。
  3. 将N1替换A与E,插入有序序列中,重复步骤二,直到最后两个值合并。
IMG_5516

6.12.3 赫夫曼编码

发送一串信息,根据字母的频率构造赫夫曼树,使用赫夫曼树编码后数据被压缩,而解码方需要根据赫夫曼树来进行解码,从而计算出信息。

一般地,设需要编码的字符集为{d1,d2…dn},各个字符在电文中出现的次数为{w1,w2…wn},以d1,d2…dn为叶子结点,w1,w2…wn作为相应叶子结点的权值来构造一颗赫夫曼树。规定赫夫曼树的左分支代表0,右分支代表1,这就是赫夫曼编码。

第7章 图

7.2 图的定义

图是一种较线性表和树更加复杂的数据结构。在图形结构中,结点之间的关系可以任意的,图中任意两个数据元素之间都可能相关。

图(Graph)是由顶点的有穷非空集中和顶点之间边的集合组成,通常表示为:G(V,E),其中,V是图G中顶点的集合,E是图G中边的集合。

  • 线性表中数据元素叫元素,树中数据元素叫结点,在图中的数据元素称为顶点(Vertex)。
  • 空的线性表称为空表,空的树称为空树,而图不允许没有顶点,即V非空。
  • 线性表中相邻的数据元素之间具有线性关系,树中相邻两层的结点具有层次关系,图中任意两个顶点之间都可能有关系,顶点之间的逻辑关系用边来表示,边集可以是空的。

7.2.1 各种图定义

若顶点vi到vj之间的边没有方向,则称这条边为无向边(Edge),用无序偶对(vi,vj)来表示,如果图中所有边都是无向边,则该图称为无向图(Undirected graphs)。无向图 G 1 = ( V 1 , { E 1 } ) G_1=(V_1,\lbrace E_1 \rbrace) G1=(V1,{E1}),其中顶点集合 V 1 V_1 V1={A,B,C,D};边集合 E 1 E_1 E1={(A,B), (B,C), (C,D), (D,A), (A,C)}。

若从顶点vi到vj的边有方向,则称这条边为有向边,也称为弧(Arc)。用有序偶<Vi, Vj>来表示,vi称为弧尾(Tail),vj称为弧头(Head)。如果图中所有边都是有向边,则该图称为有向图(Directed graphs)。有向图 G 2 G_2 G2=( V 2 V_2 V2,{ E 2 E_2 E2}),其中顶点集合 V 2 V_2 V2={A,B,C,D};弧集合 E 2 = ( < A , D > , < B , A > , < B , C > , < C , A > ) E_2=(<A,D>, <B,A>, <B,C>, <C,A>) E2=(<A,D>,<B,A>,<B,C>,<C,A>)

无向图用小括号()表示,有向图用尖括号表示<>

IMG_5517

在图中,若不存在顶点到其自身的边,且同一条边不重复出现,这样的图称为简单图

在无向图中,如果任意两个顶点都存在边,则称该图为无向完全图。一共有 n × ( n − 1 ) 2 \frac{n \times (n-1)}{2} 2n×(n1)条边。在有向图中,如果任何两个顶点之间都存在方向互为相反的两条弧,则称该图为有向完全图。含有 n × ( n − 1 ) n \times (n-1) n×(n1)条边。

有些图的边或弧具有与它相关的数字,这种与图的边或弧相关的数叫做权(Weight)。带权的图通常称为网(Network)

假设有两个图G=(V,{E})和G’=(V’,{E’}),如果 V ′ ⊆ V V'\subseteq V VV E ′ ⊆ E E'\subseteq E EE,则称G’为G的子图(Subgraph)。

IMG_5524

7.2.2 图的顶点与边间关系

对于无向图 G = ( V , E ) G=(V,{E}) G=(V,E),如果边 ( v , v ′ ) ∈ E (v,v')\in E (v,v)E,则称顶点v和v’互为邻接点(Adjacent),即v和v’相邻接。边数其实就是各顶点度数和的一半 e = 1 2 ∑ i = 1 n T D ( v i ) e=\frac{1}{2}\sum_{i=1}^nTD(v_i) e=21i=1nTD(vi)

对于有向图, e = ∑ i = 1 n I D ( v i ) = ∑ i = 1 n O D ( v i ) e=\sum_{i=1}^nID(v_i)=\sum_{i=1}^nOD(v_i) e=i=1nID(vi)=i=1nOD(vi)

第一个顶点到最后一个顶点相同的路径称为回路或环(Cycle)。序列中顶点不重复出现的路径称为简单路径。除了第一个顶点和最后一个顶点之外,其余顶点不重复出现的回路,称为简单回路或简单环。

IMG_5525

7.2.3 连通图相关术语

在无向图G中,如果从顶点v到顶点v’有路径,则称v和v’是连通的。如果对于图中任意两个顶点 v i 、 v j ∈ V v_i、v_j\in V vivjV v i v_i vi v j v_j vj都是连通的,则称G是连通图(Connected Graph)。

IMG_5526

IMG_5527

IMG_5528

无向图中连通且n个顶点n-1条边叫生成树。有向图一顶点入度为0其余项点入度为1的叫有向树。一个有向图由若干颗有向树构成生成森林。

IMG_5529
IMG_5530

7.2.4 图的定义与术语总结

图按照有无方向分为无向图有向图。无向图由顶点构成,有向图由顶点和
构成。弧有弧尾和弧头之分。

图按照边或弧的多少分稀疏图稠密图。如果任意两个顶点之间都存在边叫完全
,有向的叫有向完全图。若无重复的边或顶点到自身的边则叫简单图

图中顶点之间有邻接点、依附的概念。无向图顶点的边数叫做,有向图顶点分
入度和出度

图上的边或弧上带则称为

图中顶点间存在路径,两顶点存在路径则说明是连通的,如果路径最终回到起始
点则称为,当中不重复叫简单路径。若任意两顶点都是连通的,则图就是连通图
有向则称强连通图。图中有子图,若子图极大连通则就是连通分量,有向的则称强连
通分量

无向图中连通且 n 个顶点 n一1 条边叫生成树。有向图中一顶点入度为 0 其余顶
点入度为1的叫有向树。一个有向图由若干棵有向树构成生成森林

7.3 图的抽象数据类型

ADT 图(Graph)
Data
		顶点的有穷非空集合和边的集合
Operation
		CreateGraph(*G,V,VR): 按照顶点集合V和边弧集VR的定义构造图G。
		DestoryGraph(*G): 图G存在则销毁。
		LocateVex(G,u): 若图G存在顶点u,则返回图中的位置。
		GetVex(G,v): 返回图G中点v的值。
		PutVex(G,v,value): 给v顶点赋值value。
		FirstAdjVex(G,*v): 返回顶点v的一个邻接顶点,若顶点在G中无邻接顶点返回空。
		NextAdjVex(G,v,*w): 返回v相对于w的下一个邻接顶点。
		InsertVex(*G,v): 在图G中增添新顶点v。
		DelteVex(*G,v): 删除图G中顶点v及其相关的弧。
		InsertArc(*G,v,w): 在图G中增添弧<v,w>,若G是无向图,还需要增添对称弧<w,v>。
		DeleteArc(*G,v,w)
		DFSTraverse(G): 对图G中进行深度优先遍历,在遍历过程中对每个顶点的调用。
		HFSTraverse(G): 广度优先遍历。

7.4 图的存储结构

7.4.1 邻接矩阵

分两个结构来存储。顶点部分大小、主次,用一个一维数组来存储。边或弧用二维数组来存储。

Hl9bBk

对称矩阵就是n阶矩阵的元满足 a i j = a j i , ( 0 ≤ i , j ≤ n ) a_{ij}=a_{ji},(0\leq i,j\leq n) aij=aji,(0i,jn)

IMG_5547

设图G是网图,有n个顶点,则邻接矩阵是一个 n × n n\times n n×n的方阵,定义为9um9uL

IMG_5548

无向网图的邻接矩阵表示:

const createMGraph = async (G: { vexs: string[]; arc: any[] }) => {
  let i, j, k, w;
  prompt.start();
  console.log("请输入顶点数和边数");
  const res = await prompt.get<{ numVertexes: string; numEdges: string }>(["numVertexes", "numEdges"]);
  for (let i = 0; i < Number(res.numVertexes); i++) {
    const { vertexe } = await prompt.get<{ vertexe: string }>(["vertexe"]);
    G.vexs[i] = vertexe;
  }
  for (let i = 0; i < Number(res.numVertexes); i++) {
    for (let j = 0; j < Number(res.numVertexes); j++) {
      if (!G.arc[i]) {
        G.arc[i] = [];
      }
      G.arc[i][j] = Infinity;
    }
  }
  console.log(G.arc);
  for (let k = 0; k < Number(res.numEdges); k++) {
    const { i, j, w } = await prompt.get<{ i: string; j: string; w: string }>(["i", "j", "w"]);
    G.arc[Number(i)][Number(j)] = w;
    // 因为是无向图,所以矩阵对称
    G.arc[Number(j)][Number(i)] = G.arc[Number(i)][Number(j)];
  }
  console.log(G.arc);
};

(async () => {
  await createMGraph({ vexs: [], arc: [] });
})();

// [
//  [ Infinity, '10', Infinity ],
//  [ '10', Infinity, '5' ],
//  [ Infinity, '5', Infinity ]
// ]

7.4.2 邻接表

邻接矩阵对于边数较少的图,这种结构是存在对存储空间的极大浪费

数组与链表相结合的存储方法称为邻接表。邻接表的处理方法:

  1. 顶点用一维数组存储,每个数据元素存储指向第一个邻接点的指针,便于查找该顶点的边信息。
  2. 每个顶点 V i V_i Vi的所有邻接点构成一个线性表。

无向邻接表

IMG_5549

有向邻接表(一个表来表示出度,一个表来表示入度)

IMG_5551

对于带权值的网,可以在边表结点定义中再增加一个weight的数据域存储信息即可。

7.4.3 十字链表

邻接表的缺陷:关心了入度问题,想了解出度就必须再遍历整个图才能知道。

有向图的一种存储方法:十字链表(Orthogonal List)。

顶点表结点结构,firstin表示入边表头指针,firstout表示出边表头指针。

边表结点结构,tailvex指弧起点在顶点表的下标,headvex是指弧终点在顶点表中的下标,headlink是指入边表指针域,指向终点相同的下一条边,taillink是指边表指针域,指向起点相同的下一条边。

十字链表的好处是因为把邻接表和逆邻接表整合在一起,既容易找到以 V i V_i Vi为尾的弧,也容易找到 V i V_i Vi为头的弧。

虚线表示的是此图的逆邻接表的表示。十字邻表就是把邻接表和逆邻接表整合在一起。

IMG_5552

7.4.4 邻接多重表

无向图邻接表重点在关注顶点,而如果我们更关注的操作,那么邻接表不太适合。

重新定义边表结点结构

ivex和jvex是与某条边依附的两个顶点在顶点表中的下标。ilink指向依附项点ivex的下一条边,jlink指向依附顶点jvex的下一条边。

IMG_5553

7.4.5 边集数组

边组数组是由两个一维数组构成。一个存储顶点的信息,一个存储边的信息,每个数据元素由起点下标(begin)、终点下标(end)和权(weight)组成。

边集数组适合依次对边进行处理的操作,不适合对顶点相关的操作。

IMG_5554

7.5 图的遍历

7.5.1 深度优先遍历

深度优先遍历(Depth_First_Search),简称DFS。

深度遍历其实就是一个递归的过程,像是一棵树的前序遍历。

IMG_5555

7.5.2 广度优先遍历

广度优先遍历(Breadth_First_Search),简称BFS。广度优先遍历类似于树的层序遍历。

IMG_5556

深度优先更适合目标比较明确,以找到目标为主要目的的情况,而广度优先更适合在不断扩大遍历范围时找到想回最优解的情况。

7.6 最小生成树

构造连通网的最小代价生成树称为最小生成树(Minimum Cost Spanning Tree)。

7.6.1 普里姆(Prim)算法

假设 N = ( V , { E } ) N=(V,\{E\}) N=(V,{E})是连通网,TE是N上最小生成树中边的集合。算法从 U = { u 0 } ( u 0 ∈ V ) , T E = { } U=\{u_0\}(u_0\in V),TE=\{\} U={u0}(u0V),TE={}开始。重复执行下述操作:在所有 u ∈ U , v ∈ V − U u\in U,v\in V-U uU,vVU的边 ( u , v ) ∈ E (u,v)\in E (u,v)E中找一条代价最小的边 ( u 0 , v 0 ) (u_0,v_0) (u0,v0)并入集合TE,同时 v 0 v_0 v0并入U,直至 U = V U=V U=V为止。此时TE中必有 n − 1 n-1 n1条边,则 T = ( V , { T E } ) T=(V,\{TE\}) T=(V,{TE})为N的最小生成树。

const MiniSpanTree_Prim = (G: { numVertexes: string[]; arc: any[][] }) => {
  const adjvex = [];
  const lowcost = [];
  let min, j, k;
  lowcost[0] = 0;
  adjvex[0] = 0;
  // 初始化
  for (let i = 1; i < G.numVertexes.length; i++) {
    lowcost[i] = G.arc[0][i];
    adjvex[i] = 0;
  }
  for (let i = 1; i < G.numVertexes.length; i++) {
    min = Infinity;
    j = 1;
    k = 0;
    // 找出当前的最小值
    while (j < G.numVertexes.length) {
      if (lowcost[j] !== 0 && lowcost[j] < min) {
        min = lowcost[j];
        k = j;
      }
      j++;
    }
    lowcost[k] = 0;
    console.log(`(${adjvex[k]}, ${k})`);
    for (let j = 1; j < G.numVertexes.length; j++) {
      if (lowcost[j] !== 0 && G.arc[k][j] < lowcost[j]) {
        lowcost[j] = G.arc[k][j];
        adjvex[j] = k;
      }
    }
  }
};

7.6.2 克鲁斯卡尔(Kruskal)算法

以边为目标去构建,因为权值是在边上,直接去找最小权值的边来构建生成树。

使用边集数组来处理,按权值从小到大排列。

假设 N = ( V , { E } ) N=(V,\{E\}) N=(V,{E})是连通网,则令最小生成树的初始状态为只有n个顶点而无边的非连通图 T = { V , { } } T=\{V,\{\}\} T={V,{}},图中每个顶点自成一个连通分量。在E中选择代价最小的边,若该边依附的顶点落在T中不同的连通分量上,则将此边加入到T中,否则舍去此边而选择下一条代价最小的边。时间复杂度为 e O ( l o g e ) eO(loge) eO(loge)

// begin表示起点,end表示终点,weight表示权值
const struct = [{begin: 0, end: 1, weight: 10}];
const G = {
  numVertexes: ["v0", "v1", "v2", "v3", "v4", "v5", "v6", "v7", "v8"],
  numEdges: [
    { begin: 4, end: 7, weight: 7 },
    { begin: 2, end: 8, weight: 8 },
    { begin: 0, end: 1, weight: 10 },
    { begin: 0, end: 5, weight: 11 },
    { begin: 1, end: 8, weight: 12 },
    { begin: 3, end: 7, weight: 16 },
    { begin: 1, end: 6, weight: 16 },
    { begin: 5, end: 6, weight: 17 },
    { begin: 1, end: 2, weight: 18 },
    { begin: 6, end: 7, weight: 19 },
    { begin: 3, end: 4, weight: 20 },
    { begin: 3, end: 8, weight: 21 },
    { begin: 2, end: 3, weight: 22 },
    { begin: 3, end: 6, weight: 24 },
    { begin: 4, end: 5, weight: 26 },
  ],
};
const find = (parent, f) => {
  while (parent[f] > 0) {
    f = parent[f];
  }
  return f;
};
const MiniSpanTree_Kruskal = (G: { numVertexes: string[]; numEdges: { begin: any; end: any; weight: number }[] }) => {
  let n, m;
  const edges = G.numEdges;
  const parent = G.numVertexes.map((item) => 0);
  for (let i = 0; i < G.numEdges.length; i++) {
    n = find(parent, edges[i].begin);
    m = find(parent, edges[i].end);
    if (n !== m) {
      parent[n] = m;
      console.log(`(${edges[i].begin}, ${edges[i].end}) ${edges[i].weight}`);
    }
  }
};

MiniSpanTree_Kruskal(G);

对比

克鲁斯卡尔算法针对边来展开,在稀疏图有很大的优势,而普里姆算法对于稠密图,即边数多的情况有优势。

7.7 最短路径

第8章 查找

8.2 查找概论

查找表(Search Table)是由同一类型的数据元素(或记录)构成的集合。

关键字(Key)是数据元素中某个数据项的值,又称为键值,用它可以标识一个数据元素。也可以标识一个记录的某个数据项(字段)。唯一的标识一个记录的关键字称为主关键字(Primary Key)。

可以识别多个数据元素(或记录)的关键字称为次关键字

查找(Searching)就是根据给定的某个值,在查找表中确定一个其关键字等于给定值的数据元素(或记录)。

查找表分为:

  • 静态查找表(Static Search Table),只作查找操作的查找表。
    1. 查询某个“特定的”数据元素是否在查找表中。
    2. 检索某个“特定的”数据元素和各种数据。
  • 动态查找表(Dynamic Search Table)
    1. 查找时插入数据元素。
    2. 查找时删除数据元素。

面向查找操作的数据结构称为查找结构

8.3 顺序表查找

顺序查找(Sequential Search)又叫线性查找,是最基本的查找技术,它的查找过程是:从表中第一个(或最后一个)记录开始,逐个进行记录的关键字和给定值比较,若某个记录的关键字和给定值相等,则查找成功,找到所查的记录;如果直到最后关键字和给定值比较都不相等,则查找不成功。

8.3.1 顺序表查找算法

const sequentialSearch = (arr, key) => {
  for (let i = 0; i < arr.length; i++) {
    if (arr[i] === key) return i;
  }
  return null;
};

8.3.2 顺序表查找优化

设置一个哨兵,这样就不需要让i与n作比较。

const sequentialSearch2 = (arr, key) => {
  let i = arr.length;
  arr[0] = key
  while(arr[i] !== key) {
    i--
  }
  return i
}

顺序查找的时间复杂度是 O ( n ) O(n) O(n)

顺序查找缺点:顺序查找当n很大时,查找效率极为低下。

8.4 有序表查找

8.4.1 折半查找

折半查找(Binary Search)技术,又称为二分查找。它的前提是线性表中的记录必须是关键码有序(从小到大有序),线性表必须采用顺序存储

查找思路:在有序表中,取中间记录作为比较对象,若给定值与中间记录的关键字相等,则查找成功;若给定值小于中间记录则在左半区继续查找,否则在右半区查找,直到查找成功,或查找失败。

时间复杂度为 O ( l o g n ) O(logn) O(logn)

const binarySearch = (arr: number[], key) => {
  let low = 0;
  let high = arr.length - 1;
  let mid = 0;
  while (low <= high) {
    mid = Math.floor((low + high) / 2);
    if (key < arr[mid]) {
      high = mid - 1;
    } else if (key > arr[mid]) {
      low = mid + 1;
    } else {
      return mid;
    }
  }
  return null;
};

折半查找的前提条件是需要有序表顺序存储,适合静态查找表,一次排序后不再变化,如果是需要频繁执行插入或删除操作的数据集来说,维护有序的排序会带来不小的工作量,所以不推荐使用。

8.4.2 插值查找

插值查找(Interpolation Search)是根据要查找的关键字key与查找表中最大最小记录的关键字比较后的查找方法,也是适用于有序查找表,其核心在于插值计算公式 k e y − a [ l o w ] a [ h i g h ] − a [ l o w ] \frac {key-a[low]} {a[high]-a[low]} a[high]a[low]keya[low]

时间复杂度为 O ( l o g n ) O(logn) O(logn)。对于关键字分布比较均匀的查找表来说,插值查找的平均性能会比折半查找好得多。反之,如果数组中分布的数据比较极端不均匀,插值反而不是合适的选择。

8.4.3 斐波那契查找

斐波那契查找(Fibonacci Search)利用了黄金分割原理来实现。

const FibonacciSearch = (arr: number[], key: number) => {
  const n = arr.length - 1;
  let low = 0;
  let hign = n;
  let k = 0;
  let mid = 0;
  // 找出n在斐波那契数列中的位置
  while (n > F[k] - 1) {
    k++;
  }
  for (let i = n; i < F[k] - 1; i++) {
    arr[i] = arr[n];
  }
  while (low <= hign) {
    mid = low + F[k - 1] - 1;
    if (key < arr[mid]) {
      hign = mid - 1;
      k -= 1;
    } else if (key > arr[mid]) {
      low = mid + 1;
      k -= 2;
    } else {
      if (mid <= n) {
        return mid;
      } else {
        return n;
      }
    }
  }
  return null;
};

时间复杂度为 O ( l o g n ) O(logn) O(logn)。从平均性能来说,斐波那契查找要优于折半查找。可惜如果是最坏情况,比如key=1,那么始终都处于左侧长半区查找,效率会低于折半查找。

8.5 线性索引查找

数据结构的最终目的是提高数据的处理速度,索引是为了加快查找速度而设计的一种数据结构。

索引就是把一个关键字与它对应的记录相关联的过程,一个索引由若干个索引项构成,每个索引项至少应包含关键字和其对应的记录在存储器中的位置等信息。索引技术是组织大型数据库以及磁盘文件的一种重要技术。

索引分为线性索引、树形索引和多级索引。线性索引有稠密索引、分块索引和倒排索引。

8.5.1 稠密索引

稠密索引是指在线性索引中,将数据集中的每个记录对应一个索引项

IMG_5613

索引项有序意味着可以用有序查找算法,大大提高效率。比如我要查找图中18的数据,顺序查找要查找6次,而从左侧的索引表查找用折半只需要两次。

缺点:如果数据集非常大,比如上亿,意味着索引也得是同样的数据集长度规模,对于内存有限的计算机来说,反复去访问磁盘,查找性能反而大大下降了。空间换时间。

8.5.2 分块索引

分块索引,就是把数据集的记录分成了若干块,并且这些块需要满足两个条件:

  • 块内无序,即每一块内的记录不要求有序。
  • 块间有序。块间有序才有可能在查找时带来效率。

分块索引的索引项结构分三个数据项:

  • 最大关键码,它存储每一块中的最大关键字。
  • 块中的记录个数,便于循环使用。
  • 指向块首数据元素的指针。

IMG_5614

块间用有序查找算法,块内用顺序查找,分块索引在兼顾了对细分块不需要有序的情况下,大大增加了整体查找的速度,所以普遍被用于数据库表查找等技术的应用中。

8.5.3 倒排索引

索引项的通用结构:

  • 次关键码
  • 记录号表

倒排索引源于实际应用中需要根据属性(或字段、次关键码)的值来查找记录。 这种索引表中的每一项都包括一个属性值和具有该属性值的各记录的地址,由于不是由记录来确定属性值,而是由属性值来确定记录的位置,所以称为倒排索引。

缺点:倒排索引缺点就是记录号不定长,如果对多篇文章所有单词建立倒排索引,那每个单词将对应相当多的文章编号,维护比较困难。

8.6 二叉排序树

二叉排序树插入和查找同样高效。

集合{62,88,58,47,35,73,51,99,37,93}生成的二叉树是

IMG_5630

对其进行中序遍历后得到{35,37,47,51,58,62,73,88,93,99}。

二叉排序树(Binary Sort Tree),又称为二叉排序树。它或者是一颗空树,或者是具有下列性质的二叉树。

  • 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
  • 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
  • 它的左右子树也是二叉排序树;

在一个有序数据集上的查找,速度总是要快于无序的数据集。

8.6.1 二叉排序树查找操作

interface Tree {
  data: number;
  lchild: Tree | null;
  rchild: Tree | null;
}
const searchBST = (T: Tree, key: number, f: Tree | null = null, p: Tree | null = null) => {
  if (!T) {
    // 获取当前的树
    p = f;
    return false;
  } else if (key === T.data) {
    p = T;
    return true;
  } else if (key < T.data) {
    return searchBST(T.lchild, key, T, p);
  } else {
    return searchBST(T.rchild, key, T, p);
  }
};

8.6.2 二叉排序树插入操作

const insertBST = (T: Tree, key: number) => {
  let p: Tree;
  if (!searchBST(T, key, null, p)) {
    const s: Tree = {
      data: key,
      lchild: null,
      rchild: null
    };
    if (!p) {
      T = s;
    } else if (key < p.data) {
      p.lchild = s;
    } else {
      p.rchild = s;
    }
    return T;
  }
};

8.6.3 二叉排序树删除操作

删除结点有三种情况:

  • 叶子结点。

    直接删除

  • 仅有左或右子树的结点。

    使用右或左节点替换。

  • 左右子树都有的结点。

    找到需要删除的结点p的直接前驱(或直接后继)s,用s来替换结点p,然后再删除此结点。

// 删除节点
const del = (p: Tree) => {
  let q: Tree;
  // 只有左节点
  if (!p.rchild) {
    q = p;
    p = p.lchild;
  }
  // 只有右节点
  else if (!p.lchild) {
    q = p;
    p = p.rchild;
  }
  // 有两个结点
  else {
    let s: Tree = p.lchild;
    // 拿到前驱
    while (s.rchild) {
      q = s;
      s = s.rchild;
    }
    p.data = s.data;
    if (q !== p) {
      q.rchild = s.lchild;
    } else {
      q.lchild = s.lchild;
    }
  }
  return true;
};

const deleteBST = (T: Tree, key: number) => {
  if (!T) {
    return false;
  } else {
    if (key === T.data) {
      del(T);
      return true;
    } else if (key < T.data) {
      return deleteBST(T.lchild, key);
    } else {
      return deleteBST(T.rchild, key);
    }
  }
};

8.6.4 二叉排序树总结

二叉排序树是以链接的方式存储。

二叉排序树的查找,走的就是从根结点到要查找的结点的路径,其比较次数等于给定值的结点在二叉排序树的层数。

如果二叉排序树深度与完全二叉树相同,均为 ( l o g 2 n ) + 1 (log_2n)+1 (log2n)+1,那么查找的时间复杂度为 O ( l o g n ) O(log_n) O(logn),接近折半查找。

在不平衡的最坏情况的斜树下,查找的时间复杂度为 O ( n ) O(n) O(n)

如果对一个集合按二叉排序树查找,最好是把它构建成一颗平衡的二叉排序树。

8.7 平衡二叉树(AVL树)

平衡二叉树(Self-Balancing Binary Search Tree 或 Height-Balanced Binary Search Tree),是一种二叉排序树,其中每一个节点的左子树和右子树的高度差至多等于1。

将二叉树上结点的左子树深度减去右子树深度的值称为平衡因子BF(Balance Factor),那么平衡二叉树上所有结点的平衡因子只可能是-1、0和1。

距离插入结点最近的,且平衡因子的绝对值大于1的结点为根的子树,我们称为最小不平衡子树

k4OADI

8.7.1 平衡二叉树实现原理

平衡二叉树的构建,其实就是在二叉排序树创建过程中保证它的平衡性。

当最小平衡子树根结点的平衡因子BF是大于1时,就右旋,小于-1时就左旋。插入结点后,最小不平衡子树的BF与它的子树的BF符号相反时,就需要对结点先进行一次旋转以使得符号相同后,再反向旋转一次才能够完成平衡操作。

8.7.2 平衡二叉树实现算法

interface BiTNode {
  data: number;
  bf: number;
  lchild: BiTNode;
  rchild: BiTNode;
}
const LH = 1;
const EH = 0;
const RH = -1;

// 右旋操作
const RRotate = (P: BiTNode) => {
  let L: BiTNode;
  L = P.lchild;
  P.lchild = L.rchild;
  L.rchild = P;
  P = L;
};
// 左旋操作
const LRotate = (P: BiTNode) => {
  let R: BiTNode;
  R = P.rchild;
  P.rchild = R.lchild;
  R.lchild = P;
  P = R;
};

// 左平衡旋转处理
const leftBalance = (T: BiTNode) => {
  // T的bf为1
  let L: BiTNode;
  let Lr: BiTNode;
  L = T.lchild;
  switch (L.bf) {
      // T的左子树的bf与T的bf相同,进行右旋操作
    case LH:
      T.bf = L.bf = EH;
      RRotate(T);
      break;
      // T的左子树的bf与T的bf相反,进行双旋处理
    case RH:
      Lr = L.rchild;
      switch (Lr.bf) {
        case LH:
          T.bf = RH;
          L.bf = EH;
          break;
        case EH:
          T.bf = L.bf = EH;
          break;
        case RH:
          T.bf = EH;
          L.bf = LH;
          break;
      }
      Lr.bf = EH;
      LRotate(T.lchild);
      RRotate(T);
  }
};

// 右平衡旋转处理
const rightBalance = (T: BiTNode) => {
  let R: BiTNode;
  let Rl: BiTNode;
  R = T.rchild;
  switch (R.bf) {
    case RH:
      T.bf = R.bf = EH;
      LRotate(T);
      break;
    case LH:
      Rl = R.lchild;
      switch (Rl.bf) {
        case RH:
          T.bf = LH;
          R.bf = EH;
          break;
        case EH:
          T.bf = R.bf = EH;
          break;
        case LH:
          T.bf = EH;
          R.bf = RH;
          break;
      }
      Rl.bf = EH;
      RRotate(T.rchild);
      LRotate(T);
  }
};

// 平衡二叉树插入算法
const insertAVL = (T: BiTNode, e: number, taller: boolean) => {
  if (!T) {
    T.data = e;
    T.lchild = T.rchild = null;
    T.bf = EH;
    taller = true;
  } else {
    if (e === T.data) {
      taller = false;
      return false;
    }
    // 当新结点e小于T的根结点值时,则在T的左子树查找。
    if (e < T.data) {
      if (!insertAVL(T.lchild, e, taller)) {
        return false;
      }
      if (taller) {
        switch (T.bf) {
          case LH:
            leftBalance(T);
            taller = false;
            break;
          case EH:
            T.bf = LH;
            taller = true;
            break;
          case RH:
            T.bf = EH;
            taller = false;
            break;
        }
      }
    }
    // 当新结点e大于T的根结点值时,则在T的右子树查找。
    else {
      if (!insertAVL(T.rchild, e, taller)) {
        return false;
      }
      if (taller) {
        switch (T.bf) {
          case LH:
            T.bf = EH;
            taller = false;
            break;
          case EH:
            T.bf = RH;
            taller = true;
            break;
          case RH:
            rightBalance(T);
            taller = false;
            break;
        }
      }
    }
  }
  return T;
};

// 目前还未解决T的引用问题
let T: BiTNode;
let taller: boolean = false;
const arr = [3, 2, 1, 4, 5, 6, 7, 10, 9, 8];
arr.forEach((item) => {
  insertAVL(T, item, taller);
});
console.log(T);

将二叉排序树构建成平衡二叉树后,查找的时间复杂度为 O ( log ⁡ n ) O(\log_n) O(logn),插入和删除也为 O ( log ⁡ n ) O(\log_n) O(logn)。这是一种比较理想的动态查找表算法,二叉排序树还有另外的平衡算法,如红黑树(Red Black Tree)等。

8.8 多路复查树(B树)

多路查找树(muitl-way search tree),其每一个结点的孩子数可以多于两个,且每一个结点处可以存储多个元素。

每一个结点可以存储多少个元素,以及它的孩子树的多少是非常关键的,它的4种特殊形式:2-3树、2-3-4树、B树和B+树。

8.8.1 2-3树

2-3树,其中的每一个结点都具有两个孩子(2结点)或三个孩子(3结点)。

一个2结点包含一个元素和两个孩子(或没有孩子),且与二叉排序树类似,左子树包含的元素小于该元素,右子树包含的元素大于该元素。与二叉排序树不同的是,2结点要么没有孩子,要么就两个孩子,不能没有孩子。

一个3结点包含一小一大两个元素和三个孩子(或没有孩子),一个3结点要么没有孩子,要么具有3个孩子。左子树包含较小的元素,右子树包含较大的元素,中间子树介于两元素之间的元素。

2-3树复杂的地方就在于新结点的插入和已有结点的删除。

IMG_5647

1. 2-3树的插入实现

插入操作一定是发生在叶子结点上。

插入分为三种情况:

  • 对于空树,插入一个2结点即可。
  • 插入一个结点到一个2结点的叶子上。
  • 插入一个结点到一个3结点的叶子上。(比较麻烦)

插入到3结点的情况

IMG_5651

IMG_5651-2

IMG_5652

如果2-3树插入的传播效应导致了根结点的拆分,则树的高度就会增加。

2. 2-3树的删除实现

删除分为三种情况:

  • 删除元素位于一个3结点的叶子结点上。
  • 删除元素位于一个2结点上。
  • 删除元素位于非叶子的分支结点。

删除2结点分为4种情况

  • IMG_5653
  • IMG_5653-2
  • IMG_5653-3
  • IMG_5654

8.8.2 2-3-4树

2-3-4树就是2-3树的概念扩展,一个4结点包含小中大三个元素和四个孩子(或没有孩子),一个4结点要么没有孩子,要么具有4个孩子。

IMG_5655

8.8.3 B树

B树(B-tree)是一种平衡的多路查找树,2-3树和2-3-4树都是B树的特例。结点最大的孩子数目称为B树的阶(order),因此,2-3树是3阶B树,2-3-4树是4阶B树。

一个m阶的B树具有如下属性:

  • 如果根结点不是叶结点,则其至少有两颗子树。
  • 每一个非根的分支结点都有k-1个元素和k个孩子,其中 [ m / 2 ] ≤ k ≤ m [m/2]\le k \le m [m/2]km。每一个叶子结点n都有k-1个元素,其中 [ m / 2 ] ≤ k ≤ m [m/2]\le k \le m [m/2]km
  • 所有叶子结点都位于同一层次。
  • 所有分支结点包括下列信息数据( n , A 0 , K 1 , A 1 , K 2 , A 2 , . . . , K n , A n n,A_0,K_1,A_1,K_2,A_2,...,K_n,A_n n,A0,K1,A1,K2,A2,...,Kn,An),其中: K i K_i Ki为关键字,且 K i < K i + 1 K_i \lt K_{i+1} Ki<Ki+1 A i A_i Ai为指向子树根结点的指针,且指针 A i − 1 A_{i-1} Ai1所指子树中所有结点的关键字均小于 K i K_i Ki A n A_n An所指子树中所有结点的关键字均大于 K n K_n Kn,n为关键字的个数。

IMG_5727

B树的数据结构就是为内外存的数据交互准备的。

8.8.4 B+树

B树的缺点是每次经过结点遍历时,都会对结点中的元素进行一次遍历。

在B+树中,出现的分支结点中的元素会被当作它们在该分支结点位置的中序后续者(叶子结点)中再次列出。另外,每一个叶子结点都会保存一个指向后一叶子结点的指针。

IMG_5728

B+树的结构特别适合带有范围的查找。

8.9 散列表查找(哈希表)概述

8.9.1 散列表查找定义

存储位置= f(关键字)

散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f(key)。

我们把对应关系f称为散列函数,又称为哈希(Hash)函数。采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表哈希表(Hash table)。关键字对应的记录存储位置称为散列地址

8.9.2 散列表查找步骤

  1. 在存储时,通过散列函数计算记录的散列地址,并按此散列地址存储该记录。
  2. 在查找记录时,我们通过同样的散列函数计算记录的散列地址,按此散列地址访问该记录。

散列技术即是一种存储方法,也是一种查找方法。散列技术的记录之间不存在什么逻辑关系,它只与关键字有关联。散列主要是面向查找的存储结构。

缺点:

  • 散列表不适合范围查找。
  • 同样的关键字对应很多记录的情况也不适合散列技术。

8.10 散列函数的构造方法

好的散列函数:

  1. 计算简单

    散列函数的计算时间不应该超过其他查找技术与关键字比较的时间。

  2. 散列地址分布均匀

8.10.1 直接定址法(不常用)

取关键字的某个线性函数值为散列地址

f ( k e y ) = a × k e y + b ( a 、 b 为常数) f(key) = a \times key + b (a、b为常数) f(key)=a×key+bab为常数)

优点是简单、均匀,但得事先知道关键字的分布情况,适合查找表较小且连续的情况。

8.10.2 数字分析法

抽取——抽取方法是使用关键字的一部分来计算散列存储位置的方法,这在散列函数中是常常用到的手段。

数字分析法通过适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀,可以考虑用这个方法。

8.10.3 平方取中法

假设关键字是1234,它的平方就是1522756,再抽取中间的3位就是227用作散列地址。

平方取中法比较适合不知道关键字的分布,而位数又不是很大的情况。

8.10.4 折叠法

折叠法是将关键字从左到右分割成位数相等的几部分,然后将这几部分叠加求和,并按散列表表长,取后作为散列地址。

比如关键字9876543210,散列表表长为三位,分为四组987|654|321|0,叠加求和987+654+321+0=1962,取后3位散列地址为962。

折叠法事先不需要知道关键字的分布,适合关键字位数较多的情况。

8.10.5 除留余数法

f ( k e y ) = k e y   m o d   p ( p ≤ m ) f(key) = key \space mod \space p (p \le m) f(key)=key mod p(pm)

mod是取模(求余数)的意思。

除留余数法对取模后的地址容易存在冲突的可能。

因此,若散列表表长为m,通常p为小于或等于表长(最好接近m)的最小质数或不包含小于20质因子的合数。

8.10.6 随机数法

f ( k e y ) = r a n d o m ( k e y ) f(key) = random(key) f(key)=random(key)

当关键字的长度不等时,采用这个方法构造散列函数是比较合适的。

无论是英文字符,还是中文字符,也包括各种各样的符号,它们都可以转化为某种数字来对待,比如ASCII码或者Unicaode码等等,因此也可以使用上面的这些方法。

现实中,应该视不同情况采用不同的散列函数,以下是考虑的因素:

  1. 计算散列地址所需的时间。
  2. 关键字的长度。
  3. 散列表的大小。
  4. 关键字的分布情况。
  5. 记录查找的频率。

8.11 处理散列冲突的方法

8.11.1 开放定址法

开放定址法就是一旦发生冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。

f i ( k e y ) = ( f ( k e y ) + d i )   M O D   m ( d i = 1 , 2 , 3 , . . . . . . , m − 1 ) f_i(key)=(f(key)+d_i) \space MOD \space m(d_i=1,2,3,......,m-1) fi(key)=(f(key)+di) MOD m(di=1,2,3,......,m1)

f i ( k e y ) = ( f ( k e y ) + d i )   M O D   m ( d i = 1 2 , − 1 2 , 2 2 , − 2 2 , 3 2 , − 3 2 , . . . . . . , q 2 , − q 2 , q ≤ m / 2 ) f_i(key)=(f(key)+d_i) \space MOD \space m(d_i=1^2,-1^2,2^2,-2^2,3^2,-3^2,......,q^2,-q^2,q\le m/2) fi(key)=(f(key)+di) MOD m(di=12,12,22,22,32,32,......,q2,q2,qm/2)

增加平方运算的目的是为了不让关键字都聚集在某一块区域,这种方法称为二次探测法

还有一种方法,在冲突时对于位移量 d i d_i di采用随机函数计算得到,称为随机探测法

8.11.2 再散列函数法

事先准备多个散列函数

f i ( k e y ) = R H i ( k e y ) ( i = 1 , 2 , . . . , k ) f_i(key)=RH_i(key)(i=1,2,...,k) fi(key)=RHi(key)(i=1,2,...,k)

R H i RH_i RHi代表不同的散列函数,每当发生散列地址冲突时,就换一个散列函数计算。

8.11.3 链地址法

将所有关键字为同义词的记录存储在一个单链表中。

IMG_5732

这种方法的缺点是查找需要遍历单链表会造成性能损耗。

8.11.4 公共溢出区法

将有冲突的关键字存到溢出表中。

IMG_5733

8.12 散列表查找实现

8.12.1 散列表查找算法实现

const HASHSIZE = 12;
type HashTable = {
  elem: number[];
  count: number;
};

// 初始化散列表
const initHashTable = (h: HashTable) => {
  let m = HASHSIZE;
  h.count = m;
  for (let i = 0; i < m; i++) {
    h.elem[i] = null;
  }
  return h;
};

// 定义散列函数
const hash = (key: number) => {
  return key % HASHSIZE;
};

// 插入关键字进散列表
const insertHash = (h: HashTable, key: number) => {
  let addr = hash(key);
  // 不为null说明有冲突
  while (h.elem[addr]) {
    // 开放定址法
    addr = (addr + 1) % HASHSIZE;
  }
  h.elem[addr] = key;
};

const arr = [12, 67, 56, 16, 25, 37, 22, 29, 15, 47, 48, 34];
const hashTable: HashTable = initHashTable({
  elem: [],
  count: 0
});
arr.forEach((item) => {
  insertHash(hashTable, item);
});
console.log(hashTable);
// {
//   elem: [
//     12, 25, 37, 15, 16,
//     29, 48, 67, 56, 34,
//     22, 47
//   ],
//   count: 12
// }

// 查找
const searchHash = (h: HashTable, key: number) => {
  let addr = hash(key);
  while (h.elem[addr] !== key) {
    addr = (addr + 1) % HASHSIZE;
    if (h.elem[addr] === null || addr === hash(key)) {
      return 'unsuccess';
    }
  }
  return 'success';
};
console.log(searchHash(hashTable, 12)); // success
console.log(searchHash(hashTable, 11)); // unsuccessAV
console.log(searchHash(hashTable, 48)); // success

8.12.2 散列表查找性能分析

如果没有冲突,散列查找是本章介绍的所有查找中效率最高的,因为它的时间复杂度为 O ( 1 ) O(1) O(1)。但在实际应用中,冲突是不可避免的,散列查找的平均查找长度取决于以下因素:

  • 散列函数是否均匀

  • 处理冲突的方法

  • 散列表的装填因子

    装填因子 α = 填入表中的记录个数 / 散列表长度 装填因子\alpha=填入表中的记录个数/散列表长度 装填因子α=填入表中的记录个数/散列表长度。装填因子越大,产生冲突的可能性就越大。

通常我们都是将散列表的空间设置得比查找集合大,虽然浪费了一定的空间,但换来的查找效率大大提升,总体来说是值得的。

第9章 排序

9.2 排序的基本概念与分类

假设含有n个记录的序列为 r 1 , r 2 , . . . , r n {r_1,r_2,...,r_n} r1,r2,...,rn,其对应的关键字分别为 k 1 , k 2 , . . . , k n {k_1,k_2,...,k_n} k1,k2,...,kn,需确定 1 , 2 , . . . , n 1,2,...,n 1,2,...,n的一种排序 p 1 , p 2 , . . . , p n p_1,p_2,...,p_n p1,p2,...,pn,使其相应的关键字满足 k p 1 ≤ k p 2 ≤ . . . . . . ≤ k p n k_{p1}\le k_{p2}\le ...... \le k_{pn} kp1kp2......kpn非递减(或非递增)关系,即使得序列称为一个按关键字有序的排序 r p 1 , r p 2 , . . . . . . , r p n {r_{p1},r_{p2},......,r_{pn}} rp1,rp2,......,rpn,这样的操作就称为排序

多个关键字的排序最终是可以转化为单个关键字的排序。

9.2.1 排序的稳定性

假设 k i = k j ( i ≤ i ≤ n , 1 ≤ j ≤ n , i ≠ j ) k_i=k_j(i\le i\le n,1\le j \le n,i\neq j) ki=kj(iin,1jn,i=j),且在排序前的序列中 r i r_i ri领先于 r j r_j rj(即 i < j i<j i<j)。如果排序后 r i r_i ri仍领先于 r j r_j rj,则称所用的排序方法是稳定的;反之,若可能使得排序后的序列中 r j r_j rj领先于 r i r_i ri,则称所用的排序方法是不稳定的。

9.2.2 内排序与外排序

内排序是在排序整个过程中,待排序的所有记录全部被放置在内存中。

外排序是由于排序的记录个数太多,不能同时放置在内存,整个排序过程需要在内外存之间多次交换数据才能进行。

对于内排序,影响排序算法的性能主要是受3个方面影响:

  1. 时间性能

    高效率的内排序算法应该是具有尽可能少的关键字比较次数和尽可能少的记录移动次数。

  2. 辅助空间

    辅助存储空间是除了存放待排序所占用的存储空间之外,执行算法所需要的其他存储空间。

  3. 算法的复杂度

内排序分为:插入排序、交换排序、选择排序和归并排序

9.2.3 排序用到的结构和函数

// 元素交换
const swap = (L: number[], i: number, j: number) => {
  [L[i], L[j]] = [L[j], L[i]];
  return L;
};

const arr = [1, 2, 3];

console.log(swap(arr, 1, 2)); // [1, 3, 2]

9.3 冒泡排序

9.3.1 最简单排序实现

冒泡排序(Bubble Sort)一种交换排序,基本思想是:两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止。

const bubbleSort0 = (L: number[]) => {
  for (let i = 0; i < L.length; i++) {
    for (let j = 0; j < L.length; j++) {
      if (L[i] > L[j]) {
        L = swap(L, i, j);
      }
    }
  }
  return L;
};
console.log(bubbleSort0([1, 6, 3, 2, 7, 8, 3])); // [8,7,6,3,3,2,1]

IMG_5734

从图中看出,循环时还会把3放到最后一位,这个算法的效率非常低,不推荐使用。

9.3.2 冒泡排序算法

const bubbleSort = (L: number[]) => {
  for (let i = 0; i < L.length; i++) {
    for (let j = L.length - 1; j >= i; j--) {
      if (L[j] > L[j+1]) {
        L = swap(L, j, j + 1);
      }
    }
  }
  return L;
};
console.log(bubbleSort([1, 6, 3, 2, 7, 8, 3])); // [1,2,3,3,6,7,8]

IMG_5735

这个算法与上个算法的差异是在不断循环的过程中,除了将关键字1放到第一位位置,还把关键字2从第九位置提到了第三位置,在十万条数据以上的排序过程中,这种差异会体现出来。

图中较小的数字如同气泡慢慢浮到上面,因此将此算法命名为冒泡算法。

9.3.3 冒泡排序优化

可以减少一些不必要的比较。

const bubbleSort2 = (L: number[]) => {
  let flag = true; // flag用作标记
  for (let i = 0; i < L.length && flag; i++) {
    flag = false; // 初始为false
    for (let j = L.length - 1; j >= i; j--) {
      if (L[j] > L[j + 1]) {
        L = swap(L, j, j + 1);
        flag = true; // 如果有数据交换,则flag为true
      }
    }
  }
  return L;
};

IMG_5736

9.3.4 冒泡排序复杂度分析

时间复杂度为 O ( n 2 ) O(n^2) O(n2)

9.4 简单选择排序

冒泡排序的思想就是不断地交换,通过交换完成最终的排序。选择排序是每一趟在 n − i + 1 ( i = 1 , 2 , . . . , n − 1 ) n-i+1(i=1,2,...,n-1) ni+1(i=1,2,...,n1)个记录中选取关键字最小的记录作为有序序列的第i个记录。

简单选择排序法(Simple Selection Sort)就是通过 n − i n-i ni次关键字间的比较,从 n − i + 1 n-i+1 ni+1个记录中选出关键字最小的记录,并和第 i ( 1 ≤ i ≤ n ) i(1\le i \le n) i(1in)个记录交换之。

const selectSort = (L: number[]) => {
  let min: number;
  for (let i = 0; i < L.length; i++) {
    min = i;
    for (let j = i + 1; j < L.length; j++) {
      if (L[min] > L[j]) {
        min = j;
      }
    }
    if (min !== i) {
      L = swap(L, min, i);
    }
  }
  return L;
};

IMG_5737

简单选择排序最大的特点就是交换移动数据次数相当少,无论最坏最好情况,第i躺排序都需要进行 n − i n-i ni次关键字的比较。

时间复杂度为 O ( n 2 ) O(n^2) O(n2)。尽管与冒泡排序同为 O ( n 2 ) O(n^2) O(n2),但简单排序的性能上还是要略优于冒泡排序 。

9.5 直接插入排序

直接插入排序(Straight Insertion Sort)的基本操作是将一个记录插入到已经排好序的有序表中,从而得到一个新的、记录数增1的有序表。

const insertSort = (l: number[]) => {
  let v: number; // 哨兵
  let j: number;
  for (let i = 1; i < l.length; i++) {
    if (l[i] < l[i - 1]) { // 需将l[i]插入有序子表
      v = l[i]; // 设置哨兵
      for (j = i - 1; l[j] > v; j--) {
        l[j + 1] = l[j]; // 记录后移
      }
      l[j + 1] = v; // 插入到正确位置
    }
  }
  return l;
};

时间复杂度为 O ( n 2 ) O(n^2) O(n2)

9.6 希尔排序

在希尔排序算法出来之前,排序算法的时间复杂度基本都是 O ( n 2 ) O(n^2) O(n2),希尔算法是突破这个时间复杂度的第一批算法之一。

原理: 将原本有大量记录数的记录进行分组。分割成若干个子序列,此时每个子序列待排序的记录个数就比较少了,然后在这些子序列内分别进行直接插入排序。当整个序列都基本有序时,注意只是基本有序时,再对全体记录进行一次直接插入排序

const shellSort = (l: number[]) => {
  let increment = l.length;
  let v;
  do {
    increment = Math.floor(increment / 3) + 1; // 增量
    for (let i = increment; i < l.length; i++) {
      if (l[i] < l[i - increment]) {
        v = l[i];
        let j: number;
        for (j = i - increment; j >= 0 && v < l[j]; j -= increment) { // 跟插值排序法同理
          l[j + increment] = l[j];
        }
        l[j + increment] = v;
      }
    }
  } while (increment > 1);
  return l;
};

IMG_5738

希尔排序的关键并不是随便分组后各自排序,而是将相隔某个“增量”的记录组成一个子序列,实现跳跃式的移动,使得排序的效率提高。选取什么样的增量目前还是一个数学难题,迄今为止还没有人找到一种最好的增量序列。

希尔排序的时间复杂度为 O ( n 3 / 2 ) O(n^{3/2}) O(n3/2),要好于直接排序。增量序列最后一个增量值比较等于1才行,由于记录是跳跃式的移动,希尔排序并不是一种稳定的排序算法,不过希尔排序算法的发明,使得我们终于突破了慢排序的时代。

9.7 堆排序

堆排序(Heap Sort),就是对简单选择排序的一种改进,在选择到最小记录的同时,并根据比较结果对其他记录做出相应的调整。

堆是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。

IMG_5745

按照结点层序遍历的方式给结点从1开始编号,则结点之间满足如下关系:

IMG_5744

IMG_5746

9.7.1 堆排序算法

基本思想:将待排序的序列构造成一个大顶堆。此时,整个序列的最大值就是堆顶的根结点。将他移走(将其与堆数组的末尾元素交换,此时末尾元素就是最大值),然后将剩余的n-1个序列重新构造成一个堆,这样就会得到n个元素中的次大值,如果反复执行,便能得到一个有序序列。

// 堆调整
// 已知l[s...m]中记录的关键字除l[s]之外均满足堆的定义
// 本函数调整l[s]的关键字,使得l[s...m]成为一个大顶堆
const heapAjust = (l: number[], s: number, m: number) => {
  let temp = l[s];
  // 2*s根据完全二叉树的性质,为结点的左孩子,而2s+1是结点的右孩子
  // j*=2代表孩子是以2的位数进行递增
  for (let j = 2 * s; j <= m; j *= 2) {
    // l[j] < l[j + 1]代表左孩子小于右孩子,则拿取更大的孩子
    if (j < m && l[j] < l[j + 1]) {
      ++j;
    }
    if (temp >= l[j]) break;
    l[s] = l[j];
    s = j;
  }
  l[s] = temp;
  return l;
};

const heapSort = (l: number[]) => {
  // 把l中的r构建成一个大顶堆
  // l.length / 2,代表它们都是孩子的结点
  for (let i = Math.floor((l.length - 1) / 2); i > 0; i--) {
    l = heapAjust(l, i, l.length - 1);
    // 构造完的l大顶堆为[0, 50, 10, 90, 30, 70, 40, 80, 60, 20]
  }
  for (let i = l.length - 1; i > 1; i--) {
    swap(l, 1, i);
    l = heapAjust(l, 1, i - 1);
  }
  return l;
};

// [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]
test(heapSort, [0].concat(arr));

IMG_5747

9.7.2 堆排序复杂度分析

堆排序的运行时间主要是消耗在初始构建堆和在重建堆时的反复筛选上。

在构建堆的过程中,因为是完全二叉树从最下层最右边的非终端结点开始构建,将它与其孩子进行比较和若有必要的互换,每个非终端结点的比较和互换操作不超过2次,因此整个构建堆的时间复杂度为 O ( n ) O(n) O(n)

堆排序的总体时间为 O ( n log ⁡ n ) O(n\log n) O(nlogn)。空间复杂度上,它只有一个用来交换的暂存单元,也非常的不错。不过由于记录的比较和交换是跳跃式进行,因此堆排序也是一种不稳定的排序方法 。

由于初始构建堆的比较次数较多,因此它并不适合待排序序列个数较少的情况。

9.8 归并排序

归并排序(Merging Sort)就是利用归并的思想实现的排序方法。

原理是假设初始序列含有n个记录,则可以看成是n个有序的子序列,每个子序列的长度为1,然后两两归并,得到[n/2]个长度为2或1的有序子序列;再两两归并,如此重复,直至得到一个长度为n的有序序列为止。

IMG_5758

IMG_5759

归并排序的时间复杂度为 O ( n log ⁡ n ) O(n \log n) O(nlogn),空间复杂度为 O ( n + log ⁡ n ) O(n + \log n) O(n+logn)

归并排序是一种比较占用内存,但却效率高且稳定的算法。

9.9 快速排序(最优)

希尔排序相当于直接插入排序的升级,它们同属于插入排序类,堆排序相当于简单选择排序的升级,它们同属于选择排序类。而快速排序是冒泡排序的升级,它们都属于交换排序类。

快速排序(Quick Sort)的基本思想:通过一趟排序将待排序分割成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。

// 选取当中的一个关键字,然后将关键字放到一个位置,使得它左边的值都比它小,右边的值都比它大
const partition = (l: number[], low: number, high: number) => {
  // 用子表的第一个记录作枢轴记录
  let pivotkey = l[low];
  while (low < high) { // 从表的两端交替向中间扫描
    while (low < high && l[high] >= pivotkey) {
      high--;
    }
    l = swap(l, low, high); // 将比枢轴记录小的记录交换到低端
    while (low < high && l[low] <= pivotkey) {
      low++;
    }
    l = swap(l, low, high); // 将比枢轴记录大的记录交换到高端
  }
  return { low, l };
};
const qSort = (l: number[], low: number, high: number) => {
  // pivot是枢轴的意思
  let pivot: number;
  if (low < high) {
    const obj = partition(l, low, high); // 算出枢轴值pivot
    pivot = obj.low;
    l = obj.l;
    // 通过对pivot左右子表的不断比对,最终形成一个有序的序列表
    qSort(l, low, pivot - 1); // 对低子表递归排序
    qSort(l, pivot + 1, high); // 对高子表递归排序
  }
  return l;
};
const quickSort = (l: number[]) => {
  return qSort(l, 0, l.length - 1);
};

test(quickSort, [50, 10, 90, 30, 70, 40, 80, 60, 20]);

快速排序的时间性能取决于快速排序递归的深度。最优是第一个枢轴刚好在是平均值。

时间复杂度为 O ( n log ⁡ n ) O(n \log n) O(nlogn),空间复杂度为 O ( log ⁡ n ) O(\log n) O(logn)

由于关键字的比较和交换是跳跃进行的,快速排序是一种不稳定的排序方法。

9.9.3 快速排序优化

  1. 优化选取枢轴

    取三个关键字进行排序后的中间数,一般是取左端、右端和中间,作为枢轴值。

  2. 优化不必要的交换

  3. 优化小数组时的排序方案

  4. 优化递归操作

快速排序是排序算法王者,应该好好研究并掌握。

9.10 总结

IMG_5760

IMG_5761

从待排序记录的个数来说,待排序的个数n越小,采用简单排序方法越合适。反之,n越大,采用改进排序方法越合适。

  • 18
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值