Java 内功修炼 之 数据结构与算法(二)
一、二叉树补充、多叉树
1、二叉树(非递归实现遍历)
(1)前提
前面一篇介绍了 二叉树、顺序二叉树、线索二叉树、哈夫曼树等树结构。
可参考:https://www.cnblogs.com/l-y-h/p/13751459.html#_label5_1
(2)二叉树遍历
【递归与非递归实现:】
使用递归实现时,系统隐式的维护了一个栈 用于操作节点。虽然递归代码易理解,但是对于系统的性能会造成一定的影响。
使用非递归代码实现,可以主动去维护一个栈 用于操作节点。非递归代码相对于递归代码,其性能可能会稍好(数据大的情况下)。
注:
栈是先进后出(后进先出)结构,即先存放的节点后输出(后存放的节点先输出)。
所以使用栈时,需要明确每一步需要压入的树节点。
递归实现二叉树 前序、中序、后序遍历。可参考:https://www.cnblogs.com/l-y-h/p/13751459.html#_label5_2
(3)非递归实现前序遍历
【非递归实现前序遍历:】
前序遍历顺序:当前节点(父节点)、左子节点、右子节点。
实现思路:
首先明确一点,每次出栈的树节点即为当前需要输出的节点(第一个输出的节点为 根节点)。 每次首先输出的为 当前节点(父节点),所以父节点先入栈、再出栈。
出栈之后,需要重新选择出下一次需要输出的父节点。从当前节点的 左、右子节点中选择。
而左子节点需要在 右子节点前输出,所以右子节点需要先进栈,然后左子节点再进栈。
左子节点入栈后,再次出栈即为当前节点,然后重复上面操作,依次取出栈顶元素即可。 步骤:
Step1:根节点入栈。
Step2:根节点出栈,此时为当前节点,输出或者保存。
Step2.1:若当前节点存在右子节点,则压入栈。
Step2.2:若当前节点存在左子节点,则压入栈。
Step3:重复 Step2,依次取出栈顶元素并输出,栈为空时,则树遍历完成。 【非递归前序遍历代码实现:】
package com.lyh.tree; import java.util.ArrayList;
import java.util.List;
import java.util.Stack; public class BinaryTreeSort<K> {
/**
* 前序遍历(非递归实现、使用栈模拟递归)
*/
public List<K> prefixList(TreeNode9<K> root) {
// 使用集合保存最终结果
List<K> result = new ArrayList<>();
// 根节点不存在时,返回空集合
if (root == null) {
return result;
}
// 使用栈模拟递归
Stack<TreeNode9<K>> stack = new Stack<>();
// 根节点入栈
stack.push(root);
// 栈非空时,依次取出栈顶元素,此时栈顶元素为当前节点,输出,并将当前节点 左、右子节点入栈
// 左子节点 先于 右子节点出栈,所以左子节点在 右子节点入栈之后再入栈
while(!stack.isEmpty()) {
// 取出栈顶元素(当前节点)
TreeNode9<K> tempNode = stack.pop();
// 保存(或者输出)当前节点
result.add(tempNode.data);
// 存在右子节点,则压入栈
if (tempNode.right != null) {
stack.push(tempNode.right);
}
// 存在左子节点,则压入栈
if (tempNode.left != null) {
stack.push(tempNode.left);
}
}
return result;
} public static void main(String[] args) {
// 构建二叉树
TreeNode9<String> root = new TreeNode9<>("0");
TreeNode9<String> treeNode = new TreeNode9<>("1");
TreeNode9<String> treeNode2 = new TreeNode9<>("2");
TreeNode9<String> treeNode3 = new TreeNode9<>("3");
TreeNode9<String> treeNode4 = new TreeNode9<>("4");
root.left = treeNode;
root.right = treeNode2;
treeNode.left = treeNode3;
treeNode.right = treeNode4; // 前序遍历
System.out.print("前序遍历: ");
System.out.println(new BinaryTreeSort<String>().prefixList(root));
System.out.println("\n=====================");
} } class TreeNode9<K> {
K data; // 保存节点数据
TreeNode9<K> left; // 保存节点的 左子节点
TreeNode9<K> right; // 保存节点的 右子节点 public TreeNode9(K data) {
this.data = data;
} @Override
public String toString() {
return "TreeNode9{ data= " + data + "}";
}
} 【输出结果:】
前序遍历: [0, 1, 3, 4, 2]
(4)非递归实现中序遍历
【非递归实现中序遍历:】
中序遍历顺序:左子节点、当前节点、右子节点。
实现思路:
首先明确一点,每次出栈的树节点即为当前需要输出的节点(第一次输出的节点为 最左侧节点)。 由于每次都要先输出当前节点的最左侧节点,所以需要遍历找到这个节点。
而在遍历的过程中,每次经过的树节点均为 父节点,可以使用栈保存起来。
此时,找到并输出最左侧节点后,就可以出栈获得父节点,然后根据父节点可以找到其右子节点。
将右子节点入栈,同理找到其最左子节点,并重复上面操作,依次取出栈顶元素即可。
注:
为了防止重复执行父节点遍历左子节点的操作,可以使用辅助变量记录当前操作的节点。 步骤:
Step1:记当前节点为根节点,从根节点开始,遍历找到最左子节点,并依次将经过的树节点入栈。
Step2:取出栈顶元素,此时为最左子节点(当前节点),输出或者保存。
Step2.1:若存在右子节点,则当前节点为 父节点,将右子节点入栈,并修改新的当前节点为 右子节点。遍历当前节点,同理找到最左子节点,并依次将经过的节点入栈。
Step2.2:若不存在右子节点,则当前节点为 左子节点,下一次取得的栈顶元素即为 父节点。
Step3:重复上面过程,输出顺序即为 左、根、右。 【非递归中序遍历代码实现:】
package com.lyh.tree; import java.util.ArrayList;
import java.util.List;
import java.util.Stack; public class BinaryTreeSort<K> { /**
* 中序遍历(非递归实现,使用栈模拟递归)
*/
public List<K> infixList(TreeNode9<K> root) {
// 使用集合保存遍历结果
List<K> result = new ArrayList<>();
if (root == null) {
return result;
}
// 保存当前节点
TreeNode9<K> currentNode = root;
// 使用栈模拟递归实现
Stack<TreeNode9<K>> stack = new Stack<>();
while(!stack.isEmpty() || currentNode != null) {
// 找到当前节点的左子节点,并依次将经过的节点入栈
while(currentNode != null) {
stack.push(currentNode);
currentNode = currentNode.left;
}
// 取出栈顶元素
TreeNode9<K> tempNode = stack.pop();
// 保存栈顶元素
result.add(tempNode.data);
// 存在右子节点,则右子节点入栈,
if (tempNode.right != null) {
currentNode = tempNode.right;
}
}
return result;
} public static void main(String[] args) {
// 构建二叉树
TreeNode9<String> root = new TreeNode9<>("0");
TreeNode9<String> treeNode = new TreeNode9<>("1");
TreeNode9<String> treeNode2 = new TreeNode9<>("2");
TreeNode9<String> treeNode3 = new TreeNode9<>("3");
TreeNode9<String> treeNode4 = new TreeNode9<>("4");
root.left = treeNode;
root.right = treeNode2;
treeNode.left = treeNode3;
treeNode.right = treeNode4; // 前序遍历
System.out.print("中序遍历: ");
System.out.println(new BinaryTreeSort<String>().infixList(root));
System.out.println("\n=====================");
} } class TreeNode9<K> {
K data; // 保存节点数据
TreeNode9<K> left; // 保存节点的 左子节点
TreeNode9<K> right; // 保存节点的 右子节点 public TreeNode9(K data) {
this.data = data;
} @Override
public String toString() {
return "TreeNode9{ data= " + data + "}";
}
} 【输出结果:】
中序遍历: [3, 1, 4, 0, 2]
(5)非递归实现后序遍历
【非递归实现后序遍历:】
后序遍历顺序:左子节点、右子节点、当前节点。
实现思路:
首先明确一点,每次出栈的树节点即为当前需要输出的节点(第一次输出的节点为最左侧节点)。 这里与 中序遍历还是有点类似的,同样是先输出最左侧节点。区别在于,后序遍历先输出 右子节点,再输出父节点。
同样使用一个变量,用来辅助遍历,防止父节点重复遍历子节点。
此处的变量,可以理解成上一次节点所在位置。而栈顶取出的当前节点为上一次节点的父节点。 步骤:
Step1:根节点入栈。
Step2:取出栈顶元素(当前节点),判断其是否存在子节点。
Step2.1:存在左子节点,且未被访问过,左子节点入栈(此处为遍历找到最左子节点)。
Step2.2:存在右子节点,且未被访问过,右子节点入栈。
Step2.3:不存在 或者 已经访问过 左、右子节点,输出当前节点。
Step3:重复以上操作,直至栈空。 【非递归后序遍历代码实现:】
package com.lyh.tree; import java.util.ArrayList;
import java.util.List;
import java.util.Stack; public class BinaryTreeSort<K> { /**
* 后序遍历(非递归实现,使用栈模拟递归)
*/
public List<K> suffixList(TreeNode9<K> root) {
// 使用集合保存遍历结果
List<K> result = new ArrayList<>();
if (root == null) {
return result;
} // 保存当前节点
TreeNode9<K> currentNode = root;
// 使用栈模拟递归实现
Stack<TreeNode9<K>> stack = new Stack<>();
// 根节点入栈
stack.push(root);
// 依次取出栈顶元素
while(!stack.isEmpty()) {
// 取出栈顶元素
TreeNode9<K> tempNode = stack.peek();
// 若当前节点 左子节点 存在,且未被访问,则入栈
if (tempNode.left != null && currentNode != tempNode.left && currentNode != tempNode.right) {
stack.push(tempNode.left);
} else if (tempNode.right != null && currentNode != tempNode.right){
// 若当前节点 右子节点存在,且未被访问,则入栈
stack.push(tempNode.right);
} else {
// 当前节点不存在 左、右子节点 或者 左、右子节点已被访问,则取出栈顶元素,
// 并标注当前节点位置,表示当前节点已被访问
result.add(stack.pop().data);
currentNode = tempNode;
}
}
return result;
} public static void main(String[] args) {
// 构建二叉树
TreeNode9<String> root = new TreeNode9<>("0");
TreeNode9<String> treeNode = new TreeNode9<>("1");
TreeNode9<String> treeNode2 = new TreeNode9<>("2");
TreeNode9<String> treeNode3 = new TreeNode9<>("3");
TreeNode9<String> treeNode4 = new TreeNode9<>("4");
root.left = treeNode;
root.right = treeNode2;
treeNode.left = treeNode3;
treeNode.right = treeNode4; // 前序遍历
System.out.print("后序遍历: ");
System.out.println(new BinaryTreeSort<String>().suffixList(root));
System.out.println("\n=====================");
} } class TreeNode9<K> {
K data; // 保存节点数据
TreeNode9<K> left; // 保存节点的 左子节点
TreeNode9<K> right; // 保存节点的 右子节点 public TreeNode9(K data) {
this.data = data;
} @Override
public String toString() {
return "TreeNode9{ data= " + data + "}";
}
} 【输出结果:】
后序遍历: [3, 4, 1, 2, 0]
2、多叉树、B树
(1)平衡二叉树可能存在的问题
平衡二叉树虽然效率高,但是当数据量非常大时(数据存放在 数据库 或者 文件中,需要经过磁盘 I/O 操作),此时构建平衡二叉树会消耗大量时间,影响程序执行速度。同时会出现大量的树节点,导致平衡二叉树的高度非常大,此时再去进行查找操作 性能也不是很高。
平衡二叉树中,每个节点有 一个数据项,以及两个子节点,那么能否增加 节点的子节点数 以及 数据项 来提高程序性能呢?从而引出了 多路查找树 的概念。
注:
前面介绍了平衡二叉树,可参考:https://www.cnblogs.com/l-y-h/p/13751459.html#_label5_9
即平衡二叉树只允许每个节点最多出现两个分支,而此处的多路查找树指的是允许出现多个分支(且分支有序)。
(2)多叉树、多路查找树
多叉树 允许每个节点 可以有 两个以上的子节点以及数据项。
多路查找树 即 平衡的多叉树(数据有序)。
常见多路查找树 有:2-3 树、B 树(B-树)、B+树、2-3-4 树 等。
(3)B 树(B-树)
B 树 即 Balanced-tree,简称 B-tree(B 树、B-树是同一个东西),是一种平衡的多路查找树。
树节点的子节点最多的数目称为树的阶。比如:2-3 树的阶为 3。2-3-4 树的阶为 4。
【一颗 M 阶的 B 树特点:(M 阶指的是最大节点的子节点个数)】
每个节点最多有 M 个子节点(子树)。
根节点存在 0 个或者 2 个以上子节点。
非叶子节点 若存在 j 个子节点,那么该非叶子节点保存 j - 1 个数据项,且按照递增顺序存储。
所有的叶子节点均在同一层。
注:
B 树是一个平衡多路查找树,具有与 平衡二叉树 类似的特点,
区别在于 B 树分支更多,从而构建出的树高度低。
当然 B 树也不能无限制的增大 树的阶,阶约大,则非叶子节点保存的数据项越多(变成了一个有序数组,增加查找时间)。
(4)2-3 树
2-3 树是最简单的 B 树,是一颗平衡多路查找树。
其节点可以分为 2 节点、3 节点,且 所有叶子节点均在同一个层。
【2-3 树特点:】
对于 2 节点:
只能包含一个数据项 和 两个子节点(或者没有子节点)。
左子节点值 小于 当前节点值,右子节点值 大于 当前节点值。
不存在只有一个子节点的情况。 对于 3 节点:
包含一大一小两个数据项(从小到大排序) 和 三个子节点(或者没有子节点)。
左子节点值 小于 当前节点数据项最小值,右子节点值 大于 当前节点数据项最大值,中子节点值 在 当前节点数据项值之间。
不存在有 1 子节点、2 个子节点的情况。
根据 {16, 24, 12, 32, 14, 26, 34, 10, 8, 28, 38, 20, 33} 构建的 2-3 树如下:
可使用 https://www.cs.usfca.edu/~galles/visualization/Algorithms.html 构建。
(5)B+ 树
B+ 树是 B 树的变种。
区别在于 B+ 树数据存储在叶子节点,数据最终只能在 叶子节点 中找到,而 B 树可以在 非叶子节点 找到。
B+ 树性能可以等价于 对 全部叶子节点(所有关键字)进行一次 二分查找。
【B+ 树特点:】
所有 数据项(关键字) 均存放于 叶子节点。
每个叶子节点 存放的 数据项(关键字)是有序的。
所有叶子节点使用链表相连(即进行范围查询时,只需要查找到 首尾节点、然后遍历链表 即可)。
注:
所有数据项(关键字) 均存放与 叶子节点组成的链表中,且数据有序,可以视为稠密索引。
非叶子节点 相当于 叶子节点的索引,可以视为 稀疏索引。
根据 {16, 24, 12, 32, 14, 26, 34, 10, 8, 28, 38, 20, 33} 构建的 B+ 树(3阶、2-3 树)如下:
(6)B* 树
B* 树 是 B+ 树的变体。
其在 B+树 基础上,在除 非根节点、非叶子节点 之外的其余节点之间增加指针,提高节点利用率。
【B* 树与 B+ 树 节点分裂的区别:】
对于 B+ 树:
B+ 树 节点的最低使用率是 1/2,其非叶子节点关键字(数据项)个数至少为 (1/2)*M。M 为 B+ 树的阶。
当一个节点存放满时,会增加一个节点,并将原节点 1/2 的数据移动到新的节点,然后在 父节点 添加新的节点。
B+ 树 只影响 原节点 以及 父节点,不会影响兄弟节点,兄弟之间不需要指针。 对于 B* 树:
B* 树 节点的最低使用率为 2/3,其非叶子节点关键字(数据项)个数至少为 (2/3)*M。
当一个节点存放满时,若其下一个兄弟节点未满,则将一部分数据移到兄弟节点中,在原节点 添加新节点,然后修改 父节点 中的节点(兄弟节点发生改变)。
若其下一个兄弟已满,则在 两个兄弟之间 增加一个新节点,并分别从两个兄弟节点中 移动 1/3 的数据到新节点,然后在 父节点 添加新的节点。
B* 树 影响了 兄弟节点,所以需要指针将兄弟节点连接起来。 总的来说,B* 树分配新节点的概率比 B+ 树低,B* 树的节点利用率更高。 注:
相关内容参考:https://blog.csdn.net/wyqwilliam/article/details/82935922
下图不一定正确,大概理解意思就行。
(7)B-树、B+树、B*树总结
【B 树 或者 B- 树:】
平衡的多路查找树,非叶子节点至少存储 (1/2)*M 个关键字(数据项),
关键字升序存储,且仅出现一次,
进行查找匹配操作时,可以在 非叶子节点 成功匹配。 【B+ 树:】
B 树的变种,仅在 叶子节点 保存数据项,且叶子节点之间 通过链表存储。
整体 数据项 有序存储。
非叶子节点 作为 叶子节点 的索引存在,匹配时通过 非叶子节点 快速定位到 叶子节点,然后在 叶子节点 处进行匹配操作,相当于进行 二分查找。 【B* 树:】
B+ 树的变种,给 非叶子节点 也加上指针,非叶子节点 至少存储 (2/3)*M 个关键字。
将节点利用率 从 1/2 提高到 2/3 。
二、延伸一下 MySQL 索引底层数据结构
1、索引(Index)
(1)索引是什么?
索引是一种有序的、快速查找的数据结构。
索引 由 若干个 索引项组成,每个索引项 由 数据的关键字 以及 其相对应的记录(比如:记录对应在磁盘中的 地址信息)组成。
索引的查找,就是根据 索引项中的关键字 去关联 其相应的记录 的过程。
(2)数据库为什么使用索引?
为了提高数据查询效率,数据库在维护数据的同时维护一个满足特定查找算法的数据结构,这个数据结构以某种方式指向数据、或者存储数据的引用,通过这个数据结构实现高级查找算法,这样就可以快速查找数据。
而这种数据结构就是索引。
索引按照结构划分为:线性索引、树形索引、多级索引。
如下图所示数据结构:(树形索引,仅供参考,图片来源于网络)
使用二叉树维护数据的索引值以及数据的物理地址,使用二叉树可以在一定的时间复杂度内查找到数据,然后根据该数据的物理地址找到存储在表中的数据,从而实现快速查找。
2、线性索引(稠密索引、稀疏索引)
(1)什么是线性索引?
线性索引 指的是 将索引项组合成线性结构,也可称为索引表。
常见分类:稠密索引(密集索引)、稀疏索引(分块索引)、倒排索引。
(2)稠密索引(密集索引)
稠密索引 指的线性结构是:每个索引项 对应一个数据集(记录),记录在数据区(磁盘)中可以是无序的,但是所有索引项 是有序的(方便查找)。
但由于每个索引项占用的空间较大,若数据量较大时(每个索引项对应一个记录),占用空间会很大(可能无法一次在内存中读取,需要多次磁盘 I/O,降低查找性能)。
即 占用空间大、查找效率高。
如下图(图片来源于网络):
左边索引表 中的索引项 按照关键码有序,可以使用 二分查找 或者其他高效查找算法,快速定位到对应的索引项,然后找到对应的 记录。
注:
前面介绍的 B+ 树的所有叶子节点可以看成是 稠密索引,其所有叶子节点 由链表连接,且叶子节点有序,可以应用上 稠密索引。
(3)稀疏索引(分块索引)
稠密索引 其每个索引项 对应一个记录,占用空间大。
稀疏索引 指的线性结构是:将数据集按照某种方式 分成若干个数据块,每个索引项 对应一个数据块。每个数据块可以包含多个数据(记录),这些数据之间可以是无序的。但 数据块之间是有序的(索引项有序)。
索引项无需保存 所有记录,只需要记录关键字即可,占用空间小。且索引项有序,可以快速定位到数据块。但是 数据块内没要求是有序的(维护有序序列需要付出一些代价),所以数据块中可能顺序查找(数据量较大时,查找效率较低)。
即 占用空间小、查找效率可能较低。
如下图(图片来源于网络):
左边索引表 按照关键码有序,可以通过 二分查找 等算法快速定位到 数据块,然后在数据块中查找数据。
注:
前面介绍的 B+ 树中 非叶子节点 与 叶子节点 之间可以看成 稀疏索引,非叶子节点 仅保存 叶子节点的索引,叶子节点 保存 数据块。且此时 多个数据块之间 有序、每个数据块 之内也有序。
3、MySQL 索引底层数据结构
(1)底层数据结构
MySQL 底层数据结构,一般回答都是 B+ 树。
那么为什么选择 B+ 树?哈希、二叉树、B树 等结构不可以吗?
(2)为什么不使用 哈希表 作为索引?
【常用快速查找的数据结构有两种:】
哈希表:
比如 HashMap,其查找、添加、删除、修改的平均时间复杂度均为 O(1) 树:
比如 平衡二叉树,其查询、添加、删除、修改的平均时间复杂度均为O(logn) 【什么是哈希表?】
哈希表(Hash table 、散列表),是根据键(Key)直接访问数据(Value)的一种数据结构。
规则:
使用某种方式(映射函数)将键值(Key)映射到数组中的某个位置,并在此位置存放记录,用于加快查询速度。
映射函数 也称为 散列函数,存放记录的数组 称为 散列表。 理解:
使用 散列函数,将 键值(Key)转换为一个 整型数字,
然后再对数字进行转换(取模、与运算等),将其转为 数组对应的下标,并将 value 存储在该下标对应的存储空间中。
而进行查询操作时,再次对 Key 进行运算,转换为对应的数组下标,即可定位并获取 value 值(时间复杂度为 O(1))。 【为什么不使用 哈希表?】
对于 单次写操作或者读操作 来说,哈希的速率比树快,但是为什么不用哈希表呢? 可以想一下如果是排序或者范围查询的情况下,执行哈希是什么情况,很显然,哈希无法很快的进行范围查找(其数据都是无序的),查找范围 0~n 的情况下,会执行 n 次查找,也即时间复杂度为 O(n)。 而树(AVL树、B树、B+树等)是有序的(1、2 次查找即可),其时间复杂度仍可以保证在 O(logn)。 相比较之下,哈希肯定没有树的效率高,因此不会使用哈希这种数据结构作为索引。 【平衡二叉树时间复杂度 O(logn) 怎么来的?】
在树中查找一个数字时,第一次在树的第一层(根节点)判断,第二次在树的第二层判断,依次类推,树有多少层,就会进行多少次判断,即对于 k 层的树,最坏时间复杂度为O(k)。
所以只需要知道 n 个节点的树有多少层即可。 若为满二叉树(除叶子节点外,每个节点均有两个节点),则对于第一层,有一个节点(2^0),对于第二层有两个节点(2^1),依次类推对于第 k 层有 2^k-1(2 的 k-1 次方)。
所以 n = 2^0 + 2^1 + ... + 2^k-1,从而 k = log(n + 1)。
所以时间复杂度为 O(k) = O(logn) k 为树 层数,n 为树 节点数。
(3)为什么不使用二叉查找树(BST)、平衡二叉树(AVL)?
通过上面分析,可以使用树作为 索引(解决了范围、排序等问题),但是树有很多种类,比如:二叉查找树(BST)、平衡二叉树(AVL)、B 树、B+树等。应该选择哪种树作为索引呢?
对于二叉查找树,由于左子节点小于当前节点,右子节点大于当前节点,当一个数据是有序的时候,即数据要么递增,要么递减,此处二叉树出现如下图所示情况,相当于所有节点组成了链式结构,此时时间复杂度从 O(logn) 变为 O(n)。随着数据量增大,n 肯定非常大,这种情况下肯定不可取,舍弃。
二叉查找树可参考:https://www.cnblogs.com/l-y-h/p/13751459.html#_label5_8
为了降低树的高度,引出了 平衡二叉树,其可以动态的维护树的高度,使任意一个节点左右子树高度差绝对值不大于 1。
对于平衡二叉查找树(AVL),新增节点时,会不断的调整节点位置以及树的高度。但随着数据量增大,树的高度也会增大,高度增大导致比较次数增多,若数据 无法一次读取到内存中,则每次比较前都得通过磁盘 IO 读取外存数据,导致磁盘 IO 增大,影响性能。
二叉平衡树可参考:https://www.cnblogs.com/l-y-h/p/13751459.html#_label5_9
通过上面分析,二叉查找树可能出现 只有左子树或者只有右子树的情况,当 数据量过大时,树的高度会变得很高,此时时间复杂度从 O(logn) 变为 O(n),n 为 树的高度。
为了解决这种情况,可以使用平衡二叉查找树,其会在左右子树高度差大于 1 时对树节点进行旋转,保证树之间的高度差,从而解决二叉查找树的问题,但是数据量过大时,树的高度依旧会很大,增大磁盘 IO,影响性能。
所以为了解决树的高度问题,既然 二叉平衡树 不能满足需求,那就采用多叉平衡树,让一个节点保存多个数据(两个以上子树),进一步降低树的高度。从而引出 B 树、B+树。
(4)AVL 树、B树、B+树 举例:
构建树,并按照顺序插入 1 - 10,若查找 10 这个数,需要比较几次?
AVL 树构建如下:
树总高度为 4,而 10 在叶子节点,所以需要比较 4 次。
B 树构建如下:
树高度为 3 ,10 在叶子节点,此时只需要比较 3 次即可。
但对于 AVL,需要比较 4 次,随着数据量增大,B 树 明显比 AVL 高度低。
B+ 树构建如下:
树高度为 4,10 在叶子节点,此时需要比较 4 次。
B+ 树比 B 树更适合范围查找。
(5)为什么不使用 B 树 而使用 B+ 树?
通过上面分析,可以知道 平衡二叉树不能 满足实际的需求(数据量大时,树高度太大,且可能需要与磁盘进行多次 I/O 操作,查询效率低)。
那么 B 树能否满足需求呢?B 树的定义参考前面的分析。
理论上,B 树可以增加 每个节点保存的数据项 以及 节点的子节点数,并达到平衡树的条件,从而降低树的高度。但是不能无限制的 增大,B 树阶越大,那么每个节点 就可能成为 有序数组,则每次查找时效率反而会降低。
在 InnoDB 中,索引是存储元素的,一个表的数据 行数、列数 越多,那么相对应的索引文件就会很大。其不可能一次存放在内存中,需要经过多次磁盘 I/O。所以考虑 数据结构时,需要判断哪种数据结构更适合从磁盘中读取数据,减少磁盘 I/O 次数,从而提高磁盘 I/O 效率。
假定每次读取树的节点 都是 一次 磁盘 I/O,那么树的高度 将是决定 磁盘 I/O 的关键因素。
通过上面 AVL树、B树、B+树 的举例,可以看到 AVL 树由于每个节点只能存储两个元素,数据量大时,树的高度将会很大。
那么 B树、B+树 如何选择呢?
B 树由于 非叶子节点也会存放完整数据,则 B树 每个非叶子节点 存放的 元素总数 受到数据的影响,也即 每个非叶子节点 存放的 元素 较少,从而导致树的高度 也会很大。
B+ 树由于 非叶子节点 不存放完整数据(存放主键 + 指针),其完整数据存放在 叶子节点中,也即 非叶子节点 可以存放 更多的 元素,从而树的高度可以 很低。
通过上面分析,可以知道 B+ 树的高度很低,可以减少磁盘 I/O 的次数,提高执行效率。且 B+ 树所有叶子节点之间通过链表连接,其可以提高范围查询的效率。
所以 一般采用 B+ 树作为索引结构。
(6)总结
使用 B+ 树作为索引结构可以 减少磁盘 I/O 次数,提高查找效率。
B+ 树实际应用场景一般高度为 3(见下面分析,若一条记录为 1 KB,那么高度为 3 的 B+树 可以存储 2000 多万条数据)。
4、局部性原理、磁盘预读、B+树每个节点适合存多少数据
(1)局部性原理 与 磁盘预读
局部性原理 指的是 当一个数据被使用时,那么其附近的数据通常也会被使用。
在 InnoDB 中,数据存储在磁盘上,而直接操作磁盘 I/O 操作会很耗时(比操作内存中的数据慢),降低效率。
为了提高效率、降低磁盘 I/O 次数,在真正处理数据前 先要将数据 从磁盘中读取并加载到 内存中。
若每次只从 磁盘 读一条数据到 内存中,那么效率肯定很低。所以操作系统一般采用 磁盘预读的形式,一次读取 指定长度的数据进入内存(即使不需要使用到这么多数据,局部性原理)。此处指定长度称为 页,是操作系统操作数据的基本单位,操作系统中 页的大小一般为 4KB。
(2)B+树中 一个节点存储多少数据合适?
进行磁盘预读时,将数据划分成若干个页,以 页 作为 磁盘 与 内存 交互的基本单位,InnoDB 默认页大小是 16 KB(类似于操作系统页的定义,若操作系统页大小为 4KB,那么 InnoDB 中 1页 等于 操作系统 4页),即每次最少从磁盘中读取 16KB 数据到内存,最少从 内存写入 16KB 数据到磁盘。
B+ 树每个节点 存放 一页、或者 页的倍数比较合适。(假设每次读取节点均会经过磁盘 I/O)
以一页为例,如果节点存储小于 一页,那么读取这个节点时仍然会读出一页,从而造成资源的浪费。而如果节点存储大于 一页小于二页,那么读取这个节点时将会读出 二页,同样也会造成资源的浪费。所以,一般 B+树 节点存放数据为 一页 或者 页的倍数。
【查看 InnoDB 默认页大小:】
SHOW GLOBAL STATUS like 'Innodb_page_size';
(3)为什么 InnoDB 设置默认页大小为 16KB?而不是 32KB?
【首先明确一点:】
B+ 树 非叶子节点存储的是 主键(关键字)+ 指针(指向叶子节点)。
B+ 树 叶子节点存储的是 数据(真实的数据记录)。
假设每次读取一个节点均会执行一次磁盘 I/O,即每个节点大小为页的大小。 【以节点大小为 16KB 为例:】
假设一行数据大小为 1KB,那么一个叶子节点能保存 16 条记录。
假设非叶子节点主键为 bigint 类型,那么长度为 8B,而指针在 InnoDB 中大小为 6B,即一个非叶子节点能保存 16KB / 14B = 1170 个数据(主键 + 指针)。
那么对于 高度为 2 的 B+树,能存储记录数为: 1170 * 16 = 18720 条。
对于 高度为 3 的 B+树,能存储记录数为:1170 * 1170 * 16 = 21902400 条。 也就是说,若页大小为 16KB,那么高度为 3 的 B+ 树就能支持 2千万的数据存储。
当然若页大小更大,树的高度也会低,但是一般没有必要去修改。 读取一个节点需要经过一次磁盘 I/O,那么根据主键 只需要 1-3 次磁盘 I/O 即可查询到数据,能满足绝大部分需求。
5、MySQL 表存储引擎 MyISAM 与 InnoDB 区别?
(1)MySQL 采用 插件式的表存储引擎 管理数据,基于表而非基于数据库。在 MySQL 5.5 版本前默认使用 MyISAM 为默认存储引擎,在 5.5 版本后采用 InnoDB 作为默认存储引擎。
(2)MyISAM 不支持外键、不支持事务,支持表级锁(即每次操作均会对整个表加锁,不适合高并发操作)。会存储表的总行数,占用表空间小,多用于 读操作多 的场合。只缓存索引但不缓存真实数据。
(3)InnoDB 支持外键、支持事务,支持行级锁。不存储表的总行数,占用表空间大,多用于 写操作多 的场合。缓存索引的同时缓存真实数据,对内存要求较高(内存大小影响性能)。
(4)底层索引实现:
MyISAM 使用 B+树作为 索引结构,但是其 索引文件 与 数据文件是 分开的,其叶子节点 存放的是 数据记录的地址,也即根据索引文件 找到 对应的数据记录的地址后,再去获取相应的数据。
InnoDB 使用 B+树作为 索引结构,但是其 索引文件本身就是 数据文件,其叶子节点 存放的就是 完整的数据记录。InnoDB 必须要有主键,如果没有显示指定,系统会默认选择一个能够唯一标识数据记录的列作为主键,如果不存在这样的键,系统会给表生成一个隐含字段作为主键。
注:
InnoDB 中一般使用 自增的 id 作为主键,每插入一条记录,相当于增加一个节点,如果主键是顺序的,那么直接添加在上一个记录后即可,若当前页满后,在新的页中继续存储。
若主键无序,那么在插入数据的过程中,可能或出现在 所有叶子节点任意位置,若出现在所有叶子节点头部,那么将会导致所有叶子节点均向后移一位,涉及到 页的分裂以及数据的移动,是一种耗时操作、且造成大量内存碎片,影响效率。
6、索引的代价 与 选择
(1)索引的代价:
空间上:
一个索引 对应一颗 B+ 树,树的每个节点都是一个数据页,一个数据页占用大小为 16KB 的存储空间,数据量越大,占用的空间也就越大。
时间上:
索引会根据数据进行排序,当对数据表数据进行 增、删、改 操作时,相应的 B+ 树索引也要去维护,会消耗时间 进行 记录移动、页面分裂、页面回收 等操作,并维护 数据有序。
(2)索引的选择:
索引的选择性:
指的是 不重复索引值(基数)与 表记录总数 的比值(选择性 = 不重复索引值 / 表记录总数)。
范围为 (0, 1],选择性 越大,即不重复索引值 越多,则建立索引的价值越大。
选择性越小,即 重复索引值 越多,那么索引的意义不大。
索引选择:
索引列 类型应尽量小。
主键自增。
三、图
1、图的基本介绍
(1)图是什么?
图用来描述 多对多关系 的一种数据结构。
上一篇介绍了 一对一 的数据结构(比如:单链表、队列、栈等)以及 一对多的数据结构(比如:树),参考链接:https://www.cnblogs.com/l-y-h/p/13751459.html 。
为了解决 多对多 关系,此处引入了 图 这种数据结构。
(2)图的基本概念
图是一种数据结构,其每个节点可以具有 零个 或者 多个相邻的元素。
两个节点之间的连接称为边(edge),节点可称为顶点(vertex)。
从一个节点到另一个节点 所经过的边称为 路径。
即 图由若干个顶点以及 顶点之间的边 组合而成。
图按照边可以分为:
无向图。指的是 顶点之间的连接(边) 没有方向的图。
有向图。指的是 边 有方向的图。
带权图。指的是 边 带有权值的图。
(3)图的表示方式
图的表示形式一般有两种:邻接矩阵(二维数组表示)、邻接表(链表)。
邻接矩阵:
使用 一维数组 记录图中 顶点数据,使用 二维数组 记录图中 顶点之间的相邻关系(边)。对于 n 个顶点的图,使用 n*n 的二维数组记录 边的关系。
邻接表:
使用 数组 + 链表的形式 记录 各顶点 以及顶点之间的 相邻关系(只记录存在的边)。
使用 一维数组 记录图中 顶点数据,使用链表记录 存在的边。
邻接表 与 邻接矩阵区别:
邻接矩阵中 需要为 每个顶点 记录 n 个边,其中很多边不存在(无需记录),造成空间的浪费。
邻接表只 记录存在的边,不会造成空间的浪费。
(4)使用 邻接矩阵 形式构建 无向图:
【构建思路:】
使用 一维数组 记录 图的顶点数据。
使用 二维数组 记录 图的各顶点的联系(边,其中 1 表示存在边,0 表示不存在边)。 【代码实现:】
package com.lyh.chart; import java.util.ArrayList;
import java.util.Arrays;
import java.util.List; /**
* 使用 邻接矩阵 形式构建无向图
*/
public class UndirectedGraph {
private List<String> vertexs; // 用于保存 无向图 的顶点数据(可以使用一维数组)
private int[][] edges; // 用于保存 无向图 中各顶点之间的关系,1 表示两顶点之间存在边,0 表示不存在边
private int numberOfEdges; // 用于记录 无向图中边的个数 /**
* 根据 顶点个数 进行初始化
* @param number 顶点个数
*/
public UndirectedGraph(int number) {
vertexs = new ArrayList<>(number); // 用于记录顶点
edges = new int[number][number]; // 用于记录顶点之间的关系
numberOfEdges = 0; // 用于记录边的个数
} /**
* 添加顶点
* @param vertex 顶点
*/
public void insertVertex(String vertex) {
vertexs.add(vertex);
} /**
* 添加边
* @param row 行
* @param column 列
* @param value 值(1 表示存在边,0表示不存在边)
*/
public void insertEdge(int row, int column, int value) {
edges[row][column] = value; // 设置边
edges[column][row] = value; // 设置边,对称
numberOfEdges++; // 边总数加 1
} /**
* 返回边的总数
* @return 边的总数
*/
public int getNumberOfEdges() {
return numberOfEdges;
} /**
* 返回顶点的总数
* @return 顶点总数
*/
public int getNumberOfVertex() {
return vertexs.size();
} /**
* 返回 下标对应的顶点数据
* @param index 顶点下标
* @return 顶点数据
*/
public String getValueByIndex(int index) {
return vertexs.get(index);
} /**
* 输出邻接矩阵
*/
public void showGraph() {
for (int[] row : edges) {
System.out.println(Arrays.toString(row));
}
} public static void main(String[] args) {
// 初始化无向图
UndirectedGraph undirectedGraph = new UndirectedGraph(5);
// 插入顶点数据
String[] vertexs = new String[]{"A", "B", "C", "D", "E"};
for (String vertex : vertexs) {
undirectedGraph.insertVertex(vertex);
}
// 插入边
undirectedGraph.insertEdge(0, 1, 1); // A-B
undirectedGraph.insertEdge(0, 2, 1); // A-C
undirectedGraph.insertEdge(1, 2, 1); // B-C
undirectedGraph.insertEdge(1, 3, 1); // B-D
undirectedGraph.insertEdge(1, 4, 1); // B-E // 输出
System.out.println("无向图顶点总数为: " + undirectedGraph.getNumberOfVertex());
System.out.println("无向图边总数为: " + undirectedGraph.getNumberOfEdges());
System.out.println("无向图第 3 个顶点为: " + undirectedGraph.getValueByIndex(2));
System.out.println("无向图 邻接矩阵为: ");
undirectedGraph.showGraph();
}
} 【输出结果为:】
无向图顶点总数为: 5
无向图边总数为: 5
无向图第 3 个顶点为: C
无向图 邻接矩阵为:
[0, 1, 1, 0, 0]
[1, 0, 1, 1, 1]
[1, 1, 0, 0, 0]
[0, 1, 0, 0, 0]
[0, 1, 0, 0, 0]
(5)图的遍历方式:
图的遍历,即对顶点的访问,一般遍历顶点有两种策略:DFS、BFS。
DFS 为深度优先遍历,可以联想到 树的 先序、中序、后序 遍历。即 纵向访问 节点。
BFS 为广度优先遍历,可以联想到 树的 顺序(层序)遍历,即 横向分层 访问 节点。
2、深度优先遍历(DFS)
(1)DFS
DFS 指的是 Depth First Search,即 深度优先搜索。
其从一个节点出发,优先访问该节点的第一个邻接节点,并将此邻接节点作为新的节点,继续访问其第一个邻接节点(为了防止重复访问同一节点,可以将节点分为 已访问、未访问 两种状态,若节点已访问,则跳过该节点)。
深度优先搜索是一个递归的过程(可以使用栈模拟递归实现),每次访问当前节点的第一个邻接节点。
(2)DFS 步骤 与 代码实现:
【步骤:】
Step1:访问初始节点 start,标记该节点 start 已访问。
Step2:查找节点 start 的第一个邻接节点 neighbor。
Step2.1:若 neighbor 不存在,则返回 Step1,且从 start 下一个节点继续执行。
Step2.2:若 neighbor 存在,且未被访问,则返回 Step1,且将 neighbor 视为新的 start 执行。
Step2.3:若 neighbor 存在,且已被访问,则返回 Step2,且从 neighbor 下一个节点继续执行。 【举例:】
图的 邻接矩阵表示如下:,图各顶点 按照顺序为 A B C D E。
A B C D E
A [0, 1, 1, 0, 0]
B [1, 0, 1, 1, 1]
C [1, 1, 0, 0, 0]
D [0, 1, 0, 0, 0]
E [0, 1, 0, 0, 0]
注:
1 表示两个顶点间存在边,0 表示不存在边。 则遍历过程如下:(整个过程是纵向的)
Step1:从 A 开始遍历,将 A 标记为 已访问。找到 A 的 第一个邻接节点 B。
Step2:B 未被访问,将 B 视为新的节点开始遍历,将 B 标记为已访问,找到 B 的第一个邻接节点 A。
Step3:A 被访问过,继续查找 B 下一个邻接节点为 C。
Step4:C 未被访问过,将 C 视为新节点开始遍历,将 C 标记为已访问,找到 C 的第一个邻接节点 A。
Step5:A 被访问,继续查找 C 下一个邻接节点为 B,B 也被访问,继续查找,C 没有邻接节点,回退到上一层 B。
Step6:继续查找 B 下一个邻接节点为 D,将 D 标记已访问,同理可知 D 没有 未被访问的邻接顶点,回退到上一层 B。
Step7:查找 B 下一个邻接节点为 E,将 E 标记已访问,至此遍历完成。
即顺序为:A -> B -> C -> D -> E 【代码实现:】
package com.lyh.chart; import java.util.ArrayList;
import java.util.Arrays;
import java.util.List; /**
* 使用 邻接矩阵 形式构建无向图
*/
public class UndirectedGraph {
private List<String> vertexs; // 用于保存 无向图 的顶点数据(可以使用一维数组)
private int[][] edges; // 用于保存 无向图 中各顶点之间的关系,1 表示两顶点之间存在边,0 表示不存在边
private int numberOfEdges; // 用于记录 无向图中边的个数
private boolean[] isVisit; // 用于记录 顶点是否被访问,true 表示已访问 /**
* 根据 顶点个数 进行初始化
* @param number 顶点个数
*/
public UndirectedGraph(int number) {
vertexs = new ArrayList<>(number); // 用于记录顶点
edges = new int[number][number]; // 用于记录顶点之间的关系
numberOfEdges = 0; // 用于记录边的个数
isVisit = new boolean[number]; // 用于记录顶点是否被访问
} /**
* 添加顶点
* @param vertex 顶点
*/
public void insertVertex(String vertex) {
vertexs.add(vertex);
} /**
* 添加边
* @param row 行
* @param column 列
* @param value 值(1 表示存在边,0表示不存在边)
*/
public void insertEdge(int row, int column, int value) {
edges[row][column] = value; // 设置边
edges[column][row] = value; // 设置边,对称
numberOfEdges++; // 边总数加 1
} /**
* 返回边的总数
* @return 边的总数
*/
public int getNumberOfEdges() {
return numberOfEdges;
} /**
* 返回顶点的总数
* @return 顶点总数
*/
public int getNumberOfVertex() {
return vertexs.size();
} /**
* 返回 下标对应的顶点数据
* @param index 顶点下标
* @return 顶点数据
*/
public String getValueByIndex(int index) {
return vertexs.get(index);
} /**
* 输出邻接矩阵
*/
public void showGraph() {
for (int[] row : edges) {
System.out.println(Arrays.toString(row));
}
} /**
* 获取下一个顶点的下标
* @param row 行
* @param column 列
* @return 下一个邻接顶点的下标(-1 表示不存在下一个邻接顶点)
*/
public int getNeighborVertexIndex(int row, int column) {
for (int index = column + 1; index < vertexs.size(); index++) {
if (edges[row][index] != 0) {
return index;
}
}
return -1;
} /**
* 返回当前顶点 的第一个邻接顶点的下标
* @param index 当前顶点下标
* @return 第一个邻接顶点的下标(-1 表示不存在邻接顶点)
*/
public int getFirstVertextIndex(int index) {
return getNeighborVertexIndex(index, -1);
} /**
* 深度优先遍历
*/
public void dfs() {
// 未被访问的顶点,进行深度优先遍历
for (int index = 0; index < vertexs.size(); index++) {
if (!isVisit[index]) {
dfs(index);
}
}
} /**
* 深度优先遍历
* @param index 顶点下标
*/
private void dfs(int index) {
// 输出当前顶点数据
System.out.print(getValueByIndex(index) + " ==> ");
// 标记当前顶点为 已访问
isVisit[index] = true;
// 获取当前顶点第一个邻接顶点下标
int neighborIndex = getFirstVertextIndex(index);
// 当下一个邻接顶点存在时
while(neighborIndex != -1) {
// 若邻接顶点未被访问,则递归遍历
if (!isVisit[neighborIndex]) {
dfs(neighborIndex);
} else {
// 若邻接顶点已被访问,则访问当前邻接顶点的下一个邻接顶点
neighborIndex = getNeighborVertexIndex(index, neighborIndex);
}
}
} public static void main(String[] args) {
// 初始化无向图
UndirectedGraph undirectedGraph = new UndirectedGraph(5);
// 插入顶点数据
String[] vertexs = new String[]{"A", "B", "C", "D", "E"};
for (String vertex : vertexs) {
undirectedGraph.insertVertex(vertex);
}
// 插入边
undirectedGraph.insertEdge(0, 1, 1); // A-B
undirectedGraph.insertEdge(0, 2, 1); // A-C
undirectedGraph.insertEdge(1, 2, 1); // B-C
undirectedGraph.insertEdge(1, 3, 1); // B-D
undirectedGraph.insertEdge(1, 4, 1); // B-E // 输出
System.out.println("无向图顶点总数为: " + undirectedGraph.getNumberOfVertex());
System.out.println("无向图边总数为: " + undirectedGraph.getNumberOfEdges());
System.out.println("无向图第 3 个顶点为: " + undirectedGraph.getValueByIndex(2));
System.out.println("无向图 邻接矩阵为: ");
undirectedGraph.showGraph(); System.out.println("深度优先遍历结果为: ");
undirectedGraph.dfs();
}
} 【输出结果:】
无向图顶点总数为: 5
无向图边总数为: 5
无向图第 3 个顶点为: C
无向图 邻接矩阵为:
[0, 1, 1, 0, 0]
[1, 0, 1, 1, 1]
[1, 1, 0, 0, 0]
[0, 1, 0, 0, 0]
[0, 1, 0, 0, 0]
深度优先遍历结果为:
A ==> B ==> C ==> D ==> E ==>
3、广度优先遍历(BFS)
(1)BFS
BFS 指的是 Broad First Search,即广度优先搜索。
其类似于 分层搜索的过程,依次访问各层 的节点。可以使用队列来记录 访问过的节点的顺序,用于按照该顺序来访问 这些节点的邻接节点。
(2)BFS 步骤、代码实现
【步骤:】
Step1:访问初始节点 start,并标记为 已访问,start 入队列。
Step2:循环取出队列,若队列为空,则结束循环,否则执行下面步骤。
Step3:取得队列头部节点,即为 first,并查找 first 的第一个邻接节点 neighbor。
Step3.1:若 neighbor 不存在,则返回 Step2,再取出队列 新的头节点。
Step3.2:若 neighbor 存在,且未被访问,则将其标记为 已访问并入队列。
Step3.3:若 neighbor 存在,且已被访问,则返回 Step3,并查找 neighbor 的下一个节点。
注:
Step3 将某一层 未访问的节点 入队列,当该层顶点全部被访问时,执行 Step2,
从队列中取出 头部节点,即为 新的层,并开始查找未被访问的节点入队列。 【举例:】
图的 邻接矩阵表示如下:,图各顶点 按照顺序为 A B C D E。
A B C D E
A [0, 1, 1, 0, 0]
B [1, 0, 1, 1, 1]
C [1, 1, 0, 0, 0]
D [0, 1, 0, 0, 0]
E [0, 1, 0, 0, 0]
注:
1 表示两个顶点间存在边,0 表示不存在边。 则遍历过程如下:(整个过程是横向分层的)
Step1:从 A 开始,将其标记为 已访问,将 A 存入队列末尾。
Step2:取出队列头部元素为 A,查找其邻接节点为 B,B 未被访问将其入队列、并标记为 已访问。
Step3:继续查找 A 下一个邻接节点为 C,C 为被访问将其入队列、并标记为 已访问。
Step4:A 层遍历结束,取出队列头部为元素为 B,即开始访问 B 层。
Step5:B 层未被访问的节点 依次入队列并标记为已访问。即 D、E 入队列。
Step6:同理依次取出队列头部元素 C、D、E,直至遍历完成。
即顺序为:A -> B -> C -> D -> E 【代码实现:】
package com.lyh.chart; import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List; /**
* 使用 邻接矩阵 形式构建无向图
*/
public class UndirectedGraph {
private List<String> vertexs; // 用于保存 无向图 的顶点数据(可以使用一维数组)
private int[][] edges; // 用于保存 无向图 中各顶点之间的关系,1 表示两顶点之间存在边,0 表示不存在边
private int numberOfEdges; // 用于记录 无向图中边的个数
private boolean[] isVisit; // 用于记录 顶点是否被访问,true 表示已访问 /**
* 根据 顶点个数 进行初始化
* @param number 顶点个数
*/
public UndirectedGraph(int number) {
vertexs = new ArrayList<>(number); // 用于记录顶点
edges = new int[number][number]; // 用于记录顶点之间的关系
numberOfEdges = 0; // 用于记录边的个数
isVisit = new boolean[number]; // 用于记录顶点是否被访问
} /**
* 添加顶点
* @param vertex 顶点
*/
public void insertVertex(String vertex) {
vertexs.add(vertex);
} /**
* 添加边
* @param row 行
* @param column 列
* @param value 值(1 表示存在边,0表示不存在边)
*/
public void insertEdge(int row, int column, int value) {
edges[row][column] = value; // 设置边
edges[column][row] = value; // 设置边,对称
numberOfEdges++; // 边总数加 1
} /**
* 返回边的总数
* @return 边的总数
*/
public int getNumberOfEdges() {
return numberOfEdges;
} /**
* 返回顶点的总数
* @return 顶点总数
*/
public int getNumberOfVertex() {
return vertexs.size();
} /**
* 返回 下标对应的顶点数据
* @param index 顶点下标
* @return 顶点数据
*/
public String getValueByIndex(int index) {
return vertexs.get(index);
} /**
* 输出邻接矩阵
*/
public void showGraph() {
for (int[] row : edges) {
System.out.println(Arrays.toString(row));
}
} /**
* 获取下一个顶点的下标
* @param row 行
* @param column 列
* @return 下一个邻接顶点的下标(-1 表示不存在下一个邻接顶点)
*/
public int getNeighborVertexIndex(int row, int column) {
for (int index = column + 1; index < vertexs.size(); index++) {
if (edges[row][index] != 0) {
return index;
}
}
return -1;
} /**
* 返回当前顶点 的第一个邻接顶点的下标
* @param index 当前顶点下标
* @return 第一个邻接顶点的下标(-1 表示不存在邻接顶点)
*/
public int getFirstVertextIndex(int index) {
return getNeighborVertexIndex(index, -1);
} /**
* 广度优先遍历
*/
public void bfs() {
// 未被访问的顶点,进行广度优先遍历
for (int index = 0; index < vertexs.size(); index++) {
if (!isVisit[index]) {
bfs(index);
}
}
} /**
* 广度优先遍历
* @param index 顶点下标
*/
private void bfs(int index) {
// 输出当前顶点数据
System.out.print(getValueByIndex(index) + " ==> ");
// 用于记录访问的顶点
LinkedList<Integer> queue = new LinkedList<>();
int firstIndex; // 用于记录队列的头部节点
int neighborIndex; // 用于记录邻接节点
isVisit[index] = true; // 标记当前节点已被访问
queue.addLast(index); // 当前节点入队列
// 队列不空时
while(!queue.isEmpty()) {
// 取出队列头节点
firstIndex = queue.removeFirst();
// 找到邻接节点
neighborIndex = getFirstVertextIndex(index);
while(neighborIndex != -1) {
if(!isVisit[neighborIndex]) {
// 输出当前顶点数据
System.out.print(getValueByIndex(neighborIndex) + " ==> ");
isVisit[neighborIndex] = true;
queue.addLast(neighborIndex);
} else {
neighborIndex = getNeighborVertexIndex(firstIndex, neighborIndex);
}
}
}
} public static void main(String[] args) {
// 初始化无向图
UndirectedGraph undirectedGraph = new UndirectedGraph(5);
// 插入顶点数据
String[] vertexs = new String[]{"A", "B", "C", "D", "E"};
for (String vertex : vertexs) {
undirectedGraph.insertVertex(vertex);
}
// 插入边
undirectedGraph.insertEdge(0, 1, 1); // A-B
undirectedGraph.insertEdge(0, 2, 1); // A-C
undirectedGraph.insertEdge(1, 2, 1); // B-C
undirectedGraph.insertEdge(1, 3, 1); // B-D
undirectedGraph.insertEdge(1, 4, 1); // B-E // 输出
System.out.println("无向图顶点总数为: " + undirectedGraph.getNumberOfVertex());
System.out.println("无向图边总数为: " + undirectedGraph.getNumberOfEdges());
System.out.println("无向图第 3 个顶点为: " + undirectedGraph.getValueByIndex(2));
System.out.println("无向图 邻接矩阵为: ");
undirectedGraph.showGraph(); System.out.println("广度优先遍历结果为: ");
undirectedGraph.bfs();
}
} 【输出结果:】
无向图顶点总数为: 5
无向图边总数为: 5
无向图第 3 个顶点为: C
无向图 邻接矩阵为:
[0, 1, 1, 0, 0]
[1, 0, 1, 1, 1]
[1, 1, 0, 0, 0]
[0, 1, 0, 0, 0]
[0, 1, 0, 0, 0]
广度优先遍历结果为:
A ==> B ==> C ==> D ==> E ==>
四、常用五种算法
1、二分查找算法(递归与非递归)
(1)二分查找
二分查找是一个效率较高的查找方法。其要求必须采用 顺序存储结构 且 存储数据有序。
每次查找数据时 根据 待查找数据 将总数据 分为两部分(一部分小于 待查找数据,一部分大于 待查找数据),设折半次数为 x,则 2^x = n,即折半次数为 x = logn,时间复杂度为 O(logn)。
(2)递归、非递归实现 二分查找
【代码实现:】
package com.lyh.algorithm; /**
* 二分查找、递归 与 非递归 实现
*/
public class BinarySearch {
public static void main(String[] args) {
// 构建升序序列
int[] arrays = new int[]{13, 27, 38, 49, 65, 76, 97};
// 设置待查找数据
int key = 27;
// 递归二分查找
int index = binarySearch(arrays, 0, arrays.length - 1, key);
if (index != -1) {
System.out.println("查找成功,下标为: " + index);
} else {
System.out.println("查找失败");
} // 非递归二分查找
int index2 = binarySearch2(arrays, 0, arrays.length - 1, key);
if (index2 != -1) {
System.out.println("查找成功,下标为: " + index2);
} else {
System.out.println("查找失败");
}
} /**
* 折半查找,返回元素下标(递归查找,数组升序)
* @param arrays 待查找数组
* @param left 最左侧下标
* @param right 最右侧下标
* @param key 待查找数据
* @return 查找失败返回 -1,查找成功返回元素下标 0 ~ n
*/
public static int binarySearch(int[] arrays, int left, int right, int key) {
if (left <= right) {
// 获取中间下标
int middle = (left + right) / 2;
// 查找成功返回数据
if (arrays[middle] == key) {
return middle;
}
// 待查找数据 小于 中间数据,则从 左半部分数据进行查找
if (arrays[middle] > key) {
return binarySearch(arrays, left, middle - 1, key);
}
// 待查找数据 大于 中间数据,则从 右半部分数据进行查找
if (arrays[middle] < key) {
return binarySearch(arrays, middle + 1, right, key);
}
}
return -1;
} /**
* 折半查找,返回元素下标(非递归查找,数组升序)
* @param arrays 待查找数组
* @param left 最左侧下标
* @param right 最右侧下标
* @param key 待查找数据
* @return 查找失败返回 -1,查找成功返回元素下标 0 ~ n
*/
public static int binarySearch2(int[] arrays, int left, int right, int key) {
while(left <= right) {
// 获取中间下标
int middle = (left + right) / 2;
// 查找成功返回数据
if (arrays[middle] == key) {
return middle;
}
// 待查找数据 小于 中间数据,则从 左半部分数据进行查找
if (arrays[middle] > key) {
right = middle - 1;
} else {
// 待查找数据 大于 中间数据,则从 右半部分数据进行查找
left = middle + 1;
}
}
return -1;
}
} 【输出结果:】
查找成功,下标为: 1
查找成功,下标为: 1
2、分治算法(汉诺塔问题)
(1)分治算法:
分治法 简单理解就是 分而治之,其将一个复杂的问题 分成 两个或者 若干个 相同或者 类似的 子问题,子问题 又可进一步划分为 若干个更小的子问题,直至 子问题可以很简单的求解,然后将 所有简单的子问题解合并,即可得到原问题的解。
【分治算法常见实现:】
归并排序、快速排序、汉诺塔问题等。 【分治算法基本步骤:】
Step1:分解,将原问题 分解成 若干个 规模小、相互独立、与原问题类似的 子问题。
Step2:求解,对于简单的子问题直接求解,否则递归求解各个子问题。
Step3:合并,将各个子问题的解合并为原问题的解。
(2)汉诺塔代码实现
【汉诺塔问题:】
有 3 根柱子及 N 个不同大小的穿孔圆盘,盘子可以滑入任意一根柱子。
开始时所有盘子 按照自上而下按升序依次套在第一根柱子上(即每一个盘子只能放在更大的盘子上面)。
将所有盘子 从 第一根柱子上 移动到 第三根柱子上。
移动圆盘时受到以下限制:
每次只能移动一个盘子;
盘子只能从柱子顶端滑出移到下一根柱子;
盘子只能叠在比它大的盘子上。 【汉诺塔分析:】
假设三个柱子分为为:A B C,盘子最开始放置在 A,需要从 A 将其移动到 C。
若只有一个盘子:
直接从 A 移动到 C。 若有 2 个盘子:
则先将第一个盘子从 A 移动到 B,
再将第二个盘子从 A 移动到 C,
最后再将 第一个盘子从 B 移动到 C。 若有 3 个盘子:
先将第 1 个盘子从 A 移动到 C
再将第 2个盘子从 A 移动到 B
再将第 1 个盘子从 C 移动到 B
再将第 3个盘子从 A 移动到 C
再将第 1 个盘子从 B 移动到 A
再将第 2个盘子从 B 移动到 C
最后第 1 个盘子从 A 移动到 C 同理,第 4、5...n 个盘子。
可以将 盘子分为两部分,一部分为 最底下的盘子(最大的盘子),一部分为 其余盘子。
先将其余盘子从 A 移动到 B 柱子上,再将 最大盘子从 A 移动到 C 柱子上,最后将 其余盘子从 B 移动到 C 柱子上。
注:
由于其余盘子数量可能大于 2,所以需要递归进行分治处理。
比如:
3 个盘子,原柱子为 A,目标柱子为 C,可借助中间柱子 B,将其视为 (A, B, C)。
将盘子从上到下分为两部分,第 1、2 个盘子为其余盘子,第 3 个盘子为最大盘子。
若其余盘子数量大于 2,又可以拆分成更小的盘子进行操作。
首先将 其余盘子 从 A 移动到 B,原柱子为 A,目标为 B,可借助中间柱子 C,将其视为 (A, C, B)。
然后将 最大盘子 从 A 移动到 C,直接移动。
最后将 其余盘子 从 B 移动到 C,原柱子为 B,目标为 C,可借助中间柱子 A,将其视为 (B, A, C)。 【代码实现:】
package com.lyh.algorithm; import java.util.ArrayList;
import java.util.List; public class HanoiTower {
public static void main(String[] args) {
// 打印汉诺塔盘子移动过程
hanoiTower(3, "A", "B", "C");
System.out.println("===========================\n"); // 打印汉诺塔结果
List<Integer> origin = new ArrayList<>();
origin.add(2);
origin.add(1);
origin.add(0);
List<Integer> middle = new ArrayList<>();
List<Integer> target = new ArrayList<>();
System.out.println("原汉诺塔为: ");
System.out.println("原柱子: " + origin);
System.out.println("中间柱子: " + middle);
System.out.println("目标柱子: " + target);
hanoiTower(origin, middle, target);
System.out.println("移动后的汉诺塔为: ");
System.out.println("原柱子: " + origin);
System.out.println("中间柱子: " + middle);
System.out.println("目标柱子: " + target);
} /**
* 汉诺塔问题,将盘子从原始柱子 A 移动到 目标柱子 C,可借助中间柱子 B。
* 打印出移动流程。
* @param num 盘子总数
* @param a A 原始柱子
* @param b B 中间柱子
* @param c C 目标柱子
*/
public static void hanoiTower(int num, String a, String b, String c) {
// 只剩 1 个盘子时,直接从 A 移动到 C
if (num == 1) {
System.out.println("第 1 个盘子从 " + a + " 移动到 " + c);
return;
}
// 盘子数 大于 2 时,将盘子视为两个部分,一个为 最大的盘子,另一个为 其余盘子
// 先将其余盘子 移动到中间柱子上,即从 A 移动到 B,移动过程中使用到 C,此时 原始柱子为 A,目标柱子为 B,中间柱子为 C
hanoiTower(num - 1, a, c, b);
// 再将最大的盘子从 A 移动到 C
System.out.println("第 " + num + "个盘子从 " + a + " 移动到 " + c);
// 最后将其余盘子 从中间柱子移动到目标柱子上,即从 B 移动到 C,移动过程中使用到 A,此时 原始柱子为 B,目标柱子为 C,中间柱子为 A
hanoiTower(num - 1, b, a, c);
} /**
* 将盘子从 origin 移动到 target
* @param origin 原始柱子
* @param middle 中间柱子
* @param target 目标柱子
*/
public static void hanoiTower(List<Integer> origin, List<Integer> middle, List<Integer> target) {
move(origin.size(), origin, middle, target);
} private static void move(int num, List<Integer> origin, List<Integer> middle, List<Integer> target) {
// 只剩 1 个盘子时,直接从原始柱子 origin 移动到目标柱子 target
if (num == 1) {
target.add(origin.remove(origin.size() - 1));
return;
}
// 将 其余盘子从原始柱子 origin 移动到中间柱子 middle 上,此时可将 target 视为中间柱子
move(num - 1, origin, target, middle);
// 将 最大盘子从原始柱子 origin 直接移动到 目标柱子 target 上。
target.add(origin.remove(origin.size() - 1));
// 将 其余盘子从中间柱子 middle 移动到目标柱子 target 上,此时可将 origin 视为中间柱子
move(num - 1, middle, origin, target);
} /**
* 取巧思路,非算法实现(博大家一笑)
* @param origin 原柱子
* @param middle 中间柱子
* @param target 目标柱子
*/
public static void hanoiTower2(List<Integer> origin, List<Integer> middle, List<Integer> target) {
target.addAll(origin);
origin.clear();
}
} 【输出结果:】
第 1 个盘子从 A 移动到 C
第 2个盘子从 A 移动到 B
第 1 个盘子从 C 移动到 B
第 3个盘子从 A 移动到 C
第 1 个盘子从 B 移动到 A
第 2个盘子从 B 移动到 C
第 1 个盘子从 A 移动到 C
=========================== 原汉诺塔为:
原柱子: [2, 1, 0]
中间柱子: []
目标柱子: []
移动后的汉诺塔为:
原柱子: []
中间柱子: []
目标柱子: [2, 1, 0]
3、KMP 算法(字符串匹配问题)
(1)字符串匹配问题
现有一个目标字符串 A,一个待匹配字符串(模式串) B,如何判断 A 中是否存在 B 字符串?
一般方法有两种:
暴力匹配:一个一个字符进行匹配。
KMP 算法:改进的字符串匹配。核心是 跳过无用匹配,减少匹配次数。
(2)暴力匹配
【思路:】
假设当前目标字符串 A 已经匹配到了 i 位置,待匹配字符串 B 已匹配到了 j 位置。
则
如果字符匹配成功,即 A[i] == B[j],则进行下一个字符匹配,即 i++,j++。
如果字符匹配失败,即 A[i] != B[j],则进行回溯,回到 A 开始匹配字符,并对下一个字符重新进行匹配,即 i = i - j + 1, j = 0。
注:
使用暴力匹配可能会 执行大量的回溯过程,造成时间的浪费。
时间复杂度为 O(n*m),n 为目标字符串长度,m 为模式串长度。 【举例:】
现有目标字符串 ABDABC,待匹配字符串为 ABC。
第一次匹配:
ABDABC
ABC
匹配失败。 第二次匹配:
ABDABC
ABC
匹配失败。 第三次匹配:
ABDABC
ABC
匹配失败。 同理挨个字符向后匹配。 【代码实现:】
package com.lyh.algorithm; /**
* 字符串匹配
*/
public class StringMatch {
public static void main(String[] args) {
String target = "BBC ABCDAB ABCDABCDABDE";
String current = "ABCDABD";
System.out.println("直接调用 String 的 contains 方法结果为: " + contains(target, current));
System.out.println("暴力匹配,子字符串第一次出现的下标为: " + forceMatch(target, current));
} /**
* 暴力匹配
* @param a 目标字符串
* @param b 待匹配字符串
* @return 匹配失败返回 -1,否则返回第一次出现的下标
*/
public static int forceMatch(String a, String b) {
char[] target = a.toCharArray(); // 将 目标字符串 转为 字符数组
char[] current = b.toCharArray(); // 将 待匹配字符串 转为 字符数组 // i 用于记录 目标字符串 当前匹配的下标,j 用于记录 待匹配字符串 当前匹配的下标
int i = 0, j = 0;
// 挨个字符进行匹配
while (i < target.length && j < current.length) {
// 匹配成功,则匹配下一个字符
if (target[i] == current[j]) {
i++;
j++;
} else {
// 匹配失败,回退到开始字符的下一个位置重新进行匹配
i = i - j + 1;
j = 0;
}
} // 当 j 为待匹配字符串长度时,匹配成功
if (j == current.length) {
return i - j;
}
return -1;
} /**
* 一个取巧的方法,直接调用 String 的 contains 方法进行匹配(无法返回第一次出现的位置)
* @param target 目标字符串
* @param current 待匹配字符串
* @return 匹配成功返回 true
*/
public static boolean contains(String target, String current) {
return target.contains(current);
}
} 【输出结果:】
直接调用 String 的 contains 方法结果为: true
暴力匹配,子字符串第一次出现的下标为: 15
(3)KMP 算法
KMP 指的是 Knuth-Morris-Pratt,三个人名拼接而成,用于在一个文本串 S 中 查找一个模式串 P 出现的位置。
暴力匹配 是 挨个匹配 字符,而 KMP 是利用现有模式串信息,跳过一些无用的匹配操作,省去大量回溯时间。即暴力匹配过程中,若匹配失败,主串会发生回溯。而 KMP 算法匹配失败,主串不发生回溯,移动模式串去匹配。
【KMP 核心:】
KMP 核心是通过 现有的模式串,跳过无用的匹配,减少回溯次数,从而提高匹配效率。
KMP 算法为了减少无用匹配,根据模式串前后缀关系,将模式串从前缀位置移动到与其相同的后缀位置,从而跳过一些无用匹配。
并根据前后缀关系,得到一个 next 数组,用于记录模式串下标 i 匹配失败后,应该从 next[i] 处与当前主串重新开始比较。
next[i] 存储的是前 i 个字符(即 0 ~ i - 1)组成的字符串中 最大相等前后缀的长度值。
相当于将模式串向后移动 i - next[i] 个位置(使前缀移动到后缀处),并与主串进行比较。 【推导过程:】
如何减少无用匹配(以主串:ABABABAA,模式串:ABAA 为例):
第一次匹配时:
ABABABAA
ABAA
发现 ABAB ABAA 前三个字符是一致的(ABA),
暴力匹配时,主串会回溯到开始字符的下一个位置进行比较,如下:
ABABABAA
ABAA
此时不匹配,继续匹配
ABABABAA
ABAA
可以发现,又出现了 ABAB ABAA 的情况,如果继续暴力处理,那么会多出很多无用操作。
那么能否根据现有的 模式串,作出一些处理,从而跳过一些无用的操作呢? 这里先介绍一下 字符串前缀、后缀(以字符串 ABAA 为例):
前缀:{A, AB, ABA},含头不含尾的子字符串。
后缀:{BAA, AA, A},含尾不含头的子字符串。 通过观察 ABAB ABAA 前三个字符 ABA,可以发现
ABA 前缀为 {A, AB},后缀为 {BA, A},其最大公共串为 {A},
而在暴力匹配时可以发现 ABA 的相同前缀、后缀 中间的匹配操作 均为无用操作。
即下面匹配操作是无用操作,可以直接跳过。
ABABABAA
ABAA
即可以直接将 模式串 从前缀处 移动到 相同后缀处,并重新开始比较(划重点)。 那么能否 根据 模式串的 前、后缀 总结出一个规律呢?(以 ABAA 为例)
将 ABAA 按照位置关系视为 0 ~ 3 位(此处使用),当然也可以视为 1 ~ 4 位。
即
0 1 2 3 或者 0 1 2 3 4
A B A A A B A A 进行匹配时,若模式串第 0 位就匹配失败,则模式串移动到 当前主串位置的下一个位置开始匹配:
即
主串: C B A A A
模式串: A B A A
模式串第 0 位匹配失败后,下一次匹配时 模式串的第 0 位 从当前主串位置的下一个位置开始(当前主串位置下标为 0):
主串: C B A A A
模式串: A B A A 进行匹配时,若模式串第 1 位匹配失败,且此时模式串 第 0 位(A)没有 前缀、后缀,则模式串移动到 当前主串位置开始匹配。
即
主串: A C A A A
模式串: A B A A
模式串第 1 位匹配失败后,下一次匹配时 模式串的第 0 位 从当前主串位置开始(当前主串下标为 1)比较。
主串: A C A A A
模式串: A B A A 进行匹配时,若模式串第 2 位匹配失败,其此时模式串 第 0 ~ 1 位(AB)的 前缀、后缀 中没有最大公共串,则模式串移动到 当前主串位置开始匹配。
即
主串: A B C A A
模式串:A B A A
模式串第 2 位匹配失败后,下一次匹配时 模式串的第 0 位 从当前主串位置开始(当前主串下标为 2)比较。
主串: A B C A A
模式串: A B A A 进行匹配时,若模式串第 3 位匹配失败,其此时模式串 第 0 ~ 2 位(ABA)的前缀、后缀 最大公共串为 1(A),则模式串移动到 当前主串位置开始匹配。
即
主串: A B A C A
模式串: A B A A
模式串第 3 位匹配失败后,下一次匹配时 模式串的第 1 位 从当前主串位置开始(当前主串下标为 3)比较。
主串: A B A C A
模式串: A B A A 通过上面对模式串 ABAA 的分析,
当模式串第 0 位匹配失败时,模式串需要移动 1 位,且主串需要向后移动 1 位。使得模式串下一次第 0 位 与 主串当前位置下一个位置开始比较,记此时为 -1。
当模式串第 1 位匹配失败时,模式串前 0 位没有最大公共前、后缀串,此时模式串向后移动 1 位,主串不移动。使得模式串下一次第 0 位 与 主串当前位置开始比较,记此时为 0。
当模式串第 2 位匹配失败时,模式串前 1 位没有最大公共前、后缀串,此时模式串向后移动 2 位,主串不移动。使得模式串下一次第 0 位 与 主串当前位置开始比较,记此时为 0。
当模式串第 3 位匹配失败时,模式串前 2 位存在最大公共串且长度为 1,此时模式串向后移动 2 位,主串不移动。使得模式串下一次第 1 位 与 主串当前位置开始比较,记此时为 1。
可以得到一个规律,
模式串第 i 为匹配失败时,模式串下一次匹配从 第 j 位开始与主串比较。
第 j 位指的是 模式串 第 0 ~ i-1 串的 最大前、后缀长度。
而模式串需要移动的位数则为: i - j,即将模式串前缀 移动到 最长公共 后缀处。 也即 KMP 中的 next 数组(不同人定义的 next 可能有些许区别,但大体操作类似),
next 数组元素表示 当前模式串下标 匹配失败后,下一次模式串中 需要与 主串进行匹配的 下标位置(即最长公共前、后缀的前缀的下一个位置)。
而模式串需要移动的位数则为: i - next[i]。 所以可以得到 ABAA 的 next 数组如下:
0 1 2 3
模式串: A B A A
next数组: -1 0 0 1
移动位数: 1 1 2 2 如果将 ABAA 视为 1~4 位,则每次加 1 即可,即 第 j 位指的是 模式串 第 0 ~ i-1 串的 最大前、后缀长度 加 1。
0 1 2 3 4
模式串: A B A A
next数组: 0 1 1 2
移动位数: 1 1 2 2
(4)KMP 算法的 next 数组代码实现(模式串自匹配)
【如何使用代码实现 KMP 的 next 数组:】
通过上面分析, next 数组的求解是 KMP 关键之一,那么如何求解呢?
注意,此处的 next 每个元素表示的是 模式串当前元素下标 与 主串 匹配失败后,下一次 模式串 开始匹配的下标值。
即 next[j] 表示 模式串下标为 j 的元素 与 主串匹配失败后,下一次模式串 开始匹配的下标位置(即 0 ~ j-1 表示的 字符串中 最长公共前、后缀的 前缀的下一个位置,也即最长前后缀长度)。
可参考上述推导过程推理 next 的计算。 假设现在模式串 str 长度为 n, i 表示当前模式串下标(0 ~ n-1),j 表示最大前缀的下一个位置的下标。
由于模式串第 0 位前面没有字符,此处设定为 next[0] = -1。
而模式串第 1 位前面有一个字符,但是没有前、后缀,此处设定为 next[1]= 0。
所以 模式串只需从第 2 位开始计算。记初始 i = 2, j = 0。(i 永远比 j 大) 强调一下:
此处 next[i] 指的是 前 i 个字符(即 0 ~ i - 1 所表示的字符串)中最大相等前后缀长度。
i - 1 表示当前最大相等后缀的下一个位置,j 表示当前最大相等前缀的下一个位置。
也就是说前 j 个(0 ~ j-1)字符是当前最大的相等前缀。
而 下标为 j 与 i - 1 所在字符 分别表示下一次需要比较的前缀 与 后缀,即比较 str[j] 与 str[i - 1] 是否相等即可。
若 str[j] == str[i - 1],那么最大相等前后缀长度又可以增加一位,此时 j++,i++。
若 str[j] != str[i - 1],则说明当前 0 ~ j 所表示的前缀 与 (i - j - 1) ~ (i - 1) 所表示的后缀不匹配,则需要从 0 ~ (j - 1) 所表示的前缀中 查找最大的前后缀 重新与 (i - j ) ~ (i - 1) 所表示的后缀匹配。
而 0 ~ (j - 1) 中最大前后缀长度即 next[j] 的值,所以此时 j = next[j]。若 j 仍然不匹配,则继续调用 j = next[j] 进行回退,直至 j 退回到模式串第 0 个位置。 注:
由于 next[0] 不存在前 0 位字符串,所以定义其为 -1,表示当前模式串第 0 位与主串当前位置下一位开始比较。
i 如果从 1 开始定义,则 j 初始值可以设置为 -1。i 为 1 时,下标为 0 的字符为待匹配的后缀,但是不存在前缀,可以假定前面有一位待匹配的前缀,假定下标为 -1。
i 如果从 2 开始定义,则 j 初始值可以设置为 0。i 为 2 时,下标为 1 的字符为待匹配的后缀,下标为 0 为待匹配的前缀。 【代码实现为:(next 数组第一种写法)】
/**
* next 数组第一种写法。
* KMP 实质上是根据字符串前后缀的特点,将前缀字符移动到后缀位置,经过一系列推导根据 最大相等前后缀长度 得到一个 next 数组。
*
* next 数组存储的是 当前模式串匹配失败后,下一次与主串匹配的模式串的下标值(也可以理解为 模式串根据前后缀关系需要移动的位置)。
* 即 next[j] 表示的是 模式串中下标为 j 的元素与主串匹配失败后,下一次从 模式串中下标为 next[j] 的元素开始与 主串匹配。
* 也即相当于将 模式串移动 j - next[j] 个位置,然后重新与主串进行匹配。
* 而 next[j] 实际存储的 模式串 前 j 个字符 最大的相等的 前后缀的长度。
*
* 比如:
* 模式串为 ABAA
* 则其 next[3] 存储的是 ABA 的最大相等前后缀的长度。即 next[3] = 1.
*
* 注(以 ABAA 为例):
* 字符串前缀为其 含头不含尾的 子字符串。比如:ABA 前缀为 {A, AB}.
* 字符串后缀为其 含尾不含头的 子字符串。比如:ABA 前缀为 {BA, A}.
* 记 next[0] = -1。next[1] = 0,均表示模式串向后挪一个位置(j - next[j])。
* next[0] 表示前 0 位字符串(不存在),记为 -1.
* next[1] 表示前 1 位字符串(即 A 没有前后缀),记为 0.
* next[2] 表示前 2 位字符串(即 AB,没有最大相等的前后缀),记为 0.
* next[3] 表示前 3 位字符串(即 ABA,最大相等前后缀为 A,长度为 1),记为 1.
*
* @param pattern 模式串
* @return next 数组
*/
private static int[] getNext(String pattern) {
// 用于保存 next 数组
int[] next = new int[pattern.length()];
// 规定模式串第 0 位匹配失败后,下一次模式串第 0 位与 主串当前位置下一个位置 进行匹配,记此时为 -1
next[0] = -1;
// 模式串第 1 位匹配失败后,下一次模式串第 0 位与 主串当前位置进行匹配,记此时为 0
next[1] = 0;
// 遍历模式串,依次得到 模式串 第 i 位匹配失败后,下一次模式串需要从第 next[i] 位开始与主串进行匹配
// i 表示模式串当前下标,j 表示模式串最大相等前后缀的 前缀的下一个位置的下标
// i >= 2 时,模式串前 2 个字符才存在 前后缀,此时 next 求解才有意义
for (int i = 2, j = 0; i < pattern.length(); i++) {
// 模式串自匹配,为了求解 next[i],需在模式串前 i 个元素中找到 最大前后缀
// j 表示的是当前最大前缀的下标,i-1 表示的是最大后缀的下标,若两者所在字符不匹配,则需要找到更小的前缀进行匹配,也即 j 需要回退
// 而 j 所在下标表示 0 ~ j-1 之前属于最长前缀,现在需要从 0~j-1 中找到新的最长前缀,而 next[j] 保存的正好是该值。
// 所以在此推出 j = next[j]
while(j > 0 && pattern.charAt(i - 1) != pattern.charAt(j)) {
j = next[j];
}
// 如果匹配,则说明当前最大前缀又可以增加一位,j 表示最长前缀的下一个位置(也即前缀长度),即 j++
if (pattern.charAt(i - 1) == pattern.charAt(j)) {
j++;
}
// 保存模式串下标为 i 匹配失败后,下一次从模式串开始匹配的下标位置
next[i] = j;
}
return next;
} 【举例:】
A B C D A B C E F
next[0] = -1,
next[1] = 1.
i = 2 时,j = 0,此时 str[i - 1] != str[j],即 next[2] = 0.
i = 3 时,j = 0,此时 str[i - 1] != str[j],即 next[3] = 0.
i = 4 时,j = 0,此时 str[i - 1] != str[j],即 next[4] = 0.
i = 5 时,j = 0,此时 str[i - 1] == str[j],则 j++,即 next[5] = 1.
i = 6 时,j = 1,此时 str[i - 1] == str[j],则 j++,即 next[6] = 2.
i = 7 时,j = 2,此时 str[i - 1] == str[j],则 j++,即 next[7] = 3.
i = 8 时,j = 3,此时 str[i - 1] != str[j],则 j = next[j] = 0 即 next[8] = 0. 【代码实现为:(next 数组第二种写法)】
/**
* next 数组第二种写法。
* 上面第一种解法是 每次计算 前 i 个字符(i 从 2 开始, j 从 0 开始)的模式串的最大相等前后缀,并将最大前缀下标的下一个位置赋值给 next[i]。
* 而第二种解法是,每次计算 前 i + 1 个字符(i 从 1 开始,j 从 -1 开始)的模式串的最大相等前后缀,并将其值赋值给 next[i+1]。
* 虽然写法稍有不同,但是原理都是类似的。
* @param pattern 模式串
* @return next 数组
*/
private static int[] getNext2(String pattern) {
// 用于保存 next 数组
int[] next = new int[pattern.length()];
// 规定模式串第 0 位匹配失败后,下一次模式串第 0 位与 主串当前位置下一个位置 进行匹配,记此时为 -1
next[0] = -1;
// i 表示模式串下标, j 表示最长前缀下一个位置的下标
int i = 0, j = -1;
// 遍历求解 next 数组
while(i < pattern.length() - 1) {
// 计算出模式串以当前下标为后缀的 最大相等前后缀长度,并将其值赋给 下一个 next。
// 也即相当于 next[i] 保存的时 前 i 个字符(0 ~ i-1) 的最大前后缀长度
if (j == -1 || pattern.charAt(i) == pattern.charAt(j)) {
next[++i] = ++j;
} else {
j = next[j];
}
}
return next;
}
(5)改进的 KMP 算法(改进 next 数组求解)
【改进的 next 数组求解:】
next 数组求解优化,即改进的 KMP 算法,
与 第二个求解 next 数组方式类似,区别在于 j 回退位置。 比如:
主串 ABACABAA
模式串 ABAB
由于模式串最后一个字符匹配失败,按照之前的 next 数组求法,得到 ABAA 的 next 数组为 [-1, 0, 0, 1]。
则此时下一次匹配如下:
主串 ABACABAA
模式串 ABAB
可以很明显的看到此时的匹配无效,与上次匹配失败的字符相同。
此时应该直接一步到位,省去这次无用匹配。也即 模式串下标为 j 的字符回退时 若遇到相同的 字符,应该继续回退。
即第一次匹配失败后,直接进行如下匹配:
主串 ABACABAA
模式串 ABAB 即 模式串下标为 j 与 next[j] 字符相同时,应该继续求解 next[next[j]] 的值。
比如:
模式串: ABAB,可以得到 next 数组为 [-1, 0, 0, 1]
下标为 0 匹配失败时,初值为 -1,固定不变。
下标为 1 匹配失败时,下标为 1 的字符为 B、下标为 next[1] 的字符为 A,字符并不同,所以 next[1] = 0,与原 next 求解相同。
下标为 2 匹配失败时,下标为 2 的字符为 A、下标为 next[2] 的字符为 A,字符相同,所以 next[2] = next[next[2]] = next[0] = -1.
下标为 3 匹配失败时,下标为 3 的字符为 B,下标为 next[3] 的字符为 B,字符相同,所以 next[3] = next[next[3]] = next[1] = 0.
即改进后的 ABAB 得到的 next 数组为 [-1, 0, -1, 0]。 此时再次匹配:
主串 ABACABAA
模式串 ABAB
则下一次匹配为(跳过了无用的匹配):
主串 ABACABAA
模式串 ABAB 【代码实现:】
/**
* next 数组求解优化,即改进的 KMP 算法,
* 与 第二个求解 next 数组方式类似,区别在于 j 回退位置。
* 比如:
* 主串 ABACABAA
* 模式串 ABAB
* 由于模式串最后一个字符匹配失败,按照之前的 next 数组求法,得到 ABAA 的 next 数组为 [-1, 0, 0, 1]。
* 则此时下一次匹配如下:
* 主串 ABACABAA
* 模式串 ABAB
* 可以很明显的看到此时的匹配无效,与上次匹配失败的字符相同。
* 此时应该直接一步到位,省去这次无用匹配。也即 模式串下标为 j 的字符回退时 若遇到相同的 字符,应该继续回退。
*
* 即 模式串下标为 j 与 next[j] 字符相同时,应该继续求解 next[next[j]] 的值。
* 比如:
* 模式串: ABAB,可以得到 next 数组为 [-1, 0, 0, 1]
* 下标为 0 匹配失败时,初值为 -1,固定不变。
* 下标为 1 匹配失败时,下标为 1 的字符为 B、下标为 next[1] 的字符为 A,字符并不同,所以 next[1] = 0,与原 next 求解相同。
* 下标为 2 匹配失败时,下标为 2 的字符为 A、下标为 next[2] 的字符为 A,字符相同,所以 next[2] = next[next[2]] = next[0] = -1.
* 下标为 3 匹配失败时,下标为 3 的字符为 B,下标为 next[3] 的字符为 B,字符相同,所以 next[3] = next[next[3]] = next[1] = 0.
* 即改进后的 ABAB 得到的 next 数组为 [-1, 0, -1, 0]。
*
* 此时再次匹配:
* 主串 ABACABAA
* 模式串 ABAB
* 则下一次匹配为(跳过了无用的匹配):
* 主串 ABACABAA
* 模式串 ABAB
*
* @param pattern 模式串
* @return next 数组
*/
private static int[] getNext3(String pattern) {
int[] next = new int[pattern.length()];
next[0] = -1;
int i = 0, j = -1;
while(i < pattern.length() - 1) {
if (j == -1 || pattern.charAt(i) == pattern.charAt(j)) {
// next[++i] = ++j;
if (pattern.charAt(++i) == pattern.charAt(++j)) {
next[i] = next[j];
} else {
next[i] = j;
}
} else {
j = next[j];
}
}
return next;
}
(6)完整的 KMP 算法如下:
包括两种求解 next 数组的方式,以及 next 数组优化后的方式,以及 kmp 根据 next 数组进行匹配。
【代码实现:】
package com.lyh.algorithm; import java.util.Arrays; /**
* 字符串匹配
*/
public class StringMatch2 {
public static void main(String[] args) {
String target = "BBC ABCDAB ABCDABCDABDE";
String pattern = "ABCDABD";
System.out.println(Arrays.toString(getNext("ABAB")));
System.out.println(Arrays.toString(getNext("ABCDABCEF"))); System.out.println(Arrays.toString(getNext2("ABAB")));
System.out.println(Arrays.toString(getNext2("ABCDABCDEF"))); System.out.println(Arrays.toString(getNext3("ABAB")));
System.out.println(Arrays.toString(getNext3("ABCDABCDEF")));
System.out.println(kmp(target, pattern));
} /**
* KMP 算法,根据模式串生成 next 数组,减少无用匹配次数。
* @param target 主串
* @param pattern 模式串
* @return 匹配失败返回 -1,否则返回相应的下标
*/
public static int kmp(String target, String pattern) {
// 获取 next 数组,用于模式串匹配失败后,下一次与主串匹配的位置。
int[] next = getNext(pattern);
int i = 0, j = 0;
// 开始匹配
// i 表示主串所处下标,j 表示模式串所处下标(j 同时也表示的是最长前缀的下一个位置)
while (i < target.length() && j < pattern.length()) {
// j == -1 表示下一次模式串 第 -1 位 与 当前主串位置进行比较,也即下一次 为模式串第 0 位 与 当前主串位置下一位置进行比较, 所以 i++,j++
// target.charAt(i) == pattern.charAt(j) 表示模式串下标为 j 的字符与 主串匹配,也即当前 模式串 0~j 均可以作为最长前缀。
// 也即下一次 模式串从下标为 j + 1 处 与 主串下一个位置开始比较,所以 i++,j++。
if (j == -1 || target.charAt(i) == pattern.charAt(j)) {
i++;
j++;
} else {
// 此时属于 target.charAt(i) != pattern.charAt(j) 的情况,模式串需要进行回退。
// next[j] 表示的是模式串下标为 j 的字符匹配失败后,下一次模式串中 应该与 主串当前位置进行 匹配的字符下标
j = next[j];
}
}
// 匹配成功
if (j == pattern.length()) {
return i - j;
}
// 匹配失败
return -1;
} /**
* next 数组第一种写法。
* KMP 实质上是根据字符串前后缀的特点,将前缀字符移动到后缀位置,经过一系列推导根据 最大相等前后缀长度 得到一个 next 数组。
*
* next 数组存储的是 当前模式串匹配失败后,下一次与主串匹配的模式串的下标值(也可以理解为 模式串根据前后缀关系需要移动的位置)。
* 即 next[j] 表示的是 模式串中下标为 j 的元素与主串匹配失败后,下一次从 模式串中下标为 next[j] 的元素开始与 主串匹配。
* 也即相当于将 模式串移动 j - next[j] 个位置,然后重新与主串进行匹配。
* 而 next[j] 实际存储的 模式串 前 j 个字符 最大的相等的 前后缀的长度。
*
* 比如:
* 模式串为 ABAA
* 则其 next[3] 存储的是 ABA 的最大相等前后缀的长度。即 next[3] = 1.
*
* 注(以 ABAA 为例):
* 字符串前缀为其 含头不含尾的 子字符串。比如:ABA 前缀为 {A, AB}.
* 字符串后缀为其 含尾不含头的 子字符串。比如:ABA 前缀为 {BA, A}.
* 记 next[0] = -1。next[1] = 0,均表示模式串向后挪一个位置(j - next[j])。
* next[0] 表示前 0 位字符串(不存在),记为 -1.
* next[1] 表示前 1 位字符串(即 A 没有前后缀),记为 0.
* next[2] 表示前 2 位字符串(即 AB,没有最大相等的前后缀),记为 0.
* next[3] 表示前 3 位字符串(即 ABA,最大相等前后缀为 A,长度为 1),记为 1.
*
* @param pattern 模式串
* @return next 数组
*/
private static int[] getNext(String pattern) {
// 用于保存 next 数组
int[] next = new int[pattern.length()];
// 规定模式串第 0 位匹配失败后,下一次模式串第 0 位与 主串当前位置下一个位置 进行匹配,记此时为 -1
next[0] = -1;
// 模式串第 1 位匹配失败后,下一次模式串第 0 位与 主串当前位置进行匹配,记此时为 0
next[1] = 0;
// 遍历模式串,依次得到 模式串 第 i 位匹配失败后,下一次模式串需要从第 next[i] 位开始与主串进行匹配
// i 表示模式串当前下标,j 表示模式串最大相等前后缀的 前缀的下一个位置的下标
// i >= 2 时,模式串前 2 个字符才存在 前后缀,此时 next 求解才有意义
for (int i = 2, j = 0; i < pattern.length(); i++) {
// 模式串自匹配,为了求解 next[i],需在模式串前 i 个元素中找到 最大前后缀
// j 表示的是当前最大前缀的下标,i-1 表示的是最大后缀的下标,若两者所在字符不匹配,则需要找到更小的前缀进行匹配,也即 j 需要回退
// 而 j 所在下标表示 0 ~ j-1 之前属于最长前缀,现在需要从 0~j-1 中找到新的最长前缀,而 next[j] 保存的正好是该值。
// 所以在此推出 j = next[j]
while(j > 0 && pattern.charAt(i - 1) != pattern.charAt(j)) {
j = next[j];
}
// 如果匹配,则说明当前最大前缀又可以增加一位,j 表示最长前缀的下一个位置(也即前缀长度),即 j++
if (pattern.charAt(i - 1) == pattern.charAt(j)) {
j++;
}
// 保存模式串下标为 i 匹配失败后,下一次从模式串开始匹配的下标位置
next[i] = j;
}
return next;
} /**
* next 数组第二种写法。
* 上面第一种解法是 每次计算 前 i 个字符(i 从 2 开始, j 从 0 开始)的模式串的最大相等前后缀,并将最大前缀下标的下一个位置赋值给 next[i]。
* 而第二种解法是,每次计算 前 i + 1 个字符(i 从 1 开始,j 从 -1 开始)的模式串的最大相等前后缀,并将其值赋值给 next[i+1]。
* 虽然写法稍有不同,但是原理都是类似的。
* @param pattern 模式串
* @return next 数组
*/
private static int[] getNext2(String pattern) {
// 用于保存 next 数组
int[] next = new int[pattern.length()];
// 规定模式串第 0 位匹配失败后,下一次模式串第 0 位与 主串当前位置下一个位置 进行匹配,记此时为 -1
next[0] = -1;
// i 表示模式串下标, j 表示最长前缀下一个位置的下标
int i = 0, j = -1;
// 遍历求解 next 数组
while(i < pattern.length() - 1) {
// 计算出模式串以当前下标为后缀的 最大相等前后缀长度,并将其值赋给 下一个 next。
// 也即相当于 next[i] 保存的时 前 i 个字符(0 ~ i-1) 的最大前后缀长度
if (j == -1 || pattern.charAt(i) == pattern.charAt(j)) {
next[++i] = ++j;
} else {
j = next[j];
}
}
return next;
} /**
* next 数组求解优化,即改进的 KMP 算法,
* 与 第二个求解 next 数组方式类似,区别在于 j 回退位置。
* 比如:
* 主串 ABACABAA
* 模式串 ABAB
* 由于模式串最后一个字符匹配失败,按照之前的 next 数组求法,得到 ABAA 的 next 数组为 [-1, 0, 0, 1]。
* 则此时下一次匹配如下:
* 主串 ABACABAA
* 模式串 ABAB
* 可以很明显的看到此时的匹配无效,与上次匹配失败的字符相同。
* 此时应该直接一步到位,省去这次无用匹配。也即 模式串下标为 j 的字符回退时 若遇到相同的 字符,应该继续回退。
*
* 即 模式串下标为 j 与 next[j] 字符相同时,应该继续求解 next[next[j]] 的值。
* 比如:
* 模式串: ABAB,可以得到 next 数组为 [-1, 0, 0, 1]
* 下标为 0 匹配失败时,初值为 -1,固定不变。
* 下标为 1 匹配失败时,下标为 1 的字符为 B、下标为 next[1] 的字符为 A,字符并不同,所以 next[1] = 0,与原 next 求解相同。
* 下标为 2 匹配失败时,下标为 2 的字符为 A、下标为 next[2] 的字符为 A,字符相同,所以 next[2] = next[next[2]] = next[0] = -1.
* 下标为 3 匹配失败时,下标为 3 的字符为 B,下标为 next[3] 的字符为 B,字符相同,所以 next[3] = next[next[3]] = next[1] = 0.
* 即改进后的 ABAB 得到的 next 数组为 [-1, 0, -1, 0]。
*
* 此时再次匹配:
* 主串 ABACABAA
* 模式串 ABAB
* 则下一次匹配为(跳过了无用的匹配):
* 主串 ABACABAA
* 模式串 ABAB
*
* @param pattern 模式串
* @return next 数组
*/
private static int[] getNext3(String pattern) {
int[] next = new int[pattern.length()];
next[0] = -1;
int i = 0, j = -1;
while(i < pattern.length() - 1) {
if (j == -1 || pattern.charAt(i) == pattern.charAt(j)) {
// next[++i] = ++j;
if (pattern.charAt(++i) == pattern.charAt(++j)) {
next[i] = next[j];
} else {
next[i] = j;
}
} else {
j = next[j];
}
}
return next;
}
} 【输出结果:】
[-1, 0, 0, 1]
[-1, 0, 0, 0, 0, 1, 2, 3, 0]
[-1, 0, 0, 1]
[-1, 0, 0, 0, 0, 1, 2, 3, 4, 0]
[-1, 0, -1, 0]
[-1, 0, 0, 0, -1, 0, 0, 0, 4, 0]
15
4、贪心算法(集合覆盖问题)
(1)贪心算法
贪心算法 指的是 在对问题求解时,可以将问题简化成 若干类似的小问题,其每次解决小问题 的方式均是 当前情况下的最优选择(即 局部最优),最终得到原问题的最优解。
注:
贪心算法其虽然每一步都能保证最优解,但是其最终结果并不一定是最优的(接近最优解的结果)。
(2)集合覆盖问题
【问题:】
现有 K1 - K5 五辆公交车,其能经过的站台(A - H)如下所示:
公交车 站台
K1 "A", "B", "C"
K2 "D", "A", "E"
K3 "F", "B", "G"
K4 "B", "C"
K5 "G", "H"
如何选择最少的 公交车,能经过所有的公交站台。 【思路:】
穷举法 不切实际,肯定不能采取。
可以使用贪心算法,每次选择 当前覆盖最多公交站 的公交,可以快速选择到所有公交站台。
贪心法步骤:
Step1:首先获取到所有的公交站台信息 allStation。
Step2:遍历所有公交车,根据公交车 站台信息 与 allStation 比较,从中找到一个 覆盖最多公交站 的公交。
记录此时的公交车,并将当前公交车经过的 站台 从 allStation 中去除。
Step3:重复 Step2,直至 allStation 为空,也即经过所有公交站台 最少公交车 已经找到。 【举例分析:】
Step1:首先获取到所有公交站台信息,allStation = ["A", "B", "C", "D", "E", "F", "G", "H"];
Step2:遍历 K1 - K5 公交车,发现 K1、K2、K3 均能覆盖 3 个站台,K4、K5 能覆盖 2 个站台。
按照顺序,先记录 K1 公交车,并去除 allStation 中相应的站台,此时 allStation = ["D", "E", "F", "G", "H"];
Step3:再次遍历 K1 - K5(跳过 K1 亦可),此时 K2、K3、K5 能覆盖 2 个站台,K1、K4 能覆盖 1 个站台。
按照顺序,先记录 K2 公交车,并去除 allStation 中相应的站台,此时 allStation = ["F", "G", "H"];
Step4:再次遍历 K1 - K5,此时 K3、K5 能覆盖 2 个站台,K1、K2、K4 能覆盖 1 个站台。
按照顺序,先记录 K3 公交车,并去除 allStation 中相应的站台,此时 allStation = ["H"];
Step5:再次遍历 K1 - K5,此时 K5 能覆盖 1 个站台,K1 - K4 能覆盖 0 个站台。
记录 K5,并去除 allStation 中相应的站台,此时 allStation = [];
Step6:allStation 为空,即所有公交站台均可访问,此时公交为 [K1, K2, K3, K5] 注:
若第一次选择的并非 K1,而是 K2,则可能的结果为:[K2, k3, K5, K4].
可以发现,可能存在多个解,即 贪心法得到的结果不一定是最优解,但一定近似最优解。 【代码实现:】
package com.lyh.algorithm; import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List; public class Greedy {
public static void main(String[] args) {
greedy();
} /**
* 贪心算法求解 集合覆盖问题。
* 每次选取当前情况下的最优解,从而最终得到结果(结果不一定为最优解,但是近似最优解)
*/
public static void greedy() {
// 设置公交经过的站台
HashSet<String> k1 = new HashSet<>();
k1.add("A");
k1.add("B");
k1.add("C"); HashSet<String> k2 = new HashSet<>();
k2.add("D");
k2.add("A");
k2.add("E"); HashSet<String> k3 = new HashSet<>();
k3.add("F");
k3.add("B");
k3.add("G"); HashSet<String> k4 = new HashSet<>();
k4.add("B");
k4.add("C"); HashSet<String> k5 = new HashSet<>();
k5.add("G");
k5.add("H"); // 保存所有公交 以及 公交站台信息
HashMap<String, HashSet<String>> bus = new HashMap<>();
bus.put("K1", k1);
bus.put("K2", k2);
bus.put("K3", k3);
bus.put("K4", k4);
bus.put("K5", k5); // 保存所有站台信息
HashSet<String> allStation = new HashSet<>();
for (String k : bus.keySet()) {
allStation.addAll(bus.get(k));
} // 用于记录最终结果
List<String> result = new ArrayList<>(); // 站台非空时,记录经过 最多 站台的 公交
while(!allStation.isEmpty()) {
// 用于记录经过 最多站台 的公交
String maxKey = null;
// 遍历各公交
for (String k : bus.keySet()) {
// 获取公交经过的站台信息
HashSet<String> temp = bus.get(k);
// 取当前 公交经过的 所有站台 与 总站台 的交集,并将交集 赋给 当前公交,用于记录当前公交所经过的最多站台数量
temp.retainAll(allStation);
// 用于记录当前场合下,经过 最多站台 的公交(局部最优)
if (maxKey == null || temp.size() > bus.get(maxKey).size()) {
maxKey = k;
}
}
if (maxKey != null) {
// 记录当前经过 最多站台的 公交
result.add(maxKey);
// 从所有公交站台中 移除 已经可以被经过的 公交站台
allStation.removeAll(bus.get(maxKey));
}
} System.out.println("经过所有站台所需最少公交为:" + result);
}
} 【输出结果:】
经过所有站台所需最少公交为:[K1, K2, K3, K5]
3、动态规划(0/1 背包问题)
(1)动态规划
动态规划(Dynamic Programming)核心思想与 分治算法类似,都是将 大问题划分为若干个小问题求解,从子问题的解中得到原问题的答案。
但不同之处在于 分治法 适用于 子问题相互独立的 情况,而动态规划 适用于 子问题不相互独立的情况,即 动态规划求解 建立在 上一个子问题的解的基础上。可以采用填表的方式,逐步推算得到最优解。
【动态规划关注点:】
最优子结构:每个阶段的状态可以从之前某个阶段直接得到。
状态转移:如何从一个状态 转移 到另一个状态。
注:
动态规划是以 空间 换 时间,其将每个子问题的计算结果保存,需要时直接取出,减少了重复计算子问题的时间。
(2)0/1 背包问题
【问题描述:】
背包问题是 给定一个容量的背包,以及若干个 具有一定 价值 以及 容量的物品,
在 保证 背包的容量下,选择物品放入背包,使得背包价值 最大。
注:
背包问题可以分为:0/1 背包、完全背包。
0/1 背包指的是 同一物品只能存在一个在背包中。
完全背包指的是 有多个相同的物品可以放入背包中。 【0/1 背包分析:】
假设有 n 个物品,背包重量为 m。
使用一维数组 weight[i] 表示 第 i 个物品的 重量。
使用一维数组 value[i] 表示 第 i 个物品的 价值。
使用二维数组 maxValue 用于记录物品放入背包的结果,maxValue[i][j] 表示前 i 个物品能装入容量为 j 的背包的最大价值,则 maxValue[n][m] 即为背包所能存放的最大价值。 对于 第 i-1 个物品,装入容量为 j 的背包,则其装入背包总价值为 maxValue[i-1][j],
对于 第 i 个物品,
若其重量大于背包重量,即 weight[i] > j,则该物品肯定不能放入背包。此时背包最大价值仍为上一次的最大值,即 maxValue[i][j] = maxValue[i-1][j]。
若其重量小于等于背包重量,即 weight[i] <= j,则该物品可以放入背包。将物品放入背包,并重新计算当前最大价值。
物品放入背包,去除 当前物品容量时 背包的最大价值 并加上当前物品价值,即可得到当前物品存入背包后的最大价值,即 maxValue[i][j] = maxValue[i-1][j - weight[i]] + value[i]
计算原有价值以及 新价值的最大值作为当前背包最大价值(状态转移),即 Math.max(maxValue[i-1][j], maxValue[i-1][j-weight[i]] + value[i]))
注:
由于每次都将子问题解记录,所以可以避免重复计算子问题解。 若想输出放入背包的物品,可以根据 maxValue[i][j] 、maxValue[i-1][j] 反推。
maxValue[i][j] 大于 maxValue[i-1][j] 时,第 i 个物品肯定进入背包。 【举例:】
现有 3 个物品,价值为 {1500, 3000, 2000},重量为 {1, 4, 3}。背包容量为 4.
则使用 二维数组 记录各容量背包下物品存放最大价值如下表:
1 2 3 4
0 0 0 0 0
1 0 1500 1500 1500 1500
2 0 1500 1500 1500 3000
3 0 1500 1500 2000 3500
注:
行表示物品,列表示背包容量。行列组合起来表示 某背包容量下 物品存放的最大价值。
比如:
第一行,存放第一个物品,重量为 1,在背包容量为 1~4 的情况下,均能存放,则其最大价值为 1500.
第二行,存放第二个物品,重量为 4,在背包容量为 1~3 的情况下,不能存放,则其最大价值为 1500(只能存放第一个物品),
但其在背包容量为 4 时可以存放,此时最大价值为 3000,且去除了第一个物品,只存放第二个物品。
第三行,存放第三个物品,重量为 3,在背包容量为 1~2 的情况下,不能存放,则其最大价值为 1500,
而其在背包容量为 3 时可以存放,此时最大价值为 2000,只存放第三个物品。
在背包容量为 4 时也可以存放,此时最大价值为 3500,存放第三个物品 和 第一个物品。 【代码实现:】
package com.lyh.algorithm; import java.util.ArrayList;
import java.util.List; /**
* 0/1 背包问题
*/
public class Knapsack {
public static void main(String[] args) {
int[] value = new int[]{1500, 3000, 2000}; // 设置物品价值
int[] weight = new int[]{1, 4, 3}; // 设置物品容量
int m = 4; // 设置背包容量
int n = value.length; // 设置物品总数量
knapsack(value, weight, n, m);
} /**
* 0/1 背包
* @param value 物品价值
* @param weight 物品重量
* @param count 物品总数
* @param capacity 背包总容量
*/
public static void knapsack(int[] value, int[] weight, int count, int capacity) {
// 背包存放最大价值,maxValue[i][j] 表示第 i 个物品存放在 容量为 j 的背包中的最大价值
// 其中第一行、第一列均为 0,便于计算。
int[][] maxValue = new int[count + 1][capacity + 1];
// 依次求解各个容量背包下物品存放的最大价值
// i 表示第 i 个物品,j 表示 容量为 j 的背包
for (int i = 1; i < count + 1; i++) {
for (int j = 1; j < capacity + 1; j++) {
// 若背包容量小于当前物品,则当前物品肯定不能存进背包,直接赋值上一个求解值即可
// 由于 i 从 1 开始计数,所以访问第一个物品重量时为 weight[i - 1],访问第一个物品价值为 value[i -1],保证从下标为 0 开始访问。
if (j < weight[i - 1]) {
maxValue[i][j] = maxValue[i - 1][j];
} else {
// 背包容量 大于等于当前物品,则当前物品可以存入背包,则重新计算最大价值
// 当前物品存入背包价值为: 去除 当前物品容量时 背包的最大价值 与 当前物品价值 之和
int currentValue = maxValue[i - 1][j - weight[i - 1]] + value[i - 1];
// 计算当前物品价值 与 之前价值 最大值,并记录
maxValue[i][j] = Math.max(currentValue, maxValue[i - 1][j]);
}
}
} /**
* 遍历输出各个容量下 背包存放物品的价值
*/
System.out.println("各容量背包下,存放物品的最大值如下: ");
for (int i = 0; i < count + 1; i++) {
for (int j = 0; j < capacity + 1; j++) {
System.out.print(maxValue[i][j] + " ");
}
System.out.println();
} System.out.println("容量为: " + capacity + " 的背包存放最大物品价值为: " + maxValue[count][capacity]); // 反推出 存入 背包的物品
int j = capacity; // 记录背包容量
List<Integer> list = new ArrayList<>(); // 记录物品下标
for (int i = count; i > 0; i--) {
// maxValue[i][j] 大于 maxValue[i - 1][j],则第 i 个物品肯定进入背包了
if (maxValue[i][j] > maxValue[i - 1][j]) {
list.add(i - 1); // 记录物品下标
j -= weight[i - 1]; // 查找去除当前物品下标后的背包存储的物品
}
if (j == 0) {
break; // 背包中物品都已取出
}
}
System.out.println("存入背包的物品为: ");
for (int i = 0; i < list.size(); i++) {
System.out.println("第 " + (list.get(i) + 1) + " 个物品,价值为: " + value[list.get(i)] + " ,重量为: " + weight[list.get(i)]);
}
}
} 【输出结果:】
各容量背包下,存放物品的最大值如下:
0 0 0 0 0
0 1500 1500 1500 1500
0 1500 1500 1500 3000
0 1500 1500 2000 3500
容量为: 4 的背包存放最大物品价值为: 3500
存入背包的物品为:
第 3 个物品,价值为: 2000 ,重量为: 3
第 1 个物品,价值为: 1500 ,重量为: 1
Java 内功修炼 之 数据结构与算法(二)的更多相关文章
- Java 内功修炼 之 数据结构与算法(一)
一.基本认识 1.数据结构与算法的关系? (1)数据结构(data structure): 数据结构指的是 数据与数据 之间的结构关系.比如:数组.队列.哈希.树 等结构. (2)算法: 算法指的是 ...
- 编程内功修炼之数据结构—BTree(二)实现BTree插入、查询、删除操作
1 package edu.algorithms.btree; import java.util.ArrayList; import java.util.List; /** * BTree类 * * ...
- Java数据结构和算法(二)--队列
上一篇文章写了栈的相关知识,而本文会讲一下队列 队列是一种特殊的线性表,在尾部插入(入队Enqueue),从头部删除(出队Dequeue),和栈的特性相反,存取数据特点是:FIFO Java中queu ...
- 学习JavaScript数据结构与算法 (二)
学习JavaScript数据结构与算法 的笔记 包含第四章队列, 第五章链表 本人所有文章首发在博客园: http://www.cnblogs.com/zhangrunhao/ 04队列 实现基本队列 ...
- Android版数据结构与算法(二):基于数组的实现ArrayList源码彻底分析
版权声明:本文出自汪磊的博客,未经作者允许禁止转载. 本片我们分析基础数组的实现--ArrayList,不会分析整个集合的继承体系,这不是本系列文章重点. 源码分析都是基于"安卓版" ...
- Java内功修炼系列一反射
“JAVA反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法:对于任意一个对象,都能够调用它的任意方法和属性:这种动态获取信息以及动态调用对象方法的功能称为java语言的反射机制 ...
- javascript 数据结构与算法---二叉数
二叉树,首先了解一些关于二叉数的概念(来自百度百科) 1. 二叉树(Binary tree)是树形结构的一个重要类型 2. 定义: 二叉树(binary tree)是指树中节点的度不大于2的有序树,它 ...
- 编程内功修炼之数据结构—BTree(一)
BTree,和二叉查找树和红黑树中一样,与关键字相联系的数据作为关键字存放在同一节点上. 一颗BTree树具有如下的特性:(根为root[T]) 1)每个节点x有以下域: (a)n[x],当前存储在节 ...
- 编程内功修炼之数据结构—BTree(三)总结
BTree必须通过各种编程约束,使得不脱离BTree的本身特性: 1)BTree关键字插入操作:插入过程中,如果节点关键字达到上限,添加分裂约束,从而控制每个节点的关键字数维持在 t-1~2*t-1内 ...
随机推荐
- 多测师讲解seleniun_ ACTIONCHAUNS定位_高级讲师肖sir
1.传统方法定位 2.模拟鼠标定位
- MeteoInfoLab脚本示例:计算垂直螺旋度
尝试编写MeteoInfoLab脚本计算垂直螺旋度,结果未经验证. 脚本程序: print 'Open data files...' f_uwnd = addfile('D:/Temp/nc/uwnd ...
- allure安装
allure是一个通用的测试报告框架 下载地址:http://allure.qatools.ru/ 第一步:进入该页面,右上角有个download,点击进入github页面,选择最新版本下载到某个路径 ...
- lumen-ioc容器测试 (4)
lumen-ioc容器测试 (1) lumen-ioc容器测试 (2) lumen-ioc容器测试 (3) lumen-ioc容器测试 (4) lumen-ioc容器测试 (5) lumen-ioc容 ...
- gin框架使用orm操作数据库(转)
简介:orm俗称关系对象模型,用来映射数据库SQL和对象的工具 ,相当于mongodb里面的mongoose库,Java里面的mybatis ibatis Golang GORM使用 https: ...
- Go go.mod入门
什么是go.mod? Go.mod是Golang1.11版本新引入的官方包管理工具用于解决之前没有地方记录依赖包具体版本的问题,方便依赖包的管理. Go.mod其实就是一个Modules,关于Modu ...
- C# Hash算法
#region Hash算法 /// <summary> /// Hash算法 /// </summary> /// <param name="myStr&qu ...
- 你真的了解Python吗?这篇文章可以让你了解90%
人们为什么使用Python? 之所以选择Python的主要因素有以下几个方面: 软件质量:在很大程度上,Python更注重可读性.一致性和软件质量,从而与脚本语言世界中的其他工具区别开发.此外,Pyt ...
- 从一个例子入手Istio
转载请声明出处哦~,本篇文章发布于luozhiyun的博客:https://www.luozhiyun.com 本文使用的Istio源码是 release 1.5. 本篇是Istio系列的第一篇,希望 ...
- Kubernetes K8S之调度器kube-scheduler详解
Kubernetes K8S之调度器kube-scheduler概述与详解 kube-scheduler调度概述 在 Kubernetes 中,调度是指将 Pod 放置到合适的 Node 节点上,然后 ...