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

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

(2)二叉树遍历

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

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

复制代码
【非递归实现前序遍历:】
前序遍历顺序:当前节点(父节点)、左子节点、右子节点。
实现思路:
首先明确一点,每次出栈的树节点即为当前需要输出的节点(第一个输出的节点为 根节点)。

每次首先输出的为 当前节点(父节点),所以父节点先入栈、再出栈。
出栈之后,需要重新选择出下一次需要输出的父节点。从当前节点的 左、右子节点中选择。
而左子节点需要在 右子节点前输出,所以右子节点需要先进栈,然后左子节点再进栈。
左子节点入栈后,再次出栈即为当前节点,然后重复上面操作,依次取出栈顶元素即可。

步骤:
Step1:根节点入栈。
Step2:根节点出栈,此时为当前节点,输出或者保存。
Step2.1:若当前节点存在右子节点,则压入栈。
Step2.2:若当前节点存在左子节点,则压入栈。
Step3:重复 Step2,依次取出栈顶元素并输出,栈为空时,则树遍历完成。

【非递归前序遍历代码实现:】
package com.lyh.tree;

import java.util.ArrayList;
import java.util.List;
import java.util.Stack;

public class BinaryTreeSort {
/**
* 前序遍历(非递归实现、使用栈模拟递归)
*/
public List prefixList(TreeNode9 root) {
// 使用集合保存最终结果
List result = new ArrayList<>();
// 根节点不存在时,返回空集合
if (root == null) {
return result;
}
// 使用栈模拟递归
Stack<TreeNode9> stack = new Stack<>();
// 根节点入栈
stack.push(root);
// 栈非空时,依次取出栈顶元素,此时栈顶元素为当前节点,输出,并将当前节点 左、右子节点入栈
// 左子节点 先于 右子节点出栈,所以左子节点在 右子节点入栈之后再入栈
while(!stack.isEmpty()) {
// 取出栈顶元素(当前节点)
TreeNode9 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 data; // 保存节点数据
TreeNode9 left; // 保存节点的 左子节点
TreeNode9 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 {

/**
 * 中序遍历(非递归实现,使用栈模拟递归)
 */
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 data; // 保存节点数据
TreeNode9 left; // 保存节点的 左子节点
TreeNode9 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 {

/**
 * 后序遍历(非递归实现,使用栈模拟递归)
 */
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 data; // 保存节点数据
TreeNode9 left; // 保存节点的 左子节点
TreeNode9 right; // 保存节点的 右子节点

public TreeNode9(K data) {
    this.data = data;
}

@Override
public String toString() {
    return "TreeNode9{ data= " + data + "}";
}

}

【输出结果:】
后序遍历: [3, 4, 1, 2, 0]
复制代码

2、多叉树、B树
(1)平衡二叉树可能存在的问题
  平衡二叉树虽然效率高,但是当数据量非常大时(数据存放在 数据库 或者 文件中,需要经过磁盘 I/O 操作),此时构建平衡二叉树会消耗大量时间,影响程序执行速度。同时会出现大量的树节点,导致平衡二叉树的高度非常大,此时再去进行查找操作 性能也不是很高。
  平衡二叉树中,每个节点有 一个数据项,以及两个子节点,那么能否增加 节点的子节点数 以及 数据项 来提高程序性能呢?从而引出了 多路查找树 的概念。

注:
  前面介绍了平衡二叉树,可参考:https://www.cnblogs.com/l-y-h/p/13751459.html#_label5_9
  即平衡二叉树只允许每个节点最多出现两个分支,而此处的多路查找树指的是允许出现多个分支(且分支有序)。

(2)多叉树、多路查找树
  多叉树 允许每个节点 可以有 两个以上的子节点以及数据项。
  多路查找树 即 平衡的多叉树(数据有序)。
  常见多路查找树 有:2-3 树、B 树(B-树)、B+树、2-3-4 树 等。

(3)B 树(B-树)
  B 树 即 Balanced-tree,简称 B-tree(B 树、B-树是同一个东西),是一种平衡的多路查找树。
  树节点的子节点最多的数目称为树的阶。比如:2-3 树的阶为 3。2-3-4 树的阶为 4。

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

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

复制代码
【2-3 树特点:】
对于 2 节点:
只能包含一个数据项 和 两个子节点(或者没有子节点)。
左子节点值 小于 当前节点值,右子节点值 大于 当前节点值。
不存在只有一个子节点的情况。

对于 3 节点:
包含一大一小两个数据项(从小到大排序) 和 三个子节点(或者没有子节点)。
左子节点值 小于 当前节点数据项最小值,右子节点值 大于 当前节点数据项最大值,中子节点值 在 当前节点数据项值之间。
不存在有 1 子节点、2 个子节点的情况。
复制代码
根据 {16, 24, 12, 32, 14, 26, 34, 10, 8, 28, 38, 20, 33} 构建的 2-3 树如下:
可使用 https://www.cs.usfca.edu/~galles/visualization/Algorithms.html 构建。

(5)B+ 树
  B+ 树是 B 树的变种。
  区别在于 B+ 树数据存储在叶子节点,数据最终只能在 叶子节点 中找到,而 B 树可以在 非叶子节点 找到。
  B+ 树性能可以等价于 对 全部叶子节点(所有关键字)进行一次 二分查找。

复制代码
【B+ 树特点:】
所有 数据项(关键字) 均存放于 叶子节点。
每个叶子节点 存放的 数据项(关键字)是有序的。
所有叶子节点使用链表相连(即进行范围查询时,只需要查找到 首尾节点、然后遍历链表 即可)。
注:
所有数据项(关键字) 均存放与 叶子节点组成的链表中,且数据有序,可以视为稠密索引。
非叶子节点 相当于 叶子节点的索引,可以视为 稀疏索引。
复制代码
根据 {16, 24, 12, 32, 14, 26, 34, 10, 8, 28, 38, 20, 33} 构建的 B+ 树(3阶、2-3 树)如下:

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

复制代码
【B* 树与 B+ 树 节点分裂的区别:】
对于 B+ 树:
B+ 树 节点的最低使用率是 1/2,其非叶子节点关键字(数据项)个数至少为 (1/2)*M。M 为 B+ 树的阶。
当一个节点存放满时,会增加一个节点,并将原节点 1/2 的数据移动到新的节点,然后在 父节点 添加新的节点。
B+ 树 只影响 原节点 以及 父节点,不会影响兄弟节点,兄弟之间不需要指针。

对于 B* 树:
B* 树 节点的最低使用率为 2/3,其非叶子节点关键字(数据项)个数至少为 (2/3)M。
当一个节点存放满时,若其下一个兄弟节点未满,则将一部分数据移到兄弟节点中,在原节点 添加新节点,然后修改 父节点 中的节点(兄弟节点发生改变)。
若其下一个兄弟已满,则在 两个兄弟之间 增加一个新节点,并分别从两个兄弟节点中 移动 1/3 的数据到新节点,然后在 父节点 添加新的节点。
B
树 影响了 兄弟节点,所以需要指针将兄弟节点连接起来。

总的来说,B* 树分配新节点的概率比 B+ 树低,B* 树的节点利用率更高。

注:
相关内容参考:https://blog.csdn.net/wyqwilliam/article/details/82935922
复制代码
下图不一定正确,大概理解意思就行。

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

复制代码
【B 树 或者 B- 树:】
平衡的多路查找树,非叶子节点至少存储 (1/2)*M 个关键字(数据项),
关键字升序存储,且仅出现一次,
进行查找匹配操作时,可以在 非叶子节点 成功匹配。

【B+ 树:】
B 树的变种,仅在 叶子节点 保存数据项,且叶子节点之间 通过链表存储。
整体 数据项 有序存储。
非叶子节点 作为 叶子节点 的索引存在,匹配时通过 非叶子节点 快速定位到 叶子节点,然后在 叶子节点 处进行匹配操作,相当于进行 二分查找。

【B* 树:】
B+ 树的变种,给 非叶子节点 也加上指针,非叶子节点 至少存储 (2/3)*M 个关键字。
将节点利用率 从 1/2 提高到 2/3 。
复制代码

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

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

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

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

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

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

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

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

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

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

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

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

复制代码
【常用快速查找的数据结构有两种:】
哈希表:
比如 HashMap,其查找、添加、删除、修改的平均时间复杂度均为 O(1)

树:
比如 平衡二叉树,其查询、添加、删除、修改的平均时间复杂度均为O(logn)

【什么是哈希表?】
哈希表(Hash table 、散列表),是根据键(Key)直接访问数据(Value)的一种数据结构。
规则:
使用某种方式(映射函数)将键值(Key)映射到数组中的某个位置,并在此位置存放记录,用于加快查询速度。
映射函数 也称为 散列函数,存放记录的数组 称为 散列表。

理解:
使用 散列函数,将 键值(Key)转换为一个 整型数字,
然后再对数字进行转换(取模、与运算等),将其转为 数组对应的下标,并将 value 存储在该下标对应的存储空间中。
而进行查询操作时,再次对 Key 进行运算,转换为对应的数组下标,即可定位并获取 value 值(时间复杂度为 O(1))。

【为什么不使用 哈希表?】
对于 单次写操作或者读操作 来说,哈希的速率比树快,但是为什么不用哈希表呢?

可以想一下如果是排序或者范围查询的情况下,执行哈希是什么情况,很显然,哈希无法很快的进行范围查找(其数据都是无序的),查找范围 0~n 的情况下,会执行 n 次查找,也即时间复杂度为 O(n)。

而树(AVL树、B树、B+树等)是有序的(1、2 次查找即可),其时间复杂度仍可以保证在 O(logn)。

相比较之下,哈希肯定没有树的效率高,因此不会使用哈希这种数据结构作为索引。

【平衡二叉树时间复杂度 O(logn) 怎么来的?】
在树中查找一个数字时,第一次在树的第一层(根节点)判断,第二次在树的第二层判断,依次类推,树有多少层,就会进行多少次判断,即对于 k 层的树,最坏时间复杂度为O(k)。
所以只需要知道 n 个节点的树有多少层即可。

若为满二叉树(除叶子节点外,每个节点均有两个节点),则对于第一层,有一个节点(2^0),对于第二层有两个节点(2^1),依次类推对于第 k 层有 2^k-1(2 的 k-1 次方)。
所以 n = 2^0 + 2^1 + ... + 2^k-1,从而 k = log(n + 1)。
所以时间复杂度为 O(k) = O(logn)     k 为树 层数,n 为树 节点数。

复制代码

(3)为什么不使用二叉查找树(BST)、平衡二叉树(AVL)?
  通过上面分析,可以使用树作为 索引(解决了范围、排序等问题),但是树有很多种类,比如:二叉查找树(BST)、平衡二叉树(AVL)、B 树、B+树等。应该选择哪种树作为索引呢?

对于二叉查找树,由于左子节点小于当前节点,右子节点大于当前节点,当一个数据是有序的时候,即数据要么递增,要么递减,此处二叉树出现如下图所示情况,相当于所有节点组成了链式结构,此时时间复杂度从 O(logn) 变为 O(n)。随着数据量增大,n 肯定非常大,这种情况下肯定不可取,舍弃。
  二叉查找树可参考:https://www.cnblogs.com/l-y-h/p/13751459.html#_label5_8

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

对于平衡二叉查找树(AVL),新增节点时,会不断的调整节点位置以及树的高度。但随着数据量增大,树的高度也会增大,高度增大导致比较次数增多,若数据 无法一次读取到内存中,则每次比较前都得通过磁盘 IO 读取外存数据,导致磁盘 IO 增大,影响性能。
  二叉平衡树可参考:https://www.cnblogs.com/l-y-h/p/13751459.html#_label5_9

通过上面分析,二叉查找树可能出现 只有左子树或者只有右子树的情况,当 数据量过大时,树的高度会变得很高,此时时间复杂度从 O(logn) 变为 O(n),n 为 树的高度。

为了解决这种情况,可以使用平衡二叉查找树,其会在左右子树高度差大于 1 时对树节点进行旋转,保证树之间的高度差,从而解决二叉查找树的问题,但是数据量过大时,树的高度依旧会很大,增大磁盘 IO,影响性能。

所以为了解决树的高度问题,既然 二叉平衡树 不能满足需求,那就采用多叉平衡树,让一个节点保存多个数据(两个以上子树),进一步降低树的高度。从而引出 B 树、B+树。

(4)AVL 树、B树、B+树 举例:
  构建树,并按照顺序插入 1 - 10,若查找 10 这个数,需要比较几次?

AVL 树构建如下:
  树总高度为 4,而 10 在叶子节点,所以需要比较 4 次。

B 树构建如下:
  树高度为 3 ,10 在叶子节点,此时只需要比较 3 次即可。
  但对于 AVL,需要比较 4 次,随着数据量增大,B 树 明显比 AVL 高度低。

B+ 树构建如下:
  树高度为 4,10 在叶子节点,此时需要比较 4 次。
  B+ 树比 B 树更适合范围查找。

(5)为什么不使用 B 树 而使用 B+ 树?
  通过上面分析,可以知道 平衡二叉树不能 满足实际的需求(数据量大时,树高度太大,且可能需要与磁盘进行多次 I/O 操作,查询效率低)。
  那么 B 树能否满足需求呢?B 树的定义参考前面的分析。

理论上,B 树可以增加 每个节点保存的数据项 以及 节点的子节点数,并达到平衡树的条件,从而降低树的高度。但是不能无限制的 增大,B 树阶越大,那么每个节点 就可能成为 有序数组,则每次查找时效率反而会降低。

在 InnoDB 中,索引是存储元素的,一个表的数据 行数、列数 越多,那么相对应的索引文件就会很大。其不可能一次存放在内存中,需要经过多次磁盘 I/O。所以考虑 数据结构时,需要判断哪种数据结构更适合从磁盘中读取数据,减少磁盘 I/O 次数,从而提高磁盘 I/O 效率。

假定每次读取树的节点 都是 一次 磁盘 I/O,那么树的高度 将是决定 磁盘 I/O 的关键因素。

通过上面 AVL树、B树、B+树 的举例,可以看到 AVL 树由于每个节点只能存储两个元素,数据量大时,树的高度将会很大。

那么 B树、B+树 如何选择呢?

B 树由于 非叶子节点也会存放完整数据,则 B树 每个非叶子节点 存放的 元素总数 受到数据的影响,也即 每个非叶子节点 存放的 元素 较少,从而导致树的高度 也会很大。
  B+ 树由于 非叶子节点 不存放完整数据(存放主键 + 指针),其完整数据存放在 叶子节点中,也即 非叶子节点 可以存放 更多的 元素,从而树的高度可以 很低。

通过上面分析,可以知道 B+ 树的高度很低,可以减少磁盘 I/O 的次数,提高执行效率。且 B+ 树所有叶子节点之间通过链表连接,其可以提高范围查询的效率。
  所以 一般采用 B+ 树作为索引结构。

(6)总结
  使用 B+ 树作为索引结构可以 减少磁盘 I/O 次数,提高查找效率。
  B+ 树实际应用场景一般高度为 3(见下面分析,若一条记录为 1 KB,那么高度为 3 的 B+树 可以存储 2000 多万条数据)。

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

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

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

【查看 InnoDB 默认页大小:】
SHOW GLOBAL STATUS like ‘Innodb_page_size’;

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

复制代码
【首先明确一点:】
B+ 树 非叶子节点存储的是 主键(关键字)+ 指针(指向叶子节点)。
B+ 树 叶子节点存储的是 数据(真实的数据记录)。
假设每次读取一个节点均会执行一次磁盘 I/O,即每个节点大小为页的大小。

【以节点大小为 16KB 为例:】
假设一行数据大小为 1KB,那么一个叶子节点能保存 16 条记录。
假设非叶子节点主键为 bigint 类型,那么长度为 8B,而指针在 InnoDB 中大小为 6B,即一个非叶子节点能保存 16KB / 14B = 1170 个数据(主键 + 指针)。
那么对于 高度为 2 的 B+树,能存储记录数为: 1170 * 16 = 18720 条。
对于 高度为 3 的 B+树,能存储记录数为:1170 * 1170 * 16 = 21902400 条。

也就是说,若页大小为 16KB,那么高度为 3 的 B+ 树就能支持 2千万的数据存储。
当然若页大小更大,树的高度也会低,但是一般没有必要去修改。

读取一个节点需要经过一次磁盘 I/O,那么根据主键 只需要 1-3 次磁盘 I/O 即可查询到数据,能满足绝大部分需求。
复制代码

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

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

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

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

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

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

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

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

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

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

亚马逊测评 www.yisuping.com

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值