(1)前提
  前面一篇介绍了 二叉树、顺序二叉树、线索二叉树、哈夫曼树等树结构。
  可参考:https://www.cnblogs.com/l-y-h/p/13751459.html#_label5_1

(2)二叉树遍历

  1. 【递归与非递归实现:】
  2. 使用递归实现时,系统隐式的维护了一个栈 用于操作节点。虽然递归代码易理解,但是对于系统的性能会造成一定的影响。
  3. 使用非递归代码实现,可以主动去维护一个栈 用于操作节点。非递归代码相对于递归代码,其性能可能会稍好(数据大的情况下)。
  4. 注:
  5. 栈是先进后出(后进先出)结构,即先存放的节点后输出(后存放的节点先输出)。
  6. 所以使用栈时,需要明确每一步需要压入的树节点。
  7. 递归实现二叉树 前序、中序、后序遍历。可参考:https://www.cnblogs.com/l-y-h/p/13751459.html#_label5_2

 

(3)非递归实现前序遍历

  1. 【非递归实现前序遍历:】
  2. 前序遍历顺序:当前节点(父节点)、左子节点、右子节点。
  3. 实现思路:
  4. 首先明确一点,每次出栈的树节点即为当前需要输出的节点(第一个输出的节点为 根节点)。
  5. 每次首先输出的为 当前节点(父节点),所以父节点先入栈、再出栈。
  6. 出栈之后,需要重新选择出下一次需要输出的父节点。从当前节点的 左、右子节点中选择。
  7. 而左子节点需要在 右子节点前输出,所以右子节点需要先进栈,然后左子节点再进栈。
  8. 左子节点入栈后,再次出栈即为当前节点,然后重复上面操作,依次取出栈顶元素即可。
  9. 步骤:
  10. Step1:根节点入栈。
  11. Step2:根节点出栈,此时为当前节点,输出或者保存。
  12. Step2.1:若当前节点存在右子节点,则压入栈。
  13. Step2.2:若当前节点存在左子节点,则压入栈。
  14. Step3:重复 Step2,依次取出栈顶元素并输出,栈为空时,则树遍历完成。
  15. 【非递归前序遍历代码实现:】
  16. package com.lyh.tree;
  17. import java.util.ArrayList;
  18. import java.util.List;
  19. import java.util.Stack;
  20. public class BinaryTreeSort<K> {
  21. /**
  22. * 前序遍历(非递归实现、使用栈模拟递归)
  23. */
  24. public List<K> prefixList(TreeNode9<K> root) {
  25. // 使用集合保存最终结果
  26. List<K> result = new ArrayList<>();
  27. // 根节点不存在时,返回空集合
  28. if (root == null) {
  29. return result;
  30. }
  31. // 使用栈模拟递归
  32. Stack<TreeNode9<K>> stack = new Stack<>();
  33. // 根节点入栈
  34. stack.push(root);
  35. // 栈非空时,依次取出栈顶元素,此时栈顶元素为当前节点,输出,并将当前节点 左、右子节点入栈
  36. // 左子节点 先于 右子节点出栈,所以左子节点在 右子节点入栈之后再入栈
  37. while(!stack.isEmpty()) {
  38. // 取出栈顶元素(当前节点)
  39. TreeNode9<K> tempNode = stack.pop();
  40. // 保存(或者输出)当前节点
  41. result.add(tempNode.data);
  42. // 存在右子节点,则压入栈
  43. if (tempNode.right != null) {
  44. stack.push(tempNode.right);
  45. }
  46. // 存在左子节点,则压入栈
  47. if (tempNode.left != null) {
  48. stack.push(tempNode.left);
  49. }
  50. }
  51. return result;
  52. }
  53. public static void main(String[] args) {
  54. // 构建二叉树
  55. TreeNode9<String> root = new TreeNode9<>("0");
  56. TreeNode9<String> treeNode = new TreeNode9<>("1");
  57. TreeNode9<String> treeNode2 = new TreeNode9<>("2");
  58. TreeNode9<String> treeNode3 = new TreeNode9<>("3");
  59. TreeNode9<String> treeNode4 = new TreeNode9<>("4");
  60. root.left = treeNode;
  61. root.right = treeNode2;
  62. treeNode.left = treeNode3;
  63. treeNode.right = treeNode4;
  64. // 前序遍历
  65. System.out.print("前序遍历: ");
  66. System.out.println(new BinaryTreeSort<String>().prefixList(root));
  67. System.out.println("\n=====================");
  68. }
  69. }
  70. class TreeNode9<K> {
  71. K data; // 保存节点数据
  72. TreeNode9<K> left; // 保存节点的 左子节点
  73. TreeNode9<K> right; // 保存节点的 右子节点
  74.  
  75. public TreeNode9(K data) {
  76. this.data = data;
  77. }
  78. @Override
  79. public String toString() {
  80. return "TreeNode9{ data= " + data + "}";
  81. }
  82. }
  83. 【输出结果:】
  84. 前序遍历: [0, 1, 3, 4, 2]

 

(4)非递归实现中序遍历

  1. 【非递归实现中序遍历:】
  2. 中序遍历顺序:左子节点、当前节点、右子节点。
  3. 实现思路:
  4. 首先明确一点,每次出栈的树节点即为当前需要输出的节点(第一次输出的节点为 最左侧节点)。
  5. 由于每次都要先输出当前节点的最左侧节点,所以需要遍历找到这个节点。
  6. 而在遍历的过程中,每次经过的树节点均为 父节点,可以使用栈保存起来。
  7. 此时,找到并输出最左侧节点后,就可以出栈获得父节点,然后根据父节点可以找到其右子节点。
  8. 将右子节点入栈,同理找到其最左子节点,并重复上面操作,依次取出栈顶元素即可。
  9. 注:
  10. 为了防止重复执行父节点遍历左子节点的操作,可以使用辅助变量记录当前操作的节点。
  11. 步骤:
  12. Step1:记当前节点为根节点,从根节点开始,遍历找到最左子节点,并依次将经过的树节点入栈。
  13. Step2:取出栈顶元素,此时为最左子节点(当前节点),输出或者保存。
  14. Step2.1:若存在右子节点,则当前节点为 父节点,将右子节点入栈,并修改新的当前节点为 右子节点。遍历当前节点,同理找到最左子节点,并依次将经过的节点入栈。
  15. Step2.2:若不存在右子节点,则当前节点为 左子节点,下一次取得的栈顶元素即为 父节点。
  16. Step3:重复上面过程,输出顺序即为 左、根、右。
  17. 【非递归中序遍历代码实现:】
  18. package com.lyh.tree;
  19. import java.util.ArrayList;
  20. import java.util.List;
  21. import java.util.Stack;
  22. public class BinaryTreeSort<K> {
  23. /**
  24. * 中序遍历(非递归实现,使用栈模拟递归)
  25. */
  26. public List<K> infixList(TreeNode9<K> root) {
  27. // 使用集合保存遍历结果
  28. List<K> result = new ArrayList<>();
  29. if (root == null) {
  30. return result;
  31. }
  32. // 保存当前节点
  33. TreeNode9<K> currentNode = root;
  34. // 使用栈模拟递归实现
  35. Stack<TreeNode9<K>> stack = new Stack<>();
  36. while(!stack.isEmpty() || currentNode != null) {
  37. // 找到当前节点的左子节点,并依次将经过的节点入栈
  38. while(currentNode != null) {
  39. stack.push(currentNode);
  40. currentNode = currentNode.left;
  41. }
  42. // 取出栈顶元素
  43. TreeNode9<K> tempNode = stack.pop();
  44. // 保存栈顶元素
  45. result.add(tempNode.data);
  46. // 存在右子节点,则右子节点入栈,
  47. if (tempNode.right != null) {
  48. currentNode = tempNode.right;
  49. }
  50. }
  51. return result;
  52. }
  53. public static void main(String[] args) {
  54. // 构建二叉树
  55. TreeNode9<String> root = new TreeNode9<>("0");
  56. TreeNode9<String> treeNode = new TreeNode9<>("1");
  57. TreeNode9<String> treeNode2 = new TreeNode9<>("2");
  58. TreeNode9<String> treeNode3 = new TreeNode9<>("3");
  59. TreeNode9<String> treeNode4 = new TreeNode9<>("4");
  60. root.left = treeNode;
  61. root.right = treeNode2;
  62. treeNode.left = treeNode3;
  63. treeNode.right = treeNode4;
  64. // 前序遍历
  65. System.out.print("中序遍历: ");
  66. System.out.println(new BinaryTreeSort<String>().infixList(root));
  67. System.out.println("\n=====================");
  68. }
  69. }
  70. class TreeNode9<K> {
  71. K data; // 保存节点数据
  72. TreeNode9<K> left; // 保存节点的 左子节点
  73. TreeNode9<K> right; // 保存节点的 右子节点
  74.  
  75. public TreeNode9(K data) {
  76. this.data = data;
  77. }
  78. @Override
  79. public String toString() {
  80. return "TreeNode9{ data= " + data + "}";
  81. }
  82. }
  83. 【输出结果:】
  84. 中序遍历: [3, 1, 4, 0, 2]

 

(5)非递归实现后序遍历

  1. 【非递归实现后序遍历:】
  2. 后序遍历顺序:左子节点、右子节点、当前节点。
  3. 实现思路:
  4. 首先明确一点,每次出栈的树节点即为当前需要输出的节点(第一次输出的节点为最左侧节点)。
  5. 这里与 中序遍历还是有点类似的,同样是先输出最左侧节点。区别在于,后序遍历先输出 右子节点,再输出父节点。
  6. 同样使用一个变量,用来辅助遍历,防止父节点重复遍历子节点。
  7. 此处的变量,可以理解成上一次节点所在位置。而栈顶取出的当前节点为上一次节点的父节点。
  8. 步骤:
  9. Step1:根节点入栈。
  10. Step2:取出栈顶元素(当前节点),判断其是否存在子节点。
  11. Step2.1:存在左子节点,且未被访问过,左子节点入栈(此处为遍历找到最左子节点)。
  12. Step2.2:存在右子节点,且未被访问过,右子节点入栈。
  13. Step2.3:不存在 或者 已经访问过 左、右子节点,输出当前节点。
  14. Step3:重复以上操作,直至栈空。
  15. 【非递归后序遍历代码实现:】
  16. package com.lyh.tree;
  17. import java.util.ArrayList;
  18. import java.util.List;
  19. import java.util.Stack;
  20. public class BinaryTreeSort<K> {
  21. /**
  22. * 后序遍历(非递归实现,使用栈模拟递归)
  23. */
  24. public List<K> suffixList(TreeNode9<K> root) {
  25. // 使用集合保存遍历结果
  26. List<K> result = new ArrayList<>();
  27. if (root == null) {
  28. return result;
  29. }
  30. // 保存当前节点
  31. TreeNode9<K> currentNode = root;
  32. // 使用栈模拟递归实现
  33. Stack<TreeNode9<K>> stack = new Stack<>();
  34. // 根节点入栈
  35. stack.push(root);
  36. // 依次取出栈顶元素
  37. while(!stack.isEmpty()) {
  38. // 取出栈顶元素
  39. TreeNode9<K> tempNode = stack.peek();
  40. // 若当前节点 左子节点 存在,且未被访问,则入栈
  41. if (tempNode.left != null && currentNode != tempNode.left && currentNode != tempNode.right) {
  42. stack.push(tempNode.left);
  43. } else if (tempNode.right != null && currentNode != tempNode.right){
  44. // 若当前节点 右子节点存在,且未被访问,则入栈
  45. stack.push(tempNode.right);
  46. } else {
  47. // 当前节点不存在 左、右子节点 或者 左、右子节点已被访问,则取出栈顶元素,
  48. // 并标注当前节点位置,表示当前节点已被访问
  49. result.add(stack.pop().data);
  50. currentNode = tempNode;
  51. }
  52. }
  53. return result;
  54. }
  55. public static void main(String[] args) {
  56. // 构建二叉树
  57. TreeNode9<String> root = new TreeNode9<>("0");
  58. TreeNode9<String> treeNode = new TreeNode9<>("1");
  59. TreeNode9<String> treeNode2 = new TreeNode9<>("2");
  60. TreeNode9<String> treeNode3 = new TreeNode9<>("3");
  61. TreeNode9<String> treeNode4 = new TreeNode9<>("4");
  62. root.left = treeNode;
  63. root.right = treeNode2;
  64. treeNode.left = treeNode3;
  65. treeNode.right = treeNode4;
  66. // 前序遍历
  67. System.out.print("后序遍历: ");
  68. System.out.println(new BinaryTreeSort<String>().suffixList(root));
  69. System.out.println("\n=====================");
  70. }
  71. }
  72. class TreeNode9<K> {
  73. K data; // 保存节点数据
  74. TreeNode9<K> left; // 保存节点的 左子节点
  75. TreeNode9<K> right; // 保存节点的 右子节点
  76.  
  77. public TreeNode9(K data) {
  78. this.data = data;
  79. }
  80. @Override
  81. public String toString() {
  82. return "TreeNode9{ data= " + data + "}";
  83. }
  84. }
  85. 【输出结果:】
  86. 后序遍历: [3, 4, 1, 2, 0]

 

(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。

  1. 【一颗 M 阶的 B 树特点:(M 阶指的是最大节点的子节点个数)】
  2. 每个节点最多有 M 个子节点(子树)。
  3. 根节点存在 0 个或者 2 个以上子节点。
  4. 非叶子节点 若存在 j 个子节点,那么该非叶子节点保存 j - 1 个数据项,且按照递增顺序存储。
  5. 所有的叶子节点均在同一层。
  6. 注:
  7. B 树是一个平衡多路查找树,具有与 平衡二叉树 类似的特点,
  8. 区别在于 B 树分支更多,从而构建出的树高度低。
  9. 当然 B 树也不能无限制的增大 树的阶,阶约大,则非叶子节点保存的数据项越多(变成了一个有序数组,增加查找时间)。

 

(4)2-3 树
  2-3 树是最简单的 B 树,是一颗平衡多路查找树。
  其节点可以分为 2 节点、3 节点,且 所有叶子节点均在同一个层。

  1. 2-3 树特点:】
  2. 对于 2 节点:
  3. 只能包含一个数据项 两个子节点(或者没有子节点)。
  4. 左子节点值 小于 当前节点值,右子节点值 大于 当前节点值。
  5. 不存在只有一个子节点的情况。
  6. 对于 3 节点:
  7. 包含一大一小两个数据项(从小到大排序) 三个子节点(或者没有子节点)。
  8. 左子节点值 小于 当前节点数据项最小值,右子节点值 大于 当前节点数据项最大值,中子节点值 当前节点数据项值之间。
  9. 不存在有 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+ 树性能可以等价于 对 全部叶子节点(所有关键字)进行一次 二分查找。

  1. B+ 树特点:】
  2. 所有 数据项(关键字) 均存放于 叶子节点。
  3. 每个叶子节点 存放的 数据项(关键字)是有序的。
  4. 所有叶子节点使用链表相连(即进行范围查询时,只需要查找到 首尾节点、然后遍历链表 即可)。
  5. 注:
  6. 所有数据项(关键字) 均存放与 叶子节点组成的链表中,且数据有序,可以视为稠密索引。
  7. 非叶子节点 相当于 叶子节点的索引,可以视为 稀疏索引。

根据 {16, 24, 12, 32, 14, 26, 34, 10, 8, 28, 38, 20, 33} 构建的 B+ 树(3阶、2-3 树)如下:

 

 

 

 

(6)B* 树
  B* 树 是 B+ 树的变体。
  其在 B+树 基础上,在除 非根节点、非叶子节点 之外的其余节点之间增加指针,提高节点利用率。

  1. B* 树与 B+ 节点分裂的区别:】
  2. 对于 B+ 树:
  3. B+ 节点的最低使用率是 1/2,其非叶子节点关键字(数据项)个数至少为 (1/2)*MM B+ 树的阶。
  4. 当一个节点存放满时,会增加一个节点,并将原节点 1/2 的数据移动到新的节点,然后在 父节点 添加新的节点。
  5. B+ 只影响 原节点 以及 父节点,不会影响兄弟节点,兄弟之间不需要指针。
  6. 对于 B* 树:
  7. B* 节点的最低使用率为 2/3,其非叶子节点关键字(数据项)个数至少为 (2/3)*M
  8. 当一个节点存放满时,若其下一个兄弟节点未满,则将一部分数据移到兄弟节点中,在原节点 添加新节点,然后修改 父节点 中的节点(兄弟节点发生改变)。
  9. 若其下一个兄弟已满,则在 两个兄弟之间 增加一个新节点,并分别从两个兄弟节点中 移动 1/3 的数据到新节点,然后在 父节点 添加新的节点。
  10. B* 影响了 兄弟节点,所以需要指针将兄弟节点连接起来。
  11. 总的来说,B* 树分配新节点的概率比 B+ 树低,B* 树的节点利用率更高。
  12. 注:
  13. 相关内容参考:https://blog.csdn.net/wyqwilliam/article/details/82935922

下图不一定正确,大概理解意思就行。

 

 

(7)B-树、B+树、B*树总结

  1. B 或者 B- 树:】
  2. 平衡的多路查找树,非叶子节点至少存储 (1/2)*M 个关键字(数据项),
  3. 关键字升序存储,且仅出现一次,
  4. 进行查找匹配操作时,可以在 非叶子节点 成功匹配。
  5. B+ 树:】
  6. B 树的变种,仅在 叶子节点 保存数据项,且叶子节点之间 通过链表存储。
  7. 整体 数据项 有序存储。
  8. 非叶子节点 作为 叶子节点 的索引存在,匹配时通过 非叶子节点 快速定位到 叶子节点,然后在 叶子节点 处进行匹配操作,相当于进行 二分查找。
  9. B* 树:】
  10. B+ 树的变种,给 非叶子节点 也加上指针,非叶子节点 至少存储 (2/3)*M 个关键字。
  11. 将节点利用率 1/2 提高到 2/3

 

(1)索引是什么?
  索引是一种有序的、快速查找的数据结构。
  索引 由 若干个 索引项组成,每个索引项 由 数据的关键字 以及 其相对应的记录(比如:记录对应在磁盘中的 地址信息)组成。
  索引的查找,就是根据 索引项中的关键字 去关联 其相应的记录 的过程。

(2)数据库为什么使用索引?
  为了提高数据查询效率,数据库在维护数据的同时维护一个满足特定查找算法的数据结构,这个数据结构以某种方式指向数据、或者存储数据的引用,通过这个数据结构实现高级查找算法,这样就可以快速查找数据。
  而这种数据结构就是索引。
  索引按照结构划分为:线性索引、树形索引、多级索引。

 

如下图所示数据结构:(树形索引,仅供参考,图片来源于网络)
使用二叉树维护数据的索引值以及数据的物理地址,使用二叉树可以在一定的时间复杂度内查找到数据,然后根据该数据的物理地址找到存储在表中的数据,从而实现快速查找。

 

 

(1)什么是线性索引?
  线性索引 指的是 将索引项组合成线性结构,也可称为索引表。
  常见分类:稠密索引(密集索引)、稀疏索引(分块索引)、倒排索引。

(2)稠密索引(密集索引)
  稠密索引 指的线性结构是:每个索引项 对应一个数据集(记录),记录在数据区(磁盘)中可以是无序的,但是所有索引项 是有序的(方便查找)。
  但由于每个索引项占用的空间较大,若数据量较大时(每个索引项对应一个记录),占用空间会很大(可能无法一次在内存中读取,需要多次磁盘 I/O,降低查找性能)。
  即 占用空间大、查找效率高。

 

如下图(图片来源于网络):
左边索引表 中的索引项 按照关键码有序,可以使用 二分查找 或者其他高效查找算法,快速定位到对应的索引项,然后找到对应的 记录。

 

 

注:
  前面介绍的 B+ 树的所有叶子节点可以看成是 稠密索引,其所有叶子节点 由链表连接,且叶子节点有序,可以应用上 稠密索引。

(3)稀疏索引(分块索引)
  稠密索引 其每个索引项 对应一个记录,占用空间大。
  稀疏索引 指的线性结构是:将数据集按照某种方式 分成若干个数据块,每个索引项 对应一个数据块。每个数据块可以包含多个数据(记录),这些数据之间可以是无序的。但 数据块之间是有序的(索引项有序)。
  索引项无需保存 所有记录,只需要记录关键字即可,占用空间小。且索引项有序,可以快速定位到数据块。但是 数据块内没要求是有序的(维护有序序列需要付出一些代价),所以数据块中可能顺序查找(数据量较大时,查找效率较低)。
  即 占用空间小、查找效率可能较低。

 

如下图(图片来源于网络):
左边索引表 按照关键码有序,可以通过 二分查找 等算法快速定位到 数据块,然后在数据块中查找数据。

 

 

注:
  前面介绍的 B+ 树中 非叶子节点 与 叶子节点 之间可以看成 稀疏索引,非叶子节点 仅保存 叶子节点的索引,叶子节点 保存 数据块。且此时 多个数据块之间 有序、每个数据块 之内也有序。

(1)底层数据结构
  MySQL 底层数据结构,一般回答都是 B+ 树。
  那么为什么选择 B+ 树?哈希、二叉树、B树 等结构不可以吗?

(2)为什么不使用 哈希表 作为索引?

  1. 【常用快速查找的数据结构有两种:】
  2. 哈希表:
  3. 比如 HashMap,其查找、添加、删除、修改的平均时间复杂度均为 O(1)
  4. 树:
  5. 比如 平衡二叉树,其查询、添加、删除、修改的平均时间复杂度均为O(logn)
  6. 【什么是哈希表?】
  7. 哈希表(Hash table 、散列表),是根据键(Key)直接访问数据(Value)的一种数据结构。
  8. 规则:
  9. 使用某种方式(映射函数)将键值(Key)映射到数组中的某个位置,并在此位置存放记录,用于加快查询速度。
  10. 映射函数 也称为 散列函数,存放记录的数组 称为 散列表。
  11. 理解:
  12. 使用 散列函数,将 键值(Key)转换为一个 整型数字,
  13. 然后再对数字进行转换(取模、与运算等),将其转为 数组对应的下标,并将 value 存储在该下标对应的存储空间中。
  14. 而进行查询操作时,再次对 Key 进行运算,转换为对应的数组下标,即可定位并获取 value 值(时间复杂度为 O(1))。
  15. 【为什么不使用 哈希表?】
  16. 对于 单次写操作或者读操作 来说,哈希的速率比树快,但是为什么不用哈希表呢?
  17. 可以想一下如果是排序或者范围查询的情况下,执行哈希是什么情况,很显然,哈希无法很快的进行范围查找(其数据都是无序的),查找范围 0~n 的情况下,会执行 n 次查找,也即时间复杂度为 O(n)。
  18. 而树(AVL树、B树、B+树等)是有序的(12 次查找即可),其时间复杂度仍可以保证在 O(logn)。
  19. 相比较之下,哈希肯定没有树的效率高,因此不会使用哈希这种数据结构作为索引。
  20. 【平衡二叉树时间复杂度 O(logn) 怎么来的?】
  21. 在树中查找一个数字时,第一次在树的第一层(根节点)判断,第二次在树的第二层判断,依次类推,树有多少层,就会进行多少次判断,即对于 k 层的树,最坏时间复杂度为O(k)。
  22. 所以只需要知道 n 个节点的树有多少层即可。
  23. 若为满二叉树(除叶子节点外,每个节点均有两个节点),则对于第一层,有一个节点(2^0),对于第二层有两个节点(2^1),依次类推对于第 k 层有 2^k-12 k-1 次方)。
  24. 所以 n = 2^0 + 2^1 + ... + 2^k-1,从而 k = log(n + 1)。
  25. 所以时间复杂度为 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 多万条数据)。

 

(1)局部性原理 与 磁盘预读
  局部性原理 指的是 当一个数据被使用时,那么其附近的数据通常也会被使用。
  在 InnoDB 中,数据存储在磁盘上,而直接操作磁盘 I/O 操作会很耗时(比操作内存中的数据慢),降低效率。
  为了提高效率、降低磁盘 I/O 次数,在真正处理数据前 先要将数据 从磁盘中读取并加载到 内存中。
  若每次只从 磁盘 读一条数据到 内存中,那么效率肯定很低。所以操作系统一般采用 磁盘预读的形式,一次读取 指定长度的数据进入内存(即使不需要使用到这么多数据,局部性原理)。此处指定长度称为 页,是操作系统操作数据的基本单位,操作系统中 页的大小一般为 4KB。

(2)B+树中 一个节点存储多少数据合适?
  进行磁盘预读时,将数据划分成若干个页,以 页 作为 磁盘 与 内存 交互的基本单位,InnoDB 默认页大小是 16 KB(类似于操作系统页的定义,若操作系统页大小为 4KB,那么 InnoDB 中 1页 等于 操作系统 4页),即每次最少从磁盘中读取 16KB 数据到内存,最少从 内存写入 16KB 数据到磁盘。

  B+ 树每个节点 存放 一页、或者 页的倍数比较合适。(假设每次读取节点均会经过磁盘 I/O)
  以一页为例,如果节点存储小于 一页,那么读取这个节点时仍然会读出一页,从而造成资源的浪费。而如果节点存储大于 一页小于二页,那么读取这个节点时将会读出 二页,同样也会造成资源的浪费。所以,一般 B+树 节点存放数据为 一页 或者 页的倍数。

  1. 【查看 InnoDB 默认页大小:】
  2. SHOW GLOBAL STATUS like 'Innodb_page_size';

 

 

(3)为什么 InnoDB 设置默认页大小为 16KB?而不是 32KB?

  1. 【首先明确一点:】
  2. B+ 非叶子节点存储的是 主键(关键字)+ 指针(指向叶子节点)。
  3. B+ 叶子节点存储的是 数据(真实的数据记录)。
  4. 假设每次读取一个节点均会执行一次磁盘 I/O,即每个节点大小为页的大小。
  5. 【以节点大小为 16KB 为例:】
  6. 假设一行数据大小为 1KB,那么一个叶子节点能保存 16 条记录。
  7. 假设非叶子节点主键为 bigint 类型,那么长度为 8B,而指针在 InnoDB 中大小为 6B,即一个非叶子节点能保存 16KB / 14B = 1170 个数据(主键 + 指针)。
  8. 那么对于 高度为 2 B+树,能存储记录数为: 1170 * 16 = 18720 条。
  9. 对于 高度为 3 B+树,能存储记录数为:1170 * 1170 * 16 = 21902400 条。
  10. 也就是说,若页大小为 16KB,那么高度为 3 B+ 树就能支持 2千万的数据存储。
  11. 当然若页大小更大,树的高度也会低,但是一般没有必要去修改。
  12. 读取一个节点需要经过一次磁盘 I/O,那么根据主键 只需要 1-3 次磁盘 I/O 即可查询到数据,能满足绝大部分需求。

 

(1)MySQL 采用 插件式的表存储引擎 管理数据,基于表而非基于数据库。在 MySQL 5.5 版本前默认使用 MyISAM 为默认存储引擎,在 5.5 版本后采用 InnoDB 作为默认存储引擎。

(2)MyISAM 不支持外键、不支持事务,支持表级锁(即每次操作均会对整个表加锁,不适合高并发操作)。会存储表的总行数,占用表空间小,多用于 读操作多 的场合。只缓存索引但不缓存真实数据。

(3)InnoDB 支持外键、支持事务,支持行级锁。不存储表的总行数,占用表空间大,多用于 写操作多 的场合。缓存索引的同时缓存真实数据,对内存要求较高(内存大小影响性能)。

(4)底层索引实现:
  MyISAM 使用 B+树作为 索引结构,但是其 索引文件 与 数据文件是 分开的,其叶子节点 存放的是 数据记录的地址,也即根据索引文件 找到 对应的数据记录的地址后,再去获取相应的数据。

  InnoDB 使用 B+树作为 索引结构,但是其 索引文件本身就是 数据文件,其叶子节点 存放的就是 完整的数据记录。InnoDB 必须要有主键,如果没有显示指定,系统会默认选择一个能够唯一标识数据记录的列作为主键,如果不存在这样的键,系统会给表生成一个隐含字段作为主键。

注:
  InnoDB 中一般使用 自增的 id 作为主键,每插入一条记录,相当于增加一个节点,如果主键是顺序的,那么直接添加在上一个记录后即可,若当前页满后,在新的页中继续存储。
  若主键无序,那么在插入数据的过程中,可能或出现在 所有叶子节点任意位置,若出现在所有叶子节点头部,那么将会导致所有叶子节点均向后移一位,涉及到 页的分裂以及数据的移动,是一种耗时操作、且造成大量内存碎片,影响效率。

 

(1)索引的代价:
空间上:
  一个索引 对应一颗 B+ 树,树的每个节点都是一个数据页,一个数据页占用大小为 16KB 的存储空间,数据量越大,占用的空间也就越大。

时间上:
  索引会根据数据进行排序,当对数据表数据进行 增、删、改 操作时,相应的 B+ 树索引也要去维护,会消耗时间 进行 记录移动、页面分裂、页面回收 等操作,并维护 数据有序。

(2)索引的选择:
索引的选择性:
  指的是 不重复索引值(基数)与 表记录总数 的比值(选择性 = 不重复索引值 / 表记录总数)。
  范围为 (0, 1],选择性 越大,即不重复索引值 越多,则建立索引的价值越大。
  选择性越小,即 重复索引值 越多,那么索引的意义不大。

索引选择:
  索引列 类型应尽量小。
  主键自增。

 

(1)图是什么?
  图用来描述 多对多关系 的一种数据结构。
  上一篇介绍了 一对一 的数据结构(比如:单链表、队列、栈等)以及 一对多的数据结构(比如:树),参考链接:https://www.cnblogs.com/l-y-h/p/13751459.html
  为了解决 多对多 关系,此处引入了 图 这种数据结构。

(2)图的基本概念
  图是一种数据结构,其每个节点可以具有 零个 或者 多个相邻的元素。
  两个节点之间的连接称为边(edge),节点可称为顶点(vertex)。
  从一个节点到另一个节点 所经过的边称为 路径。
  即 图由若干个顶点以及 顶点之间的边 组合而成。

图按照边可以分为:
  无向图。指的是 顶点之间的连接(边) 没有方向的图。
  有向图。指的是 边 有方向的图。
  带权图。指的是 边 带有权值的图。

 

 

(3)图的表示方式
  图的表示形式一般有两种:邻接矩阵(二维数组表示)、邻接表(链表)。
邻接矩阵:
  使用 一维数组 记录图中 顶点数据,使用 二维数组 记录图中 顶点之间的相邻关系(边)。对于 n 个顶点的图,使用 n*n 的二维数组记录 边的关系。

 

 

邻接表:
  使用 数组 + 链表的形式 记录 各顶点 以及顶点之间的 相邻关系(只记录存在的边)。
  使用 一维数组 记录图中 顶点数据,使用链表记录 存在的边。

 

 

邻接表 与 邻接矩阵区别:
  邻接矩阵中 需要为 每个顶点 记录 n 个边,其中很多边不存在(无需记录),造成空间的浪费。
  邻接表只 记录存在的边,不会造成空间的浪费。

 

(4)使用 邻接矩阵 形式构建 无向图:

  1. 【构建思路:】
  2. 使用 一维数组 记录 图的顶点数据。
  3. 使用 二维数组 记录 图的各顶点的联系(边,其中 1 表示存在边,0 表示不存在边)。
  4. 【代码实现:】
  5. package com.lyh.chart;
  6. import java.util.ArrayList;
  7. import java.util.Arrays;
  8. import java.util.List;
  9. /**
  10. * 使用 邻接矩阵 形式构建无向图
  11. */
  12. public class UndirectedGraph {
  13. private List<String> vertexs; // 用于保存 无向图 的顶点数据(可以使用一维数组)
  14. private int[][] edges; // 用于保存 无向图 中各顶点之间的关系,1 表示两顶点之间存在边,0 表示不存在边
  15. private int numberOfEdges; // 用于记录 无向图中边的个数
  16.  
  17. /**
  18. * 根据 顶点个数 进行初始化
  19. * @param number 顶点个数
  20. */
  21. public UndirectedGraph(int number) {
  22. vertexs = new ArrayList<>(number); // 用于记录顶点
  23. edges = new int[number][number]; // 用于记录顶点之间的关系
  24. numberOfEdges = 0; // 用于记录边的个数
  25. }
  26. /**
  27. * 添加顶点
  28. * @param vertex 顶点
  29. */
  30. public void insertVertex(String vertex) {
  31. vertexs.add(vertex);
  32. }
  33. /**
  34. * 添加边
  35. * @param row 行
  36. * @param column 列
  37. * @param value 值(1 表示存在边,0表示不存在边)
  38. */
  39. public void insertEdge(int row, int column, int value) {
  40. edges[row][column] = value; // 设置边
  41. edges[column][row] = value; // 设置边,对称
  42. numberOfEdges++; // 边总数加 1
  43. }
  44. /**
  45. * 返回边的总数
  46. * @return 边的总数
  47. */
  48. public int getNumberOfEdges() {
  49. return numberOfEdges;
  50. }
  51. /**
  52. * 返回顶点的总数
  53. * @return 顶点总数
  54. */
  55. public int getNumberOfVertex() {
  56. return vertexs.size();
  57. }
  58. /**
  59. * 返回 下标对应的顶点数据
  60. * @param index 顶点下标
  61. * @return 顶点数据
  62. */
  63. public String getValueByIndex(int index) {
  64. return vertexs.get(index);
  65. }
  66. /**
  67. * 输出邻接矩阵
  68. */
  69. public void showGraph() {
  70. for (int[] row : edges) {
  71. System.out.println(Arrays.toString(row));
  72. }
  73. }
  74. public static void main(String[] args) {
  75. // 初始化无向图
  76. UndirectedGraph undirectedGraph = new UndirectedGraph(5);
  77. // 插入顶点数据
  78. String[] vertexs = new String[]{"A", "B", "C", "D", "E"};
  79. for (String vertex : vertexs) {
  80. undirectedGraph.insertVertex(vertex);
  81. }
  82. // 插入边
  83. undirectedGraph.insertEdge(0, 1, 1); // A-B
  84. undirectedGraph.insertEdge(0, 2, 1); // A-C
  85. undirectedGraph.insertEdge(1, 2, 1); // B-C
  86. undirectedGraph.insertEdge(1, 3, 1); // B-D
  87. undirectedGraph.insertEdge(1, 4, 1); // B-E
  88. // 输出
  89. System.out.println("无向图顶点总数为: " + undirectedGraph.getNumberOfVertex());
  90. System.out.println("无向图边总数为: " + undirectedGraph.getNumberOfEdges());
  91. System.out.println("无向图第 3 个顶点为: " + undirectedGraph.getValueByIndex(2));
  92. System.out.println("无向图 邻接矩阵为: ");
  93. undirectedGraph.showGraph();
  94. }
  95. }
  96. 【输出结果为:】
  97. 无向图顶点总数为: 5
  98. 无向图边总数为: 5
  99. 无向图第 3 个顶点为: C
  100. 无向图 邻接矩阵为:
  101. [0, 1, 1, 0, 0]
  102. [1, 0, 1, 1, 1]
  103. [1, 1, 0, 0, 0]
  104. [0, 1, 0, 0, 0]
  105. [0, 1, 0, 0, 0]

 

(5)图的遍历方式:
  图的遍历,即对顶点的访问,一般遍历顶点有两种策略:DFS、BFS。
  DFS 为深度优先遍历,可以联想到 树的 先序、中序、后序 遍历。即 纵向访问 节点。
  BFS 为广度优先遍历,可以联想到 树的 顺序(层序)遍历,即 横向分层 访问 节点。

 

 

(1)DFS
  DFS 指的是 Depth First Search,即 深度优先搜索。
  其从一个节点出发,优先访问该节点的第一个邻接节点,并将此邻接节点作为新的节点,继续访问其第一个邻接节点(为了防止重复访问同一节点,可以将节点分为 已访问、未访问 两种状态,若节点已访问,则跳过该节点)。
  深度优先搜索是一个递归的过程(可以使用栈模拟递归实现),每次访问当前节点的第一个邻接节点。

(2)DFS 步骤 与 代码实现:

  1. 【步骤:】
  2. Step1:访问初始节点 start,标记该节点 start 已访问。
  3. Step2:查找节点 start 的第一个邻接节点 neighbor
  4. Step2.1:若 neighbor 不存在,则返回 Step1,且从 start 下一个节点继续执行。
  5. Step2.2:若 neighbor 存在,且未被访问,则返回 Step1,且将 neighbor 视为新的 start 执行。
  6. Step2.3:若 neighbor 存在,且已被访问,则返回 Step2,且从 neighbor 下一个节点继续执行。
  7. 【举例:】
  8. 图的 邻接矩阵表示如下:,图各顶点 按照顺序为 A B C D E
  9. A B C D E
  10. A [0, 1, 1, 0, 0]
  11. B [1, 0, 1, 1, 1]
  12. C [1, 1, 0, 0, 0]
  13. D [0, 1, 0, 0, 0]
  14. E [0, 1, 0, 0, 0]
  15. 注:
  16. 1 表示两个顶点间存在边,0 表示不存在边。
  17. 则遍历过程如下:(整个过程是纵向的)
  18. Step1:从 A 开始遍历,将 A 标记为 已访问。找到 A 第一个邻接节点 B
  19. Step2B 未被访问,将 B 视为新的节点开始遍历,将 B 标记为已访问,找到 B 的第一个邻接节点 A
  20. Step3A 被访问过,继续查找 B 下一个邻接节点为 C
  21. Step4C 未被访问过,将 C 视为新节点开始遍历,将 C 标记为已访问,找到 C 的第一个邻接节点 A
  22. Step5A 被访问,继续查找 C 下一个邻接节点为 BB 也被访问,继续查找,C 没有邻接节点,回退到上一层 B
  23. Step6:继续查找 B 下一个邻接节点为 D,将 D 标记已访问,同理可知 D 没有 未被访问的邻接顶点,回退到上一层 B
  24. Step7:查找 B 下一个邻接节点为 E,将 E 标记已访问,至此遍历完成。
  25. 即顺序为:A -> B -> C -> D -> E
  26. 【代码实现:】
  27. package com.lyh.chart;
  28. import java.util.ArrayList;
  29. import java.util.Arrays;
  30. import java.util.List;
  31. /**
  32. * 使用 邻接矩阵 形式构建无向图
  33. */
  34. public class UndirectedGraph {
  35. private List<String> vertexs; // 用于保存 无向图 的顶点数据(可以使用一维数组)
  36. private int[][] edges; // 用于保存 无向图 中各顶点之间的关系,1 表示两顶点之间存在边,0 表示不存在边
  37. private int numberOfEdges; // 用于记录 无向图中边的个数
  38. private boolean[] isVisit; // 用于记录 顶点是否被访问,true 表示已访问
  39.  
  40. /**
  41. * 根据 顶点个数 进行初始化
  42. * @param number 顶点个数
  43. */
  44. public UndirectedGraph(int number) {
  45. vertexs = new ArrayList<>(number); // 用于记录顶点
  46. edges = new int[number][number]; // 用于记录顶点之间的关系
  47. numberOfEdges = 0; // 用于记录边的个数
  48. isVisit = new boolean[number]; // 用于记录顶点是否被访问
  49. }
  50. /**
  51. * 添加顶点
  52. * @param vertex 顶点
  53. */
  54. public void insertVertex(String vertex) {
  55. vertexs.add(vertex);
  56. }
  57. /**
  58. * 添加边
  59. * @param row 行
  60. * @param column 列
  61. * @param value 值(1 表示存在边,0表示不存在边)
  62. */
  63. public void insertEdge(int row, int column, int value) {
  64. edges[row][column] = value; // 设置边
  65. edges[column][row] = value; // 设置边,对称
  66. numberOfEdges++; // 边总数加 1
  67. }
  68. /**
  69. * 返回边的总数
  70. * @return 边的总数
  71. */
  72. public int getNumberOfEdges() {
  73. return numberOfEdges;
  74. }
  75. /**
  76. * 返回顶点的总数
  77. * @return 顶点总数
  78. */
  79. public int getNumberOfVertex() {
  80. return vertexs.size();
  81. }
  82. /**
  83. * 返回 下标对应的顶点数据
  84. * @param index 顶点下标
  85. * @return 顶点数据
  86. */
  87. public String getValueByIndex(int index) {
  88. return vertexs.get(index);
  89. }
  90. /**
  91. * 输出邻接矩阵
  92. */
  93. public void showGraph() {
  94. for (int[] row : edges) {
  95. System.out.println(Arrays.toString(row));
  96. }
  97. }
  98. /**
  99. * 获取下一个顶点的下标
  100. * @param row 行
  101. * @param column 列
  102. * @return 下一个邻接顶点的下标(-1 表示不存在下一个邻接顶点)
  103. */
  104. public int getNeighborVertexIndex(int row, int column) {
  105. for (int index = column + 1; index < vertexs.size(); index++) {
  106. if (edges[row][index] != 0) {
  107. return index;
  108. }
  109. }
  110. return -1;
  111. }
  112. /**
  113. * 返回当前顶点 的第一个邻接顶点的下标
  114. * @param index 当前顶点下标
  115. * @return 第一个邻接顶点的下标(-1 表示不存在邻接顶点)
  116. */
  117. public int getFirstVertextIndex(int index) {
  118. return getNeighborVertexIndex(index, -1);
  119. }
  120. /**
  121. * 深度优先遍历
  122. */
  123. public void dfs() {
  124. // 未被访问的顶点,进行深度优先遍历
  125. for (int index = 0; index < vertexs.size(); index++) {
  126. if (!isVisit[index]) {
  127. dfs(index);
  128. }
  129. }
  130. }
  131. /**
  132. * 深度优先遍历
  133. * @param index 顶点下标
  134. */
  135. private void dfs(int index) {
  136. // 输出当前顶点数据
  137. System.out.print(getValueByIndex(index) + " ==> ");
  138. // 标记当前顶点为 已访问
  139. isVisit[index] = true;
  140. // 获取当前顶点第一个邻接顶点下标
  141. int neighborIndex = getFirstVertextIndex(index);
  142. // 当下一个邻接顶点存在时
  143. while(neighborIndex != -1) {
  144. // 若邻接顶点未被访问,则递归遍历
  145. if (!isVisit[neighborIndex]) {
  146. dfs(neighborIndex);
  147. } else {
  148. // 若邻接顶点已被访问,则访问当前邻接顶点的下一个邻接顶点
  149. neighborIndex = getNeighborVertexIndex(index, neighborIndex);
  150. }
  151. }
  152. }
  153. public static void main(String[] args) {
  154. // 初始化无向图
  155. UndirectedGraph undirectedGraph = new UndirectedGraph(5);
  156. // 插入顶点数据
  157. String[] vertexs = new String[]{"A", "B", "C", "D", "E"};
  158. for (String vertex : vertexs) {
  159. undirectedGraph.insertVertex(vertex);
  160. }
  161. // 插入边
  162. undirectedGraph.insertEdge(0, 1, 1); // A-B
  163. undirectedGraph.insertEdge(0, 2, 1); // A-C
  164. undirectedGraph.insertEdge(1, 2, 1); // B-C
  165. undirectedGraph.insertEdge(1, 3, 1); // B-D
  166. undirectedGraph.insertEdge(1, 4, 1); // B-E
  167. // 输出
  168. System.out.println("无向图顶点总数为: " + undirectedGraph.getNumberOfVertex());
  169. System.out.println("无向图边总数为: " + undirectedGraph.getNumberOfEdges());
  170. System.out.println("无向图第 3 个顶点为: " + undirectedGraph.getValueByIndex(2));
  171. System.out.println("无向图 邻接矩阵为: ");
  172. undirectedGraph.showGraph();
  173. System.out.println("深度优先遍历结果为: ");
  174. undirectedGraph.dfs();
  175. }
  176. }
  177. 【输出结果:】
  178. 无向图顶点总数为: 5
  179. 无向图边总数为: 5
  180. 无向图第 3 个顶点为: C
  181. 无向图 邻接矩阵为:
  182. [0, 1, 1, 0, 0]
  183. [1, 0, 1, 1, 1]
  184. [1, 1, 0, 0, 0]
  185. [0, 1, 0, 0, 0]
  186. [0, 1, 0, 0, 0]
  187. 深度优先遍历结果为:
  188. A ==> B ==> C ==> D ==> E ==>

 

(1)BFS
  BFS 指的是 Broad First Search,即广度优先搜索。
  其类似于 分层搜索的过程,依次访问各层 的节点。可以使用队列来记录 访问过的节点的顺序,用于按照该顺序来访问 这些节点的邻接节点。

(2)BFS 步骤、代码实现

  1. 【步骤:】
  2. Step1:访问初始节点 start,并标记为 已访问,start 入队列。
  3. Step2:循环取出队列,若队列为空,则结束循环,否则执行下面步骤。
  4. Step3:取得队列头部节点,即为 first,并查找 first 的第一个邻接节点 neighbor
  5. Step3.1:若 neighbor 不存在,则返回 Step2,再取出队列 新的头节点。
  6. Step3.2:若 neighbor 存在,且未被访问,则将其标记为 已访问并入队列。
  7. Step3.3:若 neighbor 存在,且已被访问,则返回 Step3,并查找 neighbor 的下一个节点。
  8. 注:
  9. Step3 将某一层 未访问的节点 入队列,当该层顶点全部被访问时,执行 Step2
  10. 从队列中取出 头部节点,即为 新的层,并开始查找未被访问的节点入队列。
  11. 【举例:】
  12. 图的 邻接矩阵表示如下:,图各顶点 按照顺序为 A B C D E
  13. A B C D E
  14. A [0, 1, 1, 0, 0]
  15. B [1, 0, 1, 1, 1]
  16. C [1, 1, 0, 0, 0]
  17. D [0, 1, 0, 0, 0]
  18. E [0, 1, 0, 0, 0]
  19. 注:
  20. 1 表示两个顶点间存在边,0 表示不存在边。
  21. 则遍历过程如下:(整个过程是横向分层的)
  22. Step1:从 A 开始,将其标记为 已访问,将 A 存入队列末尾。
  23. Step2:取出队列头部元素为 A,查找其邻接节点为 BB 未被访问将其入队列、并标记为 已访问。
  24. Step3:继续查找 A 下一个邻接节点为 CC 为被访问将其入队列、并标记为 已访问。
  25. Step4A 层遍历结束,取出队列头部为元素为 B,即开始访问 B 层。
  26. Step5B 层未被访问的节点 依次入队列并标记为已访问。即 DE 入队列。
  27. Step6:同理依次取出队列头部元素 CDE,直至遍历完成。
  28. 即顺序为:A -> B -> C -> D -> E
  29. 【代码实现:】
  30. package com.lyh.chart;
  31. import java.util.ArrayList;
  32. import java.util.Arrays;
  33. import java.util.LinkedList;
  34. import java.util.List;
  35. /**
  36. * 使用 邻接矩阵 形式构建无向图
  37. */
  38. public class UndirectedGraph {
  39. private List<String> vertexs; // 用于保存 无向图 的顶点数据(可以使用一维数组)
  40. private int[][] edges; // 用于保存 无向图 中各顶点之间的关系,1 表示两顶点之间存在边,0 表示不存在边
  41. private int numberOfEdges; // 用于记录 无向图中边的个数
  42. private boolean[] isVisit; // 用于记录 顶点是否被访问,true 表示已访问
  43.  
  44. /**
  45. * 根据 顶点个数 进行初始化
  46. * @param number 顶点个数
  47. */
  48. public UndirectedGraph(int number) {
  49. vertexs = new ArrayList<>(number); // 用于记录顶点
  50. edges = new int[number][number]; // 用于记录顶点之间的关系
  51. numberOfEdges = 0; // 用于记录边的个数
  52. isVisit = new boolean[number]; // 用于记录顶点是否被访问
  53. }
  54. /**
  55. * 添加顶点
  56. * @param vertex 顶点
  57. */
  58. public void insertVertex(String vertex) {
  59. vertexs.add(vertex);
  60. }
  61. /**
  62. * 添加边
  63. * @param row 行
  64. * @param column 列
  65. * @param value 值(1 表示存在边,0表示不存在边)
  66. */
  67. public void insertEdge(int row, int column, int value) {
  68. edges[row][column] = value; // 设置边
  69. edges[column][row] = value; // 设置边,对称
  70. numberOfEdges++; // 边总数加 1
  71. }
  72. /**
  73. * 返回边的总数
  74. * @return 边的总数
  75. */
  76. public int getNumberOfEdges() {
  77. return numberOfEdges;
  78. }
  79. /**
  80. * 返回顶点的总数
  81. * @return 顶点总数
  82. */
  83. public int getNumberOfVertex() {
  84. return vertexs.size();
  85. }
  86. /**
  87. * 返回 下标对应的顶点数据
  88. * @param index 顶点下标
  89. * @return 顶点数据
  90. */
  91. public String getValueByIndex(int index) {
  92. return vertexs.get(index);
  93. }
  94. /**
  95. * 输出邻接矩阵
  96. */
  97. public void showGraph() {
  98. for (int[] row : edges) {
  99. System.out.println(Arrays.toString(row));
  100. }
  101. }
  102. /**
  103. * 获取下一个顶点的下标
  104. * @param row 行
  105. * @param column 列
  106. * @return 下一个邻接顶点的下标(-1 表示不存在下一个邻接顶点)
  107. */
  108. public int getNeighborVertexIndex(int row, int column) {
  109. for (int index = column + 1; index < vertexs.size(); index++) {
  110. if (edges[row][index] != 0) {
  111. return index;
  112. }
  113. }
  114. return -1;
  115. }
  116. /**
  117. * 返回当前顶点 的第一个邻接顶点的下标
  118. * @param index 当前顶点下标
  119. * @return 第一个邻接顶点的下标(-1 表示不存在邻接顶点)
  120. */
  121. public int getFirstVertextIndex(int index) {
  122. return getNeighborVertexIndex(index, -1);
  123. }
  124. /**
  125. * 广度优先遍历
  126. */
  127. public void bfs() {
  128. // 未被访问的顶点,进行广度优先遍历
  129. for (int index = 0; index < vertexs.size(); index++) {
  130. if (!isVisit[index]) {
  131. bfs(index);
  132. }
  133. }
  134. }
  135. /**
  136. * 广度优先遍历
  137. * @param index 顶点下标
  138. */
  139. private void bfs(int index) {
  140. // 输出当前顶点数据
  141. System.out.print(getValueByIndex(index) + " ==> ");
  142. // 用于记录访问的顶点
  143. LinkedList<Integer> queue = new LinkedList<>();
  144. int firstIndex; // 用于记录队列的头部节点
  145. int neighborIndex; // 用于记录邻接节点
  146. isVisit[index] = true; // 标记当前节点已被访问
  147. queue.addLast(index); // 当前节点入队列
  148. // 队列不空时
  149. while(!queue.isEmpty()) {
  150. // 取出队列头节点
  151. firstIndex = queue.removeFirst();
  152. // 找到邻接节点
  153. neighborIndex = getFirstVertextIndex(index);
  154. while(neighborIndex != -1) {
  155. if(!isVisit[neighborIndex]) {
  156. // 输出当前顶点数据
  157. System.out.print(getValueByIndex(neighborIndex) + " ==> ");
  158. isVisit[neighborIndex] = true;
  159. queue.addLast(neighborIndex);
  160. } else {
  161. neighborIndex = getNeighborVertexIndex(firstIndex, neighborIndex);
  162. }
  163. }
  164. }
  165. }
  166. public static void main(String[] args) {
  167. // 初始化无向图
  168. UndirectedGraph undirectedGraph = new UndirectedGraph(5);
  169. // 插入顶点数据
  170. String[] vertexs = new String[]{"A", "B", "C", "D", "E"};
  171. for (String vertex : vertexs) {
  172. undirectedGraph.insertVertex(vertex);
  173. }
  174. // 插入边
  175. undirectedGraph.insertEdge(0, 1, 1); // A-B
  176. undirectedGraph.insertEdge(0, 2, 1); // A-C
  177. undirectedGraph.insertEdge(1, 2, 1); // B-C
  178. undirectedGraph.insertEdge(1, 3, 1); // B-D
  179. undirectedGraph.insertEdge(1, 4, 1); // B-E
  180. // 输出
  181. System.out.println("无向图顶点总数为: " + undirectedGraph.getNumberOfVertex());
  182. System.out.println("无向图边总数为: " + undirectedGraph.getNumberOfEdges());
  183. System.out.println("无向图第 3 个顶点为: " + undirectedGraph.getValueByIndex(2));
  184. System.out.println("无向图 邻接矩阵为: ");
  185. undirectedGraph.showGraph();
  186. System.out.println("广度优先遍历结果为: ");
  187. undirectedGraph.bfs();
  188. }
  189. }
  190. 【输出结果:】
  191. 无向图顶点总数为: 5
  192. 无向图边总数为: 5
  193. 无向图第 3 个顶点为: C
  194. 无向图 邻接矩阵为:
  195. [0, 1, 1, 0, 0]
  196. [1, 0, 1, 1, 1]
  197. [1, 1, 0, 0, 0]
  198. [0, 1, 0, 0, 0]
  199. [0, 1, 0, 0, 0]
  200. 广度优先遍历结果为:
  201. A ==> B ==> C ==> D ==> E ==>

 

(1)二分查找
  二分查找是一个效率较高的查找方法。其要求必须采用 顺序存储结构 且 存储数据有序。
  每次查找数据时 根据 待查找数据 将总数据 分为两部分(一部分小于 待查找数据,一部分大于 待查找数据),设折半次数为 x,则 2^x = n,即折半次数为 x = logn,时间复杂度为 O(logn)。

(2)递归、非递归实现 二分查找

  1. 【代码实现:】
  2. package com.lyh.algorithm;
  3. /**
  4. * 二分查找、递归 与 非递归 实现
  5. */
  6. public class BinarySearch {
  7. public static void main(String[] args) {
  8. // 构建升序序列
  9. int[] arrays = new int[]{13, 27, 38, 49, 65, 76, 97};
  10. // 设置待查找数据
  11. int key = 27;
  12. // 递归二分查找
  13. int index = binarySearch(arrays, 0, arrays.length - 1, key);
  14. if (index != -1) {
  15. System.out.println("查找成功,下标为: " + index);
  16. } else {
  17. System.out.println("查找失败");
  18. }
  19. // 非递归二分查找
  20. int index2 = binarySearch2(arrays, 0, arrays.length - 1, key);
  21. if (index2 != -1) {
  22. System.out.println("查找成功,下标为: " + index2);
  23. } else {
  24. System.out.println("查找失败");
  25. }
  26. }
  27. /**
  28. * 折半查找,返回元素下标(递归查找,数组升序)
  29. * @param arrays 待查找数组
  30. * @param left 最左侧下标
  31. * @param right 最右侧下标
  32. * @param key 待查找数据
  33. * @return 查找失败返回 -1,查找成功返回元素下标 0 ~ n
  34. */
  35. public static int binarySearch(int[] arrays, int left, int right, int key) {
  36. if (left <= right) {
  37. // 获取中间下标
  38. int middle = (left + right) / 2;
  39. // 查找成功返回数据
  40. if (arrays[middle] == key) {
  41. return middle;
  42. }
  43. // 待查找数据 小于 中间数据,则从 左半部分数据进行查找
  44. if (arrays[middle] > key) {
  45. return binarySearch(arrays, left, middle - 1, key);
  46. }
  47. // 待查找数据 大于 中间数据,则从 右半部分数据进行查找
  48. if (arrays[middle] < key) {
  49. return binarySearch(arrays, middle + 1, right, key);
  50. }
  51. }
  52. return -1;
  53. }
  54. /**
  55. * 折半查找,返回元素下标(非递归查找,数组升序)
  56. * @param arrays 待查找数组
  57. * @param left 最左侧下标
  58. * @param right 最右侧下标
  59. * @param key 待查找数据
  60. * @return 查找失败返回 -1,查找成功返回元素下标 0 ~ n
  61. */
  62. public static int binarySearch2(int[] arrays, int left, int right, int key) {
  63. while(left <= right) {
  64. // 获取中间下标
  65. int middle = (left + right) / 2;
  66. // 查找成功返回数据
  67. if (arrays[middle] == key) {
  68. return middle;
  69. }
  70. // 待查找数据 小于 中间数据,则从 左半部分数据进行查找
  71. if (arrays[middle] > key) {
  72. right = middle - 1;
  73. } else {
  74. // 待查找数据 大于 中间数据,则从 右半部分数据进行查找
  75. left = middle + 1;
  76. }
  77. }
  78. return -1;
  79. }
  80. }
  81. 【输出结果:】
  82. 查找成功,下标为: 1
  83. 查找成功,下标为: 1

 

(1)分治算法:
  分治法 简单理解就是 分而治之,其将一个复杂的问题 分成 两个或者 若干个 相同或者 类似的 子问题,子问题 又可进一步划分为 若干个更小的子问题,直至 子问题可以很简单的求解,然后将 所有简单的子问题解合并,即可得到原问题的解。

  1. 【分治算法常见实现:】
  2. 归并排序、快速排序、汉诺塔问题等。
  3. 【分治算法基本步骤:】
  4. Step1:分解,将原问题 分解成 若干个 规模小、相互独立、与原问题类似的 子问题。
  5. Step2:求解,对于简单的子问题直接求解,否则递归求解各个子问题。
  6. Step3:合并,将各个子问题的解合并为原问题的解。

 

(2)汉诺塔代码实现

  1. 【汉诺塔问题:】
  2. 3 根柱子及 N 个不同大小的穿孔圆盘,盘子可以滑入任意一根柱子。
  3. 开始时所有盘子 按照自上而下按升序依次套在第一根柱子上(即每一个盘子只能放在更大的盘子上面)。
  4. 将所有盘子 第一根柱子上 移动到 第三根柱子上。
  5. 移动圆盘时受到以下限制:
  6. 每次只能移动一个盘子;
  7. 盘子只能从柱子顶端滑出移到下一根柱子;
  8. 盘子只能叠在比它大的盘子上。
  9. 【汉诺塔分析:】
  10. 假设三个柱子分为为:A B C,盘子最开始放置在 A,需要从 A 将其移动到 C
  11. 若只有一个盘子:
  12. 直接从 A 移动到 C
  13. 若有 2 个盘子:
  14. 则先将第一个盘子从 A 移动到 B
  15. 再将第二个盘子从 A 移动到 C
  16. 最后再将 第一个盘子从 B 移动到 C
  17. 若有 3 个盘子:
  18. 先将第 1 个盘子从 A 移动到 C
  19. 再将第 2个盘子从 A 移动到 B
  20. 再将第 1 个盘子从 C 移动到 B
  21. 再将第 3个盘子从 A 移动到 C
  22. 再将第 1 个盘子从 B 移动到 A
  23. 再将第 2个盘子从 B 移动到 C
  24. 最后第 1 个盘子从 A 移动到 C
  25. 同理,第 45...n 个盘子。
  26. 可以将 盘子分为两部分,一部分为 最底下的盘子(最大的盘子),一部分为 其余盘子。
  27. 先将其余盘子从 A 移动到 B 柱子上,再将 最大盘子从 A 移动到 C 柱子上,最后将 其余盘子从 B 移动到 C 柱子上。
  28. 注:
  29. 由于其余盘子数量可能大于 2,所以需要递归进行分治处理。
  30. 比如:
  31. 3 个盘子,原柱子为 A,目标柱子为 C,可借助中间柱子 B,将其视为 (A, B, C)。
  32. 将盘子从上到下分为两部分,第 12 个盘子为其余盘子,第 3 个盘子为最大盘子。
  33. 若其余盘子数量大于 2,又可以拆分成更小的盘子进行操作。
  34. 首先将 其余盘子 A 移动到 B,原柱子为 A,目标为 B,可借助中间柱子 C,将其视为 (A, C, B)。
  35. 然后将 最大盘子 A 移动到 C,直接移动。
  36. 最后将 其余盘子 B 移动到 C,原柱子为 B,目标为 C,可借助中间柱子 A,将其视为 (B, A, C)。
  37. 【代码实现:】
  38. package com.lyh.algorithm;
  39. import java.util.ArrayList;
  40. import java.util.List;
  41. public class HanoiTower {
  42. public static void main(String[] args) {
  43. // 打印汉诺塔盘子移动过程
  44. hanoiTower(3, "A", "B", "C");
  45. System.out.println("===========================\n");
  46. // 打印汉诺塔结果
  47. List<Integer> origin = new ArrayList<>();
  48. origin.add(2);
  49. origin.add(1);
  50. origin.add(0);
  51. List<Integer> middle = new ArrayList<>();
  52. List<Integer> target = new ArrayList<>();
  53. System.out.println("原汉诺塔为: ");
  54. System.out.println("原柱子: " + origin);
  55. System.out.println("中间柱子: " + middle);
  56. System.out.println("目标柱子: " + target);
  57. hanoiTower(origin, middle, target);
  58. System.out.println("移动后的汉诺塔为: ");
  59. System.out.println("原柱子: " + origin);
  60. System.out.println("中间柱子: " + middle);
  61. System.out.println("目标柱子: " + target);
  62. }
  63. /**
  64. * 汉诺塔问题,将盘子从原始柱子 A 移动到 目标柱子 C,可借助中间柱子 B。
  65. * 打印出移动流程。
  66. * @param num 盘子总数
  67. * @param a A 原始柱子
  68. * @param b B 中间柱子
  69. * @param c C 目标柱子
  70. */
  71. public static void hanoiTower(int num, String a, String b, String c) {
  72. // 只剩 1 个盘子时,直接从 A 移动到 C
  73. if (num == 1) {
  74. System.out.println("第 1 个盘子从 " + a + " 移动到 " + c);
  75. return;
  76. }
  77. // 盘子数 大于 2 时,将盘子视为两个部分,一个为 最大的盘子,另一个为 其余盘子
  78. // 先将其余盘子 移动到中间柱子上,即从 A 移动到 B,移动过程中使用到 C,此时 原始柱子为 A,目标柱子为 B,中间柱子为 C
  79. hanoiTower(num - 1, a, c, b);
  80. // 再将最大的盘子从 A 移动到 C
  81. System.out.println("第 " + num + "个盘子从 " + a + " 移动到 " + c);
  82. // 最后将其余盘子 从中间柱子移动到目标柱子上,即从 B 移动到 C,移动过程中使用到 A,此时 原始柱子为 B,目标柱子为 C,中间柱子为 A
  83. hanoiTower(num - 1, b, a, c);
  84. }
  85. /**
  86. * 将盘子从 origin 移动到 target
  87. * @param origin 原始柱子
  88. * @param middle 中间柱子
  89. * @param target 目标柱子
  90. */
  91. public static void hanoiTower(List<Integer> origin, List<Integer> middle, List<Integer> target) {
  92. move(origin.size(), origin, middle, target);
  93. }
  94. private static void move(int num, List<Integer> origin, List<Integer> middle, List<Integer> target) {
  95. // 只剩 1 个盘子时,直接从原始柱子 origin 移动到目标柱子 target
  96. if (num == 1) {
  97. target.add(origin.remove(origin.size() - 1));
  98. return;
  99. }
  100. // 将 其余盘子从原始柱子 origin 移动到中间柱子 middle 上,此时可将 target 视为中间柱子
  101. move(num - 1, origin, target, middle);
  102. // 将 最大盘子从原始柱子 origin 直接移动到 目标柱子 target 上。
  103. target.add(origin.remove(origin.size() - 1));
  104. // 将 其余盘子从中间柱子 middle 移动到目标柱子 target 上,此时可将 origin 视为中间柱子
  105. move(num - 1, middle, origin, target);
  106. }
  107. /**
  108. * 取巧思路,非算法实现(博大家一笑)
  109. * @param origin 原柱子
  110. * @param middle 中间柱子
  111. * @param target 目标柱子
  112. */
  113. public static void hanoiTower2(List<Integer> origin, List<Integer> middle, List<Integer> target) {
  114. target.addAll(origin);
  115. origin.clear();
  116. }
  117. }
  118. 【输出结果:】
  119. 1 个盘子从 A 移动到 C
  120. 2个盘子从 A 移动到 B
  121. 1 个盘子从 C 移动到 B
  122. 3个盘子从 A 移动到 C
  123. 1 个盘子从 B 移动到 A
  124. 2个盘子从 B 移动到 C
  125. 1 个盘子从 A 移动到 C
  126. ===========================
  127. 原汉诺塔为:
  128. 原柱子: [2, 1, 0]
  129. 中间柱子: []
  130. 目标柱子: []
  131. 移动后的汉诺塔为:
  132. 原柱子: []
  133. 中间柱子: []
  134. 目标柱子: [2, 1, 0]

 

(1)字符串匹配问题
  现有一个目标字符串 A,一个待匹配字符串(模式串) B,如何判断 A 中是否存在 B 字符串?
一般方法有两种:
  暴力匹配:一个一个字符进行匹配。
  KMP 算法:改进的字符串匹配。核心是 跳过无用匹配,减少匹配次数。

(2)暴力匹配

  1. 【思路:】
  2. 假设当前目标字符串 A 已经匹配到了 i 位置,待匹配字符串 B 已匹配到了 j 位置。
  3. 如果字符匹配成功,即 A[i] == B[j],则进行下一个字符匹配,即 i++,j++
  4. 如果字符匹配失败,即 A[i] != B[j],则进行回溯,回到 A 开始匹配字符,并对下一个字符重新进行匹配,即 i = i - j + 1, j = 0
  5. 注:
  6. 使用暴力匹配可能会 执行大量的回溯过程,造成时间的浪费。
  7. 时间复杂度为 O(n*m),n 为目标字符串长度,m 为模式串长度。
  8. 【举例:】
  9. 现有目标字符串 ABDABC,待匹配字符串为 ABC
  10. 第一次匹配:
  11. ABDABC
  12. ABC
  13. 匹配失败。
  14. 第二次匹配:
  15. ABDABC
  16. ABC
  17. 匹配失败。
  18. 第三次匹配:
  19. ABDABC
  20. ABC
  21. 匹配失败。
  22. 同理挨个字符向后匹配。
  23. 【代码实现:】
  24. package com.lyh.algorithm;
  25. /**
  26. * 字符串匹配
  27. */
  28. public class StringMatch {
  29. public static void main(String[] args) {
  30. String target = "BBC ABCDAB ABCDABCDABDE";
  31. String current = "ABCDABD";
  32. System.out.println("直接调用 String 的 contains 方法结果为: " + contains(target, current));
  33. System.out.println("暴力匹配,子字符串第一次出现的下标为: " + forceMatch(target, current));
  34. }
  35. /**
  36. * 暴力匹配
  37. * @param a 目标字符串
  38. * @param b 待匹配字符串
  39. * @return 匹配失败返回 -1,否则返回第一次出现的下标
  40. */
  41. public static int forceMatch(String a, String b) {
  42. char[] target = a.toCharArray(); // 将 目标字符串 转为 字符数组
  43. char[] current = b.toCharArray(); // 将 待匹配字符串 转为 字符数组
  44. // i 用于记录 目标字符串 当前匹配的下标,j 用于记录 待匹配字符串 当前匹配的下标
  45. int i = 0, j = 0;
  46. // 挨个字符进行匹配
  47. while (i < target.length && j < current.length) {
  48. // 匹配成功,则匹配下一个字符
  49. if (target[i] == current[j]) {
  50. i++;
  51. j++;
  52. } else {
  53. // 匹配失败,回退到开始字符的下一个位置重新进行匹配
  54. i = i - j + 1;
  55. j = 0;
  56. }
  57. }
  58. // 当 j 为待匹配字符串长度时,匹配成功
  59. if (j == current.length) {
  60. return i - j;
  61. }
  62. return -1;
  63. }
  64. /**
  65. * 一个取巧的方法,直接调用 String 的 contains 方法进行匹配(无法返回第一次出现的位置)
  66. * @param target 目标字符串
  67. * @param current 待匹配字符串
  68. * @return 匹配成功返回 true
  69. */
  70. public static boolean contains(String target, String current) {
  71. return target.contains(current);
  72. }
  73. }
  74. 【输出结果:】
  75. 直接调用 String contains 方法结果为: true
  76. 暴力匹配,子字符串第一次出现的下标为: 15

 

(3)KMP 算法
  KMP 指的是 Knuth-Morris-Pratt,三个人名拼接而成,用于在一个文本串 S 中 查找一个模式串 P 出现的位置。
  暴力匹配 是 挨个匹配 字符,而 KMP 是利用现有模式串信息,跳过一些无用的匹配操作,省去大量回溯时间。即暴力匹配过程中,若匹配失败,主串会发生回溯。而 KMP 算法匹配失败,主串不发生回溯,移动模式串去匹配。

 

 

  1. KMP 核心:】
  2. KMP 核心是通过 现有的模式串,跳过无用的匹配,减少回溯次数,从而提高匹配效率。
  3. KMP 算法为了减少无用匹配,根据模式串前后缀关系,将模式串从前缀位置移动到与其相同的后缀位置,从而跳过一些无用匹配。
  4. 并根据前后缀关系,得到一个 next 数组,用于记录模式串下标 i 匹配失败后,应该从 next[i] 处与当前主串重新开始比较。
  5. next[i] 存储的是前 i 个字符(即 0 ~ i - 1)组成的字符串中 最大相等前后缀的长度值。
  6. 相当于将模式串向后移动 i - next[i] 个位置(使前缀移动到后缀处),并与主串进行比较。
  7. 【推导过程:】
  8. 如何减少无用匹配(以主串:ABABABAA,模式串:ABAA 为例):
  9. 第一次匹配时:
  10. ABABABAA
  11. ABAA
  12. 发现 ABAB ABAA 前三个字符是一致的(ABA),
  13. 暴力匹配时,主串会回溯到开始字符的下一个位置进行比较,如下:
  14. ABABABAA
  15. ABAA
  16. 此时不匹配,继续匹配
  17. ABABABAA
  18. ABAA
  19. 可以发现,又出现了 ABAB ABAA 的情况,如果继续暴力处理,那么会多出很多无用操作。
  20. 那么能否根据现有的 模式串,作出一些处理,从而跳过一些无用的操作呢?
  21. 这里先介绍一下 字符串前缀、后缀(以字符串 ABAA 为例):
  22. 前缀:{A, AB, ABA},含头不含尾的子字符串。
  23. 后缀:{BAA, AA, A},含尾不含头的子字符串。
  24. 通过观察 ABAB ABAA 前三个字符 ABA,可以发现
  25. ABA 前缀为 {A, AB},后缀为 {BA, A},其最大公共串为 {A},
  26. 而在暴力匹配时可以发现 ABA 的相同前缀、后缀 中间的匹配操作 均为无用操作。
  27. 即下面匹配操作是无用操作,可以直接跳过。
  28. ABABABAA
  29. ABAA
  30. 即可以直接将 模式串 从前缀处 移动到 相同后缀处,并重新开始比较(划重点)。
  31. 那么能否 根据 模式串的 前、后缀 总结出一个规律呢?(以 ABAA 为例)
  32. ABAA 按照位置关系视为 0 ~ 3 位(此处使用),当然也可以视为 1 ~ 4 位。
  33. 0 1 2 3 或者 0 1 2 3 4
  34. A B A A A B A A
  35. 进行匹配时,若模式串第 0 位就匹配失败,则模式串移动到 当前主串位置的下一个位置开始匹配:
  36. 主串: C B A A A
  37. 模式串: A B A A
  38. 模式串第 0 位匹配失败后,下一次匹配时 模式串的第 0 从当前主串位置的下一个位置开始(当前主串位置下标为 0):
  39. 主串: C B A A A
  40. 模式串: A B A A
  41. 进行匹配时,若模式串第 1 位匹配失败,且此时模式串 0 位(A)没有 前缀、后缀,则模式串移动到 当前主串位置开始匹配。
  42. 主串: A C A A A
  43. 模式串: A B A A
  44. 模式串第 1 位匹配失败后,下一次匹配时 模式串的第 0 从当前主串位置开始(当前主串下标为 1)比较。
  45. 主串: A C A A A
  46. 模式串: A B A A
  47. 进行匹配时,若模式串第 2 位匹配失败,其此时模式串 0 ~ 1 位(AB)的 前缀、后缀 中没有最大公共串,则模式串移动到 当前主串位置开始匹配。
  48. 主串: A B C A A
  49. 模式串:A B A A
  50. 模式串第 2 位匹配失败后,下一次匹配时 模式串的第 0 从当前主串位置开始(当前主串下标为 2)比较。
  51. 主串: A B C A A
  52. 模式串: A B A A
  53. 进行匹配时,若模式串第 3 位匹配失败,其此时模式串 0 ~ 2 位(ABA)的前缀、后缀 最大公共串为 1A),则模式串移动到 当前主串位置开始匹配。
  54. 主串: A B A C A
  55. 模式串: A B A A
  56. 模式串第 3 位匹配失败后,下一次匹配时 模式串的第 1 从当前主串位置开始(当前主串下标为 3)比较。
  57. 主串: A B A C A
  58. 模式串: A B A A
  59. 通过上面对模式串 ABAA 的分析,
  60. 当模式串第 0 位匹配失败时,模式串需要移动 1 位,且主串需要向后移动 1 位。使得模式串下一次第 0 主串当前位置下一个位置开始比较,记此时为 -1
  61. 当模式串第 1 位匹配失败时,模式串前 0 位没有最大公共前、后缀串,此时模式串向后移动 1 位,主串不移动。使得模式串下一次第 0 主串当前位置开始比较,记此时为 0
  62. 当模式串第 2 位匹配失败时,模式串前 1 位没有最大公共前、后缀串,此时模式串向后移动 2 位,主串不移动。使得模式串下一次第 0 主串当前位置开始比较,记此时为 0
  63. 当模式串第 3 位匹配失败时,模式串前 2 位存在最大公共串且长度为 1,此时模式串向后移动 2 位,主串不移动。使得模式串下一次第 1 主串当前位置开始比较,记此时为 1
  64. 可以得到一个规律,
  65. 模式串第 i 为匹配失败时,模式串下一次匹配从 j 位开始与主串比较。
  66. j 位指的是 模式串 0 ~ i-1 串的 最大前、后缀长度。
  67. 而模式串需要移动的位数则为: i - j,即将模式串前缀 移动到 最长公共 后缀处。
  68. 也即 KMP 中的 next 数组(不同人定义的 next 可能有些许区别,但大体操作类似),
  69. next 数组元素表示 当前模式串下标 匹配失败后,下一次模式串中 需要与 主串进行匹配的 下标位置(即最长公共前、后缀的前缀的下一个位置)。
  70. 而模式串需要移动的位数则为: i - next[i]。
  71. 所以可以得到 ABAA next 数组如下:
  72. 0 1 2 3
  73. 模式串: A B A A
  74. next数组: -1 0 0 1
  75. 移动位数: 1 1 2 2
  76. 如果将 ABAA 视为 1~4 位,则每次加 1 即可,即 j 位指的是 模式串 0 ~ i-1 串的 最大前、后缀长度 1
  77. 0 1 2 3 4
  78. 模式串: A B A A
  79. next数组: 0 1 1 2
  80. 移动位数: 1 1 2 2

 

(4)KMP 算法的 next 数组代码实现(模式串自匹配)

  1. 【如何使用代码实现 KMP next 数组:】
  2. 通过上面分析, next 数组的求解是 KMP 关键之一,那么如何求解呢?
  3. 注意,此处的 next 每个元素表示的是 模式串当前元素下标 主串 匹配失败后,下一次 模式串 开始匹配的下标值。
  4. next[j] 表示 模式串下标为 j 的元素 主串匹配失败后,下一次模式串 开始匹配的下标位置(即 0 ~ j-1 表示的 字符串中 最长公共前、后缀的 前缀的下一个位置,也即最长前后缀长度)。
  5. 可参考上述推导过程推理 next 的计算。
  6. 假设现在模式串 str 长度为 n, i 表示当前模式串下标(0 ~ n-1),j 表示最大前缀的下一个位置的下标。
  7. 由于模式串第 0 位前面没有字符,此处设定为 next[0] = -1
  8. 而模式串第 1 位前面有一个字符,但是没有前、后缀,此处设定为 next[1]= 0
  9. 所以 模式串只需从第 2 位开始计算。记初始 i = 2, j = 0。(i 永远比 j 大)
  10. 强调一下:
  11. 此处 next[i] 指的是 i 个字符(即 0 ~ i - 1 所表示的字符串)中最大相等前后缀长度。
  12. i - 1 表示当前最大相等后缀的下一个位置,j 表示当前最大相等前缀的下一个位置。
  13. 也就是说前 j 个(0 ~ j-1)字符是当前最大的相等前缀。
  14. 下标为 j i - 1 所在字符 分别表示下一次需要比较的前缀 后缀,即比较 str[j] str[i - 1] 是否相等即可。
  15. str[j] == str[i - 1],那么最大相等前后缀长度又可以增加一位,此时 j++,i++
  16. str[j] != str[i - 1],则说明当前 0 ~ j 所表示的前缀 (i - j - 1) ~ (i - 1) 所表示的后缀不匹配,则需要从 0 ~ (j - 1) 所表示的前缀中 查找最大的前后缀 重新与 (i - j ) ~ (i - 1) 所表示的后缀匹配。
  17. 0 ~ (j - 1) 中最大前后缀长度即 next[j] 的值,所以此时 j = next[j]。若 j 仍然不匹配,则继续调用 j = next[j] 进行回退,直至 j 退回到模式串第 0 个位置。
  18. 注:
  19. 由于 next[0] 不存在前 0 位字符串,所以定义其为 -1,表示当前模式串第 0 位与主串当前位置下一位开始比较。
  20. i 如果从 1 开始定义,则 j 初始值可以设置为 -1i 1 时,下标为 0 的字符为待匹配的后缀,但是不存在前缀,可以假定前面有一位待匹配的前缀,假定下标为 -1
  21. i 如果从 2 开始定义,则 j 初始值可以设置为 0i 2 时,下标为 1 的字符为待匹配的后缀,下标为 0 为待匹配的前缀。
  22. 【代码实现为:(next 数组第一种写法)】
  23. /**
  24. * next 数组第一种写法。
  25. * KMP 实质上是根据字符串前后缀的特点,将前缀字符移动到后缀位置,经过一系列推导根据 最大相等前后缀长度 得到一个 next 数组。
  26. *
  27. * next 数组存储的是 当前模式串匹配失败后,下一次与主串匹配的模式串的下标值(也可以理解为 模式串根据前后缀关系需要移动的位置)。
  28. * 即 next[j] 表示的是 模式串中下标为 j 的元素与主串匹配失败后,下一次从 模式串中下标为 next[j] 的元素开始与 主串匹配。
  29. * 也即相当于将 模式串移动 j - next[j] 个位置,然后重新与主串进行匹配。
  30. * 而 next[j] 实际存储的 模式串 前 j 个字符 最大的相等的 前后缀的长度。
  31. *
  32. * 比如:
  33. * 模式串为 ABAA
  34. * 则其 next[3] 存储的是 ABA 的最大相等前后缀的长度。即 next[3] = 1.
  35. *
  36. * 注(以 ABAA 为例):
  37. * 字符串前缀为其 含头不含尾的 子字符串。比如:ABA 前缀为 {A, AB}.
  38. * 字符串后缀为其 含尾不含头的 子字符串。比如:ABA 前缀为 {BA, A}.
  39. * 记 next[0] = -1。next[1] = 0,均表示模式串向后挪一个位置(j - next[j])。
  40. * next[0] 表示前 0 位字符串(不存在),记为 -1.
  41. * next[1] 表示前 1 位字符串(即 A 没有前后缀),记为 0.
  42. * next[2] 表示前 2 位字符串(即 AB,没有最大相等的前后缀),记为 0.
  43. * next[3] 表示前 3 位字符串(即 ABA,最大相等前后缀为 A,长度为 1),记为 1.
  44. *
  45. * @param pattern 模式串
  46. * @return next 数组
  47. */
  48. private static int[] getNext(String pattern) {
  49. // 用于保存 next 数组
  50. int[] next = new int[pattern.length()];
  51. // 规定模式串第 0 位匹配失败后,下一次模式串第 0 位与 主串当前位置下一个位置 进行匹配,记此时为 -1
  52. next[0] = -1;
  53. // 模式串第 1 位匹配失败后,下一次模式串第 0 位与 主串当前位置进行匹配,记此时为 0
  54. next[1] = 0;
  55. // 遍历模式串,依次得到 模式串 第 i 位匹配失败后,下一次模式串需要从第 next[i] 位开始与主串进行匹配
  56. // i 表示模式串当前下标,j 表示模式串最大相等前后缀的 前缀的下一个位置的下标
  57. // i >= 2 时,模式串前 2 个字符才存在 前后缀,此时 next 求解才有意义
  58. for (int i = 2, j = 0; i < pattern.length(); i++) {
  59. // 模式串自匹配,为了求解 next[i],需在模式串前 i 个元素中找到 最大前后缀
  60. // j 表示的是当前最大前缀的下标,i-1 表示的是最大后缀的下标,若两者所在字符不匹配,则需要找到更小的前缀进行匹配,也即 j 需要回退
  61. // 而 j 所在下标表示 0 ~ j-1 之前属于最长前缀,现在需要从 0~j-1 中找到新的最长前缀,而 next[j] 保存的正好是该值。
  62. // 所以在此推出 j = next[j]
  63. while(j > 0 && pattern.charAt(i - 1) != pattern.charAt(j)) {
  64. j = next[j];
  65. }
  66. // 如果匹配,则说明当前最大前缀又可以增加一位,j 表示最长前缀的下一个位置(也即前缀长度),即 j++
  67. if (pattern.charAt(i - 1) == pattern.charAt(j)) {
  68. j++;
  69. }
  70. // 保存模式串下标为 i 匹配失败后,下一次从模式串开始匹配的下标位置
  71. next[i] = j;
  72. }
  73. return next;
  74. }
  75. 【举例:】
  76. A B C D A B C E F
  77. next[0] = -1,
  78. next[1] = 1.
  79. i = 2 时,j = 0,此时 str[i - 1] != str[j],即 next[2] = 0.
  80. i = 3 时,j = 0,此时 str[i - 1] != str[j],即 next[3] = 0.
  81. i = 4 时,j = 0,此时 str[i - 1] != str[j],即 next[4] = 0.
  82. i = 5 时,j = 0,此时 str[i - 1] == str[j],则 j++,即 next[5] = 1.
  83. i = 6 时,j = 1,此时 str[i - 1] == str[j],则 j++,即 next[6] = 2.
  84. i = 7 时,j = 2,此时 str[i - 1] == str[j],则 j++,即 next[7] = 3.
  85. i = 8 时,j = 3,此时 str[i - 1] != str[j],则 j = next[j] = 0 next[8] = 0.
  86. 【代码实现为:(next 数组第二种写法)】
  87. /**
  88. * next 数组第二种写法。
  89. * 上面第一种解法是 每次计算 前 i 个字符(i 从 2 开始, j 从 0 开始)的模式串的最大相等前后缀,并将最大前缀下标的下一个位置赋值给 next[i]。
  90. * 而第二种解法是,每次计算 前 i + 1 个字符(i 从 1 开始,j 从 -1 开始)的模式串的最大相等前后缀,并将其值赋值给 next[i+1]。
  91. * 虽然写法稍有不同,但是原理都是类似的。
  92. * @param pattern 模式串
  93. * @return next 数组
  94. */
  95. private static int[] getNext2(String pattern) {
  96. // 用于保存 next 数组
  97. int[] next = new int[pattern.length()];
  98. // 规定模式串第 0 位匹配失败后,下一次模式串第 0 位与 主串当前位置下一个位置 进行匹配,记此时为 -1
  99. next[0] = -1;
  100. // i 表示模式串下标, j 表示最长前缀下一个位置的下标
  101. int i = 0, j = -1;
  102. // 遍历求解 next 数组
  103. while(i < pattern.length() - 1) {
  104. // 计算出模式串以当前下标为后缀的 最大相等前后缀长度,并将其值赋给 下一个 next。
  105. // 也即相当于 next[i] 保存的时 前 i 个字符(0 ~ i-1) 的最大前后缀长度
  106. if (j == -1 || pattern.charAt(i) == pattern.charAt(j)) {
  107. next[++i] = ++j;
  108. } else {
  109. j = next[j];
  110. }
  111. }
  112. return next;
  113. }

 

 

 

 

(5)改进的 KMP 算法(改进 next 数组求解)

  1. 【改进的 next 数组求解:】
  2. next 数组求解优化,即改进的 KMP 算法,
  3. 第二个求解 next 数组方式类似,区别在于 j 回退位置。
  4. 比如:
  5. 主串 ABACABAA
  6. 模式串 ABAB
  7. 由于模式串最后一个字符匹配失败,按照之前的 next 数组求法,得到 ABAA next 数组为 [-1, 0, 0, 1]。
  8. 则此时下一次匹配如下:
  9. 主串 ABACABAA
  10. 模式串 ABAB
  11. 可以很明显的看到此时的匹配无效,与上次匹配失败的字符相同。
  12. 此时应该直接一步到位,省去这次无用匹配。也即 模式串下标为 j 的字符回退时 若遇到相同的 字符,应该继续回退。
  13. 即第一次匹配失败后,直接进行如下匹配:
  14. 主串 ABACABAA
  15. 模式串 ABAB
  16. 模式串下标为 j next[j] 字符相同时,应该继续求解 next[next[j]] 的值。
  17. 比如:
  18. 模式串: ABAB,可以得到 next 数组为 [-1, 0, 0, 1]
  19. 下标为 0 匹配失败时,初值为 -1,固定不变。
  20. 下标为 1 匹配失败时,下标为 1 的字符为 B、下标为 next[1] 的字符为 A,字符并不同,所以 next[1] = 0,与原 next 求解相同。
  21. 下标为 2 匹配失败时,下标为 2 的字符为 A、下标为 next[2] 的字符为 A,字符相同,所以 next[2] = next[next[2]] = next[0] = -1.
  22. 下标为 3 匹配失败时,下标为 3 的字符为 B,下标为 next[3] 的字符为 B,字符相同,所以 next[3] = next[next[3]] = next[1] = 0.
  23. 即改进后的 ABAB 得到的 next 数组为 [-1, 0, -1, 0]。
  24. 此时再次匹配:
  25. 主串 ABACABAA
  26. 模式串 ABAB
  27. 则下一次匹配为(跳过了无用的匹配):
  28. 主串 ABACABAA
  29. 模式串 ABAB
  30. 【代码实现:】
  31. /**
  32. * next 数组求解优化,即改进的 KMP 算法,
  33. * 与 第二个求解 next 数组方式类似,区别在于 j 回退位置。
  34. * 比如:
  35. * 主串 ABACABAA
  36. * 模式串 ABAB
  37. * 由于模式串最后一个字符匹配失败,按照之前的 next 数组求法,得到 ABAA 的 next 数组为 [-1, 0, 0, 1]。
  38. * 则此时下一次匹配如下:
  39. * 主串 ABACABAA
  40. * 模式串 ABAB
  41. * 可以很明显的看到此时的匹配无效,与上次匹配失败的字符相同。
  42. * 此时应该直接一步到位,省去这次无用匹配。也即 模式串下标为 j 的字符回退时 若遇到相同的 字符,应该继续回退。
  43. *
  44. * 即 模式串下标为 j 与 next[j] 字符相同时,应该继续求解 next[next[j]] 的值。
  45. * 比如:
  46. * 模式串: ABAB,可以得到 next 数组为 [-1, 0, 0, 1]
  47. * 下标为 0 匹配失败时,初值为 -1,固定不变。
  48. * 下标为 1 匹配失败时,下标为 1 的字符为 B、下标为 next[1] 的字符为 A,字符并不同,所以 next[1] = 0,与原 next 求解相同。
  49. * 下标为 2 匹配失败时,下标为 2 的字符为 A、下标为 next[2] 的字符为 A,字符相同,所以 next[2] = next[next[2]] = next[0] = -1.
  50. * 下标为 3 匹配失败时,下标为 3 的字符为 B,下标为 next[3] 的字符为 B,字符相同,所以 next[3] = next[next[3]] = next[1] = 0.
  51. * 即改进后的 ABAB 得到的 next 数组为 [-1, 0, -1, 0]。
  52. *
  53. * 此时再次匹配:
  54. * 主串 ABACABAA
  55. * 模式串 ABAB
  56. * 则下一次匹配为(跳过了无用的匹配):
  57. * 主串 ABACABAA
  58. * 模式串 ABAB
  59. *
  60. * @param pattern 模式串
  61. * @return next 数组
  62. */
  63. private static int[] getNext3(String pattern) {
  64. int[] next = new int[pattern.length()];
  65. next[0] = -1;
  66. int i = 0, j = -1;
  67. while(i < pattern.length() - 1) {
  68. if (j == -1 || pattern.charAt(i) == pattern.charAt(j)) {
  69. // next[++i] = ++j;
  70. if (pattern.charAt(++i) == pattern.charAt(++j)) {
  71. next[i] = next[j];
  72. } else {
  73. next[i] = j;
  74. }
  75. } else {
  76. j = next[j];
  77. }
  78. }
  79. return next;
  80. }

 

(6)完整的 KMP 算法如下:
  包括两种求解 next 数组的方式,以及 next 数组优化后的方式,以及 kmp 根据 next 数组进行匹配。

  1. 【代码实现:】
  2. package com.lyh.algorithm;
  3. import java.util.Arrays;
  4. /**
  5. * 字符串匹配
  6. */
  7. public class StringMatch2 {
  8. public static void main(String[] args) {
  9. String target = "BBC ABCDAB ABCDABCDABDE";
  10. String pattern = "ABCDABD";
  11. System.out.println(Arrays.toString(getNext("ABAB")));
  12. System.out.println(Arrays.toString(getNext("ABCDABCEF")));
  13. System.out.println(Arrays.toString(getNext2("ABAB")));
  14. System.out.println(Arrays.toString(getNext2("ABCDABCDEF")));
  15. System.out.println(Arrays.toString(getNext3("ABAB")));
  16. System.out.println(Arrays.toString(getNext3("ABCDABCDEF")));
  17. System.out.println(kmp(target, pattern));
  18. }
  19. /**
  20. * KMP 算法,根据模式串生成 next 数组,减少无用匹配次数。
  21. * @param target 主串
  22. * @param pattern 模式串
  23. * @return 匹配失败返回 -1,否则返回相应的下标
  24. */
  25. public static int kmp(String target, String pattern) {
  26. // 获取 next 数组,用于模式串匹配失败后,下一次与主串匹配的位置。
  27. int[] next = getNext(pattern);
  28. int i = 0, j = 0;
  29. // 开始匹配
  30. // i 表示主串所处下标,j 表示模式串所处下标(j 同时也表示的是最长前缀的下一个位置)
  31. while (i < target.length() && j < pattern.length()) {
  32. // j == -1 表示下一次模式串 第 -1 位 与 当前主串位置进行比较,也即下一次 为模式串第 0 位 与 当前主串位置下一位置进行比较, 所以 i++,j++
  33. // target.charAt(i) == pattern.charAt(j) 表示模式串下标为 j 的字符与 主串匹配,也即当前 模式串 0~j 均可以作为最长前缀。
  34. // 也即下一次 模式串从下标为 j + 1 处 与 主串下一个位置开始比较,所以 i++,j++。
  35. if (j == -1 || target.charAt(i) == pattern.charAt(j)) {
  36. i++;
  37. j++;
  38. } else {
  39. // 此时属于 target.charAt(i) != pattern.charAt(j) 的情况,模式串需要进行回退。
  40. // next[j] 表示的是模式串下标为 j 的字符匹配失败后,下一次模式串中 应该与 主串当前位置进行 匹配的字符下标
  41. j = next[j];
  42. }
  43. }
  44. // 匹配成功
  45. if (j == pattern.length()) {
  46. return i - j;
  47. }
  48. // 匹配失败
  49. return -1;
  50. }
  51. /**
  52. * next 数组第一种写法。
  53. * KMP 实质上是根据字符串前后缀的特点,将前缀字符移动到后缀位置,经过一系列推导根据 最大相等前后缀长度 得到一个 next 数组。
  54. *
  55. * next 数组存储的是 当前模式串匹配失败后,下一次与主串匹配的模式串的下标值(也可以理解为 模式串根据前后缀关系需要移动的位置)。
  56. * 即 next[j] 表示的是 模式串中下标为 j 的元素与主串匹配失败后,下一次从 模式串中下标为 next[j] 的元素开始与 主串匹配。
  57. * 也即相当于将 模式串移动 j - next[j] 个位置,然后重新与主串进行匹配。
  58. * 而 next[j] 实际存储的 模式串 前 j 个字符 最大的相等的 前后缀的长度。
  59. *
  60. * 比如:
  61. * 模式串为 ABAA
  62. * 则其 next[3] 存储的是 ABA 的最大相等前后缀的长度。即 next[3] = 1.
  63. *
  64. * 注(以 ABAA 为例):
  65. * 字符串前缀为其 含头不含尾的 子字符串。比如:ABA 前缀为 {A, AB}.
  66. * 字符串后缀为其 含尾不含头的 子字符串。比如:ABA 前缀为 {BA, A}.
  67. * 记 next[0] = -1。next[1] = 0,均表示模式串向后挪一个位置(j - next[j])。
  68. * next[0] 表示前 0 位字符串(不存在),记为 -1.
  69. * next[1] 表示前 1 位字符串(即 A 没有前后缀),记为 0.
  70. * next[2] 表示前 2 位字符串(即 AB,没有最大相等的前后缀),记为 0.
  71. * next[3] 表示前 3 位字符串(即 ABA,最大相等前后缀为 A,长度为 1),记为 1.
  72. *
  73. * @param pattern 模式串
  74. * @return next 数组
  75. */
  76. private static int[] getNext(String pattern) {
  77. // 用于保存 next 数组
  78. int[] next = new int[pattern.length()];
  79. // 规定模式串第 0 位匹配失败后,下一次模式串第 0 位与 主串当前位置下一个位置 进行匹配,记此时为 -1
  80. next[0] = -1;
  81. // 模式串第 1 位匹配失败后,下一次模式串第 0 位与 主串当前位置进行匹配,记此时为 0
  82. next[1] = 0;
  83. // 遍历模式串,依次得到 模式串 第 i 位匹配失败后,下一次模式串需要从第 next[i] 位开始与主串进行匹配
  84. // i 表示模式串当前下标,j 表示模式串最大相等前后缀的 前缀的下一个位置的下标
  85. // i >= 2 时,模式串前 2 个字符才存在 前后缀,此时 next 求解才有意义
  86. for (int i = 2, j = 0; i < pattern.length(); i++) {
  87. // 模式串自匹配,为了求解 next[i],需在模式串前 i 个元素中找到 最大前后缀
  88. // j 表示的是当前最大前缀的下标,i-1 表示的是最大后缀的下标,若两者所在字符不匹配,则需要找到更小的前缀进行匹配,也即 j 需要回退
  89. // 而 j 所在下标表示 0 ~ j-1 之前属于最长前缀,现在需要从 0~j-1 中找到新的最长前缀,而 next[j] 保存的正好是该值。
  90. // 所以在此推出 j = next[j]
  91. while(j > 0 && pattern.charAt(i - 1) != pattern.charAt(j)) {
  92. j = next[j];
  93. }
  94. // 如果匹配,则说明当前最大前缀又可以增加一位,j 表示最长前缀的下一个位置(也即前缀长度),即 j++
  95. if (pattern.charAt(i - 1) == pattern.charAt(j)) {
  96. j++;
  97. }
  98. // 保存模式串下标为 i 匹配失败后,下一次从模式串开始匹配的下标位置
  99. next[i] = j;
  100. }
  101. return next;
  102. }
  103. /**
  104. * next 数组第二种写法。
  105. * 上面第一种解法是 每次计算 前 i 个字符(i 从 2 开始, j 从 0 开始)的模式串的最大相等前后缀,并将最大前缀下标的下一个位置赋值给 next[i]。
  106. * 而第二种解法是,每次计算 前 i + 1 个字符(i 从 1 开始,j 从 -1 开始)的模式串的最大相等前后缀,并将其值赋值给 next[i+1]。
  107. * 虽然写法稍有不同,但是原理都是类似的。
  108. * @param pattern 模式串
  109. * @return next 数组
  110. */
  111. private static int[] getNext2(String pattern) {
  112. // 用于保存 next 数组
  113. int[] next = new int[pattern.length()];
  114. // 规定模式串第 0 位匹配失败后,下一次模式串第 0 位与 主串当前位置下一个位置 进行匹配,记此时为 -1
  115. next[0] = -1;
  116. // i 表示模式串下标, j 表示最长前缀下一个位置的下标
  117. int i = 0, j = -1;
  118. // 遍历求解 next 数组
  119. while(i < pattern.length() - 1) {
  120. // 计算出模式串以当前下标为后缀的 最大相等前后缀长度,并将其值赋给 下一个 next。
  121. // 也即相当于 next[i] 保存的时 前 i 个字符(0 ~ i-1) 的最大前后缀长度
  122. if (j == -1 || pattern.charAt(i) == pattern.charAt(j)) {
  123. next[++i] = ++j;
  124. } else {
  125. j = next[j];
  126. }
  127. }
  128. return next;
  129. }
  130. /**
  131. * next 数组求解优化,即改进的 KMP 算法,
  132. * 与 第二个求解 next 数组方式类似,区别在于 j 回退位置。
  133. * 比如:
  134. * 主串 ABACABAA
  135. * 模式串 ABAB
  136. * 由于模式串最后一个字符匹配失败,按照之前的 next 数组求法,得到 ABAA 的 next 数组为 [-1, 0, 0, 1]。
  137. * 则此时下一次匹配如下:
  138. * 主串 ABACABAA
  139. * 模式串 ABAB
  140. * 可以很明显的看到此时的匹配无效,与上次匹配失败的字符相同。
  141. * 此时应该直接一步到位,省去这次无用匹配。也即 模式串下标为 j 的字符回退时 若遇到相同的 字符,应该继续回退。
  142. *
  143. * 即 模式串下标为 j 与 next[j] 字符相同时,应该继续求解 next[next[j]] 的值。
  144. * 比如:
  145. * 模式串: ABAB,可以得到 next 数组为 [-1, 0, 0, 1]
  146. * 下标为 0 匹配失败时,初值为 -1,固定不变。
  147. * 下标为 1 匹配失败时,下标为 1 的字符为 B、下标为 next[1] 的字符为 A,字符并不同,所以 next[1] = 0,与原 next 求解相同。
  148. * 下标为 2 匹配失败时,下标为 2 的字符为 A、下标为 next[2] 的字符为 A,字符相同,所以 next[2] = next[next[2]] = next[0] = -1.
  149. * 下标为 3 匹配失败时,下标为 3 的字符为 B,下标为 next[3] 的字符为 B,字符相同,所以 next[3] = next[next[3]] = next[1] = 0.
  150. * 即改进后的 ABAB 得到的 next 数组为 [-1, 0, -1, 0]。
  151. *
  152. * 此时再次匹配:
  153. * 主串 ABACABAA
  154. * 模式串 ABAB
  155. * 则下一次匹配为(跳过了无用的匹配):
  156. * 主串 ABACABAA
  157. * 模式串 ABAB
  158. *
  159. * @param pattern 模式串
  160. * @return next 数组
  161. */
  162. private static int[] getNext3(String pattern) {
  163. int[] next = new int[pattern.length()];
  164. next[0] = -1;
  165. int i = 0, j = -1;
  166. while(i < pattern.length() - 1) {
  167. if (j == -1 || pattern.charAt(i) == pattern.charAt(j)) {
  168. // next[++i] = ++j;
  169. if (pattern.charAt(++i) == pattern.charAt(++j)) {
  170. next[i] = next[j];
  171. } else {
  172. next[i] = j;
  173. }
  174. } else {
  175. j = next[j];
  176. }
  177. }
  178. return next;
  179. }
  180. }
  181. 【输出结果:】
  182. [-1, 0, 0, 1]
  183. [-1, 0, 0, 0, 0, 1, 2, 3, 0]
  184. [-1, 0, 0, 1]
  185. [-1, 0, 0, 0, 0, 1, 2, 3, 4, 0]
  186. [-1, 0, -1, 0]
  187. [-1, 0, 0, 0, -1, 0, 0, 0, 4, 0]
  188. 15

 

(1)贪心算法
  贪心算法 指的是 在对问题求解时,可以将问题简化成 若干类似的小问题,其每次解决小问题 的方式均是 当前情况下的最优选择(即 局部最优),最终得到原问题的最优解。
注:
  贪心算法其虽然每一步都能保证最优解,但是其最终结果并不一定是最优的(接近最优解的结果)。

(2)集合覆盖问题

  1. 【问题:】
  2. 现有 K1 - K5 五辆公交车,其能经过的站台(A - H)如下所示:
  3. 公交车 站台
  4. K1 "A", "B", "C"
  5. K2 "D", "A", "E"
  6. K3 "F", "B", "G"
  7. K4 "B", "C"
  8. K5 "G", "H"
  9. 如何选择最少的 公交车,能经过所有的公交站台。
  10. 【思路:】
  11. 穷举法 不切实际,肯定不能采取。
  12. 可以使用贪心算法,每次选择 当前覆盖最多公交站 的公交,可以快速选择到所有公交站台。
  13. 贪心法步骤:
  14. Step1:首先获取到所有的公交站台信息 allStation
  15. Step2:遍历所有公交车,根据公交车 站台信息 allStation 比较,从中找到一个 覆盖最多公交站 的公交。
  16. 记录此时的公交车,并将当前公交车经过的 站台 allStation 中去除。
  17. Step3:重复 Step2,直至 allStation 为空,也即经过所有公交站台 最少公交车 已经找到。
  18. 【举例分析:】
  19. Step1:首先获取到所有公交站台信息,allStation = ["A", "B", "C", "D", "E", "F", "G", "H"];
  20. Step2:遍历 K1 - K5 公交车,发现 K1K2K3 均能覆盖 3 个站台,K4K5 能覆盖 2 个站台。
  21. 按照顺序,先记录 K1 公交车,并去除 allStation 中相应的站台,此时 allStation = ["D", "E", "F", "G", "H"];
  22. Step3:再次遍历 K1 - K5(跳过 K1 亦可),此时 K2K3K5 能覆盖 2 个站台,K1K4 能覆盖 1 个站台。
  23. 按照顺序,先记录 K2 公交车,并去除 allStation 中相应的站台,此时 allStation = ["F", "G", "H"];
  24. Step4:再次遍历 K1 - K5,此时 K3K5 能覆盖 2 个站台,K1K2K4 能覆盖 1 个站台。
  25. 按照顺序,先记录 K3 公交车,并去除 allStation 中相应的站台,此时 allStation = ["H"];
  26. Step5:再次遍历 K1 - K5,此时 K5 能覆盖 1 个站台,K1 - K4 能覆盖 0 个站台。
  27. 记录 K5,并去除 allStation 中相应的站台,此时 allStation = [];
  28. Step6allStation 为空,即所有公交站台均可访问,此时公交为 [K1, K2, K3, K5]
  29. 注:
  30. 若第一次选择的并非 K1,而是 K2,则可能的结果为:[K2, k3, K5, K4].
  31. 可以发现,可能存在多个解,即 贪心法得到的结果不一定是最优解,但一定近似最优解。
  32. 【代码实现:】
  33. package com.lyh.algorithm;
  34. import java.util.ArrayList;
  35. import java.util.HashMap;
  36. import java.util.HashSet;
  37. import java.util.List;
  38. public class Greedy {
  39. public static void main(String[] args) {
  40. greedy();
  41. }
  42. /**
  43. * 贪心算法求解 集合覆盖问题。
  44. * 每次选取当前情况下的最优解,从而最终得到结果(结果不一定为最优解,但是近似最优解)
  45. */
  46. public static void greedy() {
  47. // 设置公交经过的站台
  48. HashSet<String> k1 = new HashSet<>();
  49. k1.add("A");
  50. k1.add("B");
  51. k1.add("C");
  52. HashSet<String> k2 = new HashSet<>();
  53. k2.add("D");
  54. k2.add("A");
  55. k2.add("E");
  56. HashSet<String> k3 = new HashSet<>();
  57. k3.add("F");
  58. k3.add("B");
  59. k3.add("G");
  60. HashSet<String> k4 = new HashSet<>();
  61. k4.add("B");
  62. k4.add("C");
  63. HashSet<String> k5 = new HashSet<>();
  64. k5.add("G");
  65. k5.add("H");
  66. // 保存所有公交 以及 公交站台信息
  67. HashMap<String, HashSet<String>> bus = new HashMap<>();
  68. bus.put("K1", k1);
  69. bus.put("K2", k2);
  70. bus.put("K3", k3);
  71. bus.put("K4", k4);
  72. bus.put("K5", k5);
  73. // 保存所有站台信息
  74. HashSet<String> allStation = new HashSet<>();
  75. for (String k : bus.keySet()) {
  76. allStation.addAll(bus.get(k));
  77. }
  78. // 用于记录最终结果
  79. List<String> result = new ArrayList<>();
  80. // 站台非空时,记录经过 最多 站台的 公交
  81. while(!allStation.isEmpty()) {
  82. // 用于记录经过 最多站台 的公交
  83. String maxKey = null;
  84. // 遍历各公交
  85. for (String k : bus.keySet()) {
  86. // 获取公交经过的站台信息
  87. HashSet<String> temp = bus.get(k);
  88. // 取当前 公交经过的 所有站台 与 总站台 的交集,并将交集 赋给 当前公交,用于记录当前公交所经过的最多站台数量
  89. temp.retainAll(allStation);
  90. // 用于记录当前场合下,经过 最多站台 的公交(局部最优)
  91. if (maxKey == null || temp.size() > bus.get(maxKey).size()) {
  92. maxKey = k;
  93. }
  94. }
  95. if (maxKey != null) {
  96. // 记录当前经过 最多站台的 公交
  97. result.add(maxKey);
  98. // 从所有公交站台中 移除 已经可以被经过的 公交站台
  99. allStation.removeAll(bus.get(maxKey));
  100. }
  101. }
  102. System.out.println("经过所有站台所需最少公交为:" + result);
  103. }
  104. }
  105. 【输出结果:】
  106. 经过所有站台所需最少公交为:[K1, K2, K3, K5]

 

(1)动态规划
  动态规划(Dynamic Programming)核心思想与 分治算法类似,都是将 大问题划分为若干个小问题求解,从子问题的解中得到原问题的答案。
  但不同之处在于 分治法 适用于 子问题相互独立的 情况,而动态规划 适用于 子问题不相互独立的情况,即 动态规划求解 建立在 上一个子问题的解的基础上。可以采用填表的方式,逐步推算得到最优解。

  1. 【动态规划关注点:】
  2. 最优子结构:每个阶段的状态可以从之前某个阶段直接得到。
  3. 状态转移:如何从一个状态 转移 到另一个状态。
  4. 注:
  5. 动态规划是以 空间 时间,其将每个子问题的计算结果保存,需要时直接取出,减少了重复计算子问题的时间。

 

(2)0/1 背包问题

  1. 【问题描述:】
  2. 背包问题是 给定一个容量的背包,以及若干个 具有一定 价值 以及 容量的物品,
  3. 保证 背包的容量下,选择物品放入背包,使得背包价值 最大。
  4. 注:
  5. 背包问题可以分为:0/1 背包、完全背包。
  6. 0/1 背包指的是 同一物品只能存在一个在背包中。
  7. 完全背包指的是 有多个相同的物品可以放入背包中。
  8. 0/1 背包分析:】
  9. 假设有 n 个物品,背包重量为 m
  10. 使用一维数组 weight[i] 表示 i 个物品的 重量。
  11. 使用一维数组 value[i] 表示 i 个物品的 价值。
  12. 使用二维数组 maxValue 用于记录物品放入背包的结果,maxValue[i][j] 表示前 i 个物品能装入容量为 j 的背包的最大价值,则 maxValue[n][m] 即为背包所能存放的最大价值。
  13. 对于 i-1 个物品,装入容量为 j 的背包,则其装入背包总价值为 maxValue[i-1][j],
  14. 对于 i 个物品,
  15. 若其重量大于背包重量,即 weight[i] > j,则该物品肯定不能放入背包。此时背包最大价值仍为上一次的最大值,即 maxValue[i][j] = maxValue[i-1][j]。
  16. 若其重量小于等于背包重量,即 weight[i] <= j,则该物品可以放入背包。将物品放入背包,并重新计算当前最大价值。
  17. 物品放入背包,去除 当前物品容量时 背包的最大价值 并加上当前物品价值,即可得到当前物品存入背包后的最大价值,即 maxValue[i][j] = maxValue[i-1][j - weight[i]] + value[i]
  18. 计算原有价值以及 新价值的最大值作为当前背包最大价值(状态转移),即 Math.max(maxValue[i-1][j], maxValue[i-1][j-weight[i]] + value[i]))
  19. 注:
  20. 由于每次都将子问题解记录,所以可以避免重复计算子问题解。
  21. 若想输出放入背包的物品,可以根据 maxValue[i][j] maxValue[i-1][j] 反推。
  22. maxValue[i][j] 大于 maxValue[i-1][j] 时,第 i 个物品肯定进入背包。
  23. 【举例:】
  24. 现有 3 个物品,价值为 {1500, 3000, 2000},重量为 {1, 4, 3}。背包容量为 4.
  25. 则使用 二维数组 记录各容量背包下物品存放最大价值如下表:
  26. 1 2 3 4
  27. 0 0 0 0 0
  28. 1 0 1500 1500 1500 1500
  29. 2 0 1500 1500 1500 3000
  30. 3 0 1500 1500 2000 3500
  31. 注:
  32. 行表示物品,列表示背包容量。行列组合起来表示 某背包容量下 物品存放的最大价值。
  33. 比如:
  34. 第一行,存放第一个物品,重量为 1,在背包容量为 1~4 的情况下,均能存放,则其最大价值为 1500.
  35. 第二行,存放第二个物品,重量为 4,在背包容量为 1~3 的情况下,不能存放,则其最大价值为 1500(只能存放第一个物品),
  36. 但其在背包容量为 4 时可以存放,此时最大价值为 3000,且去除了第一个物品,只存放第二个物品。
  37. 第三行,存放第三个物品,重量为 3,在背包容量为 1~2 的情况下,不能存放,则其最大价值为 1500
  38. 而其在背包容量为 3 时可以存放,此时最大价值为 2000,只存放第三个物品。
  39. 在背包容量为 4 时也可以存放,此时最大价值为 3500,存放第三个物品 第一个物品。
  40. 【代码实现:】
  41. package com.lyh.algorithm;
  42. import java.util.ArrayList;
  43. import java.util.List;
  44. /**
  45. * 0/1 背包问题
  46. */
  47. public class Knapsack {
  48. public static void main(String[] args) {
  49. int[] value = new int[]{1500, 3000, 2000}; // 设置物品价值
  50. int[] weight = new int[]{1, 4, 3}; // 设置物品容量
  51. int m = 4; // 设置背包容量
  52. int n = value.length; // 设置物品总数量
  53. knapsack(value, weight, n, m);
  54. }
  55. /**
  56. * 0/1 背包
  57. * @param value 物品价值
  58. * @param weight 物品重量
  59. * @param count 物品总数
  60. * @param capacity 背包总容量
  61. */
  62. public static void knapsack(int[] value, int[] weight, int count, int capacity) {
  63. // 背包存放最大价值,maxValue[i][j] 表示第 i 个物品存放在 容量为 j 的背包中的最大价值
  64. // 其中第一行、第一列均为 0,便于计算。
  65. int[][] maxValue = new int[count + 1][capacity + 1];
  66. // 依次求解各个容量背包下物品存放的最大价值
  67. // i 表示第 i 个物品,j 表示 容量为 j 的背包
  68. for (int i = 1; i < count + 1; i++) {
  69. for (int j = 1; j < capacity + 1; j++) {
  70. // 若背包容量小于当前物品,则当前物品肯定不能存进背包,直接赋值上一个求解值即可
  71. // 由于 i 从 1 开始计数,所以访问第一个物品重量时为 weight[i - 1],访问第一个物品价值为 value[i -1],保证从下标为 0 开始访问。
  72. if (j < weight[i - 1]) {
  73. maxValue[i][j] = maxValue[i - 1][j];
  74. } else {
  75. // 背包容量 大于等于当前物品,则当前物品可以存入背包,则重新计算最大价值
  76. // 当前物品存入背包价值为: 去除 当前物品容量时 背包的最大价值 与 当前物品价值 之和
  77. int currentValue = maxValue[i - 1][j - weight[i - 1]] + value[i - 1];
  78. // 计算当前物品价值 与 之前价值 最大值,并记录
  79. maxValue[i][j] = Math.max(currentValue, maxValue[i - 1][j]);
  80. }
  81. }
  82. }
  83. /**
  84. * 遍历输出各个容量下 背包存放物品的价值
  85. */
  86. System.out.println("各容量背包下,存放物品的最大值如下: ");
  87. for (int i = 0; i < count + 1; i++) {
  88. for (int j = 0; j < capacity + 1; j++) {
  89. System.out.print(maxValue[i][j] + " ");
  90. }
  91. System.out.println();
  92. }
  93. System.out.println("容量为: " + capacity + " 的背包存放最大物品价值为: " + maxValue[count][capacity]);
  94. // 反推出 存入 背包的物品
  95. int j = capacity; // 记录背包容量
  96. List<Integer> list = new ArrayList<>(); // 记录物品下标
  97. for (int i = count; i > 0; i--) {
  98. // maxValue[i][j] 大于 maxValue[i - 1][j],则第 i 个物品肯定进入背包了
  99. if (maxValue[i][j] > maxValue[i - 1][j]) {
  100. list.add(i - 1); // 记录物品下标
  101. j -= weight[i - 1]; // 查找去除当前物品下标后的背包存储的物品
  102. }
  103. if (j == 0) {
  104. break; // 背包中物品都已取出
  105. }
  106. }
  107. System.out.println("存入背包的物品为: ");
  108. for (int i = 0; i < list.size(); i++) {
  109. System.out.println("第 " + (list.get(i) + 1) + " 个物品,价值为: " + value[list.get(i)] + " ,重量为: " + weight[list.get(i)]);
  110. }
  111. }
  112. }
  113. 【输出结果:】
  114. 各容量背包下,存放物品的最大值如下:
  115. 0 0 0 0 0
  116. 0 1500 1500 1500 1500
  117. 0 1500 1500 1500 3000
  118. 0 1500 1500 2000 3500
  119. 容量为: 4 的背包存放最大物品价值为: 3500
  120. 存入背包的物品为:
  121. 3 个物品,价值为: 2000 ,重量为: 3
  122. 1 个物品,价值为: 1500 ,重量为: 1

 

版权声明:本文为l-y-h原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://www.cnblogs.com/l-y-h/p/13986943.html