目录
- CH1 绪论
- ☆ 数据项、数据元素、数据结构
- ☆ 逻辑结构和存储结构的区别
- ☆ 顺序存储结构和链式存储结构的比较
- ☆ 算法的重要特性
- ☆ 算法的复杂度
- CH2 线性表
- ☆ 单链表
- CH3 栈、队列和数组
- ☆ 栈和堆是什么?
- ☆ 栈在括号匹配中的应用
- ☆ 栈在表达式求值中的应用
- ☆ 为什么循环队列要牺牲一个空间?
- ☆ 循环队列的长度?
- ☆ 顺序表和链表的比较
- ☆ 递归
- CH4 串
- ☆ 字符串的定义
- ☆ KMP 算法
- CH5 树与二叉树
- ☆ 二叉树的性质
- ☆ 满二叉树和完全二叉树的区别
- ☆ 二叉排序树和平衡二叉树的区别?
- ☆ 二叉排序树的查找、插入与删除过程?
- ☆ 平衡二叉树的插入与删除过程?
- ☆ B 树和 B+ 树的区别
- ☆ B 树和 B+ 树在数据库中的应用
- ☆ 能不能简单聊聊红黑树?
- ☆ 哈夫曼树
- ☆ 树的存储结构
- ☆ 二叉树的存储结构
- CH6 图
- ☆ 图的定义及其类型
- ☆ 遍历算法
- ☆ 最小生成树
- ☆ 最短路径
- ☆ 最小生成树算法和最短路径算法的优化
- ☆ AOV 网和拓扑排序
- ☆ 介绍一下 AOE 网?
- ☆ 什么是关键路径?
- ☆ 如何求解关键路径?
- CH7 查找
- ☆ 哈希表?构造方法?
- CH8 排序
- ☆ 十种排序算法
- ☆ 快速排序的复杂度分析?
- ☆ 构建堆的过程?堆排序的过程?
- ☆ 归并排序和快排的比较
- 其他问题
- ○ 循环的效率一定比递归的高吗?
- ○ 贪心算法、动态规划、分治法的区别
前言:
- 由于在面试中通常都是口头进行叙述,因此我是按照怎么说怎么方便的方式来写的,当然与那些对算法原理进行介绍的博客不能相提并论。
- 目前 5 拒夏 1 营还是本校营真是好痛苦 😇
参考:
- 保研面试复习之数据结构篇
- 计算机保研专业课复习 - 数据结构(更新中)
- 《王道 2025 年数据结构考研复习指导》
CH1 绪论
☆ 数据项、数据元素、数据结构
- 数据项:是构成数据元素的、不可分割的最小单位。
- 数据元素:是数据的基本单位。
- 数据结构:是相互之间存在特定关系的数据元素的集合。
数据结构的三要素是:逻辑结构、存储结构、数据的运算。
比如:学生记录是一个数据元素,它是由学号、姓名、性别等数据项组成的。
☆ 逻辑结构和存储结构的区别
逻辑结构
- 定义:是指数据元素之间的逻辑关系,它与数据的存储无关,是独立于计算机的。
- 分类:线性结构(线性表)和非线性结构(集合、树、图)
存储结构
- 定义:是指数据结构在计算机中的表示,即我们是如何使用计算机语言来实现逻辑结构的。
- 分类
- 顺序存储结构:在逻辑上相邻的元素在物理位置上也是相邻的。
- 链式存储结构:在逻辑上相邻的元素在物理位置上不一定相邻。
- 索引存储结构:在存储元素信息的同时建立附加的索引表,索引表包括关键字和存储地址。
- 散列存储结构:根据元素的关键字,可以直接计算出元素的存储地址。
☆ 顺序存储结构和链式存储结构的比较
- 顺序存储:是一种地址空间连续、支持随机访问的线性结构。
- 优点:支持随机访问;
- 缺点:插入或删除某个元素需要移动大量的元素;
- 链式存储:是一种不需要地址空间连续,只需要在逻辑上通过指针连接各个元素的线性结构。
- 优点:插入或删除元素简单;
- 缺点:不支持随机访问。
☆ 算法的重要特性
算法的 5 个重要特性:
- 有穷性
- 确定性
- 可行性
- 输入零个或多个
- 输出一个或多个
☆ 算法的复杂度
- 时间复杂度:是指算法中基本运算的执行次数的数量级。
- 不仅取决于问题的规模 n n n;
- 还取决于待处理数据的初始状态,比如:查找 1 1 1 次就找到了和查找 n n n 次都没找到。
- 量级排序: O ( l o g n ) < O ( n ) < O ( n l o g n ) < O ( n 2 ) < O ( 2 n ) < O ( n ! ) O(log^n)<O(n)<O(nlog^n)<O(n^2)<O(2^n)<O(n!) O(logn)<O(n)<O(nlogn)<O(n2)<O(2n)<O(n!)
- 空间复杂度:是指算法所需的存储空间的大小。
CH2 线性表
☆ 单链表
- 定义:单链表本质上是线性表的链式存储,它是指通过一组任意的存储单元来存储线性表中的数据元素。
- 组成:一个单链表的结点包括数据域和指针域,指针域用于建立数据元素之间的关系。
- 链表增加头结点的作用:
- 头结点的指针域中存储了第一个结点的位置,便于对第一个结点的处理;
- 无论链表是否为空,它的头指针都是指向头结点的非空指针,便于对空表和非空表的统一处理。
CH3 栈、队列和数组
☆ 栈和堆是什么?
- 栈:是一个只允许在一端进行插入或删除操作的线性表。
我只能想到大根堆和小根堆 😇
☆ 栈在括号匹配中的应用
- 如果出现的是左括号,则压栈;
- 如果出现的是右括号,则检查栈是否为空:
- 若为空则表示右括号多余,表明不匹配,结束。
- 若不为空,则弹出栈顶元素,继续。
表达式检验结束时,若栈为空,匹配正确,否则表明左括号有余。
原博客说在出现右括号且栈不为空时,需要先检查栈顶元素是否为左括号,然后再进行弹出操作。个人认为没有检查的必要,因为我们只会让左括号入栈,所以栈顶元素必为左括号。
☆ 栈在表达式求值中的应用
中缀表达式
A+B*(C-D)-E/F
后缀表达式
ABCD-*+EF/-
中缀表达式转后缀表达式:
- 遇到操作数,直接加入后缀表达式。
- 遇到左括号,直接入栈;遇到右括号,依次弹出栈中的运算符,并加入后缀表达式,直到弹出左括号为止。注意:左、右括号都是不加入后缀表达式的。
- 遇到运算符,如果它的优先级高于除左括号外的栈顶运算符,那么直接加入后缀表达式;否则,从栈顶开始,依次弹出栈中优先级高于或等于当前运算符的所有运算符,直到遇到优先级低于它的运算符或者左括号,之后将当前运算符加入后缀表达式。
后缀表达式求值
从左往右依次扫描后缀表达式的每一项,如果是操作数则入栈,如果是操作符则连续弹出两个操作数,并进行运算,然后将运算结果入栈。当表达式的所有操作数都被处理完后,栈顶存放的就是表达式的计算结果。
注意:中缀表达式和后缀表达式都是字符串。
☆ 为什么循环队列要牺牲一个空间?
答:牺牲一个空间是为了区分队空还是队满。
详细的原因:
- 在循环队列中通常有队头和队尾两个指针。其中,队头指针指向队列的第一个元素,队尾指针指向队列的最后一个元素的下一个位置。
- 假设没有牺牲这个空间,那么不管队列是在执行判空还是判满操作,判定条件都是队头指针等于队尾指针,容易造成混淆。
- 而通过牺牲一个空间,就可以把这两种操作分开。分开后,判空的条件是 “队头指针等于队尾指针”,判满的条件是 “队尾指针的下一个位置是队头指针”。
数学表达式:
- 判空的条件是: Q . r e a r = = Q . f o n t Q.rear == Q.font Q.rear==Q.font
- 判满的条件是: ( Q . r e a r + 1 ) % M a x S i z e = = Q . f o n t (Q.rear + 1) \% MaxSize == Q.font (Q.rear+1)%MaxSize==Q.font
☆ 循环队列的长度?
- 问:有一个循环队列 Q Q Q,编号为 0 0 0 到 n − 1 n-1 n−1,头尾指针分别为 f f f 和 r r r,求该队列的元素个数?
- 答: ( f + n − r ) % n (f + n - r) \% n (f+n−r)%n
☆ 顺序表和链表的比较
- 存取方式:顺序表可以随机存取,而链表只能从表头开始依次顺序存取。
- 逻辑结构和物理结构:采用顺序存储时,逻辑上相邻的元素,对应的物理存储位置也相邻。而采用链式存储时,逻辑上相邻的元素,物理存储位置不一定相邻,对应的逻辑关系是通过指针链接来表示的。
- 查找、插入和删除操作
- 对于顺序表,如果是按值查找,那么当顺序表无序时,时间复杂度为 O ( n ) O(n) O(n);当顺序表有序时,可采用折半查找,时间复杂度为 O ( l o g 2 n ) O(log_2^n) O(log2n);对于按序号查找,由于顺序表支持随机访问,因此时间复杂度为 O ( 1 ) O(1) O(1)。而链表的平均复杂度始终为 O ( n ) O(n) O(n)。
- 顺序表的插入和删除操作,平均需要移动半个表长的元素,而链表的插入和删除操作,只需要修改相关结点的指针域即可。
- 空间分配:顺序存储在静态存储分配的情况下,需要预先分配足够大的存储空间。否则在加入新元素时,可能出现内存溢出的情况。动态存储分配虽然可以扩充存储空间,但是需要移动大量得元素,导致操作效率降低。而链式存储只在需要时申请分配存储空间,只要内存有空间就可以分配。但由于链表的每个结点都带有指针域,因此存储的密度不够大。
☆ 递归
- 递归:是指在一个函数的定义中又应用了它自身。
- 递归的条件:递归表达式(递归体)和边界条件(递归出口)
CH4 串
☆ 字符串的定义
- 定义:字符串是由零个或多个字符组成的有限序列。
- 字符串的数据元素是单个字符。
☆ KMP 算法
- 什么是 KMP 算法?
- KMP 算法是一种字符串匹配算法,通常用于查找主串中模式串出现的位置。对于朴素的字符串匹配算法,匹配失败时需要主串和模式串同时回溯,而 KMP 算法只需要模式串回溯而不需要主串回溯,从而提高了查找的效率。
- 如何实现 KMP 算法?
- KMP 算法的实现需要借助于 next 数组,该数组记录了匹配失败时,模式串需要回溯到什么位置以开始重新匹配。next 数组的本质是模式串子串的最长相同前后缀。
- 如何计算 next 数组?
- 初始化 next 数组为模式串的大小,令 i 和 j 指针分别为 -1 和 0。当 i 等于 -1 或者 p [ i ] p[i] p[i] 等于 p [ j ] p[j] p[j] 时,i 和 j 同时加 1,否则 i 回溯到 next[i] 指向的位置。重复上述操作,直到 next 数组被填充完毕。
- 如何进行字符串匹配?
- 初始化 i 和 j 指针为 0,i 指向主串,j 指向模式串。当匹配失败时,i 不需要回溯,只需要将 j 回溯到 next[j] 指向的位置便可继续进行匹配。如果最后 j 指向模式串的最后一个字符之后,则代表匹配成功,否则代表匹配失败。
回溯的本质:既然这个前后缀的长度满足不了,那么我们就考虑较短的前后缀。
CH5 树与二叉树
☆ 二叉树的性质
回顾等比数列的求和:
= a 1 ( 1 − q n ) 1 − q = 1 × ( 2 n − 1 ) = 2 n − 1 =\frac{a_1(1-q^n)}{1-q}=1\times(2^n-1)=2^n-1 =1−qa1(1−qn)=1×(2n−1)=2n−1
假设二叉树的高度为 n n n,那么共有 2 n − 1 2^n-1 2n−1 个结点。
- 高度为 h h h 的二叉树至多有 2 h − 1 2^h-1 2h−1 个结点,第 i i i 层上至多有 2 i − 1 2^{i-1} 2i−1 个结点。
- 非空二叉树上的叶结点数等于度为 2 2 2 的结点数加 1 1 1,即 n 0 = n 2 + 1 n_0=n_2+1 n0=n2+1(度是指一个结点的子结点的个数)
- 具有 n n n 个结点的完全二叉树的高度为 l o g 2 n log_2^{n} log2n 向下取整再加 1 1 1。
对完全二叉树按从上到下、从左到右的顺序依次编号 1 , 2 , . . . , n 1,2,...,n 1,2,...,n,则有以下关系:
- 若 i = 1 i=1 i=1,则结点 i i i 为根,没有父结点;若 i > 1 i>1 i>1,则结点 i i i 的父结点为结点 i 2 \frac{i}{2} 2i;
- 若 2 × i ≤ n 2\times i\le n 2×i≤n,那么结点 i i i 的左子结点为结点 2 × i 2\times i 2×i;
- 若 2 × i ≤ n 2\times i\le n 2×i≤n,那么结点 i i i 的右子结点为结点 2 × i + 1 2\times i+1 2×i+1;
- 若 i i i 为奇数,且 i ≠ 1 i\ne 1 i=1,则它处于右兄弟位置,它的左兄弟为结点 i − 1 i-1 i−1;
- 若 i i i 为偶数,且 i ≠ 1 i\ne 1 i=1,则它处于左兄弟位置,它的右兄弟为结点 i + 1 i+1 i+1;
- 结点 i i i 所处在的层数为 ⌊ l o g 2 i ⌋ + 1 \left \lfloor log_2^i \right \rfloor + 1 ⌊log2i⌋+1
注意:上述坐标关系仅适用于二叉树的顺序存储,即采用数组存储二叉树时。
☆ 满二叉树和完全二叉树的区别
满二叉树
假设一个二叉树的高度为 h h h,如果它的结点数是 2 h − 1 2^h-1 2h−1,那么这个二叉树就是一个满二叉树。它表现为,除了最后一层的叶结点以外,其他结点都有两个子结点,即每一层都是满的。
完全二叉树
完全二叉树是在满二叉树的基础上,在最后一层从右向左依次删除了一定数量叶结点所形成的二叉树。它表现为,叶结点只出现在倒数第一层和倒数第二层。并且,如果一个分支结点只有一个子结点,那么这个子结点只能是左子结点。
☆ 二叉排序树和平衡二叉树的区别?
二叉排序树
二叉排序树是具有以下特征的二叉树
- 左子树上所有结点的值均小于根结点的值;
- 右子树上所有结点的值均大于根结点的值;
- 左、右子树也分别是一棵二叉排序树。
平衡二叉树
平衡二叉树是一种特殊的二叉排序树,其中任意结点的左、右子树高度差的绝对值不超过 1 1 1。平衡因子是指左、右子树的高度差。在平衡二叉树中,每个结点的平衡因子只能是 − 1 -1 −1、 0 0 0 或 1 1 1。
见参考图,二叉排序树中的 4 4 4 结点是一个最小不平衡点,以其为支点进行旋转后才能得到平衡二叉树。
☆ 二叉排序树的查找、插入与删除过程?
查找过程
如果二叉排序树非空,那么先将给定值和根结点的关键字比较。如果相等,那么查找成功。如果不相等且小于根结点的关键字,那么在根结点的左子树上查找;否则,在根结点的右子树上查找。重复上述操作,直到查找成功。
插入和删除过程都会使用到查找过程。
插入过程
先按查找的步骤进行查找,直至找到叶结点这一层。如果待插入的关键字大于该叶结点的关键字,那么就插入为该叶结点的右子结点;否则,插入为该叶结点的左子结点。特别地,如果二叉排序树为空,那么直接插入为根结点。如果待插入的关键字已经存在,那么就插入失败。
删除过程
先按查找的步骤进行查找,直至找到待删除的结点。如果待删除的结点是叶结点,那么可以直接删去;如果待删除的结点只有左子结点或者右子结点,那么用它的子结点代替它;如果待删除的结点既有左子结点又有右子结点,那么首先查找该结点在中序遍历结果中的直接前驱或者直接后继,然后用它的直接前驱或者直接后继代替它,并删除这个直接前驱或者直接后继。
什么是该结点的直接前驱和直接后继?
- 直接前驱:是待删除结点的左子树的最右的那个叶结点,即参考图中的 3 3 3 结点。
- 直接后继:是待删除结点的右子树的最左的那个叶结点,即参考图中的 5 5 5 结点。
☆ 平衡二叉树的插入与删除过程?
平衡二叉树的插入与删除操作与二叉排序树相同,但如果出现不平衡的现象,我们需要进行调整。我们需要寻找最小的不平衡点,也就是距离插入结点最近的、平衡因子大于 1 1 1 或者小于 − 1 -1 −1 的结点,然后对其进行调整。
🪐 四种调整思路
① LL 型:插入结点位于最小不平衡点的左子结点的左子树中。
- a. 最小不平衡点的左指针指向其左子结点的右子结点;
- b. 其左子结点的右指针指向最小不平衡点;
- c. 再将其左子结点的父结点更换为最小不平衡点的父结点;
② RR 型:插入结点位于最小不平衡点的右孩子的右子树中,与 LL 型类似。
旋转的本质:最小不平衡点和自己的某个子结点交换父结点和子树(倒转天罡),即最小不平衡点接管子结点的娃,并成为子结点的娃,子结点接管最小不平衡点的父结点。
③ LR 型:插入结点位于最小不平衡点的左孩子的右子树中。
- a. 按照 RR 型的调整方式对最小不平衡点的左子结点进行调整使其变成 LL 型;
- b. 再按照 LL 型的调整方式对最小不平衡点进行调整。
④ RL 型:插入结点位于最小不平衡点的右孩子的左子树中,与 LR 型类似。
见参考图,插入 6 6 6 结点后形成 LR 型。虽然 5 5 5 结点不是最小不平衡点,但仍然需要以其为支点进行 RR 型旋转,使整个二叉树变为 LL 型,然后再对最小不平衡点 8 8 8 结点进行 LL 型旋转。
☆ B 树和 B+ 树的区别
B 树是所有结点的平衡因子都等于 0 0 0 的多路平衡查找树。
- 多路是指对于一棵 m m m 阶的 B 树,每个结点最多有 m m m 棵子树,即结点最多有 m − 1 m-1 m−1 个关键字。
- 在一棵子树中,根结点的左子树中的所有结点的关键字都小于根结点的关键字,根结点的右子树中的所有结点的关键字都大于根结点的关键字。
- B 树中的大部分操作所需的磁盘存取次数与 B 树的高度成正比。由于每个结点中的关键字个数越多,容纳同样多关键字的 B 树的高度越小,因此 B 树要求所有结点的关键码个数不能少于 ⌈ m / 2 ⌉ − 1 \left \lceil m/2 \right \rceil - 1 ⌈m/2⌉−1 个。
m m m 阶 B+ 树与 m m m 阶 B 树的主要差异:
- 在 B+ 树中,具有 n n n 个关键字的结点只含有 n n n 棵子树;而在 B 树中,具有 n n n 个关键字的结点含有 n + 1 n+1 n+1 棵子树。
- 在 B+ 树中,每个结点的关键字个数 n n n 的范围是 ⌈ m / 2 ⌉ ≤ n ≤ m \left \lceil m/2 \right \rceil \le n \le m ⌈m/2⌉≤n≤m;而在 B 树中,每个结点的关键字个数 n n n 的范围是 ⌈ m / 2 ⌉ − 1 ≤ n ≤ m − 1 \left \lceil m/2 \right \rceil-1 \le n \le m-1 ⌈m/2⌉−1≤n≤m−1。
- 在 B+ 树中,叶结点包含了全部关键字,非叶结点中出现的关键字也会出现在叶结点中;而在 B 树中,最外层的终端结点包含的关键字和其他结点包含的关键字是不重复的。
- 在 B+ 树中,叶结点包含信息,所有非叶结点仅起索引作用,非叶结点的每个索引项只含有对应子树的最大关键字和指向该子树的指针,不含有对应记录的存储地址。这样能使一个磁盘块存储更多的关键字,使得磁盘读/写次数更少,查找速度更快。
- 在 B+ 树中,用一个指针指向关键字最小的叶结点,将所有叶结点串成一个线性链表。
☆ B 树和 B+ 树在数据库中的应用
在数据库系统中,通常以树形结构存储数据,其中 B 树和 B+ 树是最常见的索引结构。在这些树结构中,每个结点包含的关键字对应着一个磁盘块。查询过程从根结点开始,系统将根结点的磁盘块加载到内存中,并根据内存中的信息逐级向下访问,直到找到对应的关键字所指向的磁盘块。由于每访问一次都要从磁盘中读取一个结点到内存中,因此树的高度直接影响着所需的 I/O 操作次数;树的高度越低,表示需要进行的磁盘访问次数越少,从而提高了查询效率。
如果使用平衡二叉树来存储数据,那么当数据量较大时,平衡二叉树的高度也会较大。而 B 树和 B+ 树在一个结点中存储多个值,并且每次是将整个结点读取到内存中再进行处理的,因此减少了磁盘 I/O 操作的次数,提高了查询效率。B 树不擅长范围查找,因为数据记录分散在各个结点中,而 B+ 树由于有索引结点和叶结点链表结构的存在,因此可以通过链表的遍历来实现范围查找。
☆ 能不能简单聊聊红黑树?
为了保持平衡二叉树的平衡性,在插入和删除操作后,会非常频繁地调整全树整体的拓扑结构,代价较大。为此在平衡二叉树的平衡标准上进一步放宽条件,引入了红黑树的结构。
红黑树的介绍
一棵红黑树是满足如下性质的二叉排序树:
- ① 每个结点或是黑色,或是红色。
- ② 根节点是黑色。
- ③ 叶结点都是黑色(即虚拟的外部结点、NULL 结点)
- ④ 不存在两个相邻的红结点(即红结点的父结点和子结点均是黑色)
- ⑤ 对于每个结点,从该结点到任意一个叶结点的简单路径上,所含黑结点的数量相同。
结论:⑤ 确保了从根到叶结点的最长路径不大于最短路径的两倍,即红黑树是适度平衡的二叉树。
红黑树的应用
红黑树在数据结构中应用广泛,尤其在需要保持数据有序性的场景,其平均时间复杂度为 O ( l o g n ) O(\mathrm{log} n) O(logn)。
- Java 中的 TreeSet 和 TreeMap 是用红黑树来实现的;
- C++ 中的 set 和 map 容器也是用红黑树来实现的;
- 在 Linux 虚拟内存管理中,红黑树被用来管理页表,从而优化内存访问效率和系统性能。
自平衡的策略
自平衡的策略可以简单概括为三种: 左旋、右旋、变色。
红黑树和平衡二叉树的区别?
- 红黑树不像平衡二叉树那样要求任意一个结点左右子树的高度差不超过 1 1 1,红黑树只要求任意一个结点左右子树的高度差不超过 2 2 2 倍,因此红黑树只是追求树的大致平衡。
- 因为对树的平衡程度的要求不同,平衡二叉树在插入和删除的过程中会花费较大的代价来维护树的平衡,所以平衡二叉树不适合插入、删除太多的场景。而红黑树只要求适度平衡,它做到了在插入和删除时,最多只需旋转 3 3 3 次就能实现一定程度的平衡,所以能将插入和删除的时间复杂度维持在对数级别。
☆ 哈夫曼树
定义
哈夫曼树是指,在含有 n n n 个带权叶结点的二叉树中,其中带权路径长度之和最小的二叉树。
- 带权路径长度之和是指,所有叶结点的带权路径长度之和;
- 带权路径长度是指,从根结点到叶结点的路径长度与该结点的权值的乘积。
注意:在哈夫曼树中只有叶结点具有权值!
构造
- 假设这 n n n 个叶结点分别作为 n n n 棵仅含一个结点的二叉树,构成一个集合;
- 先创建一个新结点,再从集合中选取并删除两棵根结点权值最小的树,作为新结点的左、右子树,从而得到一棵新的树;
- 然后将新结点的权值置为左、右子树的根节点的权值之和,并将新得到的树加入到集合当中;
- 重复上述两个步骤,直到集合中只剩下一棵树为止。
特点
假设有 n n n 个叶结点,那么哈夫曼树中的双分支结点有 n − 1 n-1 n−1 个,共有 2 n − 1 2n-1 2n−1 个结点。
应用:设计二进制前缀编码 → 数据压缩编码
- 可变长度编码方式,即允许不同的字符使用不同长度的二进制位进行表示。对频率高的字符赋以短编码,对频率低的字符赋以长编码,从而使字符的平均编码长度变短,从而起到压缩数据的作用。
- 我们把字符视作叶结点,将它们的使用频率视作叶结点的权值。我们令左分支为 0 0 0,右分支为 1 1 1,然后构建哈夫曼树,从根到叶结点的路径上的分支标记的字符串作为该字符的编码。
在《2025 年王道》中,分支是指两个相邻结点之间的路径。
☆ 树的存储结构
对于普通的树来说,存储结构通常分顺序存储与链式存储,并且再细分为以下三种存储结构:
-
双亲表示法(本结点 + 父结点)
- 采用的是顺序存储结构;
- 每个结点有数据域和指针域,指针域存储的是该结点的父结点的下标;
- 特点:可以很快地找到每个结点的父结点,但是找子结点的时候需要遍历整个数据结构。
-
孩子表示法(本结点 + 子结点)
- 采用的是顺序存储结构与链式存储结构的结合体;
- 每个结点有数据域和指针域,指针域存储的是第一个子结点的下标;
- 以链表的形式存储该结点所有子结点的下标;
- 特点:可以很快地找到每个结点的所有子结点,但是找父结点的时候需要遍历整个数据结构。
-
孩子兄弟表示法
- 采用的是链式存储结构;
- 每个结点有数据域和两个指针域,一个是指向第一个子结点的指针,另一个是指向第一个右兄弟的指针;
- 特点:便于实现树转二叉树的操作;易于查找子结点,不易查找父结点。
树转换为二叉树的规则:每个结点的左指针指向它的第一个孩子,右指针指向它在树中的相邻右兄弟。由于根结点没有兄弟,因此树转换得到的二叉树没有右子树。
☆ 二叉树的存储结构
对于二叉树来说,存储结构可以分为顺序存储与链式存储:
-
顺序存储结构
- 按照二叉树层序遍历的顺序将结点存储于顺序表中,其中空结点也需要占有位置。在这种存储结构中,结点的序号可以反映结点之间的逻辑关系。比如,如果某个结点的下标为 i i i,那么它的左子结点的下标为 2 i 2i 2i,右子结点的下标为 2 i + 1 2i+1 2i+1,父结点的下标为 i / 2 i/2 i/2 并向下取整。
- 特点:该存储结构适合于存储满二叉树和完全二叉树。
-
链式存储结构
- 每个结点通常有一个数据域和两个指针域,指针域分别指向该结点的左子结点和右子结点。
- 升级:为了充分利用左右子结点,可以让左子结点指向自己的先序、中序或者后序遍历的直接前序,右子结点指向自己的直接后继,从而形成二叉排序树以方便查找。
CH6 图
☆ 图的定义及其类型
定义
图是由顶点集和边集组成的,顶点集是指图中顶点的有限非空集合,边集是指图中顶点之间的关系集合。
类型
- 无向图:图中的边都是无向边。
- 有向图:图中的边都是有向边。
- 完全图:具有 n ( n − 1 ) / 2 n(n-1)/2 n(n−1)/2 条边的无向图,或者具有 n ( n − 1 ) n(n-1) n(n−1) 条边的有向图。
- 连通图:无向图中任意两个顶点之间都有路径存在,否则就是非连通图。
- 强连通图:有向图中任意两个顶点之间都有路径存在,顶点 A 到顶点 B 有路径,顶点 B 到顶点 A 也有路径。
☆ 遍历算法
二叉树的遍历算法分为:
- 先序遍历 —— 深度遍历
- 中序遍历
- 后序遍历
- 层序遍历 —— 广度遍历
图遍历算法分为:
- 深度优先搜索遍历
- 广度优先搜索遍历
图的深度遍历等价于二叉树的先序遍历,图的广度遍历等价于二叉树的层序遍历。
☆ 最小生成树
生成树是指包含图中所有顶点的一个极小连通子图。一个图可以有多个生成树,而其中权值之和最小的那棵生成树就是该图的最小生成树。最小生成树的算法有普里姆算法和克鲁斯卡尔算法。
每棵树的权是指树中所有边上的权值之和。
- 普里姆算法(加点法)
- ① 初始时从图中任取一个顶点加入到树中,此时树中只有一个顶点;
- ② 每次选择一个与当前树中顶点集合距离最近的顶点,将该顶点和相应的边加入树中;
- ③ 重复上述步骤直到图中的所有结点都已加入树中,从而完成了最小生成树的构建。
- 特点:归并点,时间复杂度为 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2),适用于稠密网。
- 克鲁斯卡尔算法(加边法)
- ① 初始时是一个拥有 n n n 个顶点但是没有边的非连通图;
- ② 每次选取之前没有被选取过的并且权值最小的边,如果这条边两端的顶点落在图中的不同连通分量上,那么将这条边加入图中,否则舍弃这条边而选择下一条权值最小的边;
- ③ 重复上述步骤直到图变为了连通图,从而完成了最小生成树的构建。
- 特点:归并边,时间复杂度为 O ( ∣ E ∣ l o g 2 ∣ E ∣ ) O(|E|log_2^{|E|}) O(∣E∣log2∣E∣),适用于稀疏网。
构造最小生成树的本质:需要选择 n n n 个顶点和 n − 1 n-1 n−1 条最小的边。
☆ 最短路径
带权路径长度是指从一个顶点到图中其余任意一个顶点的路径所经过的边的权值之和,其中带权路径长度最短的那条路径被称为最短路径。
比如:从顶点 A 到顶点 B 有一条路径,那么这条路径所经过的边的权值之和,就是带权路径长度。
- Dijkstra 迪杰斯特拉算法
- 本质:求解从某个源点到其余各顶点的最短路径。
- 算法:每次找出距离源点最近且未归入集合的顶点,把它归入集合,同时以这个顶点为基础更新从源点到其他所有顶点的距离。重复上述步骤,直到所有顶点都被归入集合为止。
- 特点:上述过程不需遍历图中的所有顶点;迪杰斯特拉算法不适用于带有负权值的边。
- Floyd 弗洛伊德算法
- 本质:求解每一对顶点之间的最短路径。
- 算法:初始时,对于任意两个顶点,如果它们之间存在边,则这条边的权值作为它们之间的最短路径长度;如果它们之间不存在边,则设置它们之间的最短路径长度为无穷。之后,逐步尝试在原路径中加入其他顶点作为中间顶点。如果加入中间顶点后,得到的路径比原来的路径长度短,则用这个新路径代替原路径。迭代 n n n 次后,得到每一对顶点之间的最短路径。
回想一下计网里面是怎么教的就行了 😇
☆ 最小生成树算法和最短路径算法的优化
最小生成树中的普里姆算法与克鲁斯卡尔算法都需要排序。前者需要对点到集合的路径进行排序,后者需要对边的权值进行排序。因此二者都可以利用堆来进行优化,通过访问堆顶元素以及调整堆来实现对最短边的快速访问。与此类似,最短路径中的迪杰斯特拉算法也可以利用堆进行优化。
☆ AOV 网和拓扑排序
定义:拓扑排序是对有向无环图的顶点的一种排序,它使得若存在一条从顶点 A 到顶点 B 的路径,那么在排序中 B 一定出现在 A 的后面。每个 AOV 网都有一个或多个拓扑排序序列。
应用:① 获得拓扑排序序列;② 检查图中是否有环。
AOV 网是一个有向无环图,其中的顶点表示一个活动,有向边表示两个活动之间的先后关系。
拓扑排序的具体步骤是:利用栈或者队列,
- ① 将图中入度为 0 的结点入栈,同时在图中将该结点进行删除;
- ② 再将删除该结点之后入度变为 0 的结点入栈;
- ③ 重复上述步骤即可获得拓扑序列序列。
如果算法结束时,序列的长度不等于图中结点的个数,那么说明图中存在环。
☆ 介绍一下 AOE 网?
- 定义:AOE 网是一个带权有向图,其中的顶点表示事件,有向边表示活动,边的权值表示完成该活动的开销。
- 区别:AOE 网中的边有权值,而 AOV 网中的边没有权值,仅表示顶点之间的先后关系。
☆ 什么是关键路径?
- 关键路径和关键活动:在 AOE 网中,从源点到汇点的有向路径可能有多条。但是只有路径上的所有活动都已完成,整个工程才算结束。因此,从源点到汇点的所有路径中,具有最大路径长度的路径被称为关键路径,关键路径上的活动被称为关键活动。
- 最短完成时间:完成整个工程的最短时间就是关键路径的长度,也就是关键路径上各活动花费开销的总和。如果关键活动不能按时完成,那么整个工程的完成时间就会延长。
源点是指 AOE 网中唯一的入度为 0 0 0 的顶点,汇点是指 AOE 网中唯一的出度为 0 0 0 的顶点。
☆ 如何求解关键路径?
- ① 从源点出发,令源点的最早发生时间为 0 0 0,按拓扑排序的顺序求解其余顶点的最早发生时间;
- ② 从汇点出发,令汇点的最迟发生时间等于它的最早发生时间,按逆拓扑排序的顺序求解其余顶点的最迟发生时间;
- ③ 根据各顶点的最早发生时间求解所有弧的最早开始时间;
- ④ 根据各顶点的最迟发生时间求解所有弧的最迟开始时间;
- ⑤ 求解 AOE 网中所有活动的差额,找出所有差额为 0 0 0 的活动构成关键路径。
CH7 查找
☆ 哈希表?构造方法?
定义
- 散列表:哈希表又称散列表,是能够根据关键字直接进行访问的数据结构。也就是说,哈希表建立了关键字和存储地址之间的一种直接映射关系。
- 散列函数:哈希函数又称散列函数,它是一个把关键字映射成该关键字对应的地址的函数。
构造方法
- 直接定址法:直接取关键字的某个线性函数值作为散列地址,散列函数为 a × k e y + b a\times key+b a×key+b。
- 除留余数法:假设散列表的表长为 m m m,取一个不大于 m m m 但最接近或等于 m m m 的质数 p p p,散列函数为 k e y % p key\%p key%p。
- 数字分析法:假设关键字是 r r r 进制数,而 r r r 个数码在各位上出现的频率不一定相同,可能在某些位上分布均匀一些,每种数码出现的机会均等,我们选取数码分布均匀的若干位作为散列地址。
- 平方取中法:取关键字的平方值的中间几位作为散列地址。
解决冲突
① 开放定址法:核心思想为 H i = ( H ( k e y ) + d i ) % m H_i=(H(key)+d_i)\%m Hi=(H(key)+di)%m,其中 m m m 为散列表表长。
- 线性探测法:增量 d i = 1 , 2 , . . , m − 1 d_i=1,2,..,m-1 di=1,2,..,m−1。当冲突发生时,顺序查看表中下一个单元,直到找出一个空闲单元或查遍全表。但是容易造成大量元素在相邻的散列地址上堆积,导致查找效率下降。
- 平方探测法:在线性探测法的基础上,增量变为 d i = 1 2 , − 1 2 , 2 2 , − 2 2 , , . . , k 2 , − k 2 d_i=1^2,-1^2,2^2,-2^2,,..,k^2,-k^2 di=12,−12,22,−22,,..,k2,−k2。可以避免出现堆积问题,但是不能探测到散列表上的所有单元。
- 双散列法:使用两个散列函数,当通过第一个散列函数得到的地址发生冲突时,利用第二个散列函数计算该关键字的地址增量,即 H i = ( H 1 ( k e y ) + i × H 2 ( k e y ) ) % m H_i=(H_1(key)+i\times H_2(key))\%m Hi=(H1(key)+i×H2(key))%m,其中 i i i 是冲突次数。
- 伪随机序列法:增量 d i d_i di 是为伪随机数列。
上述四个方法的核心思想都是:如果当前位置发生冲突,那么就后移几个位置进行存放。至于往后偏移几个位置,不同的方法有不同的策略。
② 拉链法:将所有的同义词存储在同一线性链表中,这个线性链表由其散列地址唯一标识。
装填因子
等于哈希表中记录的长度除以哈希表的长度。装填因子越大,表示装填的记录越满,发生冲突的可能性越大;反之发生冲突的可能性越小。
CH8 排序
☆ 十种排序算法
插入排序:直接插入排序、折半插入排序、希尔排序
- 直接插入排序:每次将一个待排序的记录按其关键字的大小插入到前面已经排好序的子序列中,直到全部记录插入完成。特点:令第 0 0 0 个元素作为哨兵,暂存待插入的元素;边比较边移动元素。
- 折半插入排序:在直接插入排序的基础上,将比较和移动操作分离。先折半查找出元素的待插入位置,然后统一地移动待插入位置之后的所有元素。
- 希尔排序:首先,取一个小于 m m m 的增量 d 1 d_1 d1,把表中的全部记录分成 d 1 d_1 d1 组,也就是说,将所有距离为 d 1 d_1 d1 的倍数的记录放在同一组,再在各组内进行直接插入排序;然后,取一个小于 d 1 d_1 d1 的增量 d 2 d_2 d2,重复上述过程。直到取到 d t d_t dt 等于 1 1 1 时,即所有记录已放在同一组中,再进行直接插入排序。
交换排序:冒泡排序、快速排序
- 冒泡排序:从前往后两两比较相邻元素的值,如果是逆序,那么就交换它们,直到序列比较完。第一趟冒泡,结果是将最小的元素交换到待排序序列的第一个位置。下一趟冒泡时,前一趟确定的最小元素不再参与比较。特点:设置一个是否发生交换的标志,如果一趟结束后没有发生交换,那么说明排序结束。
- 快速排序:在待排序表中任取一个元素作为枢轴,通过一趟排序将待排序表划分为两个部分,使得枢轴之前的部分的所有元素小于枢轴的值,枢轴之后的部分的所有元素大于等于枢轴的值。然后分别对枢轴左、右两侧的子表重复上述操作,直到每个部分中只有一个元素或者为空为止。
int partition(int A[], int left, int right) {
int temp = A[left]; // 暂存枢轴的值
while (left < right) {
while (left < right && A[right] > temp) --right;
A[left] = A[right];
while (left < right && A[left] <= temp) ++left;
A[right] = A[left];
}
A[left] = temp;
return left;
}
void quickSort(int A[], int left, int right) {
if (left < right) {
// 确定枢轴的位置
int pos = partition(A, left, right);
// 递归对左右侧子表进行排序
quickSort(A, left, pos - 1);
quickSort(A, pos + 1, right);
}
}
由于本人对快排不太熟悉,因此给出了算法结构 😇
选择排序:简单选择排序、堆排序
- 简单选择排序:第 i i i 趟从后面 n − i + 1 n-i+1 n−i+1 个待排序元素中选取关键字最小的元素,作为有序子序列的第 i i i 个元素,直到做完 n − 1 n-1 n−1 趟,算法结束。
- 堆排序:首先将 n n n 个元素建成初始堆,因为大顶堆的堆顶元素是最大值,所以我们每次直接输出堆顶元素。输出堆顶元素后,通常将堆底元素送入堆顶,然后将堆顶元素向下调整以继续保持大顶堆的性质。
二路归并排序
归并的含义是将两个或两个以上的有序表合并成一个新的有序表。假定待排序表含有 n n n 个记录,那么可以将其视为 n n n 个有序的子表,每个子表的长度为 1 1 1,然后两两合并。重复上述操作,直到合并成一个长度为 n n n 的有序表为止。
const int maxn = 100;
void merge(int A[], int L1, int R1, int L2, int R2) {
int i = L1, j = L2;
int temp[maxn], index = 0;
while (i <= R1 && j <= R2) {
if (A[i] <= A[j]) {
temp[index++] = A[i++];
} else {
temp[index++] = A[j++];
}
}
while (i <= R1) temp[index++] = A[i++];
while (j <= R2) temp[index++] = A[j++];
for (i = 0; i < index; ++i) {
A[L1 + i] = temp[i];
}
}
void mergeSort(int A[], int left, int right) {
if (left < right) {
int mid = (left + right) + 2;
mergeSort(A, left, mid);
mergeSort(A, mid + 1, right);
merge(A, left, mid, mid + 1, right);
}
}
递归的思想:将待排序表平分为两部分,分别完成排序后再进行合并。
基数排序
基数排序的排序过程分为分配和收集两个操作。假设关键字的基数是 r r r,分配是指,设置 r r r 个空队列,然后依次考察线性表中的每个结点,如果结点的关键字和某个队列相匹配,那么将结点放入该队列中。处理完毕后,将各个队列中的结点依次首尾相接,得到新的结点序列,从而组成新的线性表。
外部排序
外部排序是指,将待排序的记录存储在外存上,排序时再把数据一部分一部分地调入内存进行排序,在排序过程中需要多次进行内存和外存之间的交换。
不太懂最后三种排序 😇
☆ 快速排序的复杂度分析?
快速排序中的递归树是一棵二叉树。这棵二叉树的最大高度为 n n n,最低高度为 l o g 2 n log_2^n log2n 向下取整再加 1 1 1。因此快速排序的最好时间复杂度为 O ( n l o g 2 n ) O(nlog_2^n) O(nlog2n),最坏时间复杂度为 O ( n 2 ) O(n^2) O(n2)。
☆ 构建堆的过程?堆排序的过程?
堆的存储结构:
使用数组来存储堆的元素。由于堆是一棵完全二叉树,因此对于下标为 i i i 的结点,它的左子结点的下标为 2 i + 1 2i+1 2i+1,右子结点的下标为 2 i + 2 2i+2 2i+2。
构建堆的过程:
- ① 首先将待排序的序列按照先后顺序存储在数组中;
- ② 从最后一个分支结点开始调整。如果该结点的值小于其左子结点和右子结点中较大的一个,那么就让它和这个较大的子结点进行交换;然后再对其余分支结点进行相同的调整,由于结点的交换可能会破坏下一级的堆,因此需要重新对下一级的堆进行调整。
- ③ 重复上述操作,直到完成对根结点的调整,算法结束。
这里以大根堆为例,小根堆请自行脑补。
堆排序的过程:
- 每次输出堆顶元素,然后将堆的最后一个元素与堆顶元素进行交换。此时堆的性质被破坏,需要向下进行调整;
- 重复上述操作,直到所有的结点都被输出,即可得到已排序的序列。
☆ 归并排序和快排的比较
由于归并排序分割子序列的过程与初始序列的排序无关,因此它的最好、最坏和平均时间复杂度均为 O ( n l o g 2 n ) O(nlog_2^n) O(nlog2n)。快速排序虽然最坏情况下的时间复杂度会达到 O ( n 2 ) O(n^2) O(n2),但是它的平均性能可以达到 O ( n l o g 2 n ) O(nlog_2^n) O(nlog2n)。此外,由于归并排序在合并操作中需要借助较多的辅助空间用于元素赋值,因此它的空间复杂度为 O ( n ) O(n) O(n)。而快速排序的平均空间复杂度只有 O ( l o g 2 n ) O(log_2^n) O(log2n)。
其他问题
○ 循环的效率一定比递归的高吗?
递归的优缺点
- 代码简洁,易于理解:递归往往能够用更少的代码量表达问题,尤其是当问题可以自然地分解为更小的相似子问题时。
- 逻辑清晰:递归的调用过程使得问题的逻辑关系更加直观,便于分析和检查。
- 堆栈溢出风险:递归调用会增加调用栈的深度,对于深度很大的递归,可能会导致栈溢出错误。
- 性能开销:递归调用比迭代多了一次函数调用的开销,虽然现代编译器可以优化某些递归形式,但在某些情况下,这可能会影响程序的运行效率。
循环的优缺点
- 高效:循环结构避免了函数调用的开销,对于大规模数据处理和重复操作,循环通常更加高效。
- 易于优化:循环代码通常更容易被编译器优化,从而提高程序的执行速度。
- 代码复杂性:循环(尤其是嵌套循环)可能导致代码可读性下降,尤其是当循环逻辑变得复杂时。
- 可能产生死循环:如果循环条件设置不当,可能会导致程序陷入死循环,这需要谨慎处理。
- 原博客:循环不能解决所有的问题,有些问题适合用递归来处理,而不适合用循环。
我感觉答非所问,难道说只有写成死循环的时候效率才比递归低吗?
○ 贪心算法、动态规划、分治法的区别
- 贪心算法:贪心算法通常会做出在当前看来是最好的选择,希望通过局部最优的选择达到全局最优。但并非所有问题都适合用贪心算法,因为它不保证能得到全局最优解。
- 动态规划:动态规划经常用于解决具有重叠子问题和最优子结构性质的问题。它会先解决子问题,并将这些子问题的解存储起来,以供后面使用,从而避免重复计算。
- 分治法:分治法的核心思想是将问题分解成小的、相互独立的部分,分别解决这些部分,然后再将它们的解合并起来。分治法通常用于那些可以分解为独立子问题的问题。