Java-挑战-八-

79 阅读59分钟

Java 挑战(八)

原文:Java Challenges

协议:CC BY-NC-SA 4.0

九、二叉树

虽然集合框架提供了丰富多样的真实世界的数据结构,但不幸的是,它不包括直接使用的树。 1 然而,树对各种用例都有帮助,因此是可取的。因为树的主题相当广泛,并且不超出本书的范围,你将主要处理二叉树和二分搜索法树(BST)作为特例。

在您更详细地了解树木之前,我想提一下一些使用领域:

  • 文件系统是分层结构的,可以建模为一棵树。这里,节点对应于目录,树叶对应于文件。

  • 数学计算可以用树来表示。您将在后面的练习中看到这一点。

  • 在数据库领域,所谓的 B 树用于有效的存储和搜索。

  • 在编译器构造中,人们使用所谓的抽象语法树(AST)来表示源代码。 2

9.1 导言

在本简介中,在简要探讨二叉树和二叉查找树之前,您首先要学习一些术语。之后,我讨论了遍历和树的一些性质。最后,我介绍了在课文和作业中反复使用的三棵树。

9.1.1 结构、术语和使用示例

树既允许结构化存储,也允许高效访问在那里管理的数据。出于这个目的,是严格分等级的,和真正的树一样,树枝不会长回树干。分支点被称为节点并存储一个值。一个分支末端的节点被称为,在那里也可以找到值。连接的分支件称为。图 9-1 给人的第一印象。

img/519691_1_En_9_Fig1_HTML.png

图 9-1

有一些节点和叶子的树

图 9-1 说明了树由分层组织的节点组成。它们从一个根开始(有趣的是,它位于计算机科学的顶端)并分支成几个子节点,这些子节点又可以有任意数量的子节点。因此它们是父树,代表子树的根。每个节点正好被一个其他节点引用。

9.1.2 二叉树

二叉树是一种特殊的树,其中每个节点存储一个值,每个节点最多拥有两个后继,通常称为左和右。这种限制使得许多算法的表达更加容易。因此,二叉树在计算机科学中被广泛使用。

家酿软件中的二叉树二叉树只需要一点点努力就能实现(由类BinaryTreeNode<T>本身实现),这里甚至简化了更多,无需数据封装(信息隐藏):

public class BinaryTreeNode<T>
{
    public final T item;

    public BinaryTreeNode<T> left;
    public BinaryTreeNode<T> right;

    public BinaryTreeNode(final T item)
    {
        this.item = item;
    }

    @Override
    public String toString()
    {
        return String.format("BinaryTreeNode [item=%s, left=%s, right=%s]",
                             item, left, right);
    }

    public boolean isLeaf()
    {
        return left == null && right == null;
    }

    // other methods like equals(), hashCode(), ...
}

对于本书中的例子,你不需要提供一个二叉树模型作为一个独立的类,但是你总是使用一个特殊的节点作为根。然而,为了简化您自己的处理,尤其是更复杂的业务应用程序,定义一个名为BinaryTree<T>的单独类是一个好主意。这也允许您在那里提供各种有用的功能。

9.1.3 有序二叉树:二分搜索法树

有时术语二叉树二叉查找树可以互换使用,但这是不正确的。二叉查找树确实是一棵二叉树,但是它有一个额外的属性,即节点是根据它们的值排列的。约束条件是根的值大于左后继的值,小于右后继的值。这个约束递归地应用于所有子树,如图 9-2 所示。因此,BST 不会多次包含任何值。

img/519691_1_En_9_Fig2_HTML.png

图 9-2

带有字母的二叉查找树示例

BST 中的搜索由于值的排序,BST 中的搜索可以在对数时间内执行。为此,您实现了方法BinaryTreeNode<T> find(BinaryTreeNode<T>, T)。根据该值与当前节点值的比较,在树的适当部分继续搜索,直到找到该值。如果没有找到,则返回null:

<T extends Comparable<T>> BinaryTreeNode<T> find(BinaryTreeNode<T> startNode,
                                               T searchFor)
{
    // recursive termination
    if (startNode == null)
        return null;

    // recursive descent to the left or right depending on the comparison
    final int compareResult = startNode.item.compareTo(searchFor);
    if (compareResult < 0)
        return find(startNode.right, searchFor);
    if (compareResult > 0)
        return find(startNode.left, searchFor);

    return startNode;
}

插入到 BST 中插入到 BST 中也可以递归地表达。插入必须从根开始,这样才能确保 BST 中值的顺序:

static <T extends Comparable<T>> BinaryTreeNode<T>
       insert(final BinaryTreeNode<T> root, final T value)
{
    // recursive termination
    if (root == null)
        return new BinaryTreeNode<>(value);

    // recursive descent: to the left or right depending on the comparison
    final int compareResult = root.item.compareTo(value);
    if (compareResult > 0)
        root.left = insert(root.left, value);
    else if (compareResult < 0)
        root.right = insert(root.right, value);

    return root;
}

BST的例子前面显示的方法也是本章的工具类TreeUtils的一部分。有了这个类,BST 就可以非常容易和易读地构建了。在下面的代码中,您使用下划线作为前缀,以使节点的名称尽可能不言自明。此外,如果想继续使用节点,只需要给变量赋值。然而,特别地,根总是被返回。

final BinaryTreeNode<Integer> _3 = new BinaryTreeNode<>(3);
TreeUtils.insert(_3, 1).
TreeUtils.insert(_3, 2);
TreeUtils.insert(_3, 4);

TreeUtils.nicePrint(_3);
System.out.println("tree contains 2? " + find(_3, 2));
System.out.println("tree contains 13? " + find(_3, 13));

这将生成以下输出:

            3
      |-----+-----|
      1           4
      |--|
         2
tree contains 2?  BinaryTreeNode [item=2, left=null, right=null]
tree contains 13? null

有问题的插入顺序请注意,添加元素的顺序会极大地影响后续操作(如搜索)的性能。我将在 9.1.5 节中简要介绍这一点。下面的示例演示了树退化为列表的速度:

final BinaryTreeNode<Integer> _4 = new BinaryTreeNode<>(4);
BinaryTreeNode.insert(_4, 3);
BinaryTreeNode.insert(_4, 2);
BinaryTreeNode.insert(_4, 1);
TreeUtils.nicePrint(_4);

这将生成以下输出:

                        4
            |-----------|
            3
      |-----|
      2
   |--|
   1

Hint: ASCII Output of Trees

对于示例和练习中的树的输出,调用方法nicePrint()。这里暂时不进一步展示它的实现。尽管如此,它仍将作为练习 13 执行。相关的解决方案从第 657 页开始逐步展开。

旅行

当遍历一棵树时,深度优先和宽度优先搜索是有区别的。图 9-3 说明了这两种情况。

img/519691_1_En_9_Fig3_HTML.png

图 9-3

深度优先搜索和宽度优先搜索的过程

在深度优先搜索中,尽可能深入地遍历树。使用广度优先搜索,您可以从根开始在树中一层一层地移动。这就是为什么它也被称为水平顺序或广度优先。

广度优先/级别顺序

然后,当从根向下遍历各级时,对于示例中的树产生以下序列。实现是练习 5 的主题。

a b c d e f g h i j k

从树到列表的转换层次顺序遍历的一个很大的优点是它良好的可追溯性和可理解性。如果您心中有一棵树,您可以很容易地预测这个遍历及其结果。这是一个重要而有用的特性,尤其是在测试的时候。

让我们假设您已经解决了练习 5,因此可以访问方法levelorder(BinaryTreeNode<T>, Consumer<T>的实现。基于它,您可以将树转换为列表,如下所示:

static List<String> convertToList(final BinaryTreeNode<String> node)
{
    final List<String> result = new ArrayList<>();

    levelorder(node, result::add);

    return result;
}

深度优先搜索

三种已知的深度优先搜索方法是前序、中序和后序。Preorder 首先处理节点本身,然后处理来自左边和右边子树的节点。对于 inorder,处理顺序首先是左子树,然后是节点本身,最后是右子树。Postorder 首先处理左边的子树,然后是右边的子树,最后是节点本身。三种深度优先搜索方法通过前面显示的值,如下所示:

Preorder:     a b d h e i j c f k g
Inorder:      h d b i e j a f k c g
Postorder:    h d i j e b k f g c a

输出不太直观。对于 BST,inorder 遍历根据节点值的顺序返回节点值。这为下面的树产生 1 2 3 4 5 6 7:

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7

深度优先搜索的递归实现有趣的是,这些遍历可以很容易地递归实现。在每种情况下,该操作都以粗体突出显示:

static <T> void preorder(final BinaryTreeNode<T> currentNode)
{
    if (currentNode != null)
    {
        System.out.println(currentNode.item);
        preorder(currentNode.left);
        preorder(currentNode.right);
    }
}

static <T> void inorder(final BinaryTreeNode<T> currentNode)
{
    if (currentNode != null)
    {
        inorder(currentNode.left);
        System.out.println(currentNode.item);
        inorder(currentNode.right);
    }
}

static <T> void postorder(final BinaryTreeNode<T> currentNode)
{
    if (currentNode != null)
    {
        postorder(currentNode.left);
        postorder(currentNode.right);
        System.out.println(currentNode.item);
    }
}

Note: Practical Relevance of Postorders

后序是一种重要的树遍历类型,尤其是在以下用例中:

  • **删除:**删除子树的根节点时,必须始终确保子节点也被正确删除。后序遍历是做到这一点的好方法。

  • **数学表达式:**为了评估所谓的反向波兰符号(RPN) 中的数学表达式,一个后序遍历是一个合适的选择。

  • **大小的计算:**要确定一个目录的大小或一个层次项目的持续时间,postorder 是最合适的。

一个一个An example is the expression (5 + 2) * 6, which in RPN is 5 2 + 6 *. Interestingly, in RPN, parenthesis can be omitted, since those elements up to the next operator are always suitably concatenated. However, the RPN is quite poorly readable for more complex expressions.

9.1.5 平衡树和其他属性

如果在一棵二叉树中,两个子树的高度最多相差 1(有时相差某个常量),那么就称之为平衡树。相反的是一棵退化树,这是由于以对树来说不方便的方式插入数据而产生的,特别是当数字以有序的方式添加到二叉查找树中时。这导致树退化成一个线性列表,正如你在 9.1.3 节的例子中看到的。

有时一次或多次旋转可以恢复平衡。对于引言中的树,可以看到向左旋转和向右旋转。在中间,你可以看到平衡的起始位置。参见图 9-4 。

img/519691_1_En_9_Fig4_HTML.png

图 9-4

向左旋转,原稿,向右旋转

属性级别和高度

如导言中所述,树是分层结构的,由节点组成,这些节点可选地具有子节点,并且可以嵌套任意深度。为了描述这一点,存在两个术语水平高度。级别通常从 0 开始计数,从根开始,然后向下到最低的叶子。对于高度,以下情况适用:对于单个节点,它是 1。它是由一个子树向下到最低叶子的节点数决定的。这在图 9-5 中可以看到,其中一些被标记为子节点的节点实际上也是其他节点的父节点。

img/519691_1_En_9_Fig5_HTML.png

图 9-5

树的水平和高度

性质的完备性和完美性

一个完整的二叉树的特点是,除了最后一层,所有层都必须完全填充。此外,在最后一层中,所有节点都必须尽可能靠左,这样就没有间隙,或者所有节点都存在。

完整的二叉树中,右边的值可能会丢失(在算法中,这也被称为左满):

     4
   /   \
  2     6
 / \   /
1   3 5

如果所有的位置都被占据,那么这被称为完美树

     4
   /   \
  2     6
 / \   / \
1   3 5   7

在完全性的上下文中,在二叉树中不允许以下星座(这里是来自上部树的缺失的 5)(因为树不是左满):

     4
   /   \
  2     6
 / \     \
1   3     7

让我们试试更正式的:

  • 一棵完美二叉树是这样的树,其中所有的叶子都在同一层,并且所有的节点都有两个后继者。

  • 一棵完全二叉树是其中所有层都被完全填充,除了最后一层,在最后一层节点可能会丢失,但只是尽可能地向右。

  • 然后你有了一个全二叉树的定义,这意味着每个节点要么没有子节点,要么有两个子节点,如下图所示:

     4
   /   \
  2     6
       / \
      5   7

这是来自最弱的需求。在 www.programiz.com/dsa/complete-binary-tree 可以在线找到图解说明。

9.1.6 示例和练习的树

因为您将在下面重复引用一些典型的树结构,所以您在实用程序类ExampleTrees中实现了三种创建方法。

有字母和数字的树

为了尝试树遍历和其他操作,您构建了一个包含七个节点的树。因此,您定义了类型为BinaryTreeNode<T>的对象,这些对象在创建后仍然需要适当地连接。为了简单起见,这里的例子是在没有信息隐藏的情况下实现的。因此,您可以直接访问属性leftright

static BinaryTreeNode<String> createExampleTree()
{
    final BinaryTreeNode<String> a1 = new BinaryTreeNode<>("a1");
    final BinaryTreeNode<String> b2 = new BinaryTreeNode<>("b2");
    final BinaryTreeNode<String> c3 = new BinaryTreeNode<>("c3");
    final BinaryTreeNode<String> d4 = new BinaryTreeNode<>("d4");
    final BinaryTreeNode<String> e5 = new BinaryTreeNode<>("e5");
    final BinaryTreeNode<String> f6 = new BinaryTreeNode<>("f6");
    final BinaryTreeNode<String> g7 = new BinaryTreeNode<>("g7");

    d4.left = b2;
    d4.right = f6;
    b2.left = a1;
    b2.right = c3;
    f6.left = e5;
    f6.right = g7;

    return d4;
}

这导致了以下带有根d4的树:

           d4
      |-----+-----|
     b2          f6
   |--+--|     |--+--|
  a1    c3    e5    g7

你可能会对字母和数字的组合感到惊讶。我有意选择这个,因为它可以让理解一些算法变得更容易一些——例如,检查遍历的顺序。

具有文本和真实数字的树

对于一些练习,您还需要一个树,其中节点的值只由数字组成(但在文本上是字符串)。因为不可能用数字来命名单个节点的变量,所以再次使用以下划线开始变量名的技巧。

为了构建树,您利用了方法insert(),该方法将要插入的值放在适当的位置。只有当您使用 BST 及其顺序时,这才是可能的。正如你可以很容易地看到的,这将比前面显示的手动链接容易得多。

static BinaryTreeNode<String> createNumberTree()
{
    final BinaryTreeNode<String> _4 = new BinaryTreeNode<>("4");
    TreeUtils.insert(_4, "2");
    TreeUtils.insert(_4, "1");
    TreeUtils.insert(_4, "3");
    TreeUtils.insert(_4, "6");
    TreeUtils.insert(_4, "5");
    TreeUtils.insert(_4, "7");

    return _4;
}

这会产生以下树:

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7

**整数变量:**所示的树是作为整数变量生成的,如下所示:

static BinaryTreeNode<Integer> createIntegerNumberTree()
{
    final BinaryTreeNode<Integer> _4 = new BinaryTreeNode<>(4);
    TreeUtils.insert(_4, 2);
    TreeUtils.insert(_4, 1);
    TreeUtils.insert(_4, 3);
    TreeUtils.insert(_4, 6);
    TreeUtils.insert(_4, 5);
    TreeUtils.insert(_4, 7);

    return _4

;
}

9.2 练习

9.2.1 练习 1:树遍历(★★✩✩✩)

扩展引言中介绍的树遍历方法,以便在遍历时对当前节点执行操作。将Consumer<T>添加到各自的方法签名中,例如 for order:void inorder(BinaryTreeNode<T>Consumer<T>)

奖励:将树填充到列表中用节点的值填充列表。因此,编写方法List<T> toList(BinaryTreeNode<T>)进行有序遍历。此外,方法List<T> toListPreorder(BinaryTreeNode<T>)List<T> toListPostorder(BinaryTreeNode<T>)分别基于前序和后序遍历。

9.2.2 练习 2:前序、中序和后序迭代(★★★★✩)

在简介中,您了解了作为递归变量的前序、中序和后序。现在迭代实现这些类型的遍历。

例子

使用以下树:

           d4
      |-----+-----|
     b2          f6
   |--+--|     |--+--|
  a1    c3    e5    g7

三种深度优先搜索方法遍历该树,如下所示:

Preorder:     d4 b2 a1 c3 f6 e5 g7
Inorder:      a1 b2 c3 d4 e5 f6 g7
Postorder:    a1 c3 b2 e5 g7 f6 d4

9.2.3 练习 3:树的高度(★★✩✩✩)

实现方法int getHeight(BinaryTreeNode<T>)来确定树和以单个节点为根的子树的高度。

例子

以下高度为 4 的树用作起点:

                        E
            |-----------+-----------|
            C                       G
      |-----|                 |-----+-----|
      A                       F           H
                                          |--|
                                             I

9.2.4 练习 4:最低共同祖先(★★★✩✩)

计算托管在任意二叉查找树中的两个节点 A 和 B 的最低共同祖先(LCA)。LCA 表示作为 A 和 B 的祖先的节点,并且位于树中尽可能深的位置(根总是 A 和 B 的祖先)。编写方法BinaryTreeNode<Integer> findLCA(BinaryTreeNode<Integer>, int, int),除了搜索的开始节点(通常是根节点)之外,还接收下限和上限,间接描述了最接近这些值的节点。如果限制的值在值的范围之外,那么就没有 LCA,它应该被返回null

例子

下面显示了一个二叉树。如果为值为 1 和 5 的节点确定了最不常见的祖先,则这是值为 4 的节点。在树中,各个节点被圈起来,并且祖先被额外地用粗体标记。

                        6
            |-----------+-----------|
             ➃                        7
      |-----+-----|
      2            ➄
   |--+--|
    ➀      3  

9.2.5 练习 5:广度优先(★★★✩✩)

在本练习中,您应该使用方法void levelorder(BinaryTreeNode<T>, Consumer<T>)实现广度优先搜索,也称为级别顺序。广度优先搜索从给定的节点(通常是根节点)开始,然后逐层遍历树。

Note

使用一个Queue<E>来存储待访问节点上的数据。迭代的变体比递归的稍微容易一点。

例子

对于以下两棵树,序列 1 2 3 4 5 6 7(用于左侧)和 M I C H A E L(用于右侧)将被确定为结果。

            1                         M
      |-----+-----|             |-----+-----|
      2           3             I           C
   |--+--|     |--+--|       |--+--|     |--+--|
   4     5     6     7       H     A     E     L

9.2.6 练习 6:水平求和(★★★★✩)

在上一个练习中,您实现了广度优先搜索。现在,您想对树的每一层的值进行求和。为此,让我们假设这些值属于类型Integer。写方法Map<Integer, Integer> levelSum(BinaryTreeNode<Integer>)

例子

对于所示的树,应该计算每个级别的节点值的总和,并返回以下结果:{0=4,1=8,2=17,3=16}。

|

水平

|

价值

|

结果

| | --- | --- | --- | | Zero | four | four | | one | 2, 6 | eight | | Two | 1, 3, 5, 8 | Seventeen | | three | 7, 9 | Sixteen |

                        4
            |-----------+-----------|
            2                       6
      |-----+-----|           |-----+-----|
      1           3           5           8
                                       |--+--|
                                       7     9

9.2.7 练习 7:树旋转(★★★✩✩)

如果只按升序或降序插入值,二叉树,尤其是二分搜索法树,可能会退化为列表。不平衡可以通过旋转树的部分来解决。编写方法BinaryTreeNode<T> rotateLeft(BinaryTreeNode<T>)BinaryTreeNode<T> rotateRight(BinaryTreeNode<T>),这将分别围绕作为参数传递的节点向左或向右旋转树。

例子

图 9-6 显示向左旋转和向右旋转,平衡起始位置在中间。

img/519691_1_En_9_Fig6_HTML.png

图 9-6

向左旋转,原稿,向右旋转

9.2.8 练习 8:重建(★★★✩✩)

练习 8a:从阵列重建(★★✩✩✩)

在本练习中,您希望从升序排序的int[]中重建一个尽可能平衡的二叉查找树。

例子

给定的int值为

final int[] values = { 1, 2, 3, 4, 5, 6, 7 };

那么下面的树应该从它重新构建:

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7

练习 8b:从预订/未预订重建(★★★✩✩)

假设您有一系列的值,分别是 preorder 和 inorder,每一个都准备为一个列表。这个关于任意二叉树的信息应该被用来从其重建相应的树。写方法BinaryTreeNode<T> reconstruct(List<T>, List<T>)

例子

下面给出了两个遍历值序列。根据这些值,您应该重新构建本练习上一部分中显示的树。

var preorderValues = List.of(4, 2, 1, 3, 6, 5, 7);
var inorderValues = List.of(1, 2, 3, 4, 5, 6, 7);

9.2.9 练习 9:数学评估(★★✩✩✩)

考虑使用一棵树来模拟带有四个运算符+、-、 / 和∑的数学表达式。您的任务是计算单个节点的值,特别是包括根节点的值。为此,编写方法int evaluate(BinaryTreeNode<String>)

例子

用下面的树表示表达式 3+7∫(71)来计算根节点的值 45:

                        +
            |-----------+-----------|
            3                       *
                              |-----+-----|
                              7           -
                                       |--+--|
                                       7     1

练习 10:对称性(★★✩✩✩)

检查任意二叉树的结构是否对称。因此写方法boolean isSymmetric(BinaryTreeNode<T>)。除了结构检查之外,您还可以检查值是否相等。

例子

为了检查对称性,使用一个结构对称的二叉树(左)和一个关于值也对称的二叉树(右)。

            4                            1
      |-----+-----|                    /   \
      2           6                   2     2
   |--+--|     |--+--|               /       \
   1     3     5     7              3         3

Note: The Symmetry Property

在对称二叉树中,左右子树通过根沿着假想的垂直线(由|)镜像:

     1
   / | \
  2  |  2
 /   |   \
3    |    3

根据定义,对于对称性,可以省略值的比较。在这种情况下,只有结构组织可以算作相关。

奖励:镜像树在提示框中,我通过树根指示了一个镜像轴。创建方法BinaryTreeNode<T> invert(BinaryTreeNode<T>),该方法通过根在这条隐含线上镜像树的节点。

例子

镜像看起来像这样:

            4                              4
      |-----+-----|                  |-----+-----|
      2           6        =>        6           2
   |--+--|     |--+--|            |--+--|     |--+--|
   1     3     5     7            7     5     3     1

练习 11:检查★★✩✩✩的二叉查找树

在本练习中,您将检查任意二叉树是否满足二叉查找树的属性(即,左子树中的值小于根节点的值,而右子树中的值大于根节点的值,这适用于从根节点开始的每个子树)。为了简化,假设int值。写方法boolean isBST(BinaryTreeNode<Integer>)

例子

使用下面的二叉树,它也是二叉查找树。例如,如果你把左边的数字 1 换成一个更大的数字,它就不再是二叉查找树了。但是,6 下面的右子树仍然是二叉查找树。

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7

9.2.12 练习 12:完整性(★★★★)

在本练习中,要求您检查树的完整性。要做到这一点,您首先要解决练习的前两部分中的基本问题,然后进行更复杂的完整性检查。

练习 12a:节点数(★✩✩✩✩)

计算任何二叉树中包含多少个节点。为此,编写方法int nodeCount(BinaryTreeNode<T>)

例子

对于所示的二叉树,应该确定值 7。如果删除右边的子树,树只包含 4 个节点。

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7

练习 12b:检查完整/完美(★★✩✩✩)

对于一个任意的二叉树,检查是否所有的节点都有两个后继或叶子,因此树是满的。为了完美,所有的叶子必须在同一高度。写方法boolean isFull(BinaryTreeNode<T>)boolean isPerfect(BinaryTreeNode<T>)

例子

所示的二叉树既完美又完整。如果去掉 2 下面的两片叶子,就不再完美但依然饱满。

     Full and perfect         Full but not perfect
            4                          4
      |-----+-----|              |-----+-----|
      2           6              2           6
   |--+--|     |--+--|                    |--+--|
   1     3     5     7                    5     7

练习 12c:完整性(★★★★✩)

在该子任务中,要求您检查树是否如简介中所定义的那样完整(即,所有级别都被完全填充的二叉树,最后一级允许的例外是节点可能缺失,但只有尽可能靠右的间隙)。

例子

除了目前为止使用的完美树,下面的树根据定义也是完整的。但是,如果您从节点 H 中移除子节点,树就不再完整。

                        F
            |-----------+-----------|
            D                       H
      |-----+-----|           |-----+-----|
      B           E           G           I
   |--+--|
   A     C

练习 12d:完备性递归(★★★★)

在这最后一个子任务中,下面的挑战仍然需要作为特殊对待来处理:在没有额外的数据结构和纯递归的情况下解决检查。起初,这听起来几乎不可行,所以我给个提示。

Tip

逐步开发解决方案。创建一个boolean[]作为辅助数据结构,模拟某个位置是否存在节点。然后遍历树并适当标记位置。将这个实现转换成没有boolean[]的纯递归实现。

例子

和以前一样,下面的树根据定义是完整的:

                        F
            |-----------+-----------|
            D                       H
      |-----+-----|           |-----+-----|
      B           E           G           I
   |--+--|
   A     C

9.2.13 练习 13:树打印机(★★★★)

在本练习中,您将实现一个二叉树的图形输出,正如您在前面的示例中看到的那样。

因此,你首先要解决作业前三部分的基础问题,然后再进行更复杂的树的图形展示。

Tip

使用宽度为 3 的块的固定网格。这大大有助于平衡表示并降低复杂性。

例子

下面的树应该涵盖各种特殊情况:

                         F
             |-----------+-----------|
             D                       H
       |-----+                       +-----|
       B                                   I
    |--+--|
    A     C

练习 13a:子树的宽度(★★✩✩✩)

在练习的这一部分中,要求您使用方法int subtreeWidth(int)找出给定高度的子树的最大宽度。为简单起见,假设最多三个字符代表节点。而且,它们之间至少有三个字符的距离。树满了叶子也是如此。在更高的层次上,两个子树的节点之间自然有更多的空间。

例子

在左边,你看到一棵高度为 2 的树,在右边,一棵高度为 3 的树。基于三个网格,宽度为 9 和 21。参见图 9-7 。

img/519691_1_En_9_Fig7_HTML.png

图 9-7

树宽

|

高度

|

总宽度

|

子树宽度

| | --- | --- | --- | | one | three | 0(不存在子树) | | Two | nine | three | | three | Twenty-one | nine | | four | Forty-five | Twenty-one |

练习 13b:绘制节点(★★✩✩✩)

编写方法String drawNode(BinaryTreeNode<T>, int),创建一个节点的图形输出,适当地生成给定的一组空间。节点值最多应该有三个数字,并放在中间。

Tip

请记住,如果当前节点有一个左后继节点,则下面层的表示从左边开始,以字符串'|-'开始。

例子

图 9-8 中的例子显示了一个间距为 5 个字符的单个节点。此外,节点值在三个字符的框中居中对齐。

img/519691_1_En_9_Fig8_HTML.png

图 9-8

绘制节点时的尺寸

练习 13c:画连接线(★★✩✩✩)

编写方法String drawConnections(BinaryTreeNode<T>, int) to构建一个节点到它的两个后继节点的连接线的图形输出。必须正确处理缺失的继任者。

Tip

行长度指的是节点表示之间的字符。在每种情况下,代表末端的部分以及中间的连接器仍需适当附加。

例子

下图显示了绘图中所有相关的情况,例如无后续、一个后续和两个后续。

                         F
             |-----------+-----------|
             D                       H
       |-----+                       +-----|
       B                                   I
    |--+--|
    A     C

示意图如图 9-9 所示。

img/519691_1_En_9_Fig9_HTML.png

图 9-9

连接线的示意图

练习 13d:树的表示(★★★★)

组合练习各部分的所有解决方案,并完成必要的步骤,以便能够在控制台上适当地打印任意二叉树。为此,编写方法nicePrint(BinaryTreeNode<T>)

例子

通过nicePrint(),介绍性示例中显示的树的输出也应该类似如下:

                            F
                |-----------+-----------|
                D                       H
          |-----+                       +-----|
          B                                   I
       |--+--|
       A     C

另外,用源代码中可以找到的一棵真正的树来检查你的算法。这是一个瘦了很多的代表:

                                       BIG
                |-----------------------+---------------------|
               b2                                            f6
    |----------+-----------|                      |-----------+---------|
   a1                     d4                     d4                    g7
                     |-----+-----|           |-----+-----|
                     c3          f6         b2          e5
                              |--+--|    |--+---|
                             e5    g7   a1     c3

9.3 解决方案

9.3.1 解决方案 1:树遍历(★★✩✩✩)

扩展介绍中介绍的树遍历方法,以便在遍历时对当前节点执行操作。将Consumer<T>添加到各自的方法签名中,例如 for inorder: void inorder(BinaryTreeNode<T>, Consumer<T>)

算法通过这个扩展,每个遍历树的方法都接收一个Consumer<T>类型的附加参数。然后在适当的地方调用方法accept(T),而不是控制台输出。

static <T> void inorder(final BinaryTreeNode<T> node,
                        final Consumer<T> action)
{
    if (node == null)
        return;

    inorder(node.left, action);
    action.accept(node.item);
    inorder(node.right, action);
}

static <T> void preorder(final BinaryTreeNode<T> node,
                         final Consumer<T> action)
{
    if (node == null)
        return;

    action.accept(node.item);
    preorder(node.left, action);
    preorder(node.right, action);
}

static <T> void postorder(final BinaryTreeNode<T> node,
                          final Consumer<T> action)
{
    if (node == null)
        return;

    postorder(node.left, action);
    postorder(node.right, action);
    action.accept(node.item);
}

额外收获:将一棵树填充到列表中

用节点的值填充列表。因此,编写方法List<T> toList(BinaryTreeNode<T>)进行有序遍历。此外,方法List<T> toListPreorder(BinaryTreeNode<T>)List<T> toListPostorder(BinaryTreeNode<T>)分别基于前序和后序遍历。

算法代替目前作为动作使用的控制台输出,根据所选的遍历策略添加当前值。对于递归下降,然后使用addAll()添加部分结果,并使用来自List<E>的方法add()获取当前节点的值。

static <T> List<T> toList(final BinaryTreeNode<T> startNode)
{
    if (startNode == null)
        return List.of();

    final List<T> result = new ArrayList<>();
    result.addAll(toList(startNode.left));
    result.add(startNode.item);
    result.addAll(toList(startNode.right));

    return result;
}

static <T> List<T> toListPreorder(final BinaryTreeNode<T> startNode)
{
    final List<T> result = new ArrayList<>();
    preorder(startNode, result::add);
    return result;
}

static <T> List<T> toListPostorder(final BinaryTreeNode<T> startNode)
{
    final List<T> result = new ArrayList<>();
    postorder(startNode, result::add);
    return result;
}

您使用前面实现的带有Consumer<T>的遍历(用于前序和后序)来适当地填充一个列表。但是,请记住,通常您应该避免在 lambdas 或方法引用中进行状态更改,因为这与函数式思维方式相矛盾。在这种本地环境中,尤其是在没有多线程的情况下,在这里破例是可以接受的。

确认

首先,定义一个树,然后通过传递的Consumer<T>执行一个 inorder 遍历,最后从树中填充另外两个列表:

public static void main(final String[] args)
{
    final BinaryTreeNode<String> root = ExampleTrees.createExampleTree();
    TreeUtils.nicePrint(root);

    System.out.println("\nInorder with consumer: ");
    inorder(root, str -> System.out.print(str + " "));
    System.out.println();

    System.out.println("\ntpList: " + toList(root));
    System.out.println("toListPreorder: " + toListPreorder(root));
}

如果您执行这个main()方法,您将获得以下输出,这表明您的实现按预期工作:

           d4
      |-----+-----|
     b2          f6
   |--+--|     |--+--|
  a1    c3    e5    g7

Inorder with consumer:
a1 b2 c3 d4 e5 f6 g7

toList: [a1, b2, c3, d4, e5, f6, g7]
toListPreorder: [d4, b2, a1, c3, f6, e5, g7]

9.3.2 解决方案 2:前序、中序和后序迭代(★★★★✩)

在简介中,您了解了作为递归变量的前序、中序和后序。现在迭代实现这些类型的遍历。

例子

使用以下树:

           d4
      |-----+-----|
     b2          f6
   |--+--|     |--+--|
  a1    c3    e5    g7

三种深度优先搜索方法遍历该树,如下所示:

Preorder:     d4 b2 a1 c3 f6 e5 g7
Inorder:      a1 b2 c3 d4 e5 f6 g7
Postorder:    a1 c3 b2 e5 g7 f6 d4

算法的初步考虑对于每个迭代实现,都需要一个辅助数据结构。这就是我现在将详细讨论的三种变体。

预排序的算法( ★★✩✩✩ ) 有趣的是,预排序相当简单,因为总是先处理一个子树的根。然后处理左右子树。为此,您使用一个堆栈,最初用当前节点填充它。只要堆栈不为空,就可以确定顶层元素并执行所需的操作。然后,如果左右后继节点存在,就将它们放在堆栈上。需要注意的是,加法的顺序与阅读的顺序相反。为了首先处理左边的子树,必须将右边的节点放在左边的节点之前。重复这一过程,直到堆栈为空。示例中的树的以下序列结果:

|

电流节点

|

|

行动

| | --- | --- | --- | |   | [d4] | 开始:按下 d4 | | d4 | [b2,f6] | 弹出+动作 d4,按 f6,按 b2 | | b2 | [a1、c3、f6] | 弹出+动作 b2,按 c3,按 a1 | | 第一等的 | [c3,f6] | Pop +动作 a1 | | c3 | [f6] | 弹出+动作 c3 | | f6 | [e5,g7] | 弹出+动作 f6,按 g7,按 e5 | | e5 | [七国集团] | Pop + action e5 | | 七国集团 | [] | 弹出+动作 g7 | | 空 | [] | 目标 |

这导致以下迭代前序实现,其在结构上非常类似于递归变体:

static <T> void preorderIterative(final BinaryTreeNode<T> startNode,
                                  final Consumer<T> action)
{
    if (startNode == null)
        return;

    final Stack<BinaryTreeNode<T>> nodesToProcess = new Stack<>();
    nodesToProcess.push(startNode);

    while (!nodesToProcess.isEmpty())
    {
        final BinaryTreeNode<T> currentNode = nodesToProcess.pop();
        if (currentNode != null)
        {
            action.accept(currentNode.item);

            // so that left is processed first, here order reversed
            nodesToProcess.push(currentNode.right);
            nodesToProcess.push(currentNode.left);
        }
    }
}

为了保持尽可能强的类比性,集合还可以存储null值是很有帮助的。这允许您在从堆栈中提取时执行一次null检查,否则保持源代码免于特殊处理。

Inorder 的算法( ★★★✩✩ ) 在实现 in order 遍历时,使用一个堆栈来临时存储稍后要处理的节点,使用变量currentNode来存储当前节点。基本思想是从根开始,移动到树的左下方,将当前节点放入堆栈,直到没有后继节点。然后从栈中取出最上面的节点并处理它(通过Consumer<T>)。现在你继续找合适的接班人。同样:如果没有后继节点,处理堆栈中的顶层节点。

示例中的树的以下序列结果:

|

电流节点

|

|

行动

|

下降方向

| | --- | --- | --- | --- | | d4 | [ ] | 按下 d4 | (六) | | b2 | [d4] | 按 b2 | (六) | | 第一等的 | [b2、d4] | 按 a1 | (六) | | 空 | [a1、b2、d4] | Pop +动作 a1 | *本文件迟交 | | 空 | [b2、d4] | Pop +动作 b2 | *本文件迟交 | | c3 | [d4] | 按 c3 | (六) | | 空 | [c3、d4] | 弹出+动作 c3 | *本文件迟交 | | 空 | [d4] | pop+D4 操作 | *本文件迟交 | | f6 | [] | 按 f6 键 | (六) | | e5 | [f6] | 按 e5 | (六) | | 空 | [e5,f6] | Pop + action e5 | *本文件迟交 | | 空 | [f6] | 弹出+操作 f6 | *本文件迟交 | | 七国集团 | [] | 推动 g7 | (六) | | 空 | [七国集团] | 弹出+动作 g7 | *本文件迟交 | | 空 | [] | 目标 |   |

基于此,inorder 的迭代实现如下所示:

static <T> void inorderIterative(final BinaryTreeNode<T> startNode,
                                 final Consumer<T> action)
{
    if (startNode == null)
        return;

    final Stack<BinaryTreeNode<T>> nodesToProcess = new Stack<>();
    BinaryTreeNode<T> currentNode = startNode;

    // are there still nodes on the stack or is the current node not null?
    while (!nodesToProcess.isEmpty() || currentNode != null)
    {
        if (currentNode != null)
        {
            // descent to the left
            nodesToProcess.push(currentNode);
            currentNode = currentNode.left;
        }
        else
        {
            // no left successor, then process current node
            currentNode = nodesToProcess.pop();
            action.accept(currentNode.item);

            // continue with right successor
            currentNode = currentNode.right;
        }
    }
}

后序的算法( ★★★★✩ ) 有了后序,你还用一个栈来做后面要处理的节点的中间存储。然而,在这三种算法中,这种算法是最具挑战性的一种,并且很难实现。因为使用 postorder,虽然遍历从根开始,但动作必须在访问左、右子树后执行。因此,与前两个算法相比,你有一个有趣的变化。在那些算法中,如果从堆栈中取出一个元素,那么它被处理并且不再被触及。对于后序实现,一个元素可能要用peek()检查两次或更多次,之后才移除。

这一次,您将首先查看源代码,然后我将给出进一步的解释:

static <T> void postorderIterative(final BinaryTreeNode<T> startNode,
                                   final Consumer<T> action)
{
    if (startNode == null)
        return;

    final Stack<BinaryTreeNode<T>> nodesToProcess = new Stack<>();
    BinaryTreeNode<T> currentNode = startNode;
    BinaryTreeNode<T> lastNodeVisited = null;

    while (!nodesToProcess.isEmpty() || currentNode != null)
    {
        if (currentNode != null)
        {
            // descent to the left
            nodesToProcess.push(currentNode);
            currentNode = currentNode.left;
        }
        else
        {
            final BinaryTreeNode<T> peekNode = nodesToProcess.peek();
            if (peekNode.right != null && lastNodeVisited != peekNode.right)
            {
                // descent to the right
                currentNode = peekNode.right;
            }
            else
            {
                // sub root or leaf processing
                lastNodeVisited = nodesToProcess.pop();
                action.accept(lastNodeVisited.item);
            }
        }
    }
}

这个过程是这样工作的:从根节点开始,把它放到堆栈上,然后继续左子树。你重复这个过程,直到你再也找不到左边的后继者。现在你必须找一个合适的继任者。只有在此之后,才能对根进行处理。因为您已经保存了堆栈上的所有节点,所以现在从堆栈中检查节点。如果这个节点没有正确的子节点,并且您没有访问过它,那么您将执行传递的操作,并将这个节点记为最后访问的节点。对于另一种情况,即有一个右子树,您也可以像刚才描述的那样遍历它。重复此过程,直到堆栈为空。

|

电流节点

|

|

peekNode

|

行动

| | --- | --- | --- | --- | | d4 | [d4] |   | 按下 d4 | | b2 | [b2、d4] |   | 按 b2 | | 第一等的 | [a1、b2、d4] |   | 按 a1 | | 空 | [a1、b2、d4] | 第一等的 | 行动 a1 | | 空 | [b2、d4] | b2 | 窥视+向右 | | c3 | [c3、b2、d4] |   | 按 c3 | | 空 | [c3、b2、d4] | c3 | 行动 c3 | | 空 | [b2、d4] | b2 | 行动 b2 | | f6 | [f6,d4] |   | 按 f6 键 | | e5 | [e5,f6,d4] |   | 按 e5 | | 空 | [f6,d4] | e5 | 行动 e5 | | 空 | [f6,d4] | f6 | 窥视+向右 | | 七国集团 | [g7、f6、d4] |   | 推动 g7 | | 空 | [g7、f6、d4] | 七国集团 | 行动 g7 | | 空 | [f6,d4] | f6 | 动作 f6 | | 空 | [d4] | d4 | 行动 d4 | | 空 | [] |   |   |

Note: Iterative Implementation of Postorder

虽然这三种遍历的递归变体的实现都同样简单,并且每个都不是很复杂,但这并不适用于迭代实现。Preorder 和 inorder 仍然可以通过一点思考实现,没有大的困难。然而,对于后序,你真的必须战斗。因此,在那里需要几次尝试并不得不应用错误修正并不可耻。

别担心。并不总是那么棘手。即使是后面讨论的逐层遍历的广度优先遍历,在我看来也比迭代后序实现起来简单得多。

在某些情况下,递归是简单的关键。但是,对于很多问题,有非常好理解的迭代变体。

确认

从介绍性示例中定义树,然后每次都使用所需的过程遍历树:

public static void main(final String[] args)
{
    final BinaryTreeNode<String> d4 = ExampleTrees.createExampleTree();
    TreeUtils.nicePrint(d4);

    System.out.println("\ninorder iterative");
    inorderIterative(d4, str -> System.out.print(str + " "));

    System.out.println("\npreorder iterative");
    preorderIterative(d4, str -> System.out.print(str + " "));

    System.out.println("\npostorder iterative");
    postorderIterative(d4, str -> System.out.print(str + " "));
}

如果您执行main()方法,您将获得以下输出,这表明您的实现按预期工作:

           d4
      |-----+-----|
     b2          f6
   |--+--|     |--+--|
  a1    c3    e5    g7

inorder iterative
a1 b2 c3 d4 e5 f6 g7
preorder iterative
d4 b2 a1 c3 f6 e5 g7
postorder iterative
a1 c3 b2 e5 g7 f6 d4

作为一个单元测试,我开始展示如何测试一个有序的遍历。为此,在遍历过程中将当前值填充到一个列表中,然后对照预期值进行检查:

@Test
void testInorderIterative()
{
    var root = ExampleTrees.createExampleTree();
    var expected = List.of("a1", "b2", "c3", "d4", "e5", "f6", "g7");

    final List<String> result = new ArrayList<>();
    Ex02_IterativeTreeTraversals.inorderIterative(root, result::add);

    assertEquals(expected, result);
}

为了完整起见,我将展示前序和后序的两个测试。尤其是后一种遍历应该更仔细地检查,因为实现已经对您提出了很多要求。

@Test
void testPreorderIterative()
{
    var root = ExampleTrees.createExampleTree();
    var expected = List.of("d4", "b2", "a1", "c3", "f6", "e5", "g7");

    final List<String> result = new ArrayList<>();
    Ex02_IterativeTreeTraversals.preorderIterative(root, result::add);

    assertEquals(expected, result);
}

@Test
void testPostOrderIterative() throws Exception
{
    var root = ExampleTrees.createExampleTree();
    var expected = List.of("a1", "c3", "b2", "e5", "g7", "f6", "d4");

    final List<String> result = new ArrayList<>();
    Ex02_IterativeTreeTraversals.postorderIterative(root, result::add);

    assertEquals(expected, result);
}

树的创建是在每个测试方法中完成,还是被转移到一个带@Before注释的设置方法中,这是一个风格问题。有了上面的变体,你总是能看到一切。对于带有@Before的变型,初始化被移到测试夹具中。如有必要,只需调整一次。

惊奇算法

虽然前序很容易迭代设计,但对于 inorder 就变得有点困难,对于后序甚至非常棘手。

但是后来我从张秀坤·格兰茨教授那里得到了一个关于如何迭代简化整个过程的提示。非常感谢张秀坤这个伟大的算法建议。这是因为您保持了类似于递归序列的序列,但是顺序相反,因为您使用的是堆栈。此外,您还集成了人工的新树节点。

static <T> void inorder(final BinaryTreeNode<T> root)
{
    final Stack<BinaryTreeNode<T>> stack = new Stack<>();
    stack.push(root);

    while (!stack.isEmpty())
    {
        final BinaryTreeNode<T> currentNode = stack.pop();
        if (currentNode != null)
        {
            if (currentNode.isLeaf())
                System.out.print(currentNode.item + " ");
            else
            {
                stack.push(currentNode.right);
                stack.push(new BinaryTreeNode<T>(currentNode.item));
                stack.push(currentNode.left);
            }
        }
    }
    System.out.println();
}

更好的是,您可以把它变成一个通用的方法,允许所有三种遍历变化。为此,首先定义一个枚举,然后使用方法traverse()在序列中的每个适当点创建一个带有树节点的人工条目。如上所述,这些特殊的节点确保处理发生在正确的位置。

enum Order {
    PREORDER, INORDER, POSTORDER
}

static <T> void traverse(final BinaryTreeNode<T> root, final Order order)
{
    final Stack<BinaryTreeNode<T>> stack = new Stack<>();
    stack.push(root);

    while (!stack.isEmpty())
    {
        final BinaryTreeNode<T> currentNode = stack.pop();
        if (currentNode != null)
        {
            if (currentNode.isLeaf())
                System.out.print(currentNode.item + " ");
            else
            {
                if (order == Order.POSTORDER)
                    stack.push(new BinaryTreeNode<T>(currentNode.item));

                stack.push(currentNode.right);

                if (order == Order.INORDER)
                    stack.push(new BinaryTreeNode<T>(currentNode.item));

                stack.push(currentNode.left);

                if (order == Order.PREORDER)
                     stack.push(new BinaryTreeNode<T>(currentNode.item));
            }
        }
    }
    System.out.println();
}

Hint: Insight

在这个例子的帮助下,很容易理解对一个问题的透彻思考可以导致一个更简单、更容易理解和不太复杂的源代码。此外,如果解决方案比预期的更复杂,听取第二个或第三个意见总是好的。

9.3.3 解决方案 3:树高(★★✩✩✩)

实现method int getHeight(BinaryTreeNode<T>)来确定树和以单个节点为根的子树的高度。

例子

以下高度为 4 的树用作起点:

                        E
            |-----------+-----------|
            C                       G
      |-----|                 |-----+-----|
      A                       F           H
                                          |--|
                                             I

算法树高计算使用递归算法,确定左右子树的高度。最后,您必须由此计算最大值,然后为当前级别加上值 1。

static <T> int getHeight(final BinaryTreeNode<T> node)
{
    // recursive termination
    if (node == null)
        return 0;

    // recursive descent
    final int leftHeight = getHeight(node.left);
    final int rightHeight = getHeight(node.right);

    return 1 + Math.max(leftHeight, rightHeight);
}

确认

从示例中构造树,然后计算一些选定节点的高度:

public static void main(final String[] args)
{
    final BinaryTreeNode<String> e = createHeightExampleTree();
    TreeUtils.nicePrint(e);

    printInfo(e.left, e, e.right, e.right.right.right);
}

protected static BinaryTreeNode<String> createHeightExampleTree()
{
    final BinaryTreeNode<String> e = new BinaryTreeNode<>("E");
    TreeUtils.insert(e, "C");
    TreeUtils.insert(e, "A");
    TreeUtils.insert(e, "G");
    TreeUtils.insert(e, "F");
    TreeUtils.insert(e, "H");
    TreeUtils.insert(e, "I");
    return e;
}

private static void printInfos(final BinaryTreeNode<String> c,
                               final BinaryTreeNode<String> e,
                               final BinaryTreeNode<String> g,
                               final BinaryTreeNode<String> i)
{
    System.out.println("\nHeight of root E: " + getHeight(e));
    System.out.println("Height from leftParent C:  " + getHeight(c));
    System.out.println("Height from rightParent G: " + getHeight(g));
    System.out.println("Height from rightChild I:  " + getHeight(i));
}

出现以下输出:

                        E
            |-----------+-----------|
            C                       G
      |-----|                 |-----+-----|
      A                       F           H
                                          |--|
                                             I

Height of root E: 4
Height from leftParent C:  2
Height from rightParent G: 3
Height from rightChild I:  1

9.3.4 解决方案 4:最低共同祖先(★★★✩✩)

计算托管在任意二叉查找树中的两个节点 A 和 B 的最低共同祖先(LCA)。LCA 表示作为 A 和 B 的祖先的节点,并且位于树中尽可能深的位置(根总是 A 和 B 的祖先)。编写方法BinaryTreeNode<Integer> findLCA(BinaryTreeNode<Integer>, int, int),除了搜索的开始节点(通常是根节点)之外,还接收下限和上限,间接描述了最接近这些值的节点。如果限制的值在值的范围之外,那么就没有 LCA,它应该被返回null

例子

下面显示了一个二叉树。如果为值为 1 和 5 的节点确定了最不常见的祖先,则这是值为 4 的节点。各个节点被圈起来,并且祖先被额外地用粗体标记。

                        6
            |-----------+-----------|
                                     7
      |-----+-----|
      2            ➄
   |--+--|
    ➀      3

算法直觉上,你很想从两个节点往上走,直到路径交叉。然而,这是不可能的,只要节点中不存在到父节点的向后方向,就像这里一样。

但是有一个从根开始的简单实现。从那里,您继续如下:让currentValue是当前节点的值。另外,设value1value2为传递的节点值(即潜在后继的两个节点的值)。如果value1value2小于currentValue,那么由于二叉查找树内的排序属性,两者都必须位于左子树中,因此继续在那里搜索。如果value1value2都大于currentValue,则继续在右侧搜索。否则对于value1 < currentValue < value2value2 < currentValue < value1的情况,你已经找到了 LCA 这是当前节点。

static BinaryTreeNode<Integer> findLCA(final BinaryTreeNode<Integer> startNode,
                                       final int value1, final int value2)
{
    // recursive termination
    if (startNode == null)
        return null;

    final int currentValue = startNode.item;

    // recursive descent
    if (value1 < currentValue && value2 < currentValue)
        return lca(startNode.left, value1, value2);

    if (value1 > currentValue && value2 > currentValue)
        return lca(startNode.right, value1, value2);

    // recursive termination
    // value1 < currentValue && currentValue < value2  bzw.
    // value2 < currentValue && currentValue < value1
    return startNode;
}

确认

您构建示例中所示的树并调用您的方法:

@ParameterizedTest(name = "findLCA({0}, {1}) = {2}")
@CsvSource({ "1, 3, 2", "1, 5, 4", "2, 5, 4",
             "3, 5, 4", "1, 7, 6" })
void findLCA(int value1, int value2, int expected)
{
    var root = createLcaExampleTree();

    var result = Ex04_LowestCommonAncestor.findLCA(root, value1, value2);

    assertEquals(expected, result.item);
}

@Test
void findLCASpecial()
{
    var root = createLcaExampleTree();

    var result = Ex04_LowestCommonAncestor.findLCA(root, 1, 2);

    assertEquals(2, result.item);
}

如果您只检查非常明显的情况,一切都很好。如果您考虑检查父子关系中的两个节点,即值为 1 和 2 的节点,您将直观地期待值为 4 的节点。但是,会计算值为 2 的节点。根据定义(维基百科中的其他定义),每个节点也被视为自身的继承者。因此,在这种情况下,值为 2 的节点确实是 LCA。

为了完整起见,树的结构如下所示:

static BinaryTreeNode<Integer> createLcaExampleTree()
{
    final BinaryTreeNode<Integer> _6 = new BinaryTreeNode<>(6);
    TreeUtils.insert(_6, 7);
    TreeUtils.insert(_6, 4);
    TreeUtils.insert(_6, 5);
    TreeUtils.insert(_6, 2);
    TreeUtils.insert(_6, 1);
    TreeUtils.insert(_6, 3);

    return _6;
}

Hint: Modification for any Comparable Types

所示的算法有点复杂,但是稍加思考就很容易理解。很遗憾,我在这里仅限于具体类型。能普遍适用就更好了。

实际上,这只需要一些改变。首先,您一般地定义方法findLCA(),并要求类型满足Comparable<T>。如果这是给定的,而不是使用<和>的数字,你必须比较这里的值与compareTo(T)

static <T extends Comparable<T>> BinaryTreeNode<T>
                                 findLCA(final BinaryTreeNode<T> startNode,
                                         final T value1, final T value2)
{
    if (startNode == null)
        return null;

    final T currentValue = startNode.item;

    if (value1.compareTo(currentValue) < 0 &&
        value2.compareTo(currentValue) < 0)
    {
        return findLCA(startNode.left, value1, value2);
    }

    if (value1.compareTo(currentValue) > 0 &&
        value2.compareTo(currentValue) > 0)
    {
        return findLCA(startNode.right, value1, value2);
    }

    return startNode;
}

这可以通过向左旋转现在众所周知的示例树来容易地理解,这导致该树作为基础:

                       f6
            |-----------+-----------|
           d4                      g7
      |-----+-----|
     b2          e5
   |--+--|
  a1    c3

旋转和一些检查的实现如下:

final BinaryTreeNode<String> root = ExampleTrees.createExampleTree();
final BinaryTreeNode<String> rotatedRoot = TreeUtils.rotateLeft(root);
TreeUtils.nicePrint(rotatedRoot);

System.out.println("LCA(a, c) = " + findLCA(rotatedRoot, "a", "c")); // b2
System.out.println("LCA(a, e) = " + findLCA(rotatedRoot, "a", "e")); // d4
System.out.println("LCA(b, e) = " + findLCA(rotatedRoot, "b", "e")); // d4
System.out.println("LCA(a, g) = " + findLCA(rotatedRoot, "a", "g")); // f6

9.3.5 解决方案 5:广度优先(★★★✩✩)

在本练习中,您应该使用方法void levelorder(BinaryTreeNode<T>, Consumer<T>)实现广度优先搜索,也称为级别顺序。广度优先搜索从给定的节点(通常是根节点)开始,然后一层一层地遍历树。

Note

使用一个Queue<E>来存储待访问节点上的数据。迭代的变体比递归的稍微容易一点。

例子

对于以下两棵树,序列 1 2 3 4 5 6 7(用于左侧)和 M I C H A E L(用于右侧)将被确定为结果。

            1                         M
      |-----+-----|             |-----+-----|
      2           3             I           C
   |--+--|     |--+--|       |--+--|     |--+--|
   4     5     6     7       H     A     E     L

算法对于广度优先搜索,您使用一个队列作为稍后要处理的节点的缓存。首先,将根目录插入队列。然后,只要队列中有元素,就处理元素。这分为以下几个步骤:首先,为每个元素执行所需的操作。然后将左右后继节点放入队列中(如果这样的节点存在的话)。

static <T> void levelorder(final BinaryTreeNode<T> startNode,
                           final Consumer<T> action)
{
    if (startNode == null)
        return;

    final Queue<BinaryTreeNode<T>> toProcess = new LinkedList<>();
    toProcess.offer(startNode);

    while (!toProcess.isEmpty())
    {
        final BinaryTreeNode<T> currentNode = toProcess.poll();
        if (currentNode != null)
        {
            action.accept(currentNode.item);

            toProcess.offer(currentNode.left);
            toProcess.offer(currentNode.right);
        }
    }
}

为了尽量减少特殊处理和避免null检查,集合还可以存储null值是很有帮助的。这允许您在从堆栈中移除值时执行一次null检查,而不必在添加子节点时检查它。

除了while循环,您还可以使用递归调用来解决这个问题。如果您感兴趣,请研究配套项目中的源代码。

下面详细说一下流程。

|

长队

|

行动

| | --- | --- | | [1] | one | | [3, 2] | Two | | [5, 4, 3] | three | | [7, 6, 5, 4] | four | | [7, 6, 5] | five | | [7, 6] | six | | [7] | seven | | [] | 目标 |

确认

构建树并调用刚刚创建的方法:

public static void main(final String[] args)
{
    final BinaryTreeNode<String> _1 = createExampleLevelorderTree();
    TreeUtils.nicePrint(_1);

    System.out.print("\nLevelorder: ");
    levelorder(_1, str -> System.out.print(str + " "));
}

static BinaryTreeNode<String> createExampleLevelorderTree()
{
    final BinaryTreeNode<String> _1 = new BinaryTreeNode<>("1");
    final BinaryTreeNode<String> _2 = new BinaryTreeNode<>("2");
    final BinaryTreeNode<String> _3 = new BinaryTreeNode<>("3");
    final BinaryTreeNode<String> _4 = new BinaryTreeNode<>("4");
    final BinaryTreeNode<String> _5 = new BinaryTreeNode<>("5");
    final BinaryTreeNode<String> _6 = new BinaryTreeNode<>("6");
    final BinaryTreeNode<String> _7 = new BinaryTreeNode<>("7");

    _1.left = _2;
    _1.right = _3;
    _2.left = _4;
    _2.right = _5;
    _3.left = _6;
    _3.right = _7;

    return _1;
}

出现以下输出:

            1
      |-----+-----|
      2           3
   |--+--|     |--+--|
   4     5     6     7

Levelorder: 1 2 3 4 5 6 7

用单元测试验证当前值被填入一个列表,并作为一个单元测试对照期望值进行检查:

@Test
void testLevelorder()
{
    var root = Ex05_BreadthFirst.createExampleLevelorderTree();
    var expected = List.of("1", "2", "3", "4", "5", "6", "7");

    final List<String> result = new ArrayList<>();
    Ex05_BreadthFirst.levelorder(root, result::add);

    assertEquals(expected, result);
}

9.3.6 解决方案 6:级别和(★★★★✩)

在上一个练习中,您实现了广度优先搜索。现在,您想对树的每一层的值进行求和。为此,让我们假设这些值属于类型Integer。写方法Map<Integer, Integer> levelSum(BinaryTreeNode<Integer>)

例子

对于所示的树,应该计算每个级别的节点值的总和,并返回以下结果:{0=4,1=8,2=17,3=16}。

|

水平

|

价值

|

结果

| | --- | --- | --- | | Zero | four | four | | one | 2, 6 | eight | | Two | 1, 3, 5, 8 | Seventeen | | three | 7, 9 | Sixteen |

                        4
            |-----------+-----------|
            2                       6
      |-----+-----|           |-----+-----|
      1           3           5           8
                                       |--+--|
                                       7     9

算法广度优先搜索提供了很好的基础。您仍然缺少一个合适的数据结构和一种方法来确定完成解决方案的当前级别。稍加思考,您就会想到使用地图作为结果数据结构。当前级别作为密钥。该值由下面显示的类(或者更准确地说,记录)Pair<BinaryTreeNode<Integer>, Integer>表示。您像处理等级顺序一样遍历树。为了确定等级,你作弊。由于您是从根(子树的根)开始的,所以可以假定级别为 0。每降低一个级别都会增加该值。为此,您使用来自Pair<BinaryTreeNode<Integer>, Integer>的第二个值。这样,您总是知道当前处理的节点位于哪个级别。通过位于接口Map<K,V>中的两种方法putIfAbsent()computeIfPresent(),求和可以很容易地公式化。

static Map<Integer, Integer> levelSum(final BinaryTreeNode<Integer> startNode)
{
    if (startNode == null)
        return Map.of();

    final Map<Integer, Integer> result = new TreeMap<>();

    final Queue<TreeNodeLevelPair> toProcess = new LinkedList<>();
    toProcess.offer(new TreeNodeLevelPair(startNode, 0));

    while (!toProcess.isEmpty())
    {
        final TreeNodeLevelPair current = toProcess.poll();

        final BinaryTreeNode<Integer> currentNode = current.first;
        final int nodeValue = currentNode.item;
        final int level = current.second;

        result.putIfAbsent(level, 0);
        result.computeIfPresent(level, (key, value) -> value + nodeValue);

        if (currentNode.left != null)
            toProcess.offer(new TreeNodeLevelPair(currentNode.left, level + 1));

        if (currentNode.right != null)
            toProcess.offer(new TreeNodeLevelPair(currentNode.right, level + 1));
    }

    return result;
}

作为一个辅助数据结构,您使用记录作为现代 Java 的语言特性,为自己定义了一对极小值,如下所示:

record TreeNodeLevelPair(BinaryTreeNode<Integer> first, Integer second)
{
}

具有深度优先搜索的算法有趣的是,使用深度优先搜索可以很容易地实现这一点,而不管遍历的类型。在下文中,它是用 inorder 实现的,前序和后序的变体表示为注释:

static Map<Integer, Integer>
       levelSumDepthFirst(final BinaryTreeNode<Integer> root)
{
    final Map<Integer, Integer> result = new TreeMap<>();

    traverseDepthFirst(root, 0, result);

    return result;
}

static void traverseDepthFirst(final BinaryTreeNode<Integer> currentNode,
                               final int level,
                               final Map<Integer, Integer> result)
{
    if (currentNode != null)
    {
        // PREORDER
        //result.put(level, result.getOrDefault(level, 0) + currentNode.item);
        traverseDepthFirst(currentNode.left, level + 1, result);

        // INORDER
        result.put(level, result.getOrDefault(level, 0) + currentNode.item);

        traverseDepthFirst(currentNode.right, level + 1, result);

        // POSTORDER
        //map.result(level, result.getOrDefault(level, 0) + currentNode.item);
    }
}

作为一种数据结构,您使用的映射的关键字是级别。如果该级别已经有一个条目,则添加当前节点的值。否则,通过使用getOrDefault(),您可以提供 0 的起始值。

确认

让我们像往常一样从示例中构造树,并调用您刚刚实现的方法:

public static void main(final String[] args)
{
    final BinaryTreeNode<Integer> root = createExampleLevelSumTree();
    TreeUtils.nicePrint(root);

    final Map<Integer, Integer> result = levelSum(root);
    System.out.println("\nlevelSum: " + result);
}

static BinaryTreeNode<Integer> createExampleLevelSumTree()
{
    final BinaryTreeNode<Integer> _4 = new BinaryTreeNode<>(4);
    TreeUtils.insert(_4, 2);
    TreeUtils.insert(_4, 1);
    TreeUtils.insert(_4, 3);
    TreeUtils.insert(_4, 6);
    TreeUtils.insert(_4, 5);
    TreeUtils.insert(_4, 8);
    TreeUtils.insert(_4, 7);
    TreeUtils.insert(_4, 9);
    return _4;
}

然后,您会得到以下输出:

                        4
            |-----------+-----------|
            2                       6
      |-----+-----|           |-----+-----|
      1           3           5           8
                                       |--+--|
                                       7     9
levelSum: {0=4, 1=8, 2=17, 3=16}

用单元测试进行验证这也可以简单地表述为单元测试。这里您使用集合工厂方法Map.ofEntries(),因为这减轻了键和值之间的区别:

@Test
public void testLevelSum()
{
    var root = Ex06_LevelSum.createExampleLevelSumTree();
    var expected = Map.ofEntries(Map.entry(0, 4), Map.entry(1, 8),
                                 Map.entry(2, 17), Map.entry(3, 16));

    var resultBreadthFirst = Ex06_LevelSum.levelSum(root);
    var resultDepthFirst = Ex06_LevelSum.levelSumDepthFirst(root);

    assertAll(() -> assertEquals(expected, resultBreadthFirst),
              () -> assertEquals(expected, resultDepthFirst));
}

9.3.7 解决方案 7:树木轮换(★★★✩✩)

如果只按升序或降序插入值,二叉树,尤其是二分搜索法树,可能会退化为列表。不平衡可以通过旋转树的部分来解决。编写方法BinaryTreeNode<T> rotateLeft(BinaryTreeNode<T>)BinaryTreeNode<T> rotateRight(BinaryTreeNode<T>),这两个方法将分别围绕作为参数传递的节点向左或向右旋转树。

例子

图 9-10 显示向左旋转和向右旋转,平衡起始位置在中间。

img/519691_1_En_9_Fig10_HTML.png

图 9-10

向左旋转,原稿,向右旋转

起初,你可能会被预期的,但实际上只是假设的任务的复杂性吓到。总的来说,用一个简单的例子,比如上面的例子,在心里经历这个过程是一个好主意。很快,您就会意识到涉及的节点比预期的少得多,需要的操作也比预期的少得多。为了执行相应的旋转,你实际上只需要考虑根和左或右邻居以及下一层的一个节点,如图 9-11 所示。

img/519691_1_En_9_Fig11_HTML.png

图 9-11

受影响的旋转节点

该图说明了您只需重新分配树中的两个链接即可完成旋转。为了更好地理解这一点,相应地命名相关节点。在图 9-11 中,LC 和 RC 代表左儿童和右儿童,LLC 和 LRC 代表左左儿童和左右儿童,RLC 和 RRC 代表右左儿童和右右儿童。

考虑到这些初步因素,循环的实现完全遵循图表中所示的顺序:

static <T> BinaryTreeNode<T> rotateLeft(final BinaryTreeNode<T> rootNode)
{
    if (rootNode.right == null)
        throw new IllegalStateException("can't rotate left, no valid root");

    final BinaryTreeNode<T> rc = rootNode.right;
    final BinaryTreeNode<T> rlc = rootNode.right.left;
    rootNode.right = rlc;
    rc.left = rootNode;

    return rc;
}

static <T> BinaryTreeNode<T> rotateRight(final BinaryTreeNode<T> rootNode)
{
    if (rootNode.left == null)
        throw new IllegalStateException("can't rotate right, no valid root");

    final BinaryTreeNode<T> lc = rootNode.left;
    final BinaryTreeNode<T> lrc = rootNode.left.right;
    rootNode.left = lrc;
    lc.right = rootNode;

    return lc;
}

请记住,这些方法会改变子树的引用,因此可能会影响以前缓存的节点。然后,根突然不再是根,而是位于下一层。

确认

首先,像示例中那样定义中间的树。然后你首先向左旋转它,然后向右旋转两次,这应该相当于从中间的树开始向右旋转。

public static void main(final String[] args)
{
    final BinaryTreeNode<String> root = ExampleTrees.createExampleTree();
    TreeUtils.nicePrint(root);

    System.out.println("\nRotate left");
    var leftRotatedRoot = rotateLeft(root);
    TreeUtils.nicePrint(leftRotatedRoot);

    System.out.println("\nRotate right");
    var rightRotatedRoot = rotateRight(rotateRight(leftRotatedRoot));
    TreeUtils.nicePrint(rightRotatedRoot);
}

首先,树显示为未旋转:

           d4
      |-----+-----|
     b2          f6
   |--+--|     |--+--|
  a1    c3    e5    g7

之后,执行旋转并产生以下输出:

Rotate left
                       f6
            |-----------+-----------|
           d4                      g7
      |-----+-----|
     b2          e5
   |--+--|
  a1    c3

Rotate right
                       b2
            |-----------+-----------|
           a1                      d4
                              |-----+-----|
                             c3          f6
                                       |--+--|
                                      e5    g7

用单元测试进行验证让我们考虑一下如何使用单元测试来测试它。同样,这取决于适当的想法和数据结构。检查生成的树在结构上的一致性是困难和昂贵的。如果将遍历的结果与预期值进行比较,就会容易得多。但是要注意。这样做时,您必须避免使用 inorder 遍历,因为它总是为任意的二叉查找树产生相同的节点顺序,而不管树的结构如何!这里,无论是前序还是后序,或者更好的是,一个级别顺序的通行证是合适的。后者有很大的优势,顺序可以很容易地从树的图形表示中导出,因此,最适合单元测试,因为这是可以理解的。您已经在第 9.1.4 节的开头将转换实现为方法convertToList()

@Test
void testRotateLeft()
{
    final BinaryTreeNode<String> root = ExampleTrees.createExampleTree();
    var expected = List.of("f6", "d4", "g7", "b2", "e5", "a1", "c3");

    var leftRotatedRoot = Ex07_RotateBinaryTree.rotateLeft(root);
    final List<String> result = convertToList(leftRotatedRoot);

    assertEquals(expected, result);
}

@Test
void testRotateRight()
{
    final BinaryTreeNode<String> root = ExampleTrees.createExampleTree();
    var expected = List.of("b2", "a1", "d4", "c3", "f6", "e5", "g7");

    var rightRotatedRoot = Ex07_RotateBinaryTree.rotateRight(root);
    final List<String> result = convertToList(rightRotatedRoot);

    assertEquals(expected, result);
}

9.3.8 解决方案 8:重建(★★★✩✩)

解决方案 8a:从阵列重建(★★✩✩✩)

在本练习中,您希望从升序排序的int[]中重建一个尽可能平衡的二叉查找树。

例子

给定的int值为

final int[] values = { 1, 2, 3, 4, 5, 6, 7 };

那么下面的树应该从它们中重建:

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7

算法从一个按升序排序的数组中重构一个二叉查找树并没有那么难。由于排序的原因,您可以将数组一分为二,并使用中间的值作为新节点的基础。分别从数组的左右部分递归构造左右子树。继续对分,直到子数组的大小只有 0 或 1。

static BinaryTreeNode<Integer> reconstruct(final int[] values)
{
    // recursive termination
    if (values.length == 0)
        return null;

    final int midIdx = values.length / 2;

    final int midValue = values[midIdx];
    final BinaryTreeNode<Integer> newNode = new BinaryTreeNode<>(midValue);

    // recursive termination
    if (values.length == 1)
        return newNode;

    // recursive descent
    final int[] leftPart = Arrays.copyOfRange(values, 0, midIdx);
    final int[] rightPart = Arrays.copyOfRange(values, midIdx + 1,
                                               values.length);

    newNode.left = reconstruct(leftPart);
    newNode.right = reconstruct(rightPart);

    return newNode;
}

您可以在方法中间省略长度为 1 的查询,而不改变功能。然后,该方法将简单地为空数组调用两次,从而直接终止。对我来说,这种特殊待遇更容易理解,但这是个人喜好的问题。

确认

让我们看看您的实际实现,并提供一个任意但适当排序的int值数组。这样,您就调用了您的方法,该方法返回树的根作为结果。最后,您通过将各种信息打印到控制台来验证树确实被正确地重构了。

public static void main(final String[] args)
{
    final int[][] inputs = { { 1, 2, 3, 4, 5, 6, 7 },
                             { 1, 2, 3, 4, 5, 6, 7, 8 } };

    for (int[] values : inputs)
    {
        final BinaryTreeNode<Integer> root = reconstruct(values);
        printInfo(root);
    }
}

输出方法实现起来很简单:

private static void printInfo(final BinaryTreeNode<Integer> root)
{
    TreeUtils.nicePrint(root);

    System.out.println("Root:  " + root);
    System.out.println("Left:  " + root.left);
    System.out.println("Right: " + root.right);
    System.out.println();
}

以下简短的输出显示这两棵树被正确地重建了:

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7
Root:  BinaryTreeNode [item=4, left=BinaryTreeNode [item=2, ..
Left:  BinaryTreeNode [item=2, left=BinaryTreeNode [item=1, ...
Right: BinaryTreeNode [item=6, left=BinaryTreeNode [item=5, ...

                        5
            |-----------+-----------|
            3                       7
      |-----+-----|           |-----+-----|
      2           4           6           8
   |--|
   1
Root:  BinaryTreeNode [item=5, left=BinaryTreeNode [item=3, ...
Left:  BinaryTreeNode [item=3, left=BinaryTreeNode [item=2, ...
Right: BinaryTreeNode [item=7, left=BinaryTreeNode [item=6, ...

用单元测试进行验证再次使用单元测试的层次顺序遍历来验证重构:

@Test
void testReconstructFromIntArray()
{
    final int[] inputs = { 1, 2, 3, 4, 5, 6, 7 };

    var expected = List.of(4, 2, 6, 1, 3, 5, 7);

    var resultRoot = Ex08_ReconstructTree.reconstruct(inputs);
    var result = convertToList(resultRoot);

    assertEquals(expected, result);
}

9.1.4 节中已经实现了树到列表的转换,为了便于理解,这里再次显示:

private List<String> convertToList(final BinaryTreeNode<String> node)
{
    final List<String> result = new ArrayList<>();
    Ex05_BreadthFirst.levelorder(node, result::add);
    return result;
}

解决方案 8b:根据预订/未预订进行重建(★★★✩✩)

假设您有一系列的值,分别是 preorder 和 inorder,每一个都准备为一个列表。这个关于任意二叉树的信息应该被用来从其重建相应的树。写方法BinaryTreeNode<T> reconstruct(List<T>, List<T>)

例子

下面给出了两个遍历值序列。根据这些值,您应该重新构建本练习上一部分中显示的树。

var preorderValues = List.of(4, 2, 1, 3, 6, 5, 7);
var inorderValues = List.of(1, 2, 3, 4, 5, 6, 7);

这里再次显示:

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7

算法为了更好地理解对两个输入和算法的需求,让我们再看一下前序和中序遍历的值,以粗体突出显示的根的值为例:

Preorder    4 2 1 3 6 5 7
Inorder     1 2 3 4 5 6 7

前序遍历总是从根开始,所以基于第一个值,可以先创建根。通过在有序遍历的值序列中搜索根的值,可以确定如何将这些值分成左右两个子树。根的值左边的顺序中的所有内容表示左子树的值。类似地,这也适用于它右边的值和右边的子树。这会产生以下子列表:

Left:   1 2 3
Right:  5 6 7

要递归调用您的方法,您需要为 preorder 找到相应的值序列。你是怎么做到的?

让我们详细看看一个预订单和一个订单的价值。通过仔细观察,您可以看到以下模式:

{\displaystyle \begin{array}{l} Preorder\;\overset{root}{\overbrace{4}}\;\overset{left}{\overbrace{2\;1\;3}}\;\overset{right}{\overbrace{6\;5\;7}}\\ {} Inorder\kern0.72em \underset{left}{\underbrace{1\;2\;3}}\kern0.24em \underset{root}{\underbrace{4}}\;\underset{right}{\underbrace{5\;6\;7}}\end{array}}

有了这些知识,算法可以如下实现,利用List.subList()方法从原始部分生成适当的子部分,并将它们用于递归下降:

static <T> BinaryTreeNode<T> reconstruct(final List<T> preorderValues,
                                         final List<T> inorderValues)
{
    if (preorderValues.size() != inorderValues.size())
        throw new IllegalStateException("inputs differ in length");

    // recursive termination
    if (preorderValues.size() == 0 || inorderValues.size() == 0)
        return null;

    final T rootValue = preorderValues.get(0);
    final BinaryTreeNode<T> root = new BinaryTreeNode<>(rootValue);

    // recursive termination
    if (preorderValues.size() == 1 && inorderValues.size() == 1)
        return root;

    // recursive descent
    final int index = inorderValues.indexOf(rootValue);

    // left and right part for preorder
    root.left = reconstruct(preorderValues.subList(1, index + 1),
                            inorderValues.subList(0, index));
    root.right = reconstruct(preorderValues.subList(index + 1,
                                                    preorderValues.size()),
                            inorderValues.subList(index + 1,
                                                 inorderValues.size()));

    return root;
}

同样,可以在方法中间省略长度为 1 的查询,而不改变功能。然后,该方法将简单地为空数组调用两次,从而直接终止。对我来说,这种特殊待遇更容易理解,但这是个人喜好的问题。

确认

为了跟踪重构,您在一个Stream<Arguments>中以三个嵌套列表的形式提供匹配的值序列。像往常一样,JUnit 自动从这些输入中提取 preorder 和 inorder 值。结果作为层次顺序遍历传入。我提到过,这种方式基于图形表示提供了良好的可追溯性。因此,也在控制台上打印单元测试中的树。

@ParameterizedTest(name = "reconstruct(pre={0}, in={2}) => levelorder: {2}")
@MethodSource("preInorderAndResult")
void testReconstructFromLists(List<Integer> preorderValues,
                              List<Integer> inorderValues,
                              List<Integer> expectedLevelorder)
{
    var resultRoot = Ex08_ReconstructTree2.reconstruct(preorderValues,
                                                       inorderValues);
    TreeUtils.nicePrint(resultRoot);

    var result = convertToList(resultRoot);

    assertEquals(expectedLevelorder, result);
}

private static Stream<Arguments> preInorderAndResult()
{
    return Stream.of(Arguments.of(List.of(4, 2, 1, 3, 6, 5, 7),
                                  List.of(1, 2, 3, 4, 5, 6, 7),
                                  List.of(4, 2, 6, 1, 3, 5, 7)),
                     Arguments.of(List.of(5, 4, 2, 1, 3, 7, 6, 8),
                                  List.of(1, 2, 3, 4, 5, 6, 7, 8),
                                  List.of(5, 4, 7, 2, 6, 8, 1, 3)));
}

因此,在单元测试的执行期间,相应生成的树的以下输出出现,这支持了正确的重建:

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7
                        5
            |-----------+-----------|
            4                       7
      |-----|                 |-----+-----|
      2                       6           8
   |--+--|
   1     3

Hint: Things to know about Reconstruction

有趣的是,使用所示的算法,任何二叉树都可以被重建,不管它是否也是二叉查找树(其节点遵循一个顺序)。但更值得注意的是:在前序遍历的值源自二叉查找树的情况下,可以仅基于此来重建它,如下所示:

static <T extends Comparable<T>>
       BinaryTreeNode<T> reconstruct(final List<T> preorderValues)
{
    // recursive termination
    if (preorderValues.isEmpty())
        return null;

    final T rootValue = preorderValues.get(0);
    final BinaryTreeNode<T> root = new BinaryTreeNode<>(rootValue);

    // filtering
    final List<T> leftValues = new ArrayList<>(preorderValues);
    leftValues.removeIf(value -> value.compareTo(rootValue) >= 0);

    final List<T> rightValues = new ArrayList<>(preorderValues);
    rightValues.removeIf(value -> value.compareTo(rootValue) <= 0);

    // recursive descent
    root.left = reconstruct(leftValues);
    root.right = reconstruct(rightValues);

    return root;
}

这是可能的,因为在二叉查找树中,先序遍历的值首先是根的值,然后是小于根的值,最后是右子树的值,它们也大于根的值。这个条件也递归适用。在两个过滤条件的帮助下,可以很容易地提取所有左右子树的值——如上所示——并用作递归调用的输入。

何不试试用下面的main()方法重建:

public static void main(final String[] args)
{
    var root1 = reconstruct(List.of(4, 2, 1, 3, 6, 5, 7));
    TreeUtils.nicePrint(root1);

    var root2 = reconstruct(List.of(5, 4, 2, 1, 3, 7, 6, 8));
    TreeUtils.nicePrint(root2);
}

然后,您将获得单元测试中已经显示的树的输出。

9.3.9 解决方案 9:数学评估(★★✩✩✩)

考虑使用一棵树来模拟带有四个运算符+、-、 / 和∑的数学表达式。您的任务是计算单个节点的值,特别是包括根节点的值。为此,编写方法int evaluate(BinaryTreeNode<String>)

例子

用下面的树表示表达式 3+7∫(71)来计算根节点的值 45:

                        +
            |-----------+-----------|
            3                       *
                              |-----+-----|
                              7           -
                                       |--+--|
                                       7     1

算法有趣的是,在 Java 14 的switch final 中,通过递归调用结合适当的操作符,可以非常容易和清晰地解决赋值问题,如下所示:

static int evaluate(final BinaryTreeNode<String> node)
{
    final String value = node.item;

    return switch (value) {
    case "+" -> evaluate(node.left) + evaluate(node.right);
    case "-" -> evaluate(node.left) - evaluate(node.right);
    case "*" -> evaluate(node.left) * evaluate(node.right);
    case "/" -> evaluate(node.left) / evaluate(node.right);
    default -> Integer.valueOf(value);
    };
}

确认

让我们从示例中构造树并调用上面的方法:

public static void main(final String[] args)
{
    final BinaryTreeNode<String> plus = new BinaryTreeNode<>("+");
    final BinaryTreeNode<String> _3 = new BinaryTreeNode<>("3");
    final BinaryTreeNode<String> mult = new BinaryTreeNode<>("*");
    final BinaryTreeNode<String> _7 = new BinaryTreeNode<>("7");
    final BinaryTreeNode<String> minus = new BinaryTreeNode<>("-");
    final BinaryTreeNode<String> _1 = new BinaryTreeNode<>("1");

    plus.left = _3;
    plus.right = mult;
    mult.left = _7;
    mult.right = minus;
    minus.left = _7;
    minus.right = _1;

    TreeUtils.nicePrint(plus);
    System.out.println("+: " + evaluate(plus));
    System.out.println("*: " + evaluate(mult));
    System.out.println("-: " + evaluate(minus));
}

如果您执行这个main()方法,一方面您会得到树的输出以及所选择的单个节点的结果:

                        +
            |-----------+-----------|
            3                       *
                              |-----+-----|
                              7           -
                                       |--+--|
                                       7     1
+: 45
*: 42
-: 6

解决方案 10:对称性(★★✩✩✩)

检查任意二叉树的结构是否对称。因此写方法boolean isSymmetric(BinaryTreeNode<T>)。除了结构检查之外,您还可以检查值是否相等。

例子

为了检查对称性,使用一个结构对称的二叉树(左)和一个值对称的二叉树(右):

            4                            1
      |-----+-----|                    /   \
      2           6                   2     2
   |--+--|     |--+--|               /       \
   1     3     5     7              3         3

Note: The Symmetry Property

在对称二叉树中,左右子树通过根沿着假想的垂直线(由|)镜像:

     1
   / | \
  2  |  2
 /   |   \
3    |    3

根据定义,对于对称性,可以省略值的比较。在这种情况下,只有结构组织可以算作相关。

再一次,你受益于良好的递归基础知识。没有后继节点的节点总是对称的。如果一个节点只有一个后继节点,那么树就不可能是对称的。因此,只需要考虑两个后继节点的情况。因此,左和右子树必须镜像反转。为此,您递归地检查左侧的右侧子树和右侧节点的左侧子树在结构上是否匹配,以及左侧节点的右侧子树和右侧子树是否匹配:

static <T> boolean isSymmetric(final BinaryTreeNode<T> parent)
{
    if (parent == null)
        return true;

    return isSymmetric(parent.left, parent.right);
}

static <T> boolean isSymmetric(final BinaryTreeNode<T> left,
                               final BinaryTreeNode<T> right)
{
    if (left == null && right == null)
        return true;
    if (left == null || right == null)
            return false;

    // descend both subtrees
    return isSymmetric(left.right, right.left) &&
           isSymmetric(left.left, right.right);
}

高级算法:值对称事实上,如果您正确实现了前面的练习,对值检查的扩展就很简单。在递归下降之前,只有一个布尔参数checkValue必须被添加到签名中并在适当的位置被评估:

static <T> boolean isSymmetric(final BinaryTreeNode<T> left,
                               final BinaryTreeNode<T> right,
                               final boolean checkValue)
{
    if (left == null && right == null)
        return true;
    if (left == null || right == null)
            return false;

    // check the values
    if (checkValue && !left.item.equals(right.item))
        return false;

    return isSymmetric(left.right, right.left, checkValue) &&
           isSymmetric(left.left, right.right, checkValue);
}

确认

从简介中构造两棵树,并调用刚刚创建的方法。第一棵树是已知的代表。另一个是用createSymmetricNumberTree()为这个例子显式创建的。您创建了一个根,然后创建了带有值为 2 和 3 的节点的对称结构。之后,添加值为 4 的节点,这样就破坏了对称性。

public static void main(final String[] args)
{
    final BinaryTreeNode<String> root = ExampleTrees.createNumberTree();
    TreeUtils.nicePrint(root);
    System.out.println("symmetric: " + isSymmetric(root));

    final BinaryTreeNode<String> root2 = createSymmetricNumberTree();
    TreeUtils.nicePrint(root2);
    System.out.println("symmetric: " + isSymmetric(root2));

    // Modifizierter Baum: Füge eine 4 hinzu
    root2.right.left = new BinaryTreeNode<>("4");
    TreeUtils.nicePrint(root2);
    System.out.println("symmetric: " + isSymmetric(root2));
}

static BinaryTreeNode<String> createSymmetricNumberTree()
{
    final BinaryTreeNode<String> root = new BinaryTreeNode<>("1");
    root.left = new BinaryTreeNode<>("2");
    root.right = new BinaryTreeNode<>("2");
    root.left.left = new BinaryTreeNode<>("3");
    root.right.right = new BinaryTreeNode<>("3");
    return root;
}

如果您执行这个main()方法,您会得到预期的结果:

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7
symmetric: true
            1
      |-----+-----|
      2           2
   |--|           |--|
   3                 3
symmetric: true
            1
      |-----+-----|
      2           2
   |--|        |--+--|
   3           4     3
symmetric: false

奖励:镜像树

在提示框中,我指出了通过根的镜像轴。创建方法BinaryTreeNode<T> invert(BinaryTreeNode<T>),该方法通过根在这条隐含线上镜像树的节点。

例子

镜像看起来像这样:

            4                              4
      |-----+-----|                  |-----+-----|
      2           6        =>        6           2
   |--+--|     |--+--|            |--+--|     |--+--|
   1     3     5     7            7     5     3     1

算法起初,你可能会再次假设这个挑战很难解决。但事实上,在递归的帮助下实现比人们最初想象的要容易得多。

该算法从根开始向下进行,并交换左右子树。为此,您将这些子树存储在临时变量中,然后将它们赋给另一端。真的就是这么回事!

static <T> BinaryTreeNode<T> invert(final BinaryTreeNode<T> startNode)
{
    if (startNode == null)
        return null;

    final BinaryTreeNode<T> invertedRight = invert(startNode.right);
    final BinaryTreeNode<T> invertedLeft = invert(startNode.left);

    startNode.left = invertedRight;
    startNode.right = invertedLeft;

    return startNode;
}

确认

您从简介中构造左边的树,并调用您刚刚创建的方法。

public static void main(final String[] args)
{
    final BinaryTreeNode<String> root = ExampleTrees.createNumberTree();
    final BinaryTreeNode<String> newRoot = invert(root);
    TreeUtils.nicePrint(newRoot);
}

如果您执行这个main()方法,您将得到预期的镜像:

            4
      |-----+-----|
      6           2
   |--+--|     |--+--|
   7     5     3     1

9.3.11 解决方案 11:检查★★✩✩✩二叉查找树

在本练习中,您将检查任意二叉树是否满足二叉查找树的属性(即,左子树中的值小于根节点的值,而右子树中的值大于根节点的值——这适用于从根节点开始的每个子树)。为了简化,假设int值。写方法boolean isBST(BinaryTreeNode<Integer>)

例子

使用下面的二叉树,它也是二叉查找树。例如,如果你把左边的数字 1 换成一个更大的数字,它就不再是二叉查找树了。但是,6 下面的右子树仍然是二叉查找树。

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7

算法从作业中,你认识到一个递归设计。只有一个节点的树总是二叉查找树。如果有左或右后继,或者两者都有,则检查它们的值是否符合值关系,并对它们的后继递归地执行这个操作(如果它们存在的话)。

static boolean isBST(final BinaryTreeNode<Integer> node)
{
    // recursive termination
    if (node == null)
        return true;

    if (node.isLeaf())
        return true;

    // recursive descent
    boolean isLeftBST = true;
    boolean isRightBST = true;
    if (node.left != null)
        isLeftBST = node.left.item < node.item && isBST(node.left);

    if (node.right != null)
        isRightBST = node.right.item > node.item && isBST(node.right);

    return isLeftBST && isRightBST;
}

Note: Compare Other Types

为了支持泛型类型T,它必须满足Comparable<T>并且你调用compareTo(T)方法进行比较,而不是使用>和<./>

确认

从示例中构造树,并调用刚刚创建的方法。您还应用了两个修改并再次检查。

public static void main(final String[] args)
{
    final BinaryTreeNode<Integer> _4 = ExampleTrees.createIntegerNumberTree();
    TreeUtils.nicePrint(_4);

    final BinaryTreeNode<Integer> _2 = _4.left;
    final BinaryTreeNode<Integer> _6 = _4.right;

    // change the tree on the left in a wrong way
    // and on the right in a correct way
    _2.left = new BinaryTreeNode<>(13);
    _6.right = null;

    TreeUtils.nicePrint(_4);
    System.out.println("isBST(_4): " + isBST(_4));
    System.out.println("isBST(_2): " + isBST(_2));
    System.out.println("isBST(_6): " + isBST(_6));
}

如果您执行这个main()方法,您将获得树的输出和所选单个节点的结果,无论这些节点本身是否代表一个二叉查找树。

但是,如果您不小心在左子树中存储了一个更大的值(例如 13),那么整个树和以节点 2 为根的部分都不是 BST。对于右边的子树,如果删除了值为 7 的节点,那么节点值为 6 的右边的子树仍然是 BST。

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7
isBST(_4): true
isBST(_2): true
isBST(_6): true

            4
      |-----+-----|
      2           6
   |--+--|     |--|
  13     3     5
isBST(_4): false
isBST(_2): false
isBST(_6): true

9.3.12 解决方案 12:完备性(★★★★)

在本练习中,要求您检查树的完整性。为了做到这一点,您首先在练习的前两个部分解决一些基本问题,然后进行更棘手的完整性检查。

解决方案 12a:节点数量(★✩✩✩✩)

计算任何二叉树中包含多少个节点。为此,编写方法int nodeCount(BinaryTreeNode<T>)

例子

对于所示的二叉树,应该确定值 7。如果删除右边的子树,树只包含 4 个节点。

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7

算法如果你递归地表达,这个算法真的非常简单。每个节点计 1。然后,继续在它的左右子树中计数,并将它们的结果相加,直到找到一片叶子:

static <T> int nodeCount(final BinaryTreeNode<T> startNode)
{
    if (startNode == null)
        return 0;

    return 1 + nodeCount(startNode.left) + nodeCount(startNode.right);
}

解决方案 12b:检查完整/完美(★★✩✩✩)

对于一个任意的二叉树,检查是否所有的节点都有两个后继或叶子,因此树是满的。为了完美,所有的叶子必须在同一高度。写方法boolean isFull(BinaryTreeNode<T>)boolean isPerfect(BinaryTreeNode<T>)

例子

所示的二叉树既完美又完整。如果去掉 2 下面的两片叶子,就不再完美但依然饱满。

     Full and perfect         Full but not perfect
            4                          4
      |-----+-----|              |-----+-----|
      2           6              2           6
   |--+--|     |--+--|                    |--+--|
   1     3     5     7                    5     7

如果递归实现的话,检查一棵树是否满并不困难。对于每个节点,您检查它是没有后继还是有两个后继。否则,它不能是完整的树。

static <T> boolean isFull(final BinaryTreeNode<T> currentNode)
{
    if (currentNode == null)
        return true;

    return isFull(currentNode.left, currentNode.right);
}

static <T> boolean isFull(final BinaryTreeNode<T> leftNode,
                          final BinaryTreeNode<T> rightNode)
{
    if (leftNode == null && rightNode == null)
        return true;

    if (leftNode != null && rightNode != null)
        return isFull(leftNode) && isFull(rightNode);

    return false;
}

这已经是一个好的开始。基于此,您需要一些更小的扩展来检查完美性。首先,你要确定整棵树的高度,从根部开始。在那之后,你进行与isFull()非常相似的操作,但是现在每个节点必须有两个后继节点。在叶子的水平上,你还必须检查它们是否在正确的水平上。因此,你会发现一片叶子的高度是 1。因此,您仍然需要它们所在的级别。为此,您在方法中添加了一个额外的参数currentLevel。这导致了以下实现:

static <T> boolean isPerfect(final BinaryTreeNode<T> parent)
{
    if (parent == null)
        return true;

    final int height = Ex03_TreeHeight.getHeight(parent);

    return isPerfect(parent.left, parent.right, height, 1);
}

static <T> boolean isPerfect(final BinaryTreeNode<T> leftNode,
                             final BinaryTreeNode<T> rightNode,
                             final int height, final int currentLevel)
{
    if (leftNode == null || rightNode == null)
        return false;

    if (leftNode.isLeaf() && rightNode.isLeaf())
        return onSameHeight(leftNode, rightNode, height, currentLevel);

    return isPerfect(leftNode.left, leftNode.right, height, currentLevel + 1) && isPerfect(rightNode.left,
           rightNode.right, height, currentLevel + 1);
}

static <T> boolean onSameHeight(final BinaryTreeNode<T> leftNode,
                                final BinaryTreeNode<T> rightNode,
                                final int height, final int currentLevel)
{
    return Ex03_TreeHeight.getHeight(leftNode) + currentLevel == height &&
           Ex03_TreeHeight.getHeight(rightNode) + currentLevel == height;
}

确认

您用简介中的数字构建树,并调用刚刚创建的方法。此外,通过删除对右边子树的引用来修改树。然后再次调用这些方法。

public static void main(final String[] args)
{
    final BinaryTreeNode<String> _4 = ExampleTrees.createNumberTree();
    printInfo(_4);

    // modify the tree
    _4.left.left = null;
    _4.left.right = null;
    printInfo(_4);
}

protected static void printInfo(final BinaryTreeNode<String> root)
{
    TreeUtils.nicePrint(root);
    System.out.println("#nodes:  " + nodeCount(root));
    System.out.println("isFull?: " + isFull(root));
    System.out.println("isPerfect?: " + isPerfect(root));
    System.out.println();
}

如果您执行这个main()方法,您会得到预期的结果:

                4
          |-----+-----|
          2           6
       |--+--|     |--+--|
       1     3     5     7
#nodes:  7
isFull?: true
isPerfect?: true

                4
          |-----+-----|
          2           6
                   |--+--|
                   5     7

#nodes:  5
isFull?: true
isPerfect?: false

解决方案 12c:完整性(★★★★✩)

在该子任务中,要求您检查树是否如简介中所定义的那样完整(即,所有级别都被完全填充的二叉树,最后一级允许的例外是节点可能缺失,但只有尽可能靠右的间隙)。

例子

除了目前为止使用的完美树,下面的树根据定义也是完整的。但是,如果您从节点 H 中移除子节点,树就不再完整。

                        F
            |-----------+-----------|
            D                       H
      |-----+-----|           |-----+-----|
      B           E           G           I
   |--+--|
   A     C

算法起初,这似乎是一个相当棘手的任务,在任何情况下都比之前显示的检查复杂得多。如果您再次研究定义,树应该包含成对的后继者。此外,树中必须没有间隙(即,没有左后继者缺失但有右后继者的节点)。如果树没有被完全填满,那么只有右边的叶子可能会丢失。仔细观察,可以发现您可以逐层遍历,但是只有在最后一层可能会缺少节点。

现在我想到了关卡。您在这里使用它,只需添加一些检查。对于每个节点,没有左后继节点就没有右后继节点。此外,您还要检查是否同时发现了一个缺失的节点。怎么会这样?每当您想要将一个节点的后继节点添加到队列中,但是只有一个左边或右边的后继节点时,这是可能的。这由标志missingNode表示。因此,如果已经检测到丢失的后继,那么随后处理的节点必须仅仅是叶子。

static <T> boolean levelorderIsComplete(final BinaryTreeNode<T> startNode)
{
    if (startNode == null)
        return false;

    final Queue<BinaryTreeNode<T>> toProcess = new LinkedList<>();
    toProcess.offer(startNode);

    // indicator that a node does not have two successors
    boolean missingNode = false;

    while (!toProcess.isEmpty())
    {
        final BinaryTreeNode<T> current = toProcess.poll();

        // only descendants on the right side
        if (current.left == null && current.right != null)
            return false;

        // if a missing node was previously detected,
        // then the next may be only a leaf
        if (missingNode && !current.isLeaf())
            return false;

        // include sub-elements, mark if not complete
        if (current.left != null)
            toProcess.offer(current.left);
        else
            missingNode = true;

        if (current.right != null)
            toProcess.offer(current.right);
        else
            missingNode = true;
    }

    // all nodes successfully tested
    return true;
}

确认

从示例中构造树,并调用刚刚创建的方法。此外,通过删除 H 节点下的叶子来修改树。然后你再检查一遍。

public static void main(final String[] args)
{
    final BinaryTreeNode<String> F = createCompletenessExampleTree();
    TreeUtils.nicePrint(F);
    System.out.println("levelorderIsComplete? " + levelorderIsComplete(F));

    // modification: remove leaves under H
    F.right.left = null;
    F.right.right = null;
    TreeUtils.nicePrint(F);
    System.out.println("levelorderIsComplete? " + levelorderIsComplete(F));
}

protected static BinaryTreeNode<String> createCompletenessExampleTree()
{
    final BinaryTreeNode<String> F = new BinaryTreeNode<>("F");
    TreeUtils.insert(F, "D");
    TreeUtils.insert(F, "H");
    TreeUtils.insert(F, "B");
    TreeUtils.insert(F, "E");
    TreeUtils.insert(F, "A");
    TreeUtils.insert(F, "C");
    TreeUtils.insert(F, "G");
    TreeUtils.insert(F, "I");
    return F;
}

如果您执行这个main()方法,您会得到预期的结果:

                        F
            |-----------+-----------|
            D                       H
      |-----+-----|           |-----+-----|
      B           E           G           I
   |--+--|
   A     C
levelorderIsComplete? true
                        F
            |-----------+-----------|
            D                       H
      |-----+-----|
      B           E
   |--+--|
   A     C
levelorderIsComplete? false

解决方案 12d:完全递归(★★★★)

在这最后一个子任务中,下面的挑战仍然需要作为特殊对待来处理:在没有额外的数据结构和纯递归的情况下解决检查。起初,这听起来几乎不可行,所以我给个提示。

Tip

逐步开发解决方案。创建一个boolean[]作为辅助数据结构,模拟某个位置是否存在节点。然后遍历树并适当标记位置。将这个实现转换成没有boolean[]的纯递归实现。

例子

和以前一样,下面的树根据定义是完整的:

                        F
            |-----------+-----------|
            D                       H
      |-----+-----|           |-----+-----|
      B           E           G           I
   |--+--|
   A     C

事实上,这项任务听起来很难管理,但这就是为什么它是一项艰巨的挑战。正如经常发生的那样,从开发一个尚未满足所有要求的特性的版本开始,并逐步完善它是值得的。你从提示中的想法开始。

想法是这样的:遍历树,对于存在的每个节点,在一个boolean[]中精确地标记。执行此操作时,请按照从左到右和从上到下的级别顺序对职位进行编号。为了确定当前节点在数组中的位置,您执行以下计算:对于位置 i ,左边的后继者具有位置I∫2+1,右边的后继者具有位置I∫2+2。 3 图 9-12 对此进行了说明。

img/519691_1_En_9_Fig12_HTML.png

图 9-12

将树映射到数组

现在,您仍然需要知道数组需要有多大。理论上最多可以包含 2 个 高度 元素。然而,对于非常深且不断扩展的树来说,许多叶子可能根本就不存在。为了优化内存,需要计算节点的数量来确定实际需要的大小。这就是练习 12a 帮助你的地方。然后使用traverseAndMark()方法遍历所有的树元素。最后,您使用allAssigned()汇总数据。

static <T> boolean isComplete(final BinaryTreeNode<T> startNode)
{
    final int nodeCount = nodeCount(startNode);

    final boolean[] nodeExists = new boolean[nodeCount];

    // now you traverse the tree from the root downwards
    traverseAndMark(startNode, nodeExists, 0);

    return allAssigned(nodeExists);
}

让我们继续遍历树并填充数组。有趣的是,你在这里使用前序、中序还是后序并不重要。唯一重要的是,位置是根据上述计算规则确定的:

static <T> void traverseAndMark(final BinaryTreeNode<T> startNode,
                                final boolean[] nodeExists, final int pos)
{
    // recursive termination
    if (startNode == null)
        return;
    if (pos >= nodeExists.length)
        return;

    // perform action
    nodeExists[pos] = true;

    // recursive descent
    traverseAndMark(startNode.left, nodeExists, pos * 2 + 1);
    traverseAndMark(startNode.right, nodeExists, pos * 2 + 2);
}

最后,你需要检查数组中是否有位置没有被true占据。在这种情况下,您发现树是不完整的。这通过以下方法确定:

private static boolean allAssigned(final boolean[] nodeExists)
{
    for (boolean exists : nodeExists)
        if (!exists)
            return false;

    return true;
}

Hint: Why is Allassigned() Implemented in such an old School Way?

如果您快速浏览一下方法allAssigned(),您可能会倾向于用下面更优雅的结构来替换它:

private static boolean allAssigned(final boolean[] nodeExists)
{
    return Arrays.stream(nodeExists).noneMatch(value -> value == false);
}

不幸的是,尽管这很好,但它无法编译,因为没有为boolean[]定义Arrays.stream()

唷,到目前为止已经做了相当多的工作,而且你需要一些技巧。从积极的方面来看,这种算法是可行的。稍后,我将展示基于这些想法,将算法转换为纯粹的递归处理。

然而,不利的一面是,根据树的大小,您需要相当多的额外内存。让我们看看如何通过使用纯递归变量来避免这种情况。

递归算法你的目标是消除数组的使用,只递归工作。因此,traverseAndMark()方法是一个很好的起点。如果不允许使用数组作为数据存储,则需要将节点数作为参数。该方法不是每次都递归填充数组,而是简单地调用自身:

public static <T> boolean isCompleteRec(final BinaryTreeNode<T> startNode)
{
    return isCompleteRec(startNode, 0, nodeCount(startNode));
}

public static <T> boolean isCompleteRec(final BinaryTreeNode<T> startNode,
                                        final int pos, final int nodeCount)
{
    if (startNode == null)
        return true;
    if (pos >= nodeCount)
        return false;

    if (!isCompleteRec(startNode.left, 2* pos + 1, nodeCount))
        return false;

    if (!isCompleteRec(startNode.right, 2* pos + 2, nodeCount))
        return false;

    return true;
}

如果没有中间步骤,递归地制定任务将会很有挑战性,至少对我来说是这样,因为如果不考虑数组,很难得到位置计算中的逻辑技巧。这几行字所达到的效果令人印象深刻。

确认

同样,构建树并在测试后修改它:

public static void main(final String[] args)
{
    final BinaryTreeNode<String> F = createCompletenessExampleTree();
    TreeUtils.nicePrint(F);
    System.out.println("isComplete? " + isComplete(F));
    System.out.println("isCompleteRec? " + isCompleteRec(F));

    // modification: remove leaves under H
    F.right.left = null;
    F.right.right = null;
    TreeUtils.nicePrint(F);
    System.out.println("isComplete? " + isComplete(F));
    System.out.println("isCompleteRec? " + isCompleteRec(F));
}

protected static BinaryTreeNode<String> createCompletenessExampleTree()
{
    final BinaryTreeNode<String> F = new BinaryTreeNode<>("F");
    TreeUtils.insert(F, "D");
    TreeUtils.insert(F, "H");
    TreeUtils.insert(F, "B");
    TreeUtils.insert(F, "E");
    TreeUtils.insert(F, "A");
    TreeUtils.insert(F, "C");
    TreeUtils.insert(F, "G");
    TreeUtils.insert(F, "I");
    return F;
}

如果您执行这个main()方法,您将得到预期的结果。此外,它们对于方法变化是一致的。

                        F
            |-----------+-----------|
            D                       H
      |-----+-----|           |-----+-----|
      B           E           G           I
   |--+--|
   A     C
isComplete? true
isCompleteRec? true
                        F
            |-----------+-----------|
            D                       H
      |-----+-----|
      B           E
   |--+--|
   A     C
isComplete? false
isCompleteRec? false

9.3.13 解决方案 13:树打印机

在本练习中,您将实现一个二叉树的图形输出,正如您在前面的示例中看到的那样。

因此,你首先要解决作业前三部分的基础问题,然后再进行更复杂的树的图形展示。

Tip

使用宽度为 3 的块的固定网格。这大大有助于平衡表示并降低复杂性。

例子

下面的树应该涵盖各种特殊情况:

                            F
                |-----------+-----------|
                D                       H
          |-----+                       +-----|
          B                                   I
       |--+--|
       A     C

解答 13a:子树的宽度(★★✩✩✩)

在练习的这一部分中,要求您使用方法int subtreeWidth(int)找出给定高度的子树的最大宽度。为简单起见,假设一个节点最多由三个字符表示。而且,它们之间至少有三个字符的距离。树满了叶子也是如此。在更高的层次上,两个子树的节点之间自然有更多的空间。

例子

在左边,你看到一棵高度为 2 的树,在右边,一棵高度为 3 的树。基于三个网格,宽度为 9 和 21。参见图 9-13

|

高度

|

总宽度

|

子树宽度

| | --- | --- | --- | | one | three | 0(不存在子树) | | Two | nine | three | | three | Twenty-one | nine | | four | Forty-five | Twenty-one |

img/519691_1_En_9_Fig13_HTML.png

图 9-13

树宽

算法在图中,你认识到一棵二叉树的最低层最多可以包含 2 个 n 个 节点,以 n 作为树的高度。为了不超出范围,您希望忽略节点的可变宽度。要确定高度的最大宽度,总宽度结果如下:

maxNumOfLeaves\ast leafWidth+\left( maxNumOfLeaves-1\right)\ast spacing

这是以下实现的基础。也许最后的计算有点棘手。您必须减去间距并除以 2,因为您只想确定子树的最大宽度:

static int subtreeWidth(final int height)
{
    if (height <= 0)
        return 0;

    final int leafWidth = 3;
    final int spacing = 3;

    final int maxNumOfLeaves = (int) Math.pow(2, height - 1);
    final int widthOfTree = maxNumOfLeaves * leafWidth +
                            (maxNumOfLeaves - 1) * spacing;
    final int widthOfSubtree = (widthOfTree - spacing) / 2;

    return widthOfSubtree;
}

解决方案 13b:绘制节点(★★✩✩✩)

编写方法String drawNode(BinaryTreeNode<T>, int),创建一个节点的图形输出,适当地生成给定的一组空间。节点值最多应该有三个数字,并放在中间。

Tip

请记住,如果当前节点有一个左后继节点,则下面层的表示从左边开始,以字符串'|-'开始。

例子

图 9-14 显示了间距为 5 个字符的单个节点。此外,节点值在三个字符的框中居中对齐。

img/519691_1_En_9_Fig14_HTML.png

图 9-14

绘制节点时的尺寸

算法像往常一样,通过将一项任务细分成几个更小的子任务来降低复杂性是个好主意。使用方法spacing(int)在节点表示的左边和右边创建所需的间距。它的准备首先检查节点中不存在或没有值的特殊情况。那么这在图形上对应于三个字符的自由空间。否则,如果转换为字符串的值少于三个字符,则用空格填充该值。如果它更长,您可以将文本截断为三个字符。这在方法String stringifyNodeValue(BinaryTreeNode<T>)中完成。因为随后的行以文本'|-'开始,如果存在左后继,那么在字符串表示的前面再添加三个空格。

static <T> String drawNode(final BinaryTreeNode<T> currentNode,
                           final int lineLength)
{
    String strNode = "   ";
    strNode += spacing(lineLength);
    strNode += stringifyNodeValue(currentNode);
    strNode += spacing(lineLength);

    return strNode;
}

static <T> String stringifyNodeValue(final BinaryTreeNode<T> node)
{
    if (node == null || node.item == null)
        return "   ";

    final String nodeValue = "" + node.item;
    if (nodeValue.length() == 1)
        return " " + nodeValue + " ";
    if (nodeValue.length() == 2)
        return nodeValue + " ";

    return nodeValue.substring(0, 3);
}

static String spacing(final int lineLength)
{
    return " ".repeat(lineLength);
}

解决方案 13c:绘制连接线(★★✩✩✩)

编写方法String drawConnections(BinaryTreeNode<T>, int)构建一个节点到它的两个后继节点的连接线的图形输出。必须正确处理缺失的继任者。

Tip

行长度指的是节点表示之间的字符。在每种情况下,代表末端的部分以及中间的连接器仍需适当附加。

例子

此图显示了绘图中所有相关的情况,因此没有、有一个和两个后继者:

                         F
             |-----------+-----------|
             D                       H
       |-----+                       +-----|
       B                                   I
    |--+--|
    A     C

示意图如图 9-15 所示。

img/519691_1_En_9_Fig15_HTML.png

图 9-15

连接线的示意图

算法在一个节点下面画连接线的时候,有无左右后继的三个变体都要覆盖。更有趣的是,一个不存在的节点也必须产生相应的空白输出。如果左侧没有孩子,这是必需的。否则,右侧的节点将无法正确缩进。

你把画分成三部分。首先,用drawLeftConnectionPart()准备输出的左边部分。之后,在drawJunction(node)中,你创建了所有特殊情况的连接点。最后,你和drawRightConnectionPart()一起准备正确的部分。

static <T> String drawConnections(final BinaryTreeNode<T> currentNode,
                                  final int lineLength)
{
    if (currentNode == null)
       return " " + spacing(lineLength) + " " + spacing(lineLength) + " ";

    String connection = drawLeftConnectionPart(currentNode, lineLength);
    connection += drawJunction(currentNode);
    connection += drawRightConnectionPart(currentNode, lineLength);
    return connection;
}

static <T> String drawLeftConnectionPart(final BinaryTreeNode<T> currentNode,
                                         final int lineLength)
{
    if (currentNode.left == null)
         return "   " + spacing(lineLength);

    return " |-" + drawLine(lineLength);
}

static <T> String drawJunction(final BinaryTreeNode<T> currentNode)
{
    if(currentNode.left == null && currentNode.right == null)
        return "   ";
    else if (currentNode.left == null)
        return " +-";
    else if (currentNode.right == null)
        return "-+ ";

    return "-+-";
}

static <T> String drawRightConnectionPart(final BinaryTreeNode<T> currentNode,
                                         final int lineLength)
{
    if (currentNode.right == null)
         return spacing(lineLength) + "   ";

    return drawLine(lineLength) + "-| ";
}

static String drawLine(int lineLength)
{
    return "-".repeat(lineLength);
}

解 13d:树表示(★★★★)

组合练习各部分的所有解决方案,并完成必要的步骤,以便能够在控制台上适当地打印任意二叉树。为此,编写一个方法nicePrint(BinaryTreeNode<T>)

例子

通过nicePrint(),介绍性示例中显示的树的输出也应该类似如下:

                            F
                |-----------+-----------|
                D                       H
          |-----+                       +-----|
          B                                   I
       |--+--|
       A     C

另外,用源代码中可以找到的一棵真正的树来检查你的算法。这是一个瘦了很多的代表:

                                     BIG
              |-----------------------+-----------------------|
             b2                                              f6
    |---------+-----------|                       |-----------+-----------|
   a1                    d4                      d4                      g7
                    |-----+-----|           |-----+-----|
                   c3          f6          b2          e5
                             |--+--|     |--+--|
                            e5    g7    a1    c3

算法在之前的任务中,你学习了如何将二叉树映射到数组。这里,这必须稍微修改,因为在树中,与完整性相反,节点可以在任意位置丢失。为了计算数组的大小,您需要树的高度。这对于计算相应的距离和线长度也很重要。在这种情况下,这个技巧还有助于确定子树的最大宽度,并恰当地使用它。

可以采用前面提到的这些想法来创建一个合适的数组,其中的节点以分散的方式存储。以下方法将帮助您做到这一点:

static <T> List<BinaryTreeNode<T>>
           fillNodeArray(final BinaryTreeNode<T> startNode)
{
    final int height = Ex03_TreeHeight.getHeight(startNode);
    final int maxNodeCount = (int) Math.pow(2, height);

    final List<BinaryTreeNode<T>> nodes =
          new ArrayList<>(Collections.nCopies(maxNodeCount, null));

    traverseAndMark(startNode, nodes, 0);

    return nodes;
}

static <T> void traverseAndMark(final BinaryTreeNode<T> startNode,
                                final List<BinaryTreeNode<T>> nodes,
                                final int pos)
{
    // recursive termination
    if (startNode == null)
        return;
    if (pos >= nodes.size())
        return;

    // perform action
    nodes.set(pos, startNode);

    // recursive descent
    traverseAndMark(startNode.left, nodes, pos * 2 + 1);
    traverseAndMark(startNode.right, nodes, pos * 2 + 2);
}

对于绘图,逐层遍历树和数组,并准备图形表示。然而,这样做的缺点是,非常大的树在绘制时也需要相当多的额外内存,因为它们是作为数组或列表保存的。

仍有一些挑战等待着你:

  • 当您从顶部开始绘制时,您需要将之前为每个新级别准备的线条向右移动适当的位置。

  • 节点之间的距离和连接线的长度必须根据总高度、当前标高和位置进行计算和保存。因此,最低级别仍然需要特殊处理。

图 9-16 显示了网格和每层节点之间以及从一层到下一层的不同距离。

img/519691_1_En_9_Fig16_HTML.png

图 9-16

节点间距

相关的实现受益于助手方法的使用:

static <T> void nicePrintV1(final BinaryTreeNode<T> startNode)
{
    if (startNode == null)
        return;

    final int treeHeight = Ex03_TreeHeight.getHeight(startNode);
    final List<BinaryTreeNode<T>> allNodes = fillNodeArray(startNode);

    int offset = 0;
    final List<String> lines = new ArrayList<>();
    for (int level = 0; level < treeHeight; level++)
    {
        final int lineLength = subtreeWidth(treeHeight - 1 - level);

        // indent predecessor lines to the right
        for (int i = 0; i < lines.size(); i++)
        {
            lines.set(i, "   " + spacing(lineLength) + lines.get(i));
        }

        final int nodesPerLevel = (int) Math.pow(2, level);
        String nodeLine = "";
        String connectionLine = "";

        for (int pos = 0; pos < nodesPerLevel; pos++)
        {
            final BinaryTreeNode<T> currentNode = allNodes.get(offset + pos);

            nodeLine += drawNode(currentNode, lineLength);
            nodeLine += spacingBetweenNodes(treeHeight, level);
            connectionLine += drawConnections(currentNode, lineLength);
            connectionLine += spacingBetweenConnections(treeHeight, level);
        }

       lines.add(nodeLine.stripTrailing());
       lines.add(connectionLine.stripTrailing());

        // jump in the array further
        offset += nodesPerLevel;
    }

    lines.forEach(System.out::println);
}

此外,您还需要两种方法来分别提供节点之间的距离和连接线的距离:

static String spacingBetweenNodes(final int treeHeight, final int level)
{
    final int spacingLength = subtreeWidth(treeHeight - level);
    String spacing = " ".repeat(spacingLength);
    if (spacingLength > 0)
        spacing += "   ";
    return spacing;
}

static String spacingBetweenConnections(final int treeHeight, final int level)
{
    final int spacingLength = subtreeWidth(treeHeight - level);
    return " ".repeat(spacingLength);
}

**内存优化算法:**下面,我想提出一个修改,它不需要任何额外的内存。相反,它用层次顺序遍历来呈现树的图形表示。这里使用的是单行列表,其中包含节点和连接线的列表交替出现。在我看来,之前展示的版本在某种程度上更加清晰,尤其是因为下面的版本需要改变级别的特殊处理,这在第一个版本中表现得更加自然。

然而,总的来说,它仍然是一个清晰的层次顺序遍历,在这种情况下,它的动作更广泛一些。

static <T> void nicePrint(final BinaryTreeNode<T> startNode)
{
    if (startNode == null)
        return;

    final int treeHeight = Ex03_TreeHeight.getHeight(startNode);
    final List<String> lines = new ArrayList<>();

    int level = 0;
    String nodeLine = "";
    String connectionLine = "";

    final Queue<Pair<BinaryTreeNode<T>, Integer>> toProcess = new LinkedList<>(); toProcess.offer(new Pair<>(startNode, 0));

    while (!toProcess.isEmpty() && level < treeHeight)
    {
        // levelorder
        final Pair<BinaryTreeNode<T>, Integer> current = toProcess.poll();
        final BinaryTreeNode<T> currentNode = current.first;
        final int nodelevel = current.second;

        // perform action
        int lineLength = subtreeWidth(treeHeight - 1 - level);

        // Wechsel in der Ebene
        if (level != nodelevel)
        {
            level = nodelevel;
            lineLength = subtreeWidth(treeHeight - 1 - level);

            lines.add(nodeLine.stripTrailing());
            lines.add(connectionLine.stripTrailing());
            nodeLine = "";
            connectionLine = "";

            // indent predecessor lines to the right
            for (int i = 0; i < lines.size(); i++)
            {
                lines.set(i, "   " + spacing(lineLength) + lines.get(i));
            }
        }

        nodeLine += drawNode(currentNode, lineLength);
        nodeLine += spacingBetweenNodes(treeHeight, level);
        connectionLine += drawConnections(currentNode, lineLength);
        connectionLine += spacingBetweenConnections(treeHeight, level);

        // levelorder
        if (currentNode != null)
        {
            toProcess.offer(new Pair<>(currentNode.left, level + 1));
            toProcess.offer(new Pair<>(currentNode.right, level + 1));
        }
        else
        {
            // artificial placeholders for correct layout
            toProcess.offer(new Pair<>(null, level + 1));
            toProcess.offer(new Pair<>(null, level + 1));
        }
    }

    lines.forEach(System.out::println);
}

省略辅助数据结构导致更复杂的实现。有必要添加人工的null-节点作为占位符,以便在缺少左侧节点的情况下正确绘制树。这将导致层次顺序遍历不再终止,因为总是在添加新的节点。为了防止这种情况,不仅要查询队列,还要查询当前级别。

确认

你成长了不少。现在你想看看你的劳动成果。为此,您可以使用介绍性示例中的树。第一棵树很好地展示了工作的主要方式。第二个是前面的示例树的组合,但是向左和向右旋转,并合并在一个新的根下,值为BIG

protected static BinaryTreeNode<String> createTreePrintExampleTree()
{
    final BinaryTreeNode<String> F = new BinaryTreeNode<>("F");
    TreeUtils.insert(F, "D");
    TreeUtils.insert(F, "H");
    TreeUtils.insert(F, "B");
    TreeUtils.insert(F, "A");
    TreeUtils.insert(F, "C");
    TreeUtils.insert(F, "I");
    return F;
}

protected static BinaryTreeNode<String> createBigTree()
{
    var d4a = ExampleTrees.createExampleTree();
    var d4b = ExampleTrees.createExampleTree();
    var BIG = new BinaryTreeNode<>("BIG");
    BIG.left = Ex07_RotateBinaryTree.rotateRight(d4a);
    BIG.right = Ex07_RotateBinaryTree.rotateLeft(d4b);
    return BIG;
}

这些方法创建以下树:

                         F
             |-----------+-----------|
             D                       H
       |-----+                       +-----|
       B                                   I
    |--+--|
    A     C
                                    BIG
             |-----------------------+-------------------|
            b2                                          f6
     |-------+---------|                       |---------+---------|
    a1                d4                      d4                   g7
                 |-----+-----|           |-----+-----|
                 c3          f6          b2          e5
                          |--+--|     |--+--|
                          e5    g7    a1    c3

如果您想看看真正的大树呈现得有多美,请为以下构造调用方法:

protected static BinaryTreeNode<String> createMonsterTree()
{
    final var mon = new BinaryTreeNode<>("MON");
    mon.left = createBigTree();
    mon.right = createBigTree();
    return mon;
}

在配套项目中,你会发现这个怪物树的双重组合,为了好玩我把它命名为金刚

Footnotes 1

使用所谓的红黑树实现了类TreeMap<K,V>

  2

更准确地说,实际上它是用来表示源代码的抽象语法结构——而不是源代码本身。

  3

如果把索引 1 赋给根,计算会变得简单一点。那么子节点的位置是 2 i 和 2 i + 1。