数据结构之二叉查找树

二叉树

前言

在之前讲过了对数据的排序, 但是很快我们就会发现数据的排序解决了一部分问题, 但在某些情况下仍然不能够满足我们的要求, 一个是, 对数组的扩容, 更新 都是一件不太容易的事情, 需要重新复制数组, 甚至是简单的删除, 插入操作, 都需要对数组进行更新.

我们知道, 对于链表的删除和插入无疑是一件很简单的事情, 但是要保证更新数据之后的有序性, 我们就必须找准 插入数据的地方, 在有序数组中, 我们可以通过二分查找在对数时间内实现它, 而对于链表 必须从头到尾挨个进行访问 ,才能得到我们想要的结果.

二叉树就是这两种优点的汇集.

构建

从上面不难知道, 二叉树是以链表形式组成的有序集合.

链表保证增删简洁, 有序性和其数据结构相结合保证了查找也变得非常快速.

在这里不得不提到另一个观点, 索引, 数据大多以键值对的形式进行保存, 而键的集合 就是这里提到的索引. 在这节之后, 也就不难理解, 为什么数据库中给列加上索引之后能够对查找速度有大幅度的提升.

其实二叉树 并不陌生, 在堆排序中的 二叉堆, 甚至于 快速排序的思想都是二叉树的源泉.

回顾一下快速排序, 在每一次迭代中, 将我们取出来的 '哨兵元素' 放在中间, 将数组 或 子数组中的数据分成两半, 一半小于, 一半大于.

而相应的, 二叉树则是在每次插入数据的时候, 从根节点不断向下查找, 直到命中 或 未命中 进行 更新 或者 插入操作.

基准

二叉树的根节点, 它的每一个节点都只能有一个父节点指向自己(根节点除外, 没有父节点); 同时每个节点都有左右两个链接, 左链接(左子树) 的所有键小于 当前节点, 右链接(右子树) 的所有键大于当前节点. 如下图所示:

不存在等于 或 空值, 这里就必须提到另一个设定, 键值 非空且唯一. 在Java中的 HashTable 就存在这个设定, 而在 HashMap 中也有 唯一键这个设定.

当我们想要查找一个键值, 顺着树一路向下查找, 当查找到空节点时, 称为未命中, 查找到 等于 被查找的键值时, 称为命中.

对于一颗完全平衡树而言, 也就是从根节点到所有空节点的高度都相等的树, 当树的高度为 32 层时, 仅叶节点就存在4亿多节点, 也就是说要从上亿数据中查找到需要的键, 最多也不超过32次.

在算法这本书中多次提到过 log2 N 这个量级, 并没有太多关注, 而今才发现这是一个多么恐怖的数据, 对于函数曲线, 随着N的增大, 曲线更加平滑, 上百亿, 上千亿的数据量增长 也无非是在完美平衡树中 查找次数 增大2, 3, 随着N的不断增大, 这样的数据量对查找性能的影响微乎其微.

当然, 完美平衡二叉树, 当前的树自然是不完美的. 但也不影响这种数据结构的巨大影响力.

在这里我也就不再图文并茂的解释二叉树, 网上的资料已经很全, 已经能够让你产生一个基本的二叉树的认识.

实现

理解二叉树的原理并不是一件很难的事情, 而实现它才是我必须翻越的一座山.

在时隔一周之后继续来写这篇文章:

我用了一周的时间才二叉树用java实现, 期间几乎没有参考 算法 这本书中的任何实现, 也没有采用递归的方式去做这件事, 而是全部采用迭代的方式.

纸上得来终觉浅, 绝知此事要躬行.

代码上传至
https://github.com/zyzdisciple/algorithms4th/tree/master/src/zyx/algorithms4th/unitthree

Node

private class Node {

    private Node left;

    private Node right;

    private Key key;

    private Value value;

    private int N;

    public Node (Key key, Value value, int N) {

        this.key = key;
        this.value = value;
        this.N = N;
    }

    @Override
    public String toString() {
        return key == null ? null : key.toString();
    }
}

get()

接受一个 key 作为参数, 从root节点不断深入, 当前节点的key 大于所给值, 向左子树深入, 否则小于向右子树深入. 直到为 null(未命中) 或 等于(命中).

public Value get(Key key) {
    if (key == null)
        throw new RuntimeException("键值不能为null");

    Node tempNode = getNode(key);

    return tempNode == null ? null : tempNode.value;
}

private Node getNode(Key key) {
    Node temp = root;
    int cmp;

    while (temp != null) {
        cmp = key.compareTo(temp.key);
        if (cmp > 0) {
            temp = temp.right;
        } else if (cmp < 0) {
            temp = temp.left;
        } else {
            return temp;
        }
    }
    return null;
}

put()

方法接受 key, value 作为参数, 从root节点不断深入, 当前节点key 大于参数key, 左子树深入, 小于则向右子树深入, 直到为null(插入) 或 等于(更新).

需要注意的是, 在 插入删除操作的时候, 都需要更新节点的 N 值.

@Override
public void put(Key key, Value value) {
    if (key == null)
        throw new RuntimeException("键值不能为null");

    Node newNode = new Node(key, value, 1);
    if (root == null) {
        root = newNode;
        return;
    }
    Node parent = root, child;
    int cmp;
    boolean flag; //表示要插入左 true, 右 false;

    while (true) {
        cmp = key.compareTo(parent.key);
        if (cmp > 0) {
            child = parent.right;
            flag = false;
        } else if (cmp < 0) {
            child = parent.left;
            flag = true;
        } else {
            parent.value = value;
            return;
        }

        if (child == null) {
            if (flag) {
                parent.left = newNode;
            } else {
                parent.right = newNode;
            }
            break;
        }

        parent = child;
    }

    updateN(key, 1);
}

private void updateN(Key key, int count) {

    Node temp = root;
    int cmp;
    while (temp != null) {
        cmp = key.compareTo(temp.key);
        if (cmp > 0) {
            temp.N += count;
            temp = temp.right;
        } else if (cmp < 0) {
            temp.N += count;
            temp = temp.left;
        } else {
            return;
        }
    }
}

delete()

delete方法的实现, 我觉得是最为复杂的, 特别是使用迭代的方式, 当在删除对应节点的同时, 就必须要持有父节点. 才能够删除对应的节点.

核心思想:
同样的, 不断找到对应的节点. 如果是叶节点, 删除不影响, 如果非叶节点, 删除的同时, 需要找到左子树的最大节点, 或 右子树的最小节点, 替换当前节点, 同时令替换后的节点 的左右子节点 指向 原节点的左右子节点.

替换节点意味着, 删除并返回用来替换的节点, 更改被替换节点即可. 最后不要忘记更新 N值.

@Override
public void delete(Key key) {
    if (key == null) {
        throw new NullPointerException();
    }

    //找出需要被删除的节点及其父节点
    Node parentNode = getParentNode(key), deleteNode;
    /**
    * 如果未命中, 直接返回
    * 如果删除的节点为根节点, 直接删除
    * 如果删除的节点为叶节点, 需要找到对应的父级节点, 将其指向置空
    * 如果非根节点/叶节点, 则需要找到左/右 树的最大/最小节点, 将置换后删除
    * 在这里我取 被删除节点的左子节点的最大子节点替换.
    */
    if (parentNode == null) {
        return;
    }
    int cmp = key.compareTo(parentNode.key);
    boolean flag;
    Node temp;

    if (cmp > 0) {
        deleteNode = parentNode.right;
        if (isLeaf(deleteNode)) { //如果是叶节点, 直接删除即可.
            updateN(deleteNode.key, -1); //先更新N后删除
            parentNode.right = null;
            return;
        }
        if (deleteNode.left == null) {
            temp = deleteNode.right;
        } else {
            temp = deleteMax(deleteNode.left);
        }
        flag = true;
    } else if (cmp < 0) {
        deleteNode = parentNode.left;
        if (isLeaf(deleteNode)) {
            parentNode.left = null;
            return;
        }
        if (deleteNode.left == null) {
            temp = deleteNode.right;
        } else {
            temp = deleteMax(deleteNode.left);
        }
        flag = false;
    } else {
        //表示当前处于根节点
        root = null;
        return;
    }
    temp.left = deleteNode.left;
    temp.right = deleteNode.right;
    temp.N = deleteNode.N;
    if (flag) {
        parentNode.right = temp;
    } else {
        parentNode.left = temp;
    }
}

private Node getParentNode(Key key) {

    if (root == null) {
        return null;
    }

    Node parent = root, child;
    int cmp = key.compareTo(root.key);

    if (cmp > 0) {
        child = parent.right;
    } else if (cmp < 0) {
        child = parent.left;
    } else {
        return parent;
    }

    while (true) {
        //表示查找未命中
        if (child == null) {
            return null;
        }
        cmp = key.compareTo(child.key);

        if (cmp > 0) {
            parent = child;
            child = parent.right;
        } else if (cmp < 0) {
            parent = child;
            child = parent.left;
        } else {
            return parent;
        }
    }
}

private boolean isLeaf(Node node) {
    return node.left == null && node.right == null;
}

private Node deleteMax(Node node) {

    if (node.right == null) {
        updateN(node.key, -1);
        return node;
    }

    Node parent = node, child;

    while (true) {
        child = parent.right;
        if (child.right == null) {
            //说明当前child为最大节点
            //无论当前是否是叶节点, 都令其指向, child.left;
            /*
                * 几次陷入一个误区, 非叶节点的话, 找child 的左节点的最大节点, 然而最终的目的
                * 仅仅是找到 node 的最大节点, 其实这个时候已经达到目的了.
                */
            parent.right = child.left;
            updateN(child.key, -1);
            return child;
        }
        parent = child.right;
    }
}

其他

二叉树的最关键的三个操作已经实现, 其他操作暂时就不再多说.

与删除操作同样复杂的是, 通过迭代的方式 实现 迭代器的功能. 在这里, 我用一个数组, 长度大于等于树最大高度的数组, 保存当前被遍历节点的父节点.即可实现.

在前面的网址已经有了大多数实现, 及相关的测试类. 验证方法等. 就不再写出代码了.

在看习题的时候, 同样给出了几个比较有趣的方法:

因为在二叉树生成的时候, 二叉树本身的验证并不是一件简单的事, 比较复杂:

它分别验证了:

二叉树构成:

通过递归的方式遍历这棵树, 每遍历一个节点, 计数 count += 1, 如果 root.N 最终等于 count值, 则表示树中不存在循环等问题, 至少证明了它确实是一棵二叉树.

二叉树的有序性:

同样通过递归的方式, 以每个节点作为 参数, 求取max 和 min, 保证这棵树中的每一个节点的key值都在两者之间. 且每个节点的子节点都是有序的.

换句话来说, 需要验证每个节点的 左节点<当前节点<右节点, 即可验证节点有序性.

二叉树键值的不重复:

遍历这棵树, 保证每个节点 它的子节点中不含有和它的键值相同的节点. 在验证有序性之后来做这件事就非常简单了.

将这三者按序结合, 即可验证一棵二叉树的准确性了.

结语

二叉树的优点, 毋庸置疑, 这是一种非常强大的数据结构. 但依然存在问题, 这个问题留在下节, 红黑树再来探讨并解决这个问题.

posted @ 2018-01-12 15:12  千江月09  阅读(199)  评论(0编辑  收藏  举报