- 这个专栏是Hollis知识星球的朋友们练习算法的地方
- 欢迎广大网友参与分享算法学习经验
- 项目目前分为两部分
- solutions 目录
- 算法题解,格式是 md 格式文件,方便阅读
- 包括
- 算法平台的题解,如 Leetcode 等
- 算法书籍的题解,如 《剑指 Offer》
- codes 目录:
- 数据结构和算法的实现代码
- 包括
- 各类网络公开课的代码
- 各类算法书籍的代码
- solutions 目录
- 备注
- 本项目选取的资源都来自官方公开免费资源
- 本项目所有内容不做商业用途
- 本项目大部分代码由贡献者自己编写,如有引用则注明出处
适合短期面试突击或日常刷题练手
适合从零开始真正学习和掌握数据结构和算法知识
本课程为 DT 课堂颜群发布在 Bilibili 上的免费视频
《数据结构与算法基础-java版(罗召勇)》
https://www.bilibili.com/video/BV1Zt411o7Rn
- 本项目中实现代码:
codes/java_dataStructure_luozhaoyong
- 数据结构:数据与数据之间的关系
- 两方面讨论:
- 存储结构
- 顺序存储:存储在连续的存储单元
- 链式存储:不连续,每次存储都有数据和指针
- 逻辑结构
- 数据和数据本身之间的关系
- 集合结构:数据同属于一个集合
- 线性结构:元素之间一对一的关系
- 数组
- 栈
- 队列
- 单链表
- 循环链表
- 双链表
- 递归
- 排序算法
- 树形结构:元素之间一对多的关系
- 图形结构:元素之间多对多的关系
- 数据和数据本身之间的关系
- 存储结构
- 算法定义:解决问题的思路
- 算法的特性:
- 输入:0 到多个输入
- 输出:至少 1 个输出
- 有穷性:有限的步骤里算出结果
- 确定性:一个输入对应一个输出,结果确定
- 可行性:能够解决实际问题
- 算法的基本要求:
- 正确性:能够得出正确的结果
- 可读性:能够被看懂
- 健壮性:对于各种情形算法都有效
- 时间复杂度:消耗的时间
- 空间复杂度:占用的内存
顺序存储的线性结构称为数组
- 下标从 0 开始,下标最大值为(数组长度-1)
- 数组创建方式:
- int[] arr = new int[3];
- int 规定了数组中元素的类型
- 3 规定了数组的长度
- arr[0] = 1; // 为数组中指定位置赋值
- int[] arr = new int[]{1, 2, 3 };
- 创建数组的同时给数组赋值
- int[] arr = new int[3];
解决数组元素不可变的问题
-
- 新建一个数组,长度为原数组长度+1
-
- 将原数组中的元素逐个赋值到新数组
-
- 将目标元素添加到新数组的末尾
-
- 用新数组替换原数组
-
- 创建一个新数组,长度为原数组长度-1
-
- 将原数组中除要删除元素之外的元素,逐个赋值给新数组
-
- 用新数组替换原数组
- 在对象数组中创建一个数组成员变量,实际操作都在这个成员变量中进行
- 主要操作:
- 向数组末尾添加元素
- 删除指定位置的元素
- 获取指定位置的元素
- 插入一个元素到指定位置
- 为数组中指定位置赋值
- 遍历每一个元素,依次与目标值对比
- 适用范围:有序数组
- 步骤:
-
- 数组开始位置为 0,结束位置为数组长度 -1
-
- 通过开始位置和结束位置获取中间位置的值
-
- 将中间值与目标值对比
- 中间值 > 目标值:将结束位置左移到中间位置-1,继续向左查找更小的值
- 中间值 < 目标值:将开始位置右移到中间位置+1,继续向右查找更小的值
- 中间值 = 目标值:中间值就是要查找的值,返回中间值下标
-
- 重复步骤 2 和 3,直至找出目标位置或者遍历完数组
-
- 向数组末尾添加元素
- 从数组末尾取元素
- 依次实现下列方法:
- push
- pop
- peek
- isEmpty
- 在数组末尾加入元素
- 在数组开头取出元素
- 依次实现下列方法
- add
- poll
- isEmpty
- 定义节点类 Node,包含以下成员变量
- int data:节点内容/数据
- Node next:下一个节点,类型也是节点
- 为节点类添加下列方法
- append // 向链表末尾添加
- next // 获取当前节点的下一个节点
- getData // 获取节点的数据
- isLast // 判断当前节点是否为最后一个节点
- 删除当前节点的后继节点
- 获取后继节点的后继节点
- 将当前节点的后继节点指向新的后继节点
- 原有后继节点与前置和后继节点都失去了联系,达到了删除效果
- 让当前节点的后继节点指向要插入的节点
- 要插入的节点后继节点指向原后继节点
- 新的连接关系:当前节点-->插入节点-->原后继节点
- 链表最后一个节点的后继节点指向链表的头节点
- 实现下列方法
- next 获取后继节点
- getData 获取当前节点值
- after 插入一个新节点
- 每一个节点都会记录其前置节点和后继节点
- 三个成员变量
- 上一个节点
- 下一个节点
- 当前节点数据
- 实现下列方法
- after 新增节点
- next 获取后继节点
- pre 获取前驱节点
- getData 获取当前节点值
- 递归就是在函数内部调用该函数本身
- 斐波那契数列:1 1 2 3 ...
- 从第三项起,每一项的值都是前两项之和
- 三根柱子 + n 个盘子
- n 个盘子一开始都在第一根柱子上
- 每次只能移动一个盘子
- 用最少的步数将 n 个盘子从第一根柱子移动到第三根柱子
- 解决思路
- 求出只有 1 个盘子的情况
- 求出只有 2 个盘子的情况
- n 个盘子的情况都可以简化成 2 个盘子的情况
- 时间复杂度:运行时占用时间
- 空间复杂度:运行时占用内存
- 一个算法中语句需要执行的次数,称为语句频度,记为 T(N)
- 随着执行次数增多,时间复杂度估算时可以忽略以下内容
- 忽略常数项
- 在坐标轴上画出曲线
- 随着 n 的增大
- 2n+20 和 2n 两条曲线会趋向重合
- 3n+10 和 3n 两条曲线会趋向重合
- 在 n 较大时,常数项的影响可以忽略
- 忽略低次项
- 在坐标轴上画出曲线
- 随着 n 的增大
- 2n^2 + 3n + 10 和 2n^2 两条曲线都趋向 n^2
- n^2 + 5n + 20 和 n^2 两条曲线都趋向 n^2
- 在 n 较大时,低次项的影响可以忽略
- 忽略系数
- 在坐标轴上画出曲线
- 随着 n 的增大
- 3n^2 + 2n 和 5n^2 + 7n 两条曲线趋向重合
- n^3 + 5n 和 6n^3 + 4n 两条曲线趋向重合
- 在 n 较大时,稀疏的影响可以忽略
- 忽略常数项
- 大 O 表示法
- T(n) 表示算法中基本操作语句的重复执行次数是问题规模 n 的函数
- 如果有一个辅助函数 f(n)
- 使得 n 趋近于无穷大时,T(n) 和 f(n) 的极限值为不等于 0 的常数
- 则称 f(n) 是 T(n) 的同数量级函数,记做 T(n) = O(f(n))
- O(f(n)) 称为算法的渐进时间复杂度,简称时间复杂度
- 常见的时间复杂度
- 常数阶 O(1)
- 对数阶 O(log2n)
- 线性阶 O(n)
- 线性对数阶 O(nlog2n)
- 平方阶 O(n^2)
- 立方阶 O(n^3)
- 次方阶 O(n^k)
- 指数阶 O(2^n)
- 随着问题规模 n 的不断增大,上述时间复杂度不断增大,算法执行效率越低
- 计算时间复杂度的方法
- 常数 1 代替所有加法常数
- 只保留最高阶项
- 去掉最高阶项的系数
- 平均时间复杂度和最坏时间复杂度
- 通常只讨论最坏时间复杂度
- 交换排序
- 冒泡排序
- 快速排序
- 插入排序
- 直接插入排序
- 希尔排序
- 选择排序
- 简单选择排序
- 堆排序
- 归并排序
- 基数排序
- 第一轮
- 从第 1 个元素开始,比较相邻两个元素,将较大的元素后移,直到最大的元素移动到数组末尾
- 第二轮
- 从第 1 个元素开始,比较相邻两个元素,将较大的元素后移,直到本轮最大的元素移动到数组倒数第二个位置
- 第三轮
- 从第 1 个元素开始,比较相邻两个元素,将较大的元素后移,直到本轮最大的元素移动到数组倒数第三个位置
- 第 n 轮
- 每轮都从第 1 个元素开始,比较相邻两个元素,将较大的元素后移
- 前一轮的最大元素不再参与下一轮比较,所以每一轮参与比较的元素都比上一轮少 1 个
- 每一轮中最大元素都会从前往后移,类似气泡冒出水面,所以称为冒泡排序
-
- 从数组中找出一个基准数
-
- 数组定义左右两个指针分别向中间移动
-
- 数组左侧的值比基准值大,则移到数组右侧
-
- 数组右侧的值比基准值小,则移到数组左侧
-
- 当左右两个指针重合时,当前轮排序结束
-
- 指针重合的位置将数组分为两部分,分别对两部分递归调用快速排序
-
- 重复上述步骤,直到排序完成
- 将数组分为未排序和已排序两部分
- 从数组第二个元素位置开始
- 每次从未排序部分取出第一个数字
- 将其按规定顺序插入已排序部分
- 同时插入位置之后的数字依次后移 1 位
- 重复上述步骤,已排序部分逐渐向右扩大,直到所有数字都正确排序为止
- 插入排序的问题
- 如果待插入的数字,比已排序部分所有数字都小
- 那么已排序部分就要进行大量的元素后移操作,效率较低
- 希尔排序
- 取某个数字作为步长,按步长对数组进行插入排序
- 排序完成后,步长按规律递减
- 用新步长进行下一轮插入排序
- 重复上述步骤,直到步长变成 1,进行最后一轮普通的插入排序为止
- 将数组看作有序和无序两部分
- 从第一个元素开始,在无序部分找出最小的元素
- 将最小的元素与无序部分的第一个元素交换位置
- 重复上述步骤,直到数组完全有序为止
- 归并方法
- 原数组已经被分为两部分,每部分都各自有序
- 创建一个与原数组等长的临时数组
- 依次从两部分中取出元素进行对比,按照顺序放入临时数组
- 所有元素都放入新数组后,整个数组已经排好序
- 将临时数组重新赋值给原有数组
- 递归部分
- 将原数组折半划分为两部分
- 依次对两部分递归调用递归算法
- 直到数组不可再分
- 思路
- 第一轮按所有元素的个位数字排序
- 第二轮按所有元素的十位数字排序
- 以此类推
- 当按照数组中元素的最大位数排序之后,最终得到 1 个有序的数组
- 举例
- 例如数组 [5, 1, 72, 36, 101]
- 为便于理解,想象元素空缺的位数上都是 0
- 把数组写成如下形式
- 排序前的原始数组 [005, 001, 072, 036, 101]
- 第一轮按个位排序 [001, 101, 072, 005, 036]
- 第二轮按十位排序 [001, 101, 005, 036, 072]
- 第三轮按百位排序 [001, 005, 036, 072, 101]
- 每轮排序后,位数相同的数字,相对顺序不会改变
- 如第一次按照个位排序后,两个个位数字 1 和 5
- 1 在接下来的几轮排序过程中,总是位于 5 的前面
- 所有排序结束后,就得到了按照数字整体大小排列的数组
- 26 节的基数排序中,我们使用二维数组来表示桶
- 桶中的元素有先进先出的特点,可以将二维数组换成队列
- 线性结构:
- 顺序存储:添加删除耗时
- 链式存储:查找耗时
- 树结构:
- 解决了顺序存储和链式存储的上述问题
- 根结点:起始节点
- 双亲节点(即父节点):有子节点的节点
- 子节点:向上溯源,有双亲节点的节点
- 路径:从根结点到指定节点所要经过的所有节点
- 节点的度:子节点的个数
- 节点的权:节点的数值
- 叶子节点:没有子节点的节点,即度为 0 的树
- 子树:树中包含的树
- 层:把根结点看作第一层,根结点的子树为第二层,树有多少代,就有多少层
- 树的高度:树的最大层数
- 森林:多棵树组成一个森林
-
概念:
- 任何一个节点,子节点的数量不超过 2,这棵树就是二叉树
- 二叉树的左右节点顺序不同,视为不同的两棵树
-
满二叉树:
- 所有叶子节点都在最后一层
- 且总的节点个数为 2^n-1
- n 是树的高度
-
完全二叉树:
- 所有叶子节点都在最后一层或倒数第二层
- 且最后一层的叶子节点在左边连续
- 倒数第二层的叶子节点在右边连续
- 数节点确认:
- 从左向右从上到下数节点
- 数到最后一个节点
- 完全连续没有间断就是完全二叉树
- 链式存储
- 创建二叉树
- 添加节点
- 查找节点
- 树的遍历
- 删除节点
- 顺序存储
- 空树:无节点
- 左斜树:所有节点都在左侧
- 右斜树:所有节点都在右侧
- 三种遍历形式:
- 根据根结点的位置确定顺序
- 前序:根结点-->左节点-->右节点
- 中序:左结点-->根节点-->右节点
- 后序:左结点-->右节点-->根节点
- 与二叉树遍历方法类似
- 递归删除
- 顺序存储二叉树通常只考虑完全二叉树
- 第 n 个元素的左子节点是 2*n+1
- 第 n 个元素的右子节点是 2*n+2
- 第 n 个节点的父节点是 (n-1)/2
- 与链式存储二叉树的遍历相似,以前序遍历为例
- 双亲节点 index
- 左子节点 2*index+1
- 右子节点 2*index+2
- 需要传入一个参数,确定遍历的起点
- 大顶堆:每个节点都大于等于其左右孩子节点的值
- 小顶堆:每个节点都小于等于其左右孩子节点的值
- 升序使用大顶堆
- 降序使用小顶堆
从左至右,从上至下调整
-
- 从最后一个非叶子节点开始调整
-
- 将这个非叶子节点与其子节点对比,检查是否最大
-
- 如果不是,交换二者位置
-
- 交换位置后原有的堆结构发生了变化
-
- 对调整后的最大非叶子节点重复步骤 1~3
-
- 循环遍历一个顺序存储的二叉树,对每一个非叶子节点执行步骤 1~4
-
- 将大小为 n 的顺序存储二叉树调整成一个大顶堆
-
- 将数组第 0 个数和第 n 个数交换
-
- 将数组大小递减 1,对递减后的数组重复执行 1~2
- 顺序存储二叉树遍历到某个节点时,无法知道它的前驱节点和后续节点
- 利用二叉树节点的空链域存储前驱或者后继节点的指针,这些指针就称为线索
- 当某个节点没有左子节点时,将左指针指向它的前一个节点
- 当某个节点没有右子节点时,将右指针指向它的后一个节点
- 线索化二叉树时,可以通过标记的方式,说明指向的是前驱/后继节点还是孩子节点
-
- 创建两个变量,分别用于标识左子节点和右子节点的类型
- 默认 0 表示指针指向孩子节点
- 1 表示当前指针指向前驱或后继节点
-
- 创建一个变量,临时存储前驱节点
-
- 对左子节点和右子节点递归调用线索化方法
-
- 对当前节点的进行线索化:
- 如果左子节点为空,将空指针指向前驱节点,左指针类型标识改为 1
- 如果前驱节点的右子节点为空,将空指针指向当前节点,前驱节点的右指针类型标识改为 1
-
- 线索化结束后,让前驱节点指向当前节点,供下一轮使用
- 中序遍历,向左前溯,找到第一个被线索化的节点
- 不断输出后继节点的值,直到没有后继节点
- 找到最后一个后继节点的右子节点,继续下一轮查找和输出
-
- 不断前溯左子节点,直至找到第一个被线索化的节点
-
- 从这个节点开始,不断查找后继节点并输出节点的值
-
- 找到最后一个后继节点的右子节点,重复步骤 1 和 2,直到节点为空
- 赫夫曼编码是数据压缩的重要方法
- 叶结点的带权路径:
- 从根结点出发,到达某个叶结点时,经过的节点数量,乘以叶结点的权值
- 如叶子节点 A 的权值为 9,从根结点到达 A 节点,经过了 2 个节点,A 的权值就是 2*9=18
- 树的带权路径长度:
- WPL:weighted path length
- 树中所有叶子节点带权路径之和
- 最优二叉树:
- WPL 最小时,称为最优二叉树,也叫赫夫曼树
- 权值越大的节点离根结点越近,这样才能保证带权路径尽可能小
-
- 将数组中的每个元素都转化为二叉树,初始状态每棵二叉树只有一个节点
-
- 将数组中的二叉树按根结点的权值正序排列
-
- 从数组中取出根结点权值最小的两棵二叉树
- 将这两棵二叉树的根结点视为孩子节点,为它们创建一个父节点
- 父节点的权值是两个孩子节点的权值之和
- 新的父节点和原先的两棵二叉树组成了一棵新的二叉树
-
- 将新创建的二叉树插入数组
-
- 重复步骤 2~4,每次取出两个元素,放回一个元素,直到数组剩下一个元素为止
- 剩下的这个元素,就是整棵赫夫曼树的根结点
-
- 将数组中的所有元素转化为二叉树
-
- 取出数组中根结点权值最小的两棵二叉树
-
- 用两棵二叉树的根结点作为孩子节点,创建一棵新的二叉树
- 二叉树根结点的权值是两棵孩子节点权值之和
-
- 移除数组中取出的两棵二叉树
-
- 将新创建的二叉树放入数组
-
- 当数组中的元素大于 1 时,循环执行步骤 2~5
- 通信和压缩领域应用非常广泛
can you can a can as a can canner can a can
- 通信领域中信息的处理
- 定长编码:
99 97 110 32 121 ... 32 97 32 99 97 110 46
- 单词-->ASIIC 编码-->每个数字都转成 8 位的字节
01100011 01100001 ... 00101110
- 缺点:固定长度,传输内容太多
- 非定长编码:
- 计数:
r:1 s:1 .. n:8 :11 a:11
0=a, 1= , 10=n, 11=c ... 10=r
- 将字符串中每个字符出现的次数表示出来
- 编码:
- 出现次数多的字符用较少位的字节来表示
- 出现次数少的字符用较多位的字节来表示
- 前缀编码:字符的编码都不能是其他字符编码的前缀
- 前缀编码才能进行解码
- 计数:
- 赫夫曼编码:
- 将字符串中每个字符出现的次数表示出来
*r:1 s:1 .. a:11
- 将字符作为节点的数据,出现次数作为节点的权值
- 出现次数多的字符靠近根结点,编码长度较短
- 将左连接定义为 0,右连接定义为 1,树的路径就有了编码
- 赫夫曼树的路径是唯一的,因此每个字符的编码都是唯一的
- 将字符串中每个字符出现的次数表示出来
- 定长编码:
- 属性:
- int weight 权值,某个字符出现了多少次
- Node left 左子节点
- Node right 右子节点
- Byte data 当前节点对应的字符
- 采用包装类 Byte 可以定义空值
- 构造方法:
*public Node(Byte data, int weight)
- 共经历了 6 次形态转换
-
- 字符串 --> 未压缩的 byte 数组
-
- 未压缩的 byte 数组 --> 二叉树节点列表
-
- 二叉树节点列表 --> 赫夫曼树
-
- 赫夫曼树 --> 赫夫曼编码表
-
- 字符数组 + 赫夫曼编码表 --> 二进制字符串
-
- 二进制字符串 --> 压缩后的 byte 数组
- 调用 String 对象的 getBytes() 方法
- 创建一个 HashMap,键类型是 Byte,值类型是 Integer
- 遍历 byte 数组,通过 HashMap 存储并统计单个 byte 出现的次数
- 从 HashMap 中取出对应的键值对
- 以 Byte 值作为节点数据,出现次数作为节点权值
- 每个键值对都转为一个二叉树根节点 Node
- 将所有二叉树节点存入列表备用
-
- 遍历 Node 列表
-
- 按 Node 权值 weight 降序对列表排序
-
- 取出列表中权值最小的两个元素,即列表倒数两个元素
-
- 将两个元素作为孩子节点创建一个父节点,父节点的权值是两个孩子节点权值之和
-
- 同时将父节点的左右指针指向两个孩子节点
-
- 从原列表中删除第 3 步中取出的两个权值最小的元素
-
- 将新建的节点存入列表
-
- 当列表中元素数量大于 1 时,重复步骤 2~6
-
- 最终列表中只剩下一个元素,这个元素就是赫夫曼树的根结点
-
- 新建一个 HashMap 对象记录赫夫曼编码表
- 键:赫夫曼树上某个节点的键,即字符
- 值:赫夫曼树根节点到达当前字符的路径
- 到达左子树的路径用 0 表示,到达右子树的路径用 1 表示
- 路径变成一个由 0 和 1 组成的二进制字符串
- 这样每个节点得到的二进制字符串都是唯一的
-
- 遍历赫夫曼树,将赫夫曼树上节点的相关信息转录到赫夫曼编码表
-
- 遍历原字符数组
-
- 对照赫夫曼编码表,获取每个字符对应的赫夫曼编码
-
- 将获取到的编码拼接到单个字符串
-
- 最终字符数组转成了一个遵循赫夫曼编码表的二进制字符串
-
- 以 8 为步长遍历二进制字符串
- 8 位二进制字符串 --> 十进制数字 --> byte 字符
- 将新的 byte 字符存入新的字符数组中
-
- 最终得到一个按赫夫曼编码表压缩后的字符数组
- 共进行了 2 次形态变化
-
- 字符数组 --> 二进制字符串
- 1.1) 遍历字符数组
- 1.2) 将每个字符都转为 8 位的二进制字符串
- 如果正整数不够 8 位,数字前用 0 填充
- 1.3) 将所有字符的二进制字符串拼接成一个完整的二进制字符串
-
- 二进制字符串 + 赫夫曼编码表 --> 原字符数组
- 2.1) 将原赫夫曼编码表的键和值互换
- 即原先键是 Byte,值是 String
- 互换后键是 String,值是 Byte
- 2.2) 遍历二进制字符串,以各种可能的组合在赫夫曼编码表中查找原 byte
- 2.3) 将 byte 存入列表后转成数组
-
- 文件来源路径 --> 创建输入流
- new FileInputStream(文件来源路径)
-
- 创建和输入流指向文件大小一致的 byte 数组
- byte[] b = new byte[FileInputStream 对象.available()]
- available() 在操作前得知数据流大小
-
- 读取文件,关闭输入流
-
- 调用赫夫曼编码方法对 byte 数组编码
-
- 文件输出路径 --> 创建输出流
- new FileOutputStream(文件输出路径)
- new ObjectOutputStream(FileOutputStream 对象)
-
- 将压缩后的 byte 数组写入文件
-
- 将赫夫曼编码表写入文件
-
- 关闭输出流
-
- 创建一个输入流对象,读取压缩文件
- new FileInputStream(压缩文件路径)
- new ObjectInputStream(FileInputStream 对象)
-
- 读取压缩文件中的 byte 数组
- byte[] b = (byte[]) ObjectInputStream 对象.readObject()
-
- 读取压缩文件中的赫夫曼编码
- Map<Byte, String> codes = (Map<Byte, String>) ObjectInputStream 对象.readObject()
-
- 关闭输入流
-
- 通过赫夫曼编码将读取出来的 byte 数组解码为原 byte 数组
-
- 创建一个输出流对象
- new FileOutputStream(输出文件路径)
-
- 将解码后的 byte 数组写入文件
- FileOutputStream 对象.write(byte 数组)
-
- 关闭输出流
- 顺序存储,不排序
- 查找困难
- 顺序存储,排序
- 二分查找效率高
- 删除插入困难
- 链式存储
- 无论是否排序,查找都困难
- 二叉排序树,BST
- 概念
- 也叫二叉查找树,二叉搜索树
- 对于排序树中的任何一个非叶子节点
- 左子节点比当前节点小
- 右子节点比当前节点大
- 查找和插入删除性能都较高
- 概念
- 待添加的节点
-
- 如果当前节点为空 --> 赋给当前节点
-
- 如果值比当前节点小
- 左子节点为空 --> 赋给左子节点
- 左子节点非空 --> 递归调用左子节点的添加方法
-
- 如果值比当前节点大
- 右子节点为空 --> 赋给右子节点
- 右子节点非空 --> 递归调用右子节点的添加方法
-
- 二叉排序树的中序遍历正好是从小到大排列
- 要查找的值 == 当前节点的值 --> 返回当前节点
- 要查找的值 < 当前节点的值 --> 递归调用左子节点的查找方法
- 要查找的值 > 当前节点的值 --> 递归调用右子节点的查找方法
- 目标节点没有孩子节点
- 查找目标节点
- 查找目标节点的父节点
- 将目标节点与父节点断开连接
- 查找目标节点
- 查找目标节点的父节点
- 将目标节点的子树与父节点建立连接
- 查找目标节点
- 查找目标节点的最小子树
- 删除目标节点的最小子树,并返回最小子树的值
- 用最小子树的值替换目标节点的值
- 二叉排序树的问题
- 如果将连续递增或递减的数组转为二叉排序树
- 二叉排序树的节点都在同一边,查询效率与链表差不多
- 平衡二叉树
- 首先平衡二叉树是一棵二叉排序树
- 左子树和右子树高度差的绝对值不超过 1
- 左子树和右子树也是平衡二叉树
- 根据左右节点高度差的绝对值,判断当前节点是否为平衡二叉树
- 左左:(左子树高度 - 右子树高度) > 1
- 顺时针右旋
-
- node --> newNode
-
- node.right --> newNode.right
-
- node.left.right --> newNode.left
-
- node.left.value --> node.value
-
- node.left.left --> node.left
-
- newNode --> node.right
-
- 顺时针右旋
- 右右:(右子树高度 - 左子树高度) > 1
- 顺时针左旋
-
- node --> newNode
-
- node.left --> newNode.left
-
- node.right.left --> newNode.right
-
- node.right.value --> node.value
-
- node.right.right --> node.right
-
- newNode --> node
-
- 顺时针左旋
- node.left.left 高度 < node.left.right 高度
-
- left 左旋转
-
- node 右旋转
-
- node.right.right 高度 < node.right.left 高度
-
- right 右旋转
-
- node 左旋转
-
- 应用于内存,小数据量的树结构
- 二叉树
- 线索二叉树
- 赫夫曼树
- 二叉排序树
- AVL 树
- 应用于磁盘存储,数据量大的树结构
- 多路查找树
- 2-3 树和 2-3-4 树
- B 树和 B+ 树
- 多路查找树
- 内存
- 优点:
- 电信号保存信息,不存在机器操作
- 访问速度快
- 缺点:
- 造价高
- 断电后数据丢失
- 一般作为 CPU 告诉缓存
- 优点:
- 磁盘:
- 优点
- 造价低,容量大
- 断电数据不丢失
- 缺点:
- 存储介质特性和机械运动耗费时间,磁盘速度慢
- 磁盘的预读
- 为了减少 I/O 操作,磁盘通常不是按需读取
- 每次都会预读,顺序向后读取一定长度的数据放入内存
- 计算机科学中的局部性原理:一个数据被用到时,其附近的数据通常也会被用到
- 预读的长度一般为页(page)的整数倍
- 页
- 页是计算机管理存储的逻辑块
- 硬件及操作系统将主存和磁盘存储区分割为连续的大小相等的块
- 每个存储块为一页
- 页的大小一般为 4k
- 主存和磁盘以页为单位交换数据
- B 树存储
- 利用磁盘预读原理,将一个节点的大小设为一个页
- 单个节点进行横向扩展
- 每个节点只需一次 I/O 就可以完全载入
- 利用磁盘预读原理,将一个节点的大小设为一个页
- 二叉树与 B 树对比
- 二叉树
- 树高为 5 的二叉树
- 节点数=2^5-1 = 31 个
- B 树
- 树高为 2
- 第一层 1 个节点,横向扩展为 3 个节点
- 第二层 4 个节点,每个节点横向扩展为 7 个节点
- 节点树=1×3 + 4×7 = 31
- 同样的节点树,B 树只需要两层即可
- 如果将树的度,即子节点的个数,设为 1024
- 树高 2:1024^2 = 1,048,576 ≈ 100 万
- 树高 3:1024^3 = 1,073,741,824 ≈ 1 亿
- 树高 4:1024^4 = 1,099,511,627,776 ≈ 1000 亿
- 600 亿 < 1000 亿,至多 4 次 I/O 即可查询到
- 二叉树
- 优点
- B 树中所有的叶节点都在同一层
- 2-3 树是 B 树的一种特例
- 有两个子节点的节点叫做二节点
- 有三个子节点的节点叫做三节点
- 二节点要么有两个子节点,要么没有子节点
- 三节点要么有三个子节点,要么没有子节点
- 2-3 树有二节点和三节点 2 种情况
- 2-3-4 树有二节点、三节点和四节点 3 种情况
- 添加新节点
- 如果叶节点无法在同一层
- 先向上一层拆解
- 如果现有层都满了,则增加一层
- 2-3 树、2-3-4 树、2-3-4-5 树,等等,统称为 B 树
- B 树中最大节点的数字,称为 B 树的阶
- 2-3 树是 3 阶 B 树,2-3-4 树是 4 阶 B 树
- 在 B 树之上的改变
- 非叶节点只存储索引信息,不存储数据
- 叶子节点最右边的指针指向下一个相邻的叶节点
- 所有叶节点组成一个有序链表
- 设计原理
- 更多的索引 --> 更快的查询
- 线性查找
- 逐个对比,数据量大时效率低
- 二分查找:
- 效率更高
- 要求数组有序
- 存储位置 <--> 关键字
- 通过散列函数建立对应关系
- 设计原则
- 计算简单
- 分布均匀
- 直接定址法
- 直接把关键字作为存储地址
- 可能有空间分布不均匀的问题
- 如果数字过大,甚至会超出编程语言的有效整数范围
- 数字分析法
- 通过特定规律,将数字转换成更方便存储的格式作为关键字
- 需要事先知道数字的格式
- 如手机号码只存后四位
- 平方取中法
- 将数字平方后取结果中间的数字作为关键字
- 如 13*13 = 169,取中间数字 6
- 取余法
- 对数字取余数作为关键字
- 按预计压缩范围来确定模数
- 随机数法
- 随机数函数产生数字作为关键字
- 一般不用,数字随机,不便归类
- 遇到冲突时,从当前地址后面查找合适的位置
- 三种方式
- 线性探测法
- 在紧邻的位置放冲突的元素
- 举例
- x 位置已有元素,向后查找 x+1
- x+1 位置有元素,再向后找 x+2
- 问题:元素容易聚集在相邻的内存地址
- 二次探测法
- 第一次探测紧邻的位置
- 第二次探测地址数字的平方
- 举例:
- x 位置被占用,向后查找 x+1
- x+1 位置被占用,再向后找 (x+1)^2
- 拓宽了探测步长,元素不容易聚集在一起
- 再哈希法
- 多个散列函数
- 通常 3 个散列函数可以解决大部分的冲突
- 如果仍然有冲突,可以再使用探测法
- 线性探测法
- 将冲突的元素在同个地址上存储为链表形式
- 节点内容:元素本身
- 节点指针:下一个元素的地址
- 优点:存储地址就是散列表计算结果,更为直观
- 实际应用中更多采用链地址法
- 点和线构成
- 顶点 Vertex
- 可以存储数据
- 边 edge
- 连接顶点,表示点之间的关系
- 邻接顶点
- 两个顶点通过一条边就可以连接
- 路径
- 从某一个顶点出发,经过的所有顶点
- 无向图
- 边没有方向
- 有向图
- 边有方向
- 带权图
- 边加上有意义的值
- 如 A 城市到 B 城市的距离
- A --> B 是一个有向带权图
- 链表
- 如果数据是对象,指针很难定义
- 数组
- 用列表存储顶点
- 用邻接表存储顶点之间的关系
- 类似链表的实现
- 节点内容代表顶点
- 指针指向下一个顶点
- 类似矩阵的实现
- 将顶点放到行列式中,任意两个顶点都能在矩阵中找到交叉点
- 用数字表示两个顶点之间的关系
- 如 0 表示不通,1 表示连通
- 类似链表的实现
- 顺着路径一直查找,直到路径不通,再返回从第一个分叉的顶点处继续查找
- 栈实现
-
- A 入栈,A 标记为已访问
-
- 按顺序查找连接
- A --> B,连通,B 入栈,B 标记为已访问
- B --> C,连通,C 入栈,C 标记为已访问
- C --> D,不通,查找下一个
- C --> E,不通,E 之后没有其他顶点
- C 是栈顶元素,出栈
- B --> D,连通,D 标记为已访问,D 入栈
- D --> E,不通,E 之后没有其他顶点
- D 是栈顶元素,出栈
- B --> E,连通,E 入栈,E 标记为已访问
- E 之后没有其他元素
- E 是栈顶元素,出栈
- B 是栈顶元素,检查 B 有无其他可能的连接
- B 没有其他连接,出栈
- B --> C,连通,C 入栈,C 标记为已访问
- A 是栈顶元素,检查 A 有无其他可能的连接
- A 没有其他连接,出栈
-
- 把一个顶点所有的连接都找出来,再继续下一个顶点
- 队列实现
-
- A 入队
-
- 查找 A 的所有连接,按顺序入队
- A --> B,连通,B 入队
- A --> C,连通,C 入队
- A 无其他连接,A 出队
-
- A 出队后,B 是队首元素,从 B 开始查找
-
- 重复步骤 2~3,依次查找 B、C、D、E 所有顶点的所有连接
-
-
- 从第 0 个顶点开始查找
- 将第 0 个顶点标记为已访问
- 将第 0 个顶点压入栈中
-
- 遍历顶点数组,按序检查邻近两个顶点之间的连通关系
- 如果邻近顶点连通
- 将目标顶点压入栈中,标记为已访问
- 出发顶点和目标顶点同时后移 1 位
- 如 A --> B 连通
- 将出发顶点从 A 顺延到 B,检查 B --> C 是否连通
- 如果邻近两个顶点不通,出发顶点不变,将目标顶点的指针后移一位
- 如 C --> D 不通,将目标顶点 D 后移一位,检查 C --> E 是否连通
-
- 如果单条路径检查完毕,则弹出栈顶元素
-
- 如果栈此时非空,将出发顶点指针指向新的栈顶元素
-
- 重复执行步骤 2~4,直到栈为空