七、基本数据结构(树形结构)

一、数的概念 Tree

树形结构
  • 如上图所示,是一个树形机构,这里面每个元素叫作“节点”,用来连线相邻节点之间的关系,叫作“父子关系”。
  • A 节点是 B 节点的父节点, B 节点是 A 节点的子节点。
  • B、 C、 D 这三个节点的父节点是同一个节点,所以它们之间互称为兄弟节点。
  • 没有父节点的节点叫作根节点,也就是图中的节点 E。
  • 我们把没有子节点的节点叫作叶子节点或者叶节点,比如图中的 G、 H、 I、 J、 K、 L 都是叶子节点。
  • 节点的高度:节点到叶子节点的最长路径(边数)。
  • 节点的深度:根节点到这个节点所经历的边的个数。
  • 节点的层数:节点的深度 + 1.
  • 树的高度:就是根节点的高度。

二、二叉树

2.1、二叉树介绍

  • 二叉树,顾名思义,每个节点最多有两个“叉”,也就是两个子节点,分别是左子节点和右子节点。

  • 不过,二叉树并不要求每个节点都有两个子节点,有的节点只有左子节点,有的节点只有右子节点。

  • 二叉树
  • 编号2的二叉树中,叶子节点全都在最底层,除了叶子节点之外,每个节点都有左右两个子节点,这种二叉树就叫作满二叉树

  • 编号3的二叉树中,叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最大,这种二叉树叫作完全二叉树

  • 完全二叉树

2.2、二叉树的存储

存储一棵二叉树,有两种方法,一种是基于指针或者引用的二叉链式存储法,一种是基于数组的顺序存储法。

1.链式存储法

  • 链式存储比较简单、直观。
  • 如下图所示,每个节点有三个字段,其中一个存储数据,另外两个是指向左右子节点的指针。
  • 只要拎住根节点,就可以通过左右子节点的指针,把整棵树都串起来。
  • 这种存储方式我们比较常用。大部分二叉树代码都是通过这种结构来实现的。
  • 链式存储

2.基于数组的顺序存储

  • 如下图所示,把根节点存储在下标 i = 1 的位置,那左子节点存储在下标 2 * i = 2 的位置,右子节点存储在 2 * i + 1 = 3 的位置。

  • 以此类推, B 节点的左子节点存储在 2 * i = 2 * 2 = 4 的位置,右子节点存储在 2 * i + 1 = 2 * 2 + 1 = 5 的位置。

  • 如果节点 X 存储在数组中下标为 i 的位置,下标为 2 * i 的位置存储的就是左子节点,下标为 2 * i + 1 的位置存储的就是右子节点。

  • 反过来,下标为 i/2 的位置存储就是它的父节点。

  • 通过这种方式,我们只要知道根节点存储的位置(一般情况下,为了方便计算子节点,根节点会存储在下标为1的位置),这样就可以通过下标计算,把整棵树都串起来。

2.3。二叉树的遍历

  • 前序遍历:对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。

  • 中序遍历:对于树中的任意节点来说,先打印它的左子树,然后再打印它本身,最后打印它的右子树。

  • 后序遍历:对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身。

  • 二叉树的遍历
  • 实际上,二叉树的前、中、后序遍历就是一个递归的过程。

  • 比如,前序遍历,其实就是先打印根节点,然后再递归地打印左子树,最后递归地打印右子树。

  • 前序遍历的递推公式:preOrder(r) = print r->preOrder(r->left)->preOrder(r->right)。

  • 中序遍历的递推公式:inOrder(r) = inOrder(r->left)->print r->inOrder(r->right)

  • 后序遍历的递推公式:postOrder(r) = postOrder(r->left)->postOrder(r->right)->print r

  • 从上面的前、中、后序遍历的顺序图,可以看出来,每个节点最多会被访问两次,所以遍历操作的时间复杂度,跟节点的个数 n 成正比,也就是说二叉树遍历的时间复杂度是 O(n)

static class Node {
    Node rightNode;
    Node leftNode;
    String data;
}

/**
 * 先序遍历,先打印本身,在打印左节点,在打印右节点
 *
 * @param node
 */
public static void preOrderTraverse(Node node) {
    if (node == null) {
        return;
    }
    System.out.print(node.data + " ");
    preOrderTraverse(node.leftNode);
    preOrderTraverse(node.rightNode);
}

/**
 * 中序遍历,先打印左节点,在打印本身,在打印右节点
 *
 * @param node
 */
public static void inOrderTraverse(Node node) {
    if (node == null) {
        return;
    }
    inOrderTraverse(node.leftNode);
    System.out.print(node.data + " ");
    inOrderTraverse(node.rightNode);
}

/**
 * 后序遍历,先打印左节点,在打印右节点,在打印自己
 *
 * @param node
 */
public static void postOrderTraverse(Node node) {
    if (node == null) {
        return;
    }
    preOrderTraverse(node.rightNode);
    preOrderTraverse(node.leftNode);
    System.out.print(node.data + " ");

}

/**
 * 数的层级遍历
 *
 * @param root
 */
public static void levelTraverse(Node root) {
    if (root == null) {
        return;
    }

    LinkedList<Node> queue = new LinkedList<Node>();
    Node current = null;
    // 根节点入队
    queue.offer(root);

    // 左侧数的深度
    int leftNum = 0;
    // 右侧数的深度
    int rightNum = 0;

    // 只要队列中有元素,就可以一直执行,非常巧妙地利用了队列的特性
    while (!queue.isEmpty()) {
        // 出队队头元素
        current = queue.poll();
        System.out.print("-->" + current.data);
        // 左子树不为空,入队
        if (current.leftNode != null) {
            queue.offer(current.leftNode);
            leftNum++;
        }

        // 右子树不为空,入队
        if (current.rightNode != null) {
            queue.offer(current.rightNode);
            rightNum++;
        }
    }
    System.out.println(rightNum + "\t" + leftNum);
}

三、二叉查找树(Binary Search Tree)

  • 二叉查找树是二叉树中最常用的一种类型,也叫二叉搜索树。
  • 二叉查找树是为了实现快速查找而生的。
  • 它不仅仅支持快速查找一个数据,还支持快速插入、删除一个数据。
  • 二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值
  • 二叉查找树最大的特点就是,支持动态数据集合的快速插入、删除、查找操作。
  • 中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度是 O(n),非常高效。
  • 二叉查找树

3.1、二叉查找树的查找操作

  • 先取根节点,如果它等于要查找的数据,就返回。
  • 如果要查找的数据比根节点的值小,那就在左子树中递归查找;
  • 如果要查找的数据比根节点的值大,那就在右子树中递归查找。
  • 二叉树查找
public class BinarySearchTree {

    private Node tree;

    public Node find(int data) {
        Node p = tree;
        while (p != null) {
            if (data < p.data) p = p.leftNode;
            else if (data > p.data) p = p.rightNode;
            else return p;
        }
        return null;
    }


    class Node {
        private int data;
        private Node leftNode;
        private Node rightNode;

        public Node(int data) {
            this.data = data;
        }
    }
}

3.2、二叉查找树的插入操作

  • 二叉查找树的插入过程有点类似查找操作。
  • 新插入的数据一般都是在叶子节点上,所以只需要从根节点开始,依次比较要插入的数据和节点的大小关系。
  • 如果要插入的数据比节点的数据大,并且节点的右子树为空,就将新数据直接插到右子节点的位置;
  • 如果不为空,就再递归遍历右子树,查找插入位置。
  • 同理,如果要插入的数据比节点数值小,并且节点的左子树为空,就将新数据插入到左子节点的位置;如果不为空,就再递归遍历左子树,查找插入位置。
    二叉树插入
/**
 * 插入操作
 *
 * @param data
 * @return
 */
public Boolean insert(int data) {
    if (tree == null) {
        tree = new Node(data);
        return true;
    }
    Node p = tree;
    while (p != null) {
        if (data > p.data) {
            // 插入右节点
            if (p.rightNode == null) {
                p.rightNode = new Node(data);
                return true;
            }
            p = p.rightNode;
        } else {
            // 插入 左节点
            if (p.leftNode == null) {
                p.leftNode = new Node(data);
                return true;
            }
            p = p.leftNode;
        }
    }
    return false;
}

3.3、二叉查找树的删除操作

  • 针对要删除节点的子节点个数的不同,需要分三种情况来处理。
  • 第一种情况是,如果要删除的节点没有子节点
    • 只需要直接将父节点中,指向要删除节点的指针置为 null。
    • 比如图中的删除节点 55。
  • 第二种情况是,如果要删除的节点只有一个子节点(只有左子节点或者右子节点)
    • 只需要更新父节点中,指向要删除节点的指针,让它指向要删除节点的子节点就可以了。
    • 比如图中的删除节点 13。
  • 第三种情况是,如果要删除的节点有两个子节点
    • 需要找到这个节点的右子树中的最小节点,把它替换到要删除的节点上。
    • 然后再删除掉这个最小节点,因为最小节点肯定没有左子节点(如果有左子结点,那就不是最小节点了)。
    • 所以,可以应用上面两条规则来删除这个最小节点。比如图中的删除节点 18。
  • 删除
/**
 * 删除
 * @param data
 */
public void delete(int data) {
    // p指向要删除的节点,初始化指向根节点
    Node p = tree;
    // pp记录的是p的父节点
    Node pp = null;

    // 查找要删除的节点位置,及其父节点
    while (p != null && p.data != data) {
        pp = p;
        if (data > p.data) {
            p = p.rightNode;
        } else {
            p = p.leftNode;
        }
    }
    if (p == null) {
        return;// 没有找到
    }
    // 要删除的节点有两个子节点
    if (p.leftNode != null && p.rightNode != null) {
        // 查找右子树中最小节点
        Node minp = p.rightNode;
        Node minpp = p; // minPP表示minP的父节点
        while (minp.leftNode != null) {
            minpp = minp;
            minp = minp.leftNode;
        }
        // 将 minp 的数据替换到 p 中
        p.data = minp.data;
        // 下面就变成了删除 minp 了
        p = minp;
        pp = minpp;
    }
    // 删除节点是叶子节点或者仅有一个子节点
    Node child; // p 的子节点
    if (p.leftNode != null) {
        child = p.leftNode;
    } else if (p.rightNode != null) {
        child = p.rightNode;
    } else {
        child = null;
    }
    if (pp == null) {
        // 删除的是根节点
        tree = child;
    } else if (pp.leftNode == p) {
        pp.leftNode = child;
    } else {
        pp.rightNode = child;
    }
}
  • 实际上,关于二叉查找树的删除操作,还有个非常简单、取巧的方法,就是单纯将要删除的节点标记为“已删除”,但是并不真正从树中将这个节点去掉
  • 这样原本删除的节点还需要存储在内存中,比较浪费内存空间,但是删除操作就变得简单了很多。
  • 而且,这种处理方法也并没有增加插入、查找操作代码实现的难度。

3.4、支持重复数据的二叉查找树

  • 上文提到的二叉查找树,默认树中节点存储的都是数字。很多时候,在二叉查找树中存储的,是一个包含很多字段的对象。
  • 利用对象的某个字段作为键值(key)来构建二叉查找树。把对象中的其他字段叫作卫星数据。
  • 前面讲的二叉查找树的操作,针对的都是不存在键值相同的情况。
  • 那如果存储的两个对象键值相同,解决方法如下:
    • 第一种方法比较容易。二叉查找树中每一个节点不仅会存储一个数据,因此通过链表和支持动态扩容的数组等数据结构,把值相同的数据都存储在同一个节
      点上。

    • 第二种方法是,每个节点仍然只存储一个数据。

    • 在查找插入位置的过程中,如果碰到一个节点的值,与要插入数据的值相同,就将这个要插入的数据放到这个节点的右子树,也就是说,把这个新插入的数据当作大于这个节点的值来处理。

    • 当要查找数据的时候,遇到值相同的节点,并不停止查找操作,而是继续在右子树中查找,直到遇到叶子节点,才停止。这样就可以把键值等于要查找值的所有节点都找出来。

    • 对于删除操作,也需要先查找到每个要删除的节点,然后再按前面讲的删除操作的方法,依次删除。

3.5、二叉查找树的时间复杂度分析

  • 二叉查找树的形态各式各样。如下图所示,对于同一组数据,构造了三种二叉查找树。它们的查找、插入、删除操作的执行效率都是不一样的。

  • 图中第一种二叉查找树,根节点的左右子树极度不平衡,已经退化成了链表,所以查找的时间复杂度就变成了 O(n)。

  • 从前面的例子、图,以及还有代码来看,不管操作是插入、删除还是查找, 时间复杂度其实都跟树的高度成正比,也就是 O(height)。

  • 既然这样,现在问题就转变成另外一个了,也就是,如何求一棵包含 n 个节点的完全二叉树的高度?

  • 树的高度就等于最大层数减一,为了方便计算,转换成层来表示。

  • 从图中可以看出,包含 n 个节点的完全二叉树中,第一层包含1个节点,第二层包含2个节点,第三层包含4个节点,依次类推,下面一层节点个数是上一层的 2 倍,第 K 层包含的节点个数就是 2^(K-1)。

  • 不过,对于完全二叉树来说,最后一层的节点个数有点儿不遵守上面的规律了。它包含的节点个数在 1 个到 2^(L-1) 个之间(假设最大层数是L)。

  • 如果我们把每一层的节点个数加起来就是总的节点个数 n。也就是说,如果节点的个数是 n,那么 n 满足这样一个关系:

  • n >= 1+2+4+8+...+2^(L-2)+1

  • n <= 1+2+4+8+...+2(L-2)+2(L-1)

  • 借助等比数列的求和公式,我们可以计算出,L 的范围是 [log2(n+1), log2n +1]。完全二叉树的层数小于等于 log2n +1,也就是说,完全二叉树的高度小于等于 log2n。

  • 显然,极度不平衡的二叉查找树,它的查找性能肯定不能满足需求。

  • 需要构建一种不管怎么删除、插入数据,在任何时候,都能保持任意节点左右子树都比较平衡的二叉查找树,这就是平衡二叉查找树。

  • 平衡二叉查找树的高度接近 logn,所以插入、删除、查找操作的时间复杂度也比较稳定,是 O(logn)。

四、红黑树

  • 二叉查找树是最常用的一种二叉树,它支持快速插入、删除、查找操作,各个操作的时间复杂度跟树的高度成正比,理想情况下,时间复杂度是 O(logn)。
  • 不过,二叉查找树在频繁的动态更新过程中,可能会出现树的高度远大于 log2n 的情况,从而导致各个操作的效率下降。
  • 极端情况下,二叉树会退化为链表,时间复杂度会退化到 O(n)。
  • 要解决这个复杂度退化的问题,需要设计一种平衡二叉查找树,比如红黑树。

4.1、平衡二叉查找树

  • 平衡二叉树的严格定义是这样的:二叉树中任意一个节点的左右子树的高度相差不能大于 1

  • 从这个定义来看,完全二叉树、满二叉树其实都是平衡二叉树,但是非完全二叉树也有可能是平衡二叉树。

  • 平衡二叉查找树
  • 平衡二叉查找树不仅满足上面平衡二叉树的定义,还满足二叉查找树的特点。

  • 最先被发明的平衡二叉查找树是 AVL 树,它严格符合刚讲到的平衡二叉查找树的定义,即任何节点的左右子树高度相差不超过 1,是一种高度平衡的二叉查找树。

  • 但是很多平衡二叉查找树其实并没有严格符合上面的定义(树中任意一个节点的左右子树的高度相差不能大于1),比如红黑树,它从根节点到各个叶子节点的最长路径,有可能会比最短路径大一倍。

  • 发明平衡二叉查找树这类数据结构的初衷是,解决普通二叉查找树在频繁的插入、删除等动态更新的情况下,出现时间复杂度退化的问题。

  • 所以, 平衡二叉查找树中“平衡”的意思,其实就是让整棵树左右看起来比较“对称”、比较“平衡”,不要出现左子树很高、右子树很矮的情况。

  • 这样就能让整棵树的高度相对来说低一些,相应的插入、删除、查找等操作的效率高一些。

  • 所以,如果现在设计一个新的平衡二叉查找树,只要树的高度不比 log2n 大很多(比如树的高度仍然是对数量级的),尽管它不符合严格的平衡二叉查找树的定义,但仍然可以说,这是一个合格的平衡二叉查找树。

4.2、红黑树

  • 平衡二叉查找树其实有很多,比如, Splay Tree(伸展树)、 Treap(树堆)等,但是提到平衡二叉查找树,听到的基本都是红黑树。
  • 他的出镜率甚至要高于“平衡二叉查找树”这几个字,有时候,甚至默认平衡二叉查找树就是红黑树。
  • 红黑树的英文是“Red-Black Tree”,简称R-B Tree。它是一种不严格的平衡二叉查找树,它的定义是不严格符合平衡二叉查找树的定义的。
  • 顾名思义,红黑树中的节点,一类被标记为黑色,一类被标记为红色。除此之外,一棵红黑树还需要满足这样几个要求:
    • 根节点是黑色的;
    • 每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据;
    • 任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的;
    • 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点;
    • 这里的第二点要求“叶子节点都是黑色的空节点”,它主要是为了简化红黑树的代码实现而设置的。
    • 下图中将黑色的、空的叶子节点都省略掉了。

1.为什么说红黑树是“近似平衡”的?

  • 平衡二叉查找树的初衷,是为了解决二叉查找树因为动态更新导致的性能退化问题。所以,“平衡”的意思可以等价为性能不退化。

  • “近似平衡”就等价为性能不会退化的太严重。

  • 二叉查找树很多操作的性能都跟树的高度成正比。

  • 一棵极其平衡的二叉树(满二叉树或完全二叉树)的高度大约是 log2n,所以如果要证明红黑树是近似平衡的,只需要分析,红黑树的高度是否比较稳定地趋近 log2n 就好了。

  • 如果将红色节点从红黑树中去掉,那单纯包含黑色节点的红黑树的高度是多少呢?

  • 红色节点删除之后,有些节点就没有父节点了,它们会直接拿这些节点的祖父节点(父节点的父节点)作为父节点。所以,之前的二叉树就变成了四叉树。

  • 前面红黑树的定义里有这么一条:从任意节点到可达的叶子节点的每个路径包含相同数目的黑色节点。

  • 从四叉树中取出某些节点,放到叶节点位置,四叉树就变成了完全二叉树。所以,仅包含黑色节点的四叉树的高度,比包含相同节点个数的完全二叉树的高度还要小。

  • 完全二叉树的高度近似 log2n,这里的四叉“黑树”的高度要低于完全二叉树,所以去掉红色节点的“黑树”的高度也不会超过 log2n。

  • 现在知道只包含黑色节点的“黑树”的高度,那我们现在把红色节点加回去,高度会变成多少呢?

  • 从上面画的红黑树的例子和定义看,在红黑树中,红色节点不能相邻,也就是说,有一个红色节点就要至少有一个黑色节点,将它跟其他红色节点隔开。

  • 红黑树中包含最多黑色节点的路径不会超过l og2n,所以加入红色节点之后,最长路径不会超过 2log2n,也就是说,红黑树的高度近似 2log2n。

  • 所以,红黑树的高度只比高度平衡的AVL树的高度(log2n)仅仅大了一倍,在性能上,下降得并不多。

  • 这样推导出来的结果不够精确,实际上红黑树的性能更好。

posted @ 2020-06-17 21:29  abc十号  阅读(3391)  评论(0编辑  收藏  举报