Java 内功修炼 之 数据结构与算法(二)

一、二叉树补充、多叉树

1、二叉树(非递归实现遍历)

(1)前提
  前面一篇介绍了 二叉树、顺序二叉树、线索二叉树、哈夫曼树等树结构。
  可参考://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 操作),此时构建平衡二叉树会消耗大量时间,影响程序执行速度。同时会出现大量的树节点,导致平衡二叉树的高度非常大,此时再去进行查找操作 性能也不是很高。
  平衡二叉树中,每个节点有 一个数据项,以及两个子节点,那么能否增加 节点的子节点数 以及 数据项 来提高程序性能呢?从而引出了 多路查找树 的概念。

注:
  前面介绍了平衡二叉树,可参考://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 树如下:
可使用 //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 肯定非常大,这种情况下肯定不可取,舍弃。
  二叉查找树可参考://www.cnblogs.com/l-y-h/p/13751459.html#_label5_8

 

 

 

 

  为了降低树的高度,引出了 平衡二叉树,其可以动态的维护树的高度,使任意一个节点左右子树高度差绝对值不大于 1。

  对于平衡二叉查找树(AVL),新增节点时,会不断的调整节点位置以及树的高度。但随着数据量增大,树的高度也会增大,高度增大导致比较次数增多,若数据 无法一次读取到内存中,则每次比较前都得通过磁盘 IO 读取外存数据,导致磁盘 IO 增大,影响性能。
  二叉平衡树可参考://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)图是什么?
  图用来描述 多对多关系 的一种数据结构。
  上一篇介绍了 一对一 的数据结构(比如:单链表、队列、栈等)以及 一对多的数据结构(比如:树),参考链接://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 串的 最大前、后缀长度 加 10 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 ,重量为: 31 个物品,价值为: 1500 ,重量为: 1