数据结构与算法系列第四篇——树(2-3-4树)

引言

在二叉树中,每个节点有一个数据项,最多有两个子节点。如果允许每个节点可以有更多的数据项和更多的子节点,这就是多叉树。2-3-4树就是多叉树,它的每个节点最多有四个子节点和三个数据项。理解它可以更好地理解B树以及B+树。
B树也是一种多叉树,专门用在外部存储中来组织数据(主存储的外部,指的是磁盘驱动器)。
它的应用场景非常广泛。例如:目前大部分数据库系统及文件系统都采用B树或其变种B+树作为索引结构。B+Tree是数据库系统实现索引的首选数据结构。
因此学习B树、B+树这种数据结构对了解数据库系统的实现原理是非常有意义的

2-3-4树

如下图所示:
在这里插入图片描述
图中上面的三个节点有子节点,底层的六个节点都是叶节点,没有子节点。2-3-4树中所有的叶节点总是在同一层上

名字的含义

2-3-4树名字中的2,3,4的含义是指一个节点可能含有的子节点的个数。
对于非叶节点有三种可能的情况:

  • 有一个数据项的节点总是有两个子节点
  • 有两个数据项的节点总是有三个子节点
  • 有三个数据项的节点总是有四个子节点

总之,非叶节点的子节点数总是比它含有的数据项多1.设子节点链接的个数为L,数据项的个数为D,那么:
L=D+1。
叶节点没有子节点,然而它可能含有一个,两个或三个数据项。所以,空节点是不会存在的。
因为2-3-4树最多可以有4个子节点的节点,也可以称之为4叉树。

二叉树和2-3-4树的联系

相同之处

都是平衡树

不同之处

  • 二叉树的节点的子节点个数最多是2个,2-3-4树最多可以有4个
  • 二叉树的非叶节点也可以只有1个子节点,2-3-4树不允许只有一个子节点,有一个数据项的节点必须总是保持有两个链接。

2-3-4树的组织形式

在这里插入图片描述
2-3-4树中一般不允许出现重复关键字值,所以就拿4-节点的子树来说,它的子节点的关键字值不等于A,B,C。如下图所示:
在这里插入图片描述

查找

查找特定关键字值得数据项和在二叉树中的搜索例程类似。从根开始,除非查找的关键字值就是根,否则选择关键字值所在的合适范围,转向那个方向,直到找到为止。

插入

新的数据项总是插在叶节点里,在树的最底层。
如果插入到有子节点的节点里,子节点的编号就要发生变化来保持树的结构,这保证了节点的子节点比该节点的数据项多1.

  • 查找时没有碰到满节点时,插入比较简单,如下图所示
    在这里插入图片描述
  • 如果向下寻找要插入位置的路途中,碰到满节点的情况,插入就变得复杂了。发生这种情况时,节点必须分裂。正是这种分裂保证了树的平衡。下面重点介绍一下几种分裂情况。

节点分裂(4-节点变成两个2-节点)

在这里插入图片描述
节点分裂的操作步骤:(将要分裂的节点中的数据项设为A、B和C)

  • 创建一个新的空节点。它是要分裂节点的兄弟,在要分裂节点的右边
  • 数据项C移到新节点中
  • 数据项B移动到要分裂节点的父节点中
  • 数据项A保留在原来的位置上
  • 最右边的两个子节点从要分裂节点处断开,连到新节点上。

根的分裂(4-节点变成3个2-节点)

在这里插入图片描述
如果一开始查找插入点时就碰到满的根时,插入过程更复杂一些:

  • 创建新的根,它是要分裂节点的父节点
  • 创建第二个新的节点,它是要分裂节点的兄弟节点
  • 数据项C移动到新的兄弟节点中
  • 数据项B移动到新的根节点中
  • 数据项A保留在原来的位置上
  • 要分裂节点最右边的两个子节点断开连接,连到新的兄弟节点中。

在下行路途中分裂

所有满的节点是在下行路途中分裂的,分裂不可能向回波及到树上面的节点。任何要分裂的节点的父节点肯定是不满的,因此该节点不需要分裂就可以插入数据项B。当然,如果父节点的子节点分裂时它已经有两个子节点了,它就变满了。这意味着下次查找碰到它的时候需要分裂
下图是空树中一系列的插入过程,有四个节点分裂了,两个是根,两个是叶节点
在这里插入图片描述

案例演示插入

在下面的2-3-4树中插入140
在这里插入图片描述
1.首先根分裂
在这里插入图片描述
2.查找插入位置
在这里插入图片描述
3.节点分裂
在这里插入图片描述
4.查找插入位置
在这里插入图片描述
5.完成
在这里插入图片描述
6.全景图如下
在这里插入图片描述

Java实现234树

package test12;

/**
 * 存储在节点中的数据项
 */
public class DataItem {
    //数据项内容
    public long dData;

    public DataItem(long dd) {
        dData = dd;
    }

    /**
     * 展示方法 格式为  "/27"
     */
    public void displayItem() {
        System.out.print("/" + dData);
    }
}

package test12;

public class Node {
    //最多4个节点
    private static final int ORDER = 4;
    //存放子节点数组
    private Node childArray[] = new Node[ORDER];
    //存放本节点数据项的数组
    private DataItem itemArray[] = new DataItem[ORDER - 1];
    //定义该节点的数据项数
    private int numItems;
    //定义该节点的父节点
    private Node parent;

    /**
     * 给当前节点连接子节点
     *
     * @param childNum 子节点的索引位置
     * @param child    子节点
     */
    public void connectChild(int childNum, Node child) {
        childArray[childNum] = child;
        if (child != null) {
            child.parent = this;
        }
    }

    /**
     * 当前节点断开子节点的连接,返回它
     *
     * @param childNum 指定要断开节点的索引位置
     * @return
     */
    public Node disconnectChild(int childNum) {
        Node tempNode = childArray[childNum];
        childArray[childNum] = null;
        return tempNode;
    }

    /**
     * 根据key值查找数据项
     *
     * @param key
     * @return
     */
    public int findItem(long key) {
        for (int j = 0; j < ORDER - 1; j++) {
            if (itemArray[j] == null) {
                break;
            } else if (itemArray[j].dData == key) {
                return j;
            }
        }
        return -1;
    }

    /**
     * 插入数据项
     *
     * @param newItem
     * @return
     */
    public int insertItem(DataItem newItem) {
        // 假设节点不为空
        numItems++;
        long newKey = newItem.dData;
        //从当前节点的数据项数组的尾部开始
        for (int j = ORDER - 2; j >= 0; j--) {
            //如果数据项为空,继续从数组尾部向首部方向移动
            if (itemArray[j] == null) {
                continue;
            } else {
                long itsKey = itemArray[j].dData;
                //如果要插入的数据项比当前对比的数据项小,将当前数据项的位置后移。为要插入的数据项腾一个空位置
                if (newKey < itsKey) {
                    itemArray[j + 1] = itemArray[j];
                } else {
                    //说明查找到了插入的位置,可以进行插入,并返回插入节点的索引位置
                    itemArray[j + 1] = newItem;
                    return j + 1;
                }
            }
        }
        //数据项数组为空,直接插入第一个位置并返回索引
        itemArray[0] = newItem;
        return 0;
    }

    /**
     * 移除数据项
     * 并返回移除的数据项
     *
     * @return
     */
    public DataItem removeItem() {
        DataItem temp = itemArray[numItems - 1];
        itemArray[numItems - 1] = null;
        numItems--;
        return temp;
    }

    /**
     * 展示节点
     * 格式"/24/56/74/"
     */
    public void displayNode() {
        for (int j = 0; j < numItems; j++) {
            itemArray[j].displayItem();
        }
        System.out.println("/");
    }

    public Node getChild(int childNum) {
        return childArray[childNum];
    }


    public Node getParent() {
        return parent;
    }

    /**
     * 是否是叶子节点
     *
     * @return
     */
    public boolean isLeaf() {
        return (childArray[0] == null) ? true : false;
    }

    public int getNumItems() {
        return numItems;
    }

    /**
     * 根据索引得到数据项
     *
     * @param index
     * @return
     */
    public DataItem getItem(int index) {
        return itemArray[index];
    }

    /**
     * 判断节点是否已满
     *
     * @return
     */
    public boolean isFull() {
        return (numItems == ORDER - 1) ? true : false;
    }
}
package test12;

public class Tree234 {
    //根节点
    private Node root = new Node();

    /**
     * 根据给定关键字值查找数据项
     *
     * @param key
     * @return
     */
    public int find(long key) {
        //从根开始查找
        Node curNode = root;
        int childNumber;
        while (true) {
            if ((childNumber = curNode.findItem(key)) != -1) {
                //查找到
                return childNumber;
            } else if (curNode.isLeaf()) {
                //如果curNode是叶子节点的话,直接返回
                return -1;
            } else {
                //深度搜索 如果在当前节点找不到数据项,且当前节点不是叶子节点,需要转向节点的子节点进行查找
                curNode = getNextChild(curNode, key);
            }
        }
    }

    /**
     * 获取当前节点的子节点
     *
     * @param theNode  当前节点
     * @param theValue 搜索关键字值
     * @return
     */
    public Node getNextChild(Node theNode, long theValue) {
        int j;
        //假定当前节点不是空的,不满,也不是叶子节点
        int numItems = theNode.getNumItems();
        //遍历当前节点的数据项的数组
        for (j = 0; j < numItems; j++) {
            //如果搜索关键字值小于正在比较的数据,那就表明可在此节点的左子节点上
            if (theValue < theNode.getItem(j).dData) {
                return theNode.getChild(j);
            }
        }
        //在此节点的右子节点上
        return theNode.getChild(j);        // return right child
    }

    /**
     * 插入
     *
     * @param dValue
     */
    public void insert(long dValue) {
        //从根节点开始查找要插入的位置
        Node curNode = root;
        //将插入的数据构造成节点
        DataItem tempItem = new DataItem(dValue);
        while (true) {
            //当前节点已满
            if (curNode.isFull()) {
                //分裂节点
                split(curNode);
                curNode = curNode.getParent();
                // 查找更低一级的子节点
                curNode = getNextChild(curNode, dValue);
            } else if (curNode.isLeaf()) {
                // 当前节点是叶子节点,跳出查找 插入节点的逻辑。去做插入操作
                break;
            } else {
                // 当前节点不满,也不是叶子节点,查找更低一级的子节点
                curNode = getNextChild(curNode, dValue);
            }
        }
        //查找到了,就进行插入
        curNode.insertItem(tempItem);
    }

    /**
     * 节点分裂
     *
     * @param thisNode
     */
    public void split(Node thisNode) {
        // 假设节点不为空
        DataItem itemB, itemC;
        Node parent, child2, child3;
        int itemIndex;
        //从当前节点中删除最右边的两个数据项并保存
        itemC = thisNode.removeItem();
        itemB = thisNode.removeItem();
        //断开最右边两个子节点的连接,保存它们的引用
        child2 = thisNode.disconnectChild(2);
        child3 = thisNode.disconnectChild(3);
        //创建一个新节点,它将置于被分裂节点的右边
        Node newRight = new Node();
        //根节点分裂
        if (thisNode == root) {
            //创建一个新的根节点
            root = new Node();
            //将根节点置为父节点
            parent = root;
            //当前节点连在根节点的第一个子节点的位置
            root.connectChild(0, thisNode);
        } else {
            //普通节点分裂
            parent = thisNode.getParent();
        }

        // 处理parent节点,将B数据项插入节点中
        itemIndex = parent.insertItem(itemB);
        int n = parent.getNumItems();         // total items?
        // move parent's connections
        for (int j = n - 1; j > itemIndex; j--) {

            Node temp = parent.disconnectChild(j); // one child
            parent.connectChild(j + 1, temp);        // to the right
        }
        // 将新创建的节点连接到parent节点的子节点的位置(这个索引位置是上面腾出来)
        parent.connectChild(itemIndex + 1, newRight);

        // 处理新节点
        newRight.insertItem(itemC);       // item C to newRight
        newRight.connectChild(0, child2); // connect to 0 and 1
        newRight.connectChild(1, child3); // on newRight
    }

    /**
     * 展示树
     */
    public void displayTree() {
        recDisplayTree(root, 0, 0);
    }


    private void recDisplayTree(Node thisNode, int level,
                                int childNumber) {
        System.out.print("level=" + level + " child=" + childNumber + " ");
        thisNode.displayNode();               // display this node
        // 递归调用每个节点的孩子节点
        int numItems = thisNode.getNumItems();
        for (int j = 0; j < numItems + 1; j++) {
            Node nextNode = thisNode.getChild(j);
            if (nextNode != null) {
                recDisplayTree(nextNode, level + 1, j);
            } else {
                return;
            }
        }
    }
}
package test12;

import java.io.*;
import java.util.Scanner;

class Tree234App {
    public static void main(String[] args) throws IOException {
        long value;
        Tree234 theTree = new Tree234();
        theTree.insert(50L);
        theTree.insert(40L);
        theTree.insert(60L);
        theTree.insert(30L);
        theTree.insert(70L);

        while (true) {
            System.out.print("请选择 展示(s), 插入(i), or 查找(f): ");
            Scanner sc = new Scanner(System.in);
            String choice = sc.next();
            switch (choice) {
                case "s":
                    theTree.displayTree();
                    break;
                case "i":
                    System.out.print("输入要插入的数据: ");
                    value = sc.nextInt();
                    theTree.insert(value);
                    break;
                case "f":
                    System.out.print("输入要查找的数据: ");
                    value = sc.nextInt();
                    int found = theTree.find(value);
                    if (found != -1)
                        System.out.println("找到: " + value);
                    else
                        System.out.println("没有找到: " + value);
                    break;
                default:
                    System.out.print("不合法的输入\n");
            }
        }
    }
}

运行结果如下:

在这里插入图片描述
在这里插入图片描述

2-3-4树和红黑树

历史上,2-3-4树先被提出,后来红黑树由它发展而来
2-3-4树转化为红黑树的三条规则:

  • 把2-3-4树中的每个2-节点转化为红黑树的黑色节点
  • 把每个3-节点转化为一个子节点和一个父节点,子节点红色,父节点黑色
  • 把每个4-节点转化成一个父节点和两个子节点,子节点红色,父节点黑色
    在这里插入图片描述
    在这里插入图片描述

2-3-4树和红黑树操作的等价性

2-3-4树中使用节点分裂保持平衡
红黑树中使用颜色交换和旋转保持平衡。

4-节点分裂和颜色变换

下图中2-3-4树的一个4-节点在分裂的样子,图中也展示了与2-3-4树等价的红黑树,虚线部分等价于4-节点。
在这里插入图片描述

3-节点分裂和旋转

下图中一棵2-3-4树转化为红黑树的效果,不同的是选择3-节点的两个数据项的哪个来做父节点。
在这里插入图片描述
由图可看到图B是不平衡的,C是平衡的。对图B的红黑树,可以采取右旋(并进行颜色变换)来保持平衡,得到的结果和图C一致

2-3-4树的效率

类比红黑树,查找过程每层都要访问一个节点,可能是查找已经存在的节点,也可能是插入一个新节点,红黑树的层数大约是log₂(N+1),所以搜索时间与层数成比例。
假设2-3-4树中每个节点最多可以有4个子节点,如果每个节点都是满的,树的高度应该和log4(N)成正比。以2为底的对数和以4为底的对数底数相差2,因此,在所有节点都满的情况下,2-3-4树的高度大致是红黑树的一半,2-3-4树的高度大致在log₂(N+1)和log₂(N+1)/2之间。
另一方面,每个节点要查看的数据项就更多了,这会增加查找时间,因为节点中使用线程搜索来查看数据项,使查找时间增加的倍数和M成正比,总的查找时间和Mlog4(N)成正比。
有些节点有一个数据项,二个数据项,有的有三个。如果平均算的话,M=2。2
log4(N)。
根据大O表示法的规则,2-3-4树的查找时间和平衡二叉树大致相等,都是O(logN)

2-3树

和2-3-4树类似,2-3树的节点比2-3-4树少存一个数据项和少一个子节点。节点可以保存1个或者2个数据项,可以有0个,1个,2个或3个子节点。

节点分裂

查找,插入都和2-3-4树类似。节点分裂有很大的不同。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

外部存储

2-3-4树是多叉树的例子,多叉树是指节点的子节点多于两个并且数据项多于1个。另一种多叉树,B树,针对于在外部存储器上的数据,这种数据结构起到很大的作用。
外部存储特指某类磁盘系统,例如硬盘。

访问外部数据

遇到的问题
第一:在磁盘驱动器中访问数据比在主存中要慢得多。
第二:一次需要访问很多记录

一次访问一个数据块

当读写头到达正确的位置后开始读(写)过程,驱动器可以很快把大量数据转移到主存中,因为这个原因,为了简化驱动器的控制装置,在磁盘上的数据按块存储。
磁盘驱动器每次最少读或写一个数据块的数据。块总是2的倍数

原创文章 38 获赞 52 访问量 1万+

猜你喜欢

转载自blog.csdn.net/yemuxiaweiliang/article/details/105415454
今日推荐