Java 数据结构 树 - 概念与基础实现

Java 数据结构 树 - 概念与基础实现

Java的HashMapn内部的实现是数组加上红黑树. 所以想要真正的了解散列表, 原来自行实现的那种不是最好的. 现在就是要重新杀到树了, 来看看这种数据结构. 上次看算法第四版的时候, 图和树只是略略看过, 那本书有点太深, 直接看的话有点懵逼, 现在第二次, 感觉就好多了. 还是那句话, 看不

Java的HashMapn内部的实现是数组加上红黑树. 所以想要真正的了解散列表, 原来自行实现的那种不是最好的. 现在就是要重新杀到树了, 来看看这种数据结构. 上次看算法第四版的时候, 图和树只是略略看过, 那本书有点太深, 直接看的话有点懵逼, 现在第二次, 感觉就好多了. 还是那句话, 看不懂就硬看加实践, 多来几次, 也就明白了.
  1. 树和相关概念
  2. 二叉树
  3. 树的接口
  4. 二叉树的实现 - 节点类BinaryNode
  5. 二叉树的实现 - 组装树的方法
  6. 二叉树的实现 - 访问器和赋值方法及辅助方法

树和相关概念

树和之前学过的数据结构最大的区别就是, 树不是线性的, 而是有层次的. 当然也可以说我们的散列表也叫作有层次, 但实际上, 散列表这种线性组合起来的数据结构, 依然带有线性的色彩. 而树就完全不同, 是彻底的层次结构. 树是一组由(边edge)相连的节点(node), 边表示层之间的联系. 没有父节点的节点叫做根节点. 没有子节点的节点叫做叶子节点. 有同一个父节点的子节点是兄弟节点, 也称为后代. 父节点也称为他们的祖先. 没有规定每个父节点有几个子节点的树叫做一般树. 规定了有N个节点的, 叫做N叉树. 任意节点和其后代组成一颗所在的树的子树, 因此二叉树可以很方便的用递归描述. 比如常见的树的高度, 等于其最高的子树+1, 这就可以用递归表示. 很多数据结构比如Python的列表, 只要进行嵌套, 就可以很容易的组成一个树结构. 并非一定要使用一个Node对象. 只要能装入N个内容的容器嵌套, 都可以组成树形结构, 当然好不好操作就是另外一回事情了.

二叉树及其操作

二叉树其实用脑子简单想一下, 就知道很类似于二分查找的概念, 每次都分为两个. 所以是一种常见的树形数据结构. 二叉树如果在最低一层全部节点都不是null, 就是一个满二叉树. 不满的情况下, 最下边一层从左至右填充, 而且倒数第二层是满的, 叫做完全树. 所有节点都有两个高度完全相等的子树, 叫做完全平衡二叉树, 也就是满树. 如果高度相差<=1, 叫做平衡二叉树, 一般通常意义上的平衡二叉树就是指此种情况. 子树高度<=1的节点叫做平衡节点. 最关键的操作, 就来看一下遍历. 这个不像线性表, 遍历是线性的,而是有着各种方法. 由于二叉树可以很好的用递归来遍历, 所以很有意思:
  1. 前序遍历: 先访问根节点的内容, 再访问左子树->右子树(或者右->左), 这种方式,二叉树的实际访问可以想象的到, 是会沿着最左边一路到底, 然后在倒数第二层访问其左侧, 然后又会一路到底, 整个二叉树的左半部分会先于右侧访问完毕, 左上方的元素比右下的先访问完毕.
  2. 中序遍历: 先访问左节点的内容, 然后是根节点, 最后是右节点. 这种方式实际是从二叉树最左下开始, 然后先向上再向右访问, 中间节点在整棵树的中间访问, 左侧比右侧先访问.
  3. 后序遍历: 先访问左, 再访问右, 最后访问中间节点. 这种方式是从左树最下边一层逐步向上收回到根, 然后右树也收回到根, 最后访问根节点.
  4. 层序遍历: 这种有两种区分, 一种是每次访问一层的节点, 全部访问完毕之后, 再访问下一层的节点, 这叫做广度优先遍历(breadth-first traversal), 还有一种是先探查完一个子树再探查另一个, 结果就是第一次就会到达某个叶子节点, 叫做深度优先遍历(depth-first traversal).
其中广度和深度优先是最值得讨论的. 下边就来看看接口吧.

树的接口

树作为一个数据结构, 常用的就是获取根节点, 是否为空, 获取高度和节点的数量, 现在能想到的一般树也就这些内容. 然后就是遍历这个树, 先设置出来吧:
public interface TreeInterface<T>  {

    T getRootData();

    int getHeight();

    int getNumberOfNodes();

    boolean isEmpty();

    void clear();
}
public interface TreeIterator<T> {

    Iterator<T> iterator();

}
目前我们最关心二叉树, 所以创建一个二叉树接口来继承上边两个接口:
public interface BinaryTreeInterface<T> extends TreeInterface<T>, TreeIterator<T> {

    void setTree(T rootData);

    void setTree(T rootData, BinaryTreeInterface<T> leftTree, BinaryTreeInterface<T> rightTree);

}
由于不能强制指定构造方法, 因此这里设置了两个方法, 一个创建只有一个节点的树, 一个使用根节点和两棵子树创建一个树. 这里即使是单个节点, 我们也将其视作一棵只有一个节点的树, 这样操作就比较简单. 当然, 一般树也是应用很广泛的, 最常见的就是语法树, 只要定义一个符合语法规则, 就可以使用程序来解析树, 一个节点的子节点可以是一批树. 不过说实在自己写一个Parse确实不容易. 此外数据结构基于二叉树, 每个节点都是一个Comparable对象, 每个节点的数据对象不小于(或者不大于)其后代的所有对象, 而且是一个完全二叉树. 二叉查找树则是节点中的数据大于左子树中的所有数据, 小于右子树中的所有数据(或者相反)的一棵二叉树.

二叉树的实现 - 节点类BinaryNode

在这里, 我们把树定义为指向一棵树的根节点的数据类型. 每个节点, 我们定义一个特殊的类. 类似于链表中的节点Node, 我们这次可以将节点类BinaryNode写在包里, 而不是内部类. 首先思考一下, 每个节点内部至少需要有三个数据, 一个左引用指向左节点, 一个右引用指向右节点, 还有一个引用来保存数据对象. 因此可以写出类如下:
class BinaryNode<T> {

    private T data;
    private BinaryNode<T> leftNode;
    private BinaryNode<T> rightNode;

    public BinaryNode() {
        this(null);
    }

    public BinaryNode(T data) {
        this.data = data;
    }

    public BinaryNode(T data, BinaryNode<T> leftNode, BinaryNode<T> rightNode) {
        this.data = data;
        this.leftNode = leftNode;
        this.rightNode = rightNode;
    }

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }

    public BinaryNode<T> getLeftNode() {
        return leftNode;
    }

    public void setLeftNode(BinaryNode<T> leftNode) {
        this.leftNode = leftNode;
    }

    public BinaryNode<T> getRightNode() {
        return rightNode;
    }

    public void setRightNode(BinaryNode<T> rightNode) {
        this.rightNode = rightNode;
    }

    public boolean isLeaf() {
        return leftNode == null && rightNode == null;
    }
}
这个节点类创建了各种构造器, 可以直接使用数据对象或者创建无对象的节点. 然后是各种setter和getter方法. 加上了一个判断是不是叶子节点的方法. 可能后续还需要扩充这个节点的方法. 现在先来看看在节点的基础上创建二叉树数据结构. 回到前边的概念, 我们将树定义为指向一棵二叉树的根节点, 所以树数据结构中, 节点只需要一个, 就是根节点.

二叉树的实现 - 组装树的方法

按照之前的定义, 树里边只需要一个节点即可. 先来把最基础的类和构造器写出来:
public class BinaryTree<T> implements BinaryTreeInterface<T> {

    private BinaryNode<T> root;

    public BinaryTree() {
        root = null;
    }

    public BinaryTree(T data) {
        root = new BinaryNode<>(data);
    }

    public BinaryTree(T data, BinaryTree<T> leftTree, BinaryTree<T> rightTree) {
        privateSetTree(data, leftTree, rightTree);
    }
    
    //辅助方法, 将两棵树中的根节点, 设置到当前树的根节点的左右分支上
    private void privateSetTree(T data, BinaryTree<T> leftTree, BinaryTree<T> rightTree) {
        root = new BinaryNode<>(data);
        root.setLeftNode(leftTree.root);
        root.setRightNode(rightTree.root);
    }
    
}
这其中仅有一个指向根节点的引用. 然后就是构造器, 由于在包中, 可以直接访问类的root对象, 所以可以编写一个私有方法, 用来辅助设置相关的节点, privateSetTree实际上就是将两棵树挂在同一个节点下, 构造一棵新树. 然后需要逐个实现接口中的方法. 先是BinaryTreeInterface中的两个, 也可以使用这个私有方法:
//这个方法本质上和构造器一样, 新建一个节点. 原来的树会丢失
@Override
public void setTree(T rootData) {
    root = new BinaryNode<>(rootData);
}

//内部调用privateSetTree, 需要强制转型, 这个转型是安全的
@Override
public void setTree(T rootData, BinaryTreeInterface<T> leftTree, BinaryTreeInterface<T> rightTree) {
    privateSetTree(rootData, (BinaryTree<T>) leftTree, (BinaryTree<T>) rightTree);
}
然后这里有两个问题需要解决. 首先是复制的问题. 由于我们目前的类只是单纯连接, 可能无法阻止原来对一些树的引用, 还可能客户需要将两棵树都挂在同一棵树下边, 所以需要编写一个复制的方法.

树的复制 - 递归复制节点

所谓复制也很简单, 就是给BinaryNode中新增加一个方法, 先复制当前节点, 再复制左子树和右子树的节点, 这实际上是一个Composite模式的递归调用方法:
public BinaryNode<T> copy() {
    BinaryNode<T> newRoot = new BinaryNode<>(data);

    if (leftNode != null) {
        newRoot.setLeftNode(leftNode.copy());
    }

    if (rightNode != null) {
        newRoot.setRightNode(rightNode.copy());
    }
    return newRoot;
}
当这个方法被调用时, 会发生什么呢? 以一个两层三个节点的树为例, 会先创建一个和根节点的数据一样的节点, 然后根据代码, 会先执行leftNode.copy() , 得到左节点的一个新的复制对象, 然后将其设置到新节点的左, 右侧也是一样. 很显然, 如果再多一层, 第二层会先创建一个以自己相同的节点, 然后继续往下复制第三层. 所以全部结束之后, 实际上得到一个复制的整颗子树的根节点引用. 可见Composite模式之妙.

避免外部访问

目前的方法, 可以组装一棵新树, 但是作为参数的leftTree或者rightTree的root节点, 也指向被连接的子树. 所以很显然, privateSetTree方法需要改进, 将一棵树连接到新树上之后, 需要将原来的树对象的root节点置为null. 这样原来的树对象无法访问新树. 此外还要注意左子树和右子树不能是同一颗. 改进的privateSetTree方法如下:
private void privateSetTree(T data, BinaryTree<T> leftTree, BinaryTree<T> rightTree) {
    root = new BinaryNode<>(data);

    if ((leftTree != null) && !leftTree.isEmpty()) {
        root.setLeftNode(leftTree.root);
    }

    if ((rightTree != null) && !rightTree.isEmpty()) {
        if (rightTree != leftTree) {
            root.setRightNode(rightTree.root);
        //如果左右子树是同一棵, 就复制一份挂载上去.
        } else {
            root.setRightNode(rightTree.root.copy());
        }
    }

    //全部挂载完毕之后, 将参数的两个树清空, 这样便无法在外部访问新树
    if ((leftTree != null) && leftTree != this) {
        leftTree.clear();
    }
    
    if ((rightTree != null) && rightTree != this) {
        rightTree.clear();
        
    }

}
当然, 此时还没有编写.clear()方法, 但是这个方法的作用是清空, 所以可以先用上.

二叉树的实现 - 访问器和赋值方法及辅助方法

这里的一些方法主要是getter和setter方法, 以及isEmpty()和isClear()方法, 主要是如下:
@Override
public T getRootData() {
    if (isEmpty()) {
        throw new RuntimeException("树已经为空");
    } else {
        return root.getData();
    }
}

public void setRootData(T rootData) {
    root.setData(rootData);
}

@Override
public boolean isEmpty() {
    return root == null;
}

@Override
public void clear() {
    root = null;
}

public BinaryNode<T> getRoot() {
    return root;
}

public void setRoot(BinaryNode<T> root) {
    this.root = root;
}
目前, 去掉迭代器的部分, 还有两个方法没有实现, 一个是int getHeight(), 一个是int getNumberOfNodes(). 写到这里今天先喘口气, 在脑子里仔细想一想目前为止的树结构. 可以说, 我们的关键是依赖于将BinaryNode节点有效的连接, 然后在外层包了一个指向根节点的类, 就是树. 所以本质上直接操作节点, 和操作树也没有本质的区别, 这个数据结构在脑子里先想一晚. 后边的获取高度在当时看CS61A的时候自己用Python写出来过, 回头看看, 这个嵌套列表实际上相当于一般树, 即树的每个节点都是一个列表, 第一项是值, 第二项是一个树的列表, 需要用递归的方式, 思路就是之前说的, 等于最大高度的子树加上1:
def height(tree):
    # 如果没有子节点, 就是1
    if len(branches(tree)) == 0:
        return 1
    # 否则返回其所有子树的最大值加上1
    else:
        return max([height(t) for t in branches(tree)]) + 1
很显然这里也需用递归的方式, 设计模式中的Composite模式最典型的就是树形结构, 只要是嵌套的同种数据类型, 都可以使用Composite模式. 此外还有递归的数节点的方式, 很显然, 一个树的总节点等于左子树的节点数加上右子树的节点数再加1. 这两个加上遍历树的方式, 明天再研究了, 今晚现在脑子里好好想想树, 毕竟树之后的内容都没怎么学好.
LICENSED UNDER CC BY-NC-SA 4.0
Comment