3
二叉树问题

分别用递归和非递归方式实现二叉树先序、中序和后序遍历

【题目】

用递归和非递归方式,分别按照二叉树先序、中序和后序打印所有的节点。我们约定:先序遍历顺序为根、左、右;中序遍历顺序为左、根、右;后序遍历顺序为左、右、根。

【难度】

校 ★★★☆

【解答】

用递归方式实现三种遍历是教材上的基础内容,本书不再详述,直接给出代码实现。

先序遍历的递归实现请参看如下代码中的preOrderRecur方法。

          public class Node {
                  public int value;
                  public Node left;
                  public Node right;
                  public Node(int data) {
                          this.value = data;
                  }
          }

          public void preOrderRecur(Node head) {
                  if (head == null) {
                          return;
                  }
                  System.out.print(head.value + " ");
                  preOrderRecur(head.left);
                  preOrderRecur(head.right);
          }

中序遍历的递归实现请参看如下代码中的inOrderRecur方法。

          public void inOrderRecur(Node head) {
                  if (head == null) {
                          return;
                  }
                  inOrderRecur(head.left);
                  System.out.print(head.value + " ");
                  inOrderRecur(head.right);
          }

后序遍历的递归实现请参看如下代码中的posOrderRecur方法。

          public void posOrderRecur(Node head) {
                  if (head == null) {
                          return;
                  }
                  posOrderRecur(head.left);
                  posOrderRecur(head.right);
                  System.out.print(head.value + " ");
          }

用递归方法解决的问题都能用非递归的方法实现。这是因为递归方法无非就是利用函数栈来保存信息,如果用自己申请的数据结构来代替函数栈,也可以实现相同的功能。

用非递归的方式实现二叉树的先序遍历,具体过程如下:

1.申请一个新的栈,记为stack。然后将头节点head压入stack中。

2.从stack中弹出栈顶节点,记为cur,然后打印cur节点的值,再将节点cur的右孩子(不为空的话)先压入stack中,最后将cur的左孩子(不为空的话)压入stack中。

3.不断重复步骤2,直到stack为空,全部过程结束。

下面举例说明整个过程,一棵二叉树如图3-1所示。

image

图3-1

节点1先入栈,然后弹出并打印。接下来先把节点3压入stack,再把节点2压入,stack从栈顶到栈底依次为2,3。

节点2弹出并打印,把节点5压入stack,再把节点4压入,stack从栈顶到栈底为4,5,3。

节点4弹出并打印,节点4没有孩子压入stack,stack从栈顶到栈底依次为5,3。

节点5弹出并打印,节点5没有孩子压入stack,stack从栈顶到栈底依次为3。

节点3弹出并打印,把节点7压入stack,再把节点6压入,stack从栈顶到栈底为6,7。

节点6弹出并打印,节点6没有孩子压入stack,stack目前从栈顶到栈底为7。

节点7弹出并打印,节点7没有孩子压入stack,stack已经为空,过程停止。

整个过程请参看如下代码中的preOrderUnRecur方法。

          public void preOrderUnRecur(Node head) {
                  System.out.print("pre-order: ");
                  if (head ! = null) {
                          Stack<Node> stack = new Stack<Node>();
                          stack.add(head);
                          while (! stack.isEmpty()) {
                                  head = stack.pop();
                                  System.out.print(head.value + " ");
                                  if (head.right ! = null) {
                                          stack.push(head.right);
                                  }
                                  if (head.left ! = null) {
                                          stack.push(head.left);
                                  }
                          }
                  }
                  System.out.println();
          }

用非递归的方式实现二叉树的中序遍历,具体过程如下:

1.申请一个新的栈,记为stack。初始时,令变量cur=head。

2.先把cur节点压入栈中,对以cur节点为头的整棵子树来说,依次把左边界压入栈中,即不停地令cur=cur.left,然后重复步骤2。

3.不断重复步骤2,直到发现cur为空,此时从stack中弹出一个节点,记为node。打印node的值,并且让cur=node.right,然后继续重复步骤2。

4.当stack为空且cur为空时,整个过程停止。

还是用图3-1的例子来说明整个过程。

初始时cur为节点1,将节点1压入stack,令cur=cur.left,即cur变为节点2。(步骤1+步骤2)

cur为节点2,将节点2压入stack,令cur=cur.left,即cur变为节点4。(步骤2)

cur为节点4,将节点4压入stack,令cur=cur.left,即cur变为null,此时stack从栈顶到栈底为4,2,1。(步骤2)

cur为null,从stack弹出节点4(node)并打印,令cur=node.right,即cur为null,此时stack从栈顶到栈底为2,1。(步骤3)

cur为null,从stack弹出节点2(node)并打印,令cur=node.right,即cur变为节点5,此时stack从栈顶到栈底为1。(步骤3)

cur为节点5,将节点5压入stack,令cur=cur.left,即cur变为null,此时stack从栈顶到栈底为5,1。(步骤2)

cur为null,从stack弹出节点5(node)并打印,令cur=node.right,即cur仍为null,此时stack从栈顶到栈底为1。(步骤3)

cur为null,从stack弹出节点1(node)并打印,令cur=node.right,即cur变为节点3,此时stack为空。(步骤3)

cur为节点3,将节点3压入stack,令cur=cur.left即cur变为节点6;此时stack从栈顶到栈底为3。(步骤2)

cur为节点6,将节点6压入stack,令cur=cur.left即cur变为null,此时stack从栈顶到栈底为6,3。(步骤2)

cur为null,从stack弹出节点6(node)并打印,令cur=node.right,即cur仍为null,此时stack从栈顶到栈底为3。(步骤3)

cur为null,从stack弹出节点3(node)并打印,令cur=node.right,即cur变为节点7,此时stack为空。(步骤3)

cur为节点7,将节点7压入stack,令cur=cur.left,即cur变为null,此时stack从栈顶到栈底为7。(步骤2)

cur为null,从stack弹出节点7(node)并打印,令cur=node.right,即cur仍为null,此时stack为空。(步骤3)

cur为null,stack也为空,整个过程停止。(步骤4)

通过与例子结合的方式我们发现,步骤1到步骤4就是依次先打印左子树,然后是每棵子树的头节点,最后打印右子树。

全部过程请参看如下代码中的inOrderUnRecur方法。

          public void inOrderUnRecur(Node head) {
                  System.out.print("in-order: ");
                  if (head ! = null) {
                          Stack<Node> stack = new Stack<Node>();
                          while (! stack.isEmpty() || head ! = null) {
                                  if (head ! = null) {
                                          stack.push(head);
                                          head = head.left;
                                  } else {
                                          head = stack.pop();
                                          System.out.print(head.value + " ");
                                          head = head.right;
                                  }
                          }
                  }
                  System.out.println();
          }

用非递归的方式实现二叉树的后序遍历有点麻烦,本书实现两种方法供读者参考。

先介绍用两个栈实现后序遍历的过程,具体过程如下:

1.申请一个栈,记为s1,然后将头节点head压入s1中。

2.从s1中弹出的节点记为cur,然后依次将cur的左孩子和右孩子压入s1中。

3.在整个过程中,每一个从s1中弹出的节点都放进s2中。

4.不断重复步骤2和步骤3,直到s1为空,过程停止。

5.从s2中依次弹出节点并打印,打印的顺序就是后序遍历的顺序。

还是用图3-1的例子来说明整个过程。

节点1放入s1中。

从s1中弹出节点1,节点1放入s2,然后将节点2和节点3依次放入s1,此时s1从栈顶到栈底为3,2; s2从栈顶到栈底为1。

从s1中弹出节点3,节点3放入s2,然后将节点6和节点7依次放入s1,此时s1从栈顶到栈底为7,6,2; s2从栈顶到栈底为3,1。

从s1中弹出节点7,节点7放入s2,节点7无孩子节点,此时s1从栈顶到栈底为6,2; s2从栈顶到栈底为7,3,1。

从s1中弹出节点6,节点6放入s2,节点6无孩子节点,此时s1从栈顶到栈底为2;s2从栈顶到栈底为6,7,3,1。

从s1中弹出节点2,节点2放入s2,然后将节点4和节点5依次放入s1,此时s1从栈顶到栈底为5,4; s2从栈顶到栈底为2,6,7,3,1。

从s1中弹出节点5,节点5放入s2,节点5无孩子节点,此时s1从栈顶到栈底为4;s2从栈顶到栈底为5,2,6,7,3,1。

从s1中弹出节点4,节点4放入s2,节点4无孩子节点,此时s1为空;s2从栈顶到栈底为4,5,2,6,7,3,1。

过程结束,此时只要依次弹出s2中的节点并打印即可,顺序为4,5,2,6,7,3,1。

通过如上过程我们知道,每棵子树的头节点都最先从s1中弹出,然后把该节点的孩子节点按照先左再右的顺序压入s1,那么从s1弹出的顺序就是先右再左,所以从s1中弹出的顺序就是中、右、左。然后,s2重新收集的过程就是把s1的弹出顺序逆序,所以s2从栈顶到栈底的顺序就变成了左、右、中。

使用两个栈实现后序遍历的全部过程请参看如下代码中的posOrderUnRecur1方法。

          public void posOrderUnRecur1(Node head) {
                  System.out.print("pos-order: ");
                  if (head ! = null) {
                          Stack<Node> s1 = new Stack<Node>();
                          Stack<Node> s2 = new Stack<Node>();
                          s1.push(head);
                          while (! s1.isEmpty()) {
                                  head = s1.pop();
                                  s2.push(head);
                                  if (head.left ! = null) {
                                          s1.push(head.left);
                                  }
                                  if (head.right ! = null) {
                                          s1.push(head.right);
                                  }
                          }
                          while (! s2.isEmpty()) {
                                  System.out.print(s2.pop().value + " ");
                          }
                  }
                  System.out.println();
          }

最后介绍只用一个栈实现后序遍历的过程,具体过程如下:

1.申请一个栈,记为stack,将头节点压入stack,同时设置两个变量h和c。在整个流程中,h代表最近一次弹出并打印的节点,c代表stack的栈顶节点,初始时h为头节点,c为null。

2.每次令c等于当前stack的栈顶节点,但是不从stack中弹出,此时分以下三种情况。

①如果c的左孩子不为null,并且h不等于c的左孩子,也不等于c的右孩子,则把c的左孩子压入stack中。具体解释一下这么做的原因,首先h的意义是最近一次弹出并打印的节点,所以如果h等于c的左孩子或者右孩子,说明c的左子树与右子树已经打印完毕,此时不应该再将c的左孩子放入stack中。否则,说明左子树还没处理过,那么此时将c的左孩子压入stack中。

②如果条件①不成立,并且c的右孩子不为null,h不等于c的右孩子,则把c的右孩子压入stack中。含义是如果h等于c的右孩子,说明c的右子树已经打印完毕,此时不应该再将c的右孩子放入stack中。否则,说明右子树还没处理过,此时将c的右孩子压入stack中。

③如果条件①和条件②都不成立,说明c的左子树和右子树都已经打印完毕,那么从stack中弹出c并打印,然后令h=c。

3.一直重复步骤2,直到stack为空,过程停止。

依然用图3-1的例子来说明整个过程。

节点1压入stack,初始时h为节点1,c为null,stack从栈顶到栈底为1。

令c等于stack的栈顶节点——节点1,此时步骤2的条件①命中,将节点2压入stack,h为节点1,stack从栈顶到栈底为2,1。

令c等于stack的栈顶节点——节点2,此时步骤2的条件①命中,将节点4压入stack,h为节点1,stack从栈顶到栈底为4,2,1。

令c等于stack的栈顶节点——节点4,此时步骤2的条件③命中,将节点4从stack中弹出并打印,h变为节点4,stack从栈顶到栈底为2,1。

令c等于stack的栈顶节点——节点2,此时步骤2的条件②命中,将节点5压入stack,h为节点4,stack从栈顶到栈底为5,2,1。

令c等于stack的栈顶节点——节点5,此时步骤2的条件③命中,将节点5从stack中弹出并打印,h变为节点5,stack从栈顶到栈底为2,1。

令c等于stack的栈顶节点——节点2,此时步骤2的条件③命中,将节点2从stack中弹出并打印,h变为节点2,stack从栈顶到栈底为1。

令c等于stack的栈顶节点——节点1,此时步骤2的条件②命中,将节点3压入stack,h为节点2,stack从栈顶到栈底为3,1。

令c等于stack的栈顶节点——节点3,此时步骤2的条件①命中,将节点6压入stack,h为节点2,stack从栈顶到栈底为6,3,1。

令c等于stack的栈顶节点——节点6,此时步骤2的条件③命中,将节点6从stack中弹出并打印,h变为节点6,stack从栈顶到栈底为3,1。

令c等于stack的栈顶节点——节点3,此时步骤2的条件②命中,将节点7压入stack,h为节点6,stack从栈顶到栈底为7,3,1。

令c等于stack的栈顶节点——节点7,此时步骤2的条件③命中,将节点7从stack中弹出并打印,h变为节点7,stack从栈顶到栈底为3,1。

令c等于stack的栈顶节点——节点3,此时步骤2的条件③命中,将节点3从stack中弹出并打印,h变为节点3,stack从栈顶到栈底为1。

令c等于stack的栈顶节点——节点1,此时步骤2的条件③命中,将节点1从stack中弹出并打印,h变为节点1,stack为空。

过程结束。

只用一个栈实现后序遍历的全部过程请参看如下代码中的posOrderUnRecur2方法。

          public void posOrderUnRecur2(Node h) {
                  System.out.print("pos-order: ");
                  if (h ! = null) {
                          Stack<Node> stack = new Stack<Node>();
                          stack.push(h);
                          Node c = null;
                          while (! stack.isEmpty()) {
                                  c = stack.peek();
                                  if (c.left ! = null && h ! = c.left && h ! = c.right) {
                                          stack.push(c.left);
                                  } else if (c.right ! = null && h ! = c.right) {
                                          stack.push(c.right);
                                  } else {
                                          System.out.print(stack.pop().value + " ");
                                          h = c;
                                  }
                          }
                  }
                  System.out.println();
          }

打印二叉树的边界节点

【题目】

给定一棵二叉树的头节点head,按照如下两种标准分别实现二叉树边界节点的逆时针打印。

标准一:

1.头节点为边界节点。

2.叶节点为边界节点。

3.如果节点在其所在的层中是最左或最右的,那么也是边界节点。

标准二:

1.头节点为边界节点。

2.叶节点为边界节点。

3.树左边界延伸下去的路径为边界节点。

4.树右边界延伸下去的路径为边界节点。

例如,如图3-2所示的树。

image

图3-2

按标准一的打印结果为:1,2,4,7,11,13,14,15,16,12,10,6,3

按标准二的打印结果为:1,2,4,7,13,14,15,16,10,6,3

【要求】

1.如果节点数为N ,两种标准实现的时间复杂度要求都为O (N ),额外空间复杂度要求都为O (h ),h 为二叉树的高度。

2.两种标准都要求逆时针顺序且不重复打印所有的边界节点。

【难度】

尉 ★★☆☆

【解答】

按照标准一的要求实现打印的具体过程如下:

1.得到二叉树每一层上最左和最右的节点。以题目的例子来说,这个记录如下:

最左节点 最右节点
第一层 1 1
第二层 2 3
第三层 4 6
第四层 7 10
第五层 11 12
第六层 13 16

2.从上到下打印所有层中的最左节点。对题目的例子来说,即打印:1,2,4,7,11,13。

3.先序遍历二叉树,打印那些不属于某一层最左或最右的节点,但同时又是叶节点的节点。对题目的例子来说,即打印:14,15。

4.从下到上打印所有层中的最右节点,但节点不能既是最左节点,又是最右节点。对题目的例子来说,即打印:16,12,10,6,3。

按标准一打印的全部过程请参看如下代码中的printEdge1方法。

          public class Node {
                  public int value;
                  public Node left;
                  public Node right;
                  public Node(int data) {
                          this.value = data;
                  }
          }

          public void printEdge1(Node head) {
                  if (head == null) {
                          return;
                  }
                  int height = getHeight(head, 0);
                  Node[][] edgeMap = new Node[height][2];
                  setEdgeMap(head, 0, edgeMap);
                  // 打印左边界
                  for (int i = 0; i ! = edgeMap.length; i++) {
                          System.out.print(edgeMap[i][0].value + " ");
                  }
                  // 打印既不是左边界,也不是右边界的叶子节点
                  printLeafNotInMap(head, 0, edgeMap);
                  // 打印右边界,但不是左边界的节点
                  for (int i = edgeMap.length - 1; i ! = -1; i--) {
                          if (edgeMap[i][0] ! = edgeMap[i][1]) {
                                  System.out.print(edgeMap[i][1].value + " ");
                          }
                  }
                  System.out.println();
          }

          public int getHeight(Node h, int l) {
                  if (h == null) {
                          return l;
                  }
                  return Math.max(getHeight(h.left, l + 1), getHeight(h.right, l + 1));
          }

          public void setEdgeMap(Node h, int l, Node[][] edgeMap) {
                  if (h == null) {
                          return;
                  }
                  edgeMap[l][0] = edgeMap[l][0] == null ? h : edgeMap[l][0];
                  edgeMap[l][1] = h;
                  setEdgeMap(h.left, l + 1, edgeMap);
                  setEdgeMap(h.right, l + 1, edgeMap);
          }

          public void printLeafNotInMap(Node h, int l, Node[][] m) {
                  if (h == null) {
                          return;
                  }
                  if (h.left == null && h.right == null && h ! = m[l][0] && h ! = m[l][1]) {
                          System.out.print(h.value + " ");
                  }
                  printLeafNotInMap(h.left, l + 1, m);
                  printLeafNotInMap(h.right, l + 1, m);
          }

按照标准二的要求实现打印的具体过程如下:

1.从头节点开始往下寻找,只要找到第一个既有左孩子,又有右孩子的节点,记为h,则进入步骤2。在这个过程中,找过的节点都打印。对题目的例子来说,即打印:1,因为头节点直接符合要求,所以打印后没有后续的寻找过程,直接进入步骤2。但如果二叉树如图3-3所示,此时则打印:1,2,3。节点3是从头节点开始往下第一个符合要求的。如果二叉树从上到下一直找到叶节点也不存在符合要求的节点,说明二叉树是棒状结构,那么打印找过的节点后直接返回即可。

image

图3-3

2.h的左子树先进入步骤3的打印过程;h的右子树再进入步骤4的打印过程;最后返回。

3.打印左边界的延伸路径以及h左子树上所有的叶节点,具体请参看printLeftEdge方法。

4.打印右边界的延伸路径以及h右子树上所有的叶节点,具体请参看printRightEdge方法。

按标准二打印的全部过程请参看如下代码中的printEdge2方法。

          public void printEdge2(Node head) {
                  if (head == null) {
                          return;
                  }
                  System.out.print(head.value + " ");
                  if (head.left ! = null && head.right ! = null) {
                          printLeftEdge(head.left, true);
                          printRightEdge(head.right, true);
                  } else {
                          printEdge2(head.left ! = null ? head.left : head.right);
                  }
                  System.out.println();
          }

          public void printLeftEdge(Node h, boolean print) {
                  if (h == null) {
                          return;
                  }
                  if (print || (h.left == null && h.right == null)) {
                          System.out.print(h.value + " ");
                  }
                  printLeftEdge(h.left, print);
                  printLeftEdge(h.right, print && h.left == null ? true : false);
          }

          public void printRightEdge(Node h, boolean print) {
                  if (h == null) {
                          return;
                  }
                  printRightEdge(h.left, print && h.right == null ? true : false);
                  printRightEdge(h.right, print);
                  if (print || (h.left == null && h.right == null)) {
                          System.out.print(h.value + " ");
                  }
          }

如何较为直观地打印二叉树

【题目】

二叉树可以用常规的三种遍历结果来描述其结构,但是不够直观,尤其是二叉树中有重复值的时候,仅通过三种遍历的结果来构造二叉树的真实结构更是难上加难,有时则根本不可能。给定一棵二叉树的头节点head,已知二叉树节点值的类型为32位整型,请实现一个打印二叉树的函数,可以直观地展示树的形状,也便于画出真实的结构。

【难度】

尉 ★★☆☆

【解答】

这是一道较开放的题目,面试者不仅要设计出符合要求且不会产生歧义的打印方式,还要考虑实现难度,在面试时仅仅写出思路必然是不满足代码面试要求的。本书给出一种符合要求且代码量不大的实现,希望读者也能实现并优化自己的设计。具体过程如下:

1.设计打印的样式。实现者首先应该解决的问题是用什么样的方式来无歧义地打印二叉树。比如,二叉树如图3-4所示。

image

图3-4

对如图3-4所示的二叉树,本书设计的打印样式如图3-5所示。

image

图3-5

下面解释一下如何看打印的结果。首先,二叉树大概的样子是把打印结果顺时针旋转90°,读者可以把图3-4的打印结果(也就是图3-5顺时针旋转90°之后)做一下对比,两幅图是存在明显对应关系的;接下来,怎么清晰地确定任何一个节点的父节点呢?如果一个节点打印结果的前缀与后缀都有“H”(比如图3-5中的“H1H”),说明这个节点是头节点,当然就不存在父节点。如果一个节点打印结果的前缀与后缀都有“v”,表示父节点在该节点所在列的前一列,在该节点所在行的下方,并且是离该节点最近的节点。比如图3-5中的“v3v”、“v6v”和“v7v”,父节点分别为“H1H”、“v3v”和“^4^”。如果一个节点打印结果的前缀与后缀都有“^”,表示父节点在该节点所在列的前一列,在该节点所在行的上方,并且是离该节点最近的节点。比如,图3-5中的“^5^”、“^2^”和“^4^”,父节点分别为“v3v”、“H1H”和“^2^”。

2.一个需要重点考虑的问题——规定节点打印时占用的统一长度。我们必须规定一个节点在打印时到底占多长。试想一下,如果有些节点的值本身的长度很短,比如“1”、“2”等,而有些节点的值本身的长度很长,比如“43323232”、“78787237”等,那么如果不规定一个统一的长度,在打印一个长短值交替的二叉树时必然会出现格式对不齐的问题,进而产生歧义。在Java中,整型值占用长度最长的值是Integer.MIN_VALUE(即-2147483648),占用的长度为11,加上前缀和后缀(“H”、“v”或“^”)之后占用长度为13。为了在打印之后更好地区分,再把前面加上两个空格,后面加上两个空格,总共占用长度为17。也就是说,长度为17的空间必然可以放下任何一个32位整数,同时样式还不错。至此,我们约定,打印每一个节点的时候,必须让每一个节点在打印时占用长度都为17,如果不足,前后都用空格补齐。比如节点值为8,假设这个节点加上“v”作为前后缀,那么实质内容为“v8v”,长度才为3,在打印时在“v8v”的前面补7个空格,后面也补7个空格,让总长度为17。再如节点值为66,假设这个节点加上“v”作为前后缀,那么实质内容为“v66v”,长度才为4,在打印时在“v66v”的前面补6个空格,后面补7个空格,让总长度为17。总之,如果长度不足,前后贴上几乎数量相等的空格来补齐。

3.确定了打印的样式,规定了占用长度的标准,最后来解释具体的实现。打印的整体过程结合了二叉树先右子树、再根节点、最后左子树的递归遍历过程。如果递归到一个节点,首先遍历它的右子树。右子树遍历结束后,回到这个节点。如果这个节点所在层为l,那么先打印l×17个空格(不换行),然后开始制作该节点的打印内容,这个内容当然包括节点的值,以及确定的前后缀字符。如果该节点是其父节点的右孩子,前后缀为“v”,如果是其父节点的左孩子,前后缀为“^”,如果是头节点,前后缀为“H”。最后在前后分别贴上数量几乎一致的空格,占用长度为17的打印内容就制作完了,打印这个内容后换行。最后进行左子树的遍历过程。

直观地打印二叉树的所有过程请参看如下代码中的printTree方法。

          public class Node {
                  public int value;
                  public Node left;
                  public Node right;
                  public Node(int data) {
                          this.value = data;
                  }
          }

          public void printTree(Node head) {
                  System.out.println("Binary Tree:");
                  printInOrder(head, 0, "H", 17);
                  System.out.println();
          }

          public void printInOrder(Node head, int height, String to, int len) {
                  if (head == null) {
                          return;
                  }
                  printInOrder(head.right, height + 1, "v", len);
                  String val = to + head.value + to;
                  int lenM = val.length();
                  int lenL = (len - lenM) / 2;
                  int lenR = len - lenM - lenL;
                  val = getSpace(lenL) + val + getSpace(lenR);
                  System.out.println(getSpace(height * len) + val);
                  printInOrder(head.left, height + 1, "^", len);
          }

          public String getSpace(int num) {
                  String space = " ";
                  StringBuffer buf = new StringBuffer("");
                  for (int i = 0; i < num; i++) {
                          buf.append(space);
                  }
                  return buf.toString();
          }

【扩展】

有关功能设计的面试题,其实最难的部分并不是设计,而是在设计的优良性和实现的复杂程度之间找到一个平衡性最好的设计方案。在满足功能要求的同时,也要保证在面试场上能够完成大致的代码实现,同时对边界条件的梳理能力和代码逻辑的实现能力也是一大挑战。读者可以看到本书提供的方法在完成功能的同时其代码很少,也请读者设计自己的方案并实现它。

二叉树的序列化和反序列化

【题目】

二叉树被记录成文件的过程叫作二叉树的序列化,通过文件内容重建原来二叉树的过程叫作二叉树的反序列化。给定一棵二叉树的头节点head,并已知二叉树节点值的类型为32位整型。请设计一种二叉树序列化和反序列化的方案,并用代码实现。

【难度】

士 ★☆☆☆

【解答】

本书提供两套序列化和反序列化的实现,供读者参考。

方法一:通过先序遍历实现序列化和反序列化。

先介绍先序遍历下的序列化过程,首先假设序列化的结果字符串为str,初始时str=""。先序遍历二叉树,如果遇到null节点,就在str的末尾加上“#! ”,“#”表示这个节点为空,节点值不存在,“! ”表示一个值的结束;如果遇到不为空的节点,假设节点值为3,就在str的末尾加上“3! ”。比如图3-6所示的二叉树。

image

图3-6

根据上文的描述,先序遍历序列化,最后的结果字符串str为:12!3! #! #! #!。

为什么在每一个节点值的后面都要加上“! ”呢?因为如果不标记一个值的结束,最后产生的结果会有歧义,如图3-7所示。

image

图3-7

如果不在一个值结束时加入特殊字符,那么图3-6和图3-7的先序遍历序列化结果都是123###。也就是说,生成的字符串并不代表唯一的树。

先序遍历序列化的全部过程请参看如下代码中的serialByPre方法。

          public class Node {
                  public int value;
                  public Node left;
                  public Node right;
                  public Node(int data) {
                          this.value = data;
                  }
          }

          public String serialByPre(Node head) {
                  if (head == null) {
                          return "#! ";
                  }
                  String res = head.value + "! ";
                  res += serialByPre(head.left);
                  res += serialByPre(head.right);
                  return res;
          }

接下来介绍如何通过先序遍历序列化的结果字符串str,重构二叉树的过程,即反序列化。

把结果字符串str变成字符串类型的数组,记为values,数组代表一棵二叉树先序遍历的节点顺序。例如,str="12!3! #! #! #! ",生成的values为["12","3","#","#","#"],然后用values[0..4]按照先序遍历的顺序建立整棵树。

1.遇到"12",生成节点值为12的节点(head),然后用values[1..4]建立节点12的左子树。

2.遇到"3",生成节点值为3的节点,它是节点12的左孩子,然后用values[2..4]建立节点3的左子树。

3.遇到"#",生成null节点,它是节点3的左孩子,该节点为null,所以这个节点没有后续建立子树的过程。回到节点3后,用values[3..4]建立节点3的右子树。

4.遇到"#",生成null节点,它是节点3的右孩子,该节点为null,所以这个节点没有后续建立子树的过程。回到节点3后,再回到节点1,用values[4]建立节点1的右子树。

5.遇到"#",生成null节点,它是节点1的右孩子,该节点为null,所以这个节点没有后续建立子树的过程。整个过程结束。

先序遍历反序列化的全部过程请参看如下代码中的reconByPreString方法。

          public Node reconByPreString(String preStr) {
                  String[] values = preStr.split("! ");
                  Queue<String> queue = new LinkedList<String>();
                  for (int i = 0; i ! = values.length; i++) {
                          queue.offer(values[i]);
                  }
                  return reconPreOrder(queue);
          }

          public Node reconPreOrder(Queue<String> queue) {
                  String value = queue.poll();
                  if (value.equals("#")) {
                          return null;
                  }
                  Node head = new Node(Integer.valueOf(value));
                  head.left = reconPreOrder(queue);
                  head.right = reconPreOrder(queue);
                  return head;
          }

方法二:通过层遍历实现序列化和反序列化。

先介绍层遍历下的序列化过程,首先假设序列化的结果字符串为str,初始时str="空"。然后实现二叉树的按层遍历,具体方式是利用队列结构,这也是宽度遍历图的常见方式。例如,图3-8所示的二叉树。

image

图3-8

按层遍历图3-8所示的二叉树,最后str="1!2!3!4! #! #!5! #! #! #! #! "。

层遍历序列化的全部过程请参看如下代码中的serialByLevel方法。

          public String serialByLevel(Node head) {
                  if (head == null) {
                          return "#! ";
                  }
                  String res = head.value + "! ";
                  Queue<Node> queue = new LinkedList<Node>();
                  queue.offer(head);
                  while (! queue.isEmpty()) {
                          head = queue.poll();
                          if (head.left ! = null) {
                                  res += head.left.value + "! ";
                                  queue.offer(head.left);
                          } else {
                                  res += "#! ";
                          }
                          if (head.right ! = null) {
                                  res += head.right.value + "! ";
                                  queue.offer(head.right);
                          } else {
                                  res += "#! ";
                          }
                  }
                  return res;
          }

先序遍历的反序列化其实就是重做先序遍历,遇到"#"就生成null节点,结束生成后续子树的过程。

与根据先序遍历的反序列化过程一样,根据层遍历的反序列化是重做层遍历,遇到"#"就生成null节点,同时不把null节点放到队列里即可。

层遍历反序列化的全部过程请参看如下代码中的reconByLevelString方法。

          public Node reconByLevelString(String levelStr) {
                  String[] values = levelStr.split("! ");
                  int index = 0;
                  Node head = generateNodeByString(values[index++]);
                  Queue<Node> queue = new LinkedList<Node>();
                  if (head ! = null) {
                          queue.offer(head);
                  }
                  Node node = null;
                  while (! queue.isEmpty()) {
                          node = queue.poll();
                          node.left = generateNodeByString(values[index++]);
                          node.right = generateNodeByString(values[index++]);
                          if (node.left ! = null) {
                                  queue.offer(node.left);
                          }
                          if (node.right ! = null) {
                                  queue.offer(node.right);
                          }
                  }
                  return head;
          }

          public Node generateNodeByString(String val) {
                  if (val.equals("#")) {
                          return null;
                  }
                  return new Node(Integer.valueOf(val));
          }

遍历二叉树的神级方法

【题目】

给定一棵二叉树的头节点head,完成二叉树的先序、中序和后序遍历。如果二叉树的节点数为N ,要求时间复杂度为O (N ),额外空间复杂度为O (1)。

【难度】

将 ★★★★

【解答】

本题真正的难点在于对复杂度的要求,尤其是额外空间复杂度为O (1)的限制。之前的题目已经剖析过如何用递归和非递归的方法实现遍历二叉树,很不幸,之前所有的方法虽然常用,但都无法做到额外空间复杂度为O (1)。这是因为遍历二叉树的递归方法实际使用了函数栈,非递归的方法使用了申请的栈,两者的额外空间都与树的高度相关,所以空间复杂度为O (h ),h 为二叉树的高度。如果完全不用栈结构能完成三种遍历吗?可以。答案是使用二叉树节点中大量指向null的指针,本题实际上就是大名鼎鼎的Morris遍历,由Joseph Morris于1979年发明。

首先来看普通的递归和非递归解法,其实都使用了栈结构,在处理完二叉树某个节点后可以回到上层去。为什么从下层回到上层会如此之难?因为二叉树的结构如此,每个节点都有指向孩子节点的指针,所以从上层到下层容易,但是没有指向父节点的指针,所以从下层到上层需要用栈结构辅助完成。

Morris遍历的实质就是避免用栈结构,而是让下层到上层有指针,具体是通过让底层节点指向null的空闲指针指回上层的某个节点,从而完成下层到上层的移动。我们知道,二叉树上的很多节点都有大量的空闲指针,比如,某些节点没有右孩子,那么这个节点的right指针就指向null,我们称为空闲状态,Morris遍历正是利用了这些空闲指针。

在介绍Morris先序和后序遍历之前,我们先举例展示Morris中序遍历的过程。

假设一棵二叉树如图3-9所示,Morris中序遍历的具体过程如下:

image

图3-9

1.假设当前子树的头节点为h,让h的左子树中最右节点的right指针指向h,然后h的左子树继续步骤1的处理过程,直到遇到某一个节点没有左子树时记为node,进入步骤2。

举例:图3-9的二叉树在开始时h为节点4,通过步骤1让节点3的right指针指向节点4,接下来以节点2为头的子树继续进入步骤1,然后让节点1的right指针指向2,接下来以节点1为头的子树没有左子树了,步骤1停止,节点1进入步骤2,此时结构调整为图3-10。

image

图3-10

2.从node开始通过每个节点的right指针进行移动,并依次打印,假设移动到的节点为cur。对每一个cur节点都判断cur节点的左子树中最右节点是否指向cur。

①如果是。让cur节点的左子树中最右节点的right指针指向空,也就是把步骤1的调整后再逐渐调整回来,然后打印cur,继续通过cur的right指针移动到下一个节点,重复步骤2。

②如果不是,以cur为头的子树重回步骤1执行。

用例子说明这个过程如下:

节点1先打印,通过节点1的right指针移动到节点2。

发现节点2符合步骤2的条件①,所以令节点1的right指针指向null,然后打印节点2,再通过节点2的right指针移动到节点3。

发现节点3符合步骤2的条件②,节点3为头的子树进入步骤1处理,但因为这个子树只有节点3,所以步骤1迅速处理完,又回到节点3,打印节点3,然后通过节点3的right指针移动到节点4。

发现节点4符合步骤2的条件①,所以令节点3的right指针指向null,然后打印节点4,再通过节点4的right指针移动到节点6。到目前为止,二叉树的结构又回到了图3-9的样子。

发现节点6符合步骤2的条件②,所以,以节点6为头的子树进入步骤1进行处理,处理之后,二叉树变成图3-11所示的样子。

image

图3-11

重新来到步骤2的第一个节点是以节点6为头的子树的最左节点,即节点5,发现节点5符合步骤2的条件②,节点5为头的子树进入步骤1处理,但因为这棵子树只有节点5,所以步骤1迅速处理完,打印节点5,然后通过节点5的right指针移动到节点6。

发现节点6符合步骤2的条件①,所以令节点5的right指针指向null,然后打印节点6,再通过节点6的right指针移动到节点7。到目前为止,二叉树的结构又回到了图3-9的样子。

节点7符合步骤2的条件②,以节点7的子树经历步骤1、步骤2和步骤3并打印。然后通过节点7的right指针移动到null,整个过程结束。

3.步骤2最终移动到null,整个过程结束。

通过上述步骤描述我们知道,先序遍历在打印某个节点时,一定是在步骤2开始移动的过程中,而步骤2最初开始时的位置一定是子树的最左节点,在通过right指针移动的过程中,我们发现要么是某个节点移动到其右子树上,比如,节点2向节点3的移动、节点4向节点6的移动,以及节点6向节点7的移动,发生这种情况的时候,左子树和根节点已经打印结束,然后开始右子树的处理过程;要么是某个节点移动到某个上层的节点,比如节点1向节点2的移动、节点3向节点4的移动,以及节点5向节点6的移动,发生这种情况的时候,必然是这个上层节点的左子树整体打印完毕,然后开始处理根节点(也就是这个上层节点)和右子树的过程。Morris中序遍历的具体实现请参看如下代码中的morrisIn方法。

          public class Node {
                  public int value;
                  Node left;
                  Node right;

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

          public void morrisIn(Node head) {
                  if (head == null) {
                          return;
                  }
                  Node cur1 = head;
                  Node cur2 = null;
                  while (cur1 ! = null) {
                          cur2 = cur1.left;
                          if (cur2 ! = null) {
                                  while (cur2.right ! = null && cur2.right ! = cur1) {
                                          cur2 = cur2.right;
                                  }
                                  if (cur2.right == null) {
                                          cur2.right = cur1;
                                          cur1 = cur1.left;
                                          continue;
                                  } else {
                                          cur2.right = null;
                                  }
                          }
                          System.out.print(cur1.value + " ");
                          cur1 = cur1.right;
                  }
                  System.out.println();
          }

从代码可以轻易看出,Morris中序遍历的额外空间复杂度为O (1),只使用了有限几个变量。时间复杂度方面可以这么分析,二叉树的每条边都最多经历一次步骤1的调整过程,再最多经历一次步骤3的调回来的过程,所有边的节点个数为N ,所以调整和调回的过程,其时间复杂度为O (N ),打印所有节点的时间复杂度为O (N )。所以,总的时间复杂度为O (N )。

Morris先序遍历的实现就是Morris中序遍历实现的简单改写。先序遍历的打印时机放在了步骤2所描述的移动过程中,而先序遍历只要把打印时机放在步骤1发生的时候即可。步骤1发生的时候,正在处理以h为头的子树,并且是以h为头的子树首次进入调整过程,此时直接打印h,就可以做到先根打印。

Morris先序遍历的具体实现请参看如下代码中的morrisPre方法。

          public void morrisPre(Node head) {
                  if (head == null) {
                          return;
                  }
                  Node cur1 = head;
                  Node cur2 = null;
                  while (cur1 ! = null) {
                          cur2 = cur1.left;
                          if (cur2 ! = null) {
                                  while (cur2.right ! = null && cur2.right ! = cur1) {
                                          cur2 = cur2.right;
                                  }
                                  if (cur2.right == null) {
                                          cur2.right = cur1;
                                          System.out.print(cur1.value + " ");
                                          cur1 = cur1.left;
                                          continue;
                                  } else {
                                          cur2.right = null;
                                  }
                          } else {
                                  System.out.print(cur1.value + " ");
                          }
                          cur1 = cur1.right;
                  }
                  System.out.println();
          }

Morris后序遍历的实现也是Morris中序遍历实现的改写,但包含更复杂的调整过程。总的来说,逻辑很简单,就是依次逆序打印所有节点的左子树的右边界,打印的时机放在步骤2的条件①被触发的时候,也就是调回去的过程发生的时候。

还是以图3-9的二叉树来举例说明Morris后序遍历的打印过程,头节点(即节点4)在经过步骤1的调整过程之后,形成如图3-10所示的形式。

节点1进入步骤2,不打印节点1,而是直接通过节点1的right指针移动到节点2。

发现节点2符合步骤2的条件①,此时先把节点1的right指针指向null(调回来),节点2左子树的右边界只有节点1,所以打印节点1,通过节点2的right指针移动到节点3。

发现节点3符合步骤2的条件②,节点3为头的子树进入步骤1处理,回到节点3后不打印节点3,而是直接通过节点3的right指针移动到节点4。

发现节点4符合步骤2的条件①,此时二叉树如图3-12所示。

image

图3-12

将节点4左子树的右边界(节点2和节点3)逆序打印,但这里的逆序打印不能使用额外的数据结构,因为我们的要求是额外空间复杂度为O (1),所以采用调整右边界上节点的right指针的方式。为了更好地说明整个过程,下面举一个右边界比较长的例子,如图3-13所示。

image

图3-13

假设现在要逆序打印节点A左子树的右边界,首先将E.R指向null,然后将右边界逆序调整成图3-14所示的样子。

image

图3-14

这样我们就可以从节点E开始,依次通过每个节点的right指针逆序打印整个左边界。在打印完B后,把右边界再逆序一次,调回来即可。

回到原来的二叉树(即图3-12),先把节点3的right指针指向null(调回来),二叉树变为图3-9所示的样子,然后将节点4左子树的右边界逆序打印(3,2),通过节点4的right指针移动到节点6。

发现节点6符合步骤2的条件②,所以,以节点6为头的子树进入步骤1进行处理,处理之后的二叉树变成图3-11所示的样子。

节点5重新来到步骤2,发现节点5符合步骤2的条件②,进入步骤1并迅速处理完,不打印节点5,而是直接通过节点5的right指针移动到节点6。

发现节点6符合步骤2的条件①,先将节点5的right指针指向null,节点6左子树的右边界只有节点5,打印节点5,然后通过节点6的right指针移动到节点7。

发现节点7符合步骤2的条件②,进入步骤1并迅速处理完,不打印节点7,通过节点7的right指针移动到null,过程结束。

至此,已经依次打印了1、3、2、5,但还没有打印7、6、4,这是因为整棵二叉树并不属于任何节点的左子树,所以,整棵树的右边界就没在上述过程中逆序打印。最后,单独逆序打印一下整棵树的右边界即可。

Morris后序遍历的具体实现请参看如下代码中的morrisPos方法。

          public void morrisPos(Node head) {
                  if (head == null) {
                          return;
                  }
                  Node cur1 = head;
                  Node cur2 = null;
                  while (cur1 ! = null) {
                          cur2 = cur1.left;
                          if (cur2 ! = null) {
                                  while (cur2.right ! = null && cur2.right ! = cur1) {
                                          cur2 = cur2.right;
                                  }
                                  if (cur2.right == null) {
                                          cur2.right = cur1;
                                          cur1 = cur1.left;
                                          continue;
                                  } else {
                                          cur2.right = null;
                                          printEdge(cur1.left);
                                  }
                          }
                          cur1 = cur1.right;
                  }
                  printEdge(head);
                  System.out.println();
          }

          public void printEdge(Node head) {
                  Node tail = reverseEdge(head);
                  Node cur = tail;
                  while (cur ! = null) {
                          System.out.print(cur.value + " ");
                          cur = cur.right;
                  }
                  reverseEdge(tail);
          }

          public Node reverseEdge(Node from) {
                  Node pre = null;
                  Node next = null;
                  while (from ! = null) {
                          next = from.right;
                          from.right = pre;
                          pre = from;
                          from = next;
                  }
                  return pre;
          }

在二叉树中找到累加和为指定值的最长路径长度

【题目】

给定一棵二叉树的头节点head和一个32位整数sum,二叉树节点值类型为整型,求累加和为sum的最长路径长度。路径是指从某个节点往下,每次最多选择一个孩子节点或者不选所形成的节点链。

例如,二叉树如图3-15所示。

image

图3-15

如果sum=6,那么累加和为6的最长路径为:-3,3,0,6,所以返回4。

如果sum=-9,那么累加和为-9的最长路径为:-9,所以返回1。

注:本题不用考虑节点值相加可能溢出的情况。

【难度】

尉 ★★☆☆

【解答】

在阅读本题的解答之前,请读者先阅读本书“求未排序数组中累加和为规定值的最长子数组长度”问题。针对二叉树,本文的解法改写了这个问题的实现。如果二叉树的节点数为N ,本文的解法可以做到时间复杂度为O (N ),额外空间复杂度为O (h ),其中,h 为二叉树的高度。

具体过程如下:

1.二叉树头节点head和规定值sum已知;生成变量maxLen,负责记录累加和等于sum的最长路径长度。

2.生成哈希表sumMap。在“求未排序数组中累加和为规定值的最长子数组长度”问题中也使用了哈希表,功能是记录数组从左到右的累加和出现情况,在遍历数组的过程中,再利用这个哈希表来求得累加和为规定值的最长子数组。sumMap也一样,它负责记录从head开始的一条路径上的累加和出现情况,累加和也是从head节点的值开始累加的。sumMap的key值代表某个累加和,value值代表这个累加和在路径中最早出现的层数。如果在遍历到cur节点的时候,我们能够知道从head到cur节点这条路径上的累加和出现情况,那么求以cur节点结尾的累加和为指定值的最长路径长度就非常容易。究竟如何去更新sumMap,才能够做到在遍历到任何一个节点的时候都能有从head到这个节点的路径上的累加和出现情况呢?步骤3详细地说明了更新过程。

3.首先在sumMap中加入一个记录(0,0),它表示累加和0不用包括任何节点就可以得到。然后按照二叉树先序遍历的方式遍历节点,遍历到的当前节点记为cur,从head到cur父节点的累加和记为preSum,cur所在的层数记为level。将cur.value+preSum的值记为curSum,就是从head到cur的累加和。如果sumMap中已经包含了curSum的记录,说明curSum在上层中已经出现过,那么就不更新sumMap;如果sumMap不包含curSum的记录,说明curSum是第一次出现,就把(curSum,level)这个记录放入sumMap。接下来是求解在必须以cur结尾的情况下,累加和为规定值的最长路径长度,详细过程这里不再详述,请读者阅读“求未排序数组中累加和为规定值的最长子数组长度”问题。然后是遍历cur左子树和右子树的过程,依然按照步骤3描述的使用和更新sumMap。以cur为头节点的子树处理完,当然要返回到cur父节点,在返回前还有一项重要的工作要做,在sumMap中查询curSum这个累加和(key)出现的层数(value),如果value等于level,说明curSum这个累加和的记录是在遍历到cur时加上去的,那就把这一条记录删除;如果value不等于level,则不做任何调整。

4.步骤3会遍历二叉树所有的节点,也会求解以每个节点结尾的情况下,累加和为规定值的最长路径长度。用maxLen记录其中的最大值即可。

全部求解过程请参看如下代码中的getMaxLength方法。

          public class Node {
                  public int value;
                  public Node left;
                  public Node right;
                  public Node(int data) {
                          this.value = data;
                  }
          }

          public int getMaxLength(Node head, int sum) {
                  HashMap<Integer, Integer> sumMap = new HashMap<Integer, Integer>();
                  sumMap.put(0, 0); // 重要
                  return preOrder(head, sum, 0, 1, 0, sumMap);
          }

          public int preOrder(Node head, int sum, int preSum, int level,
                          int maxLen, HashMap<Integer, Integer> sumMap) {
                  if (head == null) {
                          return maxLen;
                  }
                  int curSum = preSum + head.value;
                  if (! sumMap.containsKey(curSum)) {
                          sumMap.put(curSum, level);
                  }
                  if (sumMap.containsKey(curSum - sum)) {
                          maxLen = Math.max(level - sumMap.get(curSum - sum), maxLen);
                  }
                  maxLen = preOrder(head.left, sum, curSum, level + 1, maxLen, sumMap);
                  maxLen = preOrder(head.right, sum, curSum, level + 1, maxLen, sumMap);
                  if (level == sumMap.get(curSum)) {
                          sumMap.remove(curSum);
                  }
                  return maxLen;
          }

找到二叉树中的最大搜索二叉子树

【题目】

给定一棵二叉树的头节点head,已知其中所有节点的值都不一样,找到含有节点最多的搜索二叉子树,并返回这棵子树的头节点。

例如,二叉树如图3-16所示。

image

图3-16

这棵树中的最大搜索二叉子树如图3-17所示。

image

图3-17

【要求】

如果节点数为N ,要求时间复杂度为O (N ),额外空间复杂度为O (h ),h 为二叉树的高度。

【难度】

尉 ★★☆☆

【解答】

以节点node为头的树中,最大的搜索二叉子树只可能来自以下两种情况。

第一种:如果来自node左子树上的最大搜索二叉子树是以node.left为头的;来自node右子树上的最大搜索二叉子树是以node.right为头的;node左子树上的最大搜索二叉子树的最大值小于node.value; node右子树上的最大搜索二叉子树的最小值大于node.value,那么以节点node为头的整棵树都是搜索二叉树。

第二种:如果不满足第一种情况,说明以节点node为头的树整体不能连成搜索二叉树。这种情况下,以node为头的树上的最大搜索二叉子树是来自node的左子树上的最大搜索二叉子树和来自node的右子树上的最大搜索二叉子树之间,节点数较多的那个。

通过以上分析,求解的具体过程如下:

1.整体过程是二叉树的后序遍历。

2.遍历到当前节点记为cur时,先遍历cur的左子树收集4个信息,分别是左子树上最大搜索二叉子树的头节点(lBST)、节点数(lSize)、最小值(lMin)和最大值(lMax)。再遍历cur的右子树收集4个信息,分别是右子树上最大搜索二叉子树的头节点(rBST)、节点数(rSize)、最小值(rMin)和最大值(rMax)。

3.根据步骤2所收集的信息,判断是否满足第一种情况,如果满足第一种情况,就返回cur节点,如果满足第二种情况,就返回lBST和rBST中较大的一个。

4.可以使用全局变量的方式实现步骤2中收集节点数、最小值和最大值的问题。

找到最大搜索二叉子树的具体过程请参看如下代码中的biggestSubBST方法。

          public class Node {
                  public int value;
                  public Node left;
                  public Node right;
                  public Node(int data) {
                          this.value = data;
                  }
          }

          public Node biggestSubBST(Node head) {
                  int[] record = new int[3];
                  return posOrder(head, record);
          }

          public Node posOrder(Node head, int[] record) {
                  if (head == null) {
                          record[0] = 0;
                          record[1] = Integer.MAX_VALUE;
                          record[2] = Integer.MIN_VALUE;
                          return null;
                  }
                  int value = head.value;
                  Node left = head.left;
                  Node right = head.right;
                  Node lBST = posOrder(left, record);
                  int lSize = record[0];
                  int lMin = record[1];
                  int lMax = record[2];
                  Node rBST = posOrder(right, record);
                  int rSize = record[0];
                  int rMin = record[1];
                  int rMax = record[2];
                  record[1] = Math.min(lMin, value);
                  record[2] = Math.max(rMax, value);
                  if (left == lBST && right == rBST && lMax < value && value < rMin) {
                          record[0] = lSize + rSize + 1;
                          return head;
                  }
                  record[0] = Math.max(lSize, rSize);
                  return lSize > rSize ? lBST : rBST;
          }

找到二叉树中符合搜索二叉树条件的最大拓扑结构

【题目】

给定一棵二叉树的头节点head,已知所有节点的值都不一样,返回其中最大的且符合搜索二叉树条件的最大拓扑结构的大小。

例如,二叉树如图3-18所示。

image

图3-18

其中最大的且符合搜索二叉树条件的最大拓扑结构如图3-19所示。

image

图3-19

这个拓扑结构节点数为8,所以返回8。

【难度】

校 ★★★☆

【解答】

方法一:二叉树的节点数为N ,时间复杂度为O (N 2 )的方法。

首先来看这样一个问题,以节点h为头的树中,在拓扑结构中也必须以h为头的情况下,怎么找到符合搜索二叉树条件的最大结构?这个问题有一种比较容易理解的解法,我们先考查h的孩子节点,根据孩子节点的值从h开始按照二叉搜索的方式移动,如果最后能移动到同一个孩子节点上,说明这个孩子节点可以作为这个拓扑的一部分,并继续考查这个孩子节点的孩子节点,一直延伸下去。

我们以题目的例子来说明一下,假设在以12这个节点为头的子树中,要求拓扑结构也必须以12为头,如何找到最多的节点,并且整个拓扑结构是符合二叉树条件的?初始时考查的节点为12节点的左右孩子,考查队列={10,13}。

考查节点10。最开始时10和12进行比较,发现10应该往12的左边找,于是节点10被找到,节点10可以加入整个拓扑结构,同时节点10的孩子节点4和14加入考查队列,考查队列为{13,4,14}。

考查节点13。13和12进行比较,应该向右,于是节点13被找到,它可以加入整个拓扑结构,同时它的两个孩子节点20和16加入考查队列,{4,14,20,16}。

考查节点4。4和12比较,应该向左,4和10比较,继续向左,节点4被找到,可以加入整个拓扑结构。同时它的孩子节点2和5加入考查队列,为{14,20,16,2,5}。

考查节点14。14和12比较,应该向右,接下来的查找过程会一直在12的右子树上,依然会找下去,但是节点14不可能被找到。所以它不能加入整个拓扑结构,它的孩子节点也都不能,此时考查队列为{20,16,2,5}。

考查节点20。20和12比较,应该向右,20和13比较,应该向右,节点20同样再也不会被发现了,所以它不能加入整个拓扑结构,此时考查队列为{16,2,5}。

按照如上方法,最后这三个节点(16,2,5)都可以加入拓扑结构,所以我们找到了必须以12为头,且整个拓扑结构是符合二叉树条件的最大结构,这个结构的节点数为7。

也就是说,我们根据一个节点的值,根据这个值的大小,从h开始,每次向左或者向右移动,如果最后能移动到原来的节点上,说明该节点可以作为以h为头的拓扑的一部分。

解决了以节点h为头的树中,在拓扑结构也必须以h为头的情况下,怎么找到符合搜索二叉树条件的最大结构?接下来只要遍历所有的二叉树节点,并在以每个节点为头的子树中都求一遍其中的最大拓扑结构,其中最大的那个就是我们想找的结构,它的大小就是我们的返回值。

具体过程请参看如下代码中的bstTopoSize1方法。

          public class Node {
                  public int value;
                  public Node left;
                  public Node right;
                  public Node(int data) {
                          this.value = data;
                  }
          }

          public int bstTopoSize1(Node head) {
                  if (head == null) {
                          return 0;
                  }
                  int max = maxTopo(head, head);
                  max = Math.max(bstTopoSize1(head.left), max);
                  max = Math.max(bstTopoSize1(head.right), max);
                  return max;
          }

          public int maxTopo(Node h, Node n) {
                  if (h ! = null && n ! = null && isBSTNode(h, n, n.value)) {
                          return maxTopo(h, n.left) + maxTopo(h, n.right) + 1;
                  }
                  return 0;
          }

          public boolean isBSTNode(Node h, Node n, int value) {
                  if (h == null) {
                          return false;
                  }
                  if (h == n) {
                          return true;
                  }
                  return isBSTNode(h.value > value ? h.left : h.right, n, value);
          }

对于方法一的时间复杂度分析,我们把所有的子树(N 个)都找了一次最大拓扑,每找一次所考查的节点数都可能是O (N )个节点,所以方法一的时间复杂度为O (N 2 )。

方法二:二叉树的节点数为N 、时间复杂度最好为O (N )、最差为O (N logN )的方法。

先来说明一个对方法二来讲非常重要的概念——拓扑贡献记录。还是举例说明,请注意题目中以节点10为头的子树,这棵子树本身就是一棵搜索二叉树,那么整棵子树都可以作为以节点10为头的符合搜索二叉树条件的拓扑结构。如果对这个拓扑结构建立贡献记录,是如图3-20所示的样子。

image

图3-20

在图3-20中,每个节点的旁边都有被括号括起来的两个值,我们把它称为节点对当前头节点的拓扑贡献记录。第一个值代表节点的左子树可以为当前头节点的拓扑贡献几个节点,第二个值代表节点的右子树可以为当前头节点的拓扑贡献几个节点。比如4(1,1),括号中的第一个1代表节点4的左子树可以为节点10为头的拓扑结构贡献1个节点,第二个1代表节点4的右子树可以为节点10为头的拓扑结构贡献1个节点。同样,我们也可以建立以节点13为头的记录,如图3-21所示。

image

图3-21

整个方法二的核心就是如果分别得到了h左右两个孩子为头的拓扑贡献记录,可以快速得到以h为头的拓扑贡献记录。比如图3-20中每一个节点的记录都是节点对以节点10为头的拓扑结构的贡献记录,图3-21中每一个节点的记录都是节点对以节点13为头的拓扑结构的贡献记录,同时节点10和节点13分别是节点12的左孩子和右孩子。那么我们可以快速得到以节点12为头的拓扑贡献记录。在图3-20和图3-21中的所有节点的记录还没有变成节点12为头的拓扑贡献记录之前,是图3-22所示的样子。

image

图3-22

如图3-22所示,在没有变更之前,节点12左子树上所有节点的记录和原来一样,都是对节点10负责的;节点12右子树上所有节点的记录也和原来一样,都是对节点13负责的。接下来我们详细展示一下,所有节点的记录如何变更为都对节点12负责,也就是所有节点的记录都变成以节点12为头的拓扑贡献记录。

先来看节点12的左子树,只需依次考查左子树右边界上的节点即可。先考查节点10,因为节点10的值比节点12的值小,所以节点10的左子树原来能给节点10贡献多少个节点,当前就一定都能贡献给节点12,所以节点10记录的第一个值不用改变,同时节点10左子树上所有节点的记录都不用改变。接下来考查节点14,此时节点14的值比节点10要大,说明以节点14为头的整棵子树都不能成为以节点12为头的拓扑结构的左边部分,那么删掉节点14的记录,让它不作为节点12为头的拓扑结构即可,同时只要删掉节点14一条记录,就可以断开节点11和节点15的记录,让节点14的整棵子树都不成为节点12的拓扑结构。后续的右边界节点也无须考查了。进行到节点14这一步,一共删掉的节点数可以直接通过节点14的记录得到,记录为14(1,1),说明节点14的左子树1个,节点14的右子树1个,再加上节点14本身,一共有3个节点。接下来的过程是从右边界的当前节点重回节点12的过程,先回到节点10,此时节点10记录的第二个值应该被修改,因为节点10的右子树上被删掉了3个节点,所以记录由10(3,3)修改为10(3,0),根据这个修改后的记录,节点12记录的第一个值也可以确定了,节点12的左子树可以贡献4个节点,其中3个来自节点10的左子树,还有1个是节点10本身,此时记录变为图3-23所示的样子。

image

图3-23

以上过程展示了怎么把关于h左孩子的拓扑贡献记录更改为以h为头的拓扑贡献记录。为了更好地展示这个过程,我们再举一个例子,如图3-24所示。

image

图3-24

在图3-24中,假设之前已经有以节点A为头的拓扑贡献记录,现在要变更为以节点S为头的拓扑贡献记录。只用考查S左子树的右边界即可(A,B,C,D...),假设A,B,C的值都比S小,到节点D才比节点S大。那么A,B,C的左子树原来能给A的拓扑贡献多少个节点,现在就都能贡献给S,所以这三个节点记录的第一个值一律不发生变化,并且它们所有左子树上的节点记录也不用变化。而D的值比S的值大,所以删除D的记录,从而让D子树上的所有记录都和以S为头的拓扑结构断开,总共删掉的节点数为d 1+d 2+1。然后再从C回到S,沿途所有节点记录的第二个值统一减掉d 1+d 2+1。最后根据节点A改变后的记录,确定S记录的第一个值,如图3-25所示。

image

图3-25

关于怎么把h左孩子的拓扑贡献记录更改为以h为头的拓扑贡献记录的问题就解释完了。把关于h右孩子的拓扑贡献记录更改为以h为头的拓扑贡献记录与之类似,就是依次考查h右子树的左边界即可。回到以节点12为头的拓扑贡献记录问题,最后生成的整个记录如图3-26所示。

image

图3-26

当我们得到以h为头的拓扑贡献记录后,相当于求出了以h为头的最大拓扑的大小。方法二正是不断地用这种方法,从小树的记录整合成大树的记录,从而求出整棵树中符合搜索二叉树条件的最大拓扑的大小。所以,整个过程大体说来是利用二叉树的后序遍历,对每个节点来说,先生成其左孩子的记录,然后是右孩子的记录,接着把两组记录修改成以这个节点为头的拓扑贡献记录,并找出所有节点的最大拓扑大小中最大的那个。

方法二的全部过程请参看如下代码中的bstTopoSize2方法。

          public class Record {
                  public int l;
                  public int r;
                  public Record(int left, int right) {
                          this.l = left;
                          this.r = right;
                  }
          }

          public int bstTopoSize2(Node head) {
                  Map<Node, Record> map = new HashMap<Node, Record>();
                  return posOrder(head, map);
          }

          public int posOrder(Node h, Map<Node, Record> map) {
                  if (h == null) {
                          return 0;
                  }
                  int ls = posOrder(h.left, map);
                  int rs = posOrder(h.right, map);
                  modifyMap(h.left, h.value, map, true);
                  modifyMap(h.right, h.value, map, false);
                  Record lr = map.get(h.left);
                  Record rr = map.get(h.right);
                  int lbst = lr == null ? 0 : lr.l + lr.r + 1;
                  int rbst = rr == null ? 0 : rr.l + rr.r + 1;
                  map.put(h, new Record(lbst, rbst));
                  return Math.max(lbst + rbst + 1, Math.max(ls, rs));
          }

          public int modifyMap(Node n, int v, Map<Node, Record> m, boolean s) {
                  if (n == null || (! m.containsKey(n))) {
                          return 0;
                  }
                  Record r = m.get(n);
                  if ((s && n.value > v) || ((! s) && n.value < v)) {
                          m.remove(n);
                          return r.l + r.r + 1;
                  } else {
                          int minus = modifyMap(s ? n.right : n.left, v, m, s);
                          if (s) {
                                  r.r = r.r - minus;
                          } else {
                                  r.l = r.l - minus;
                          }
                          m.put(n, r);
                          return minus;
                  }
          }

对于方法二的时间复杂度分析,如果二叉树类似棒状结构,即每一个非叶节点只有左子树或只有右子树,如图3-27所示。

image

图3-27

在图3-27的二叉树中,假设节点a到节点c的若干节点只有右子树记为区域A,从节点d到节点f的若干节点只有左子树记为区域B,从节点g到节点i的若干节点只有右子树记为区域C,从节点j到节点k的若干节点又只有左子树记为区域D。如果二叉树是这种形状,并且整棵二叉树都符合搜索二叉树条件,现在我们分析一下在方法二的整个过程中将走过多少个节点。

区域D:区域D的每个节点在生成自己的记录时,只有左子树记录,同时自己左子树的右边界只有自己的左孩子。所以对区域D的所有节点来说,每一个节点都只检查一个节点,就是自己的左孩子,所以走过节点的总数量就是区域D的节点数,记为numD。

区域C:在区域C中的节点i很特殊,这个节点右子树的左边界是区域D的全部节点,全部都要走一遍,数量为numD。除这个节点外,区域C中的其他节点又是只走过一个节点,是自己的右孩子,走过节点的总数量相当于C区域的节点数,记为numC。处理区域C时走过的总数量为numD+numC。

区域B同理,总数量为numB+numC。

区域A同理,总数量为numA+numB。

所以,如果二叉树的节点数为N ,那么整个过程走过的节点数大致为2N ,时间复杂度为O (N )。这是方法二最好的情况,也就是二叉树趋近于棒状结构的时候。

如果二叉树是满二叉树结构,即每一个非节点左子树和右子树全都有,如图3-28所示。

image

图3-28

图3-28的二叉树为一棵满二叉树结构,层数为5。

第1层的节点数量为1,第1层的节点在生成记录时左子树的右边界节点数为4,右子树的左边界节点数为4,总共走过8个节点。

第2层的节点数量为2,第2层每个节点在生成记录时左子树的右边界节点数为3,右子树的左边界节点数为3,总共走过12个节点。

第3层的节点数量为4,第3层每个节点在生成记录时左子树的右边界节点数为2,右子树的左边界节点数为2,总共走过16个节点。

……

我们做一下扩展,如果一棵满二叉树,层数为l

第1层的节点数量为l ,第1层的节点在生成记录时左子树的右边界节点数为l -1,右子树的左边界节点数为l -1,总共走过2(l -1)个节点。

第2层的节点数量为2,第2层的节点在生成记录时左子树的右边界节点数为l -2,右子树的左边界节点数为l -2,总共走过2×2×(l -1)个节点。

……

i 层的节点数量为2i- 1 ,第i层的节点在生成记录时左子树的右边界节点数为l-i ,右子树的左边界节点数为l -i ,总共走过2i- 1 ×2×(l -i )=2i (l -i )个节点。

……

所以全部层的所有节点走过的节点数为:

image

在满二叉树中,l -> O (logN ),2l -> N ,所以走过的节点总数为O (N logN )。

二叉树越趋近于棒状结构,方法二的时间复杂度越低,也越趋近于O (N );二叉树越趋近于满二叉树结构,方法二的时间复杂度越高,但最差也仅仅是O (N logN )。

方法二的详细证明略。

二叉树的按层打印与ZigZag打印

【题目】

给定一棵二叉树的头节点head,分别实现按层打印和ZigZag打印二叉树的函数。

例如,二叉树如图3-29所示。

image

图3-29

按层打印时,输出格式必须如下:

        Level 1 : 1
        Level 2 : 2 3
        Level 3 : 4 5 6
        Level 4 : 7 8

ZigZag打印时,输出格式必须如下:

        Level 1 from left to right: 1
        Level 2 from right to left: 3 2
        Level 3 from left to right: 4 5 6
        Level 4 from right to left: 8 7

【难度】

尉 ★★☆☆

【解答】

● 按层打印的实现。

按层打印原本是十分基础的内容,对二叉树做简单的宽度优先遍历即可,但本题确有额外的要求,那就是同一层的节点必须打印在一行上,并且要求输出行号。这就需要我们在原来宽度优先遍历的基础上做一些改进。所以关键问题是如何知道该换行。只需要用两个node类型的变量last和nLast就可以解决这个问题,last变量表示正在打印的当前行的最右节点,nLast表示下一行的最右节点。假设我们每一层都做从左到右的宽度优先遍历,如果发现遍历到的节点等于last,说明该换行了。换行之后只要令last=nLast,就可以继续下一行的打印过程,此过程重复,直到所有的节点都打印完。那么问题就变成了如何更新nLast?只需要让nLast一直跟踪记录宽度优先队列中的最新加入的节点即可。这是因为最新加入队列的节点一定是目前已经发现的下一行的最右节点。所以在当前行打印完时,nLast一定是下一行所有节点中的最右节点。接下来结合题目的例子来说明整个过程。

开始时,last=节点1,nLast=null,把节点1放入队列queue,遍历开始,queue={1}。

从queue中弹出节点1并打印,然后把节点1的孩子依次放入queue,放入节点2时,nLast=节点2,放入节点3时,nLast=节点3,此时发现弹出的节点1==last。所以换行,并令last=nLast=节点3,queue={2,3}。

从queue中弹出节点2并打印,然后把节点2的孩子放入queue,放入节点4时,nLast=节点4,queue={3,4}。

从queue中弹出节点3并打印,然后把节点3的孩子放入queue,放入节点5时,nLast=节点5,放入节点6时,nLast=节点6,此时发现弹出的节点3==last。所以换行,并令last=nLast=节点6,queue={4,5,6}。

从queue中弹出节点4并打印,节点4没有孩子,所以不放入任何节点,nLast也不更新。

从queue中弹出节点5并打印,然后把节点5的孩子依次放入queue,放入节点7时,nLast=节点7,放入节点8时,nLast=节点8,queue={6,7,8}。

从queue中弹出节点6并打印,节点6没有孩子,所以不放入任何节点,nLast也不更新,此时发现弹出的节点6==last。所以换行,并令last=nLast=节点8,queue={7,8}。

用同样的判断过程打印节点7和节点8,整个过程结束。

按层打印的详细过程请参看如下代码中的printByLevel方法。

          public class Node {
                  public int value;
                  public Node left;
                  public Node right;
                  public Node(int data) {
                          this.value = data;
                  }
          }

          public void printByLevel(Node head) {
                  if (head == null) {
                          return;
                  }
                  Queue<Node> queue = new LinkedList<Node>();
                  int level = 1;
                  Node last = head;
                  Node nLast = null;
                  queue.offer(head);
                  System.out.print("Level " + (level++) + " : ");
                  while (! queue.isEmpty()) {
                          head = queue.poll();
                          System.out.print(head.value + " ");
                          if (head.left ! = null) {
                                  queue.offer(head.left);
                                  nLast = head.left;
                          }
                          if (head.right ! = null) {
                                  queue.offer(head.right);
                                  nLast = head.right;
                          }
                          if (head == last && ! queue.isEmpty()) {
                                  System.out.print("\nLevel " + (level++) + " : ");
                                  last = nLast;
                          }
                  }
                  System.out.println();
          }

● ZigZag打印的实现。

先简单介绍一种不推荐的方法,即使用ArrayList结构的方法。两个ArrayList结构记为list1和list2,用list1去收集当前层的节点,然后从左到右打印当前层,接着把当前层的孩子节点放进list2,并从右到左打印,接下来再把list2的所有节点的孩子节点放入list1,如此反复。不推荐的原因是ArrayList结构为动态数组,在这个结构中,当元素数量到一定规模时将发生扩容操作,扩容操作的时间复杂度为O (N )是比较高的,这个结构增加和删除元素的时间复杂度也较高。总之,用这个结构对本题来讲数据结构不够纯粹和干净,如果读者不充分理解这个结构的底层实现,最好不要使用,而且还需要两个ArrayList结构。

本书提供的方法只使用了一个双端队列,具体为Java中的LinkedList结构,这个结构的底层实现就是非常纯粹的双端队列结构,本书的方法也仅使用双端队列结构的基本操作。

先举题目的例子来展示大体过程,首先生成双端队列结构dq,将节点1从dq的头部放入dq。

原则1:如果是从左到右的过程,那么一律从dq的头部弹出节点,如果弹出的节点没有孩子节点,当然不用放入任何节点到dq中;如果当前节点有孩子节点,先让左孩子从尾部进入dq,再让右孩子从尾部进入dq。

根据原则1,先从dq头部弹出节点1并打印,然后先让节点2从dq尾部进入,再让节点3从dq尾部进入,如图3-30所示。

image

图3-30

原则2:如果是从右到左的过程,那么一律从dq的尾部弹出节点,如果弹出的节点没有孩子节点,当然不用放入任何节点到dq中;如果当前节点有孩子节点,先让右孩子从头部进入dq,再让左孩子从头部进入dq。

根据原则2,先从dq尾部弹出节点3并打印,然后先让节点6从dq头部进入,再让节点5从dq头部进入,如图3-31所示。

image

图3-31

根据原则2,先从dq尾部弹出节点2并打印,然后让节点4从dq头部进入,如图3-32所示。

image

图3-22

根据原则1,依次从dq头部弹出节点4、5、6并打印,这期间先让节点7从dq尾部进入,再让节点8从dq尾部进入,如图3-33所示。

image

图3-33

最后根据原则2,依次从dq尾部弹出节点8和7并打印即可。

用原则1和原则2的过程切换,我们可以完成ZigZag的打印过程,所以现在只剩一个问题,如何确定切换原则1和原则2的时机,其实还是如何确定每一层最后一个节点的问题。

在ZigZag的打印过程中,下一层最后打印的节点是当前层有孩子的节点中最先进入dq的节点。比如,处理第1层的第1个有孩子的节点,也就是节点1时,节点1的左孩子节点2最先进的dq,那么节点2就是下一层打印时的最后一个节点。处理第2层的第一个有孩子的节点,也就是节点3时,节点3的右孩子节点6最先进的dq,那么节点6就是下一层打印时的最后一个节点。处理第3层的第一个有孩子的节点,也就是节点5时,节点5的左孩子节点7最先进的dq,那么节点7就是下一层打印时的最后一个节点。

ZigZag打印的全部过程请参看如下代码中的printByZigZag方法。

          public void printByZigZag(Node head) {
                  if (head == null) {
                          return;
                  }
                  Deque<Node> dq = new LinkedList<Node>();
                  int level = 1;
                  boolean lr = true;
                  Node last = head;
                  Node nLast = null;
                  dq.offerFirst(head);
                  pringLevelAndOrientation(level++, lr);
                  while (! dq.isEmpty()) {
                          if (lr) {
                                  head = dq.pollFirst();
                                  if (head.left ! = null) {
                                          nLast = nLast == null ? head.left : nLast;
                                          dq.offerLast(head.left);
                                  }
                                  if (head.right ! = null) {
                                          nLast = nLast == null ? head.right : nLast;
                                          dq.offerLast(head.right);
                                  }
                          } else {
                                  head = dq.pollLast();
                                  if (head.right ! = null) {
                                          nLast = nLast == null ? head.right : nLast;
                                          dq.offerFirst(head.right);
                                  }
                                  if (head.left ! = null) {
                                          nLast = nLast == null ? head.left : nLast;
                                          dq.offerFirst(head.left);
                                  }
                          }
                          System.out.print(head.value + " ");
                          if (head == last && ! dq.isEmpty()) {
                                  lr = ! lr;
                                  last = nLast;
                                  nLast = null;
                                  System.out.println();
                                  pringLevelAndOrientation(level++, lr);
                          }
                  }
                  System.out.println();
          }

          public void pringLevelAndOrientation(int level, boolean lr) {
                  System.out.print("Level " + level + " from ");
                  System.out.print(lr ? "left to right: " : "right to left: ");
          }

调整搜索二叉树中两个错误的节点

【题目】

一棵二叉树原本是搜索二叉树,但是其中有两个节点调换了位置,使得这棵二叉树不再是搜索二叉树,请找到这两个错误节点并返回。已知二叉树中所有节点的值都不一样,给定二叉树的头节点head,返回一个长度为2的二叉树节点类型的数组errs,errs[0]表示一个错误节点,errs[1]表示另一个错误节点。

进阶:如果在原问题中得到了这两个错误节点,我们当然可以通过交换两个节点的节点值的方式让整棵二叉树重新成为搜索二叉树。但现在要求你不能这么做,而是在结构上完全交换两个节点的位置,请实现调整的函数。

【难度】

原问题:尉 ★★☆☆

进阶问题:将 ★★★★

【解答】

原问题——找到这两个错误节点。如果对所有的节点值都不一样的搜索二叉树进行中序遍历,那么出现的节点值会一直升序,所以,如果有两个节点位置错了,就一定会出现降序。

如果在中序遍历时节点值出现了两次降序,第一个错误的节点为第一次降序时较大的节点,第二个错误的节点为第二次降序时较小的节点。

比如,原来的搜索二叉树在中序遍历时的节点值依次出现{1,2,3,4,5},如果因为两个节点位置错了而出现{1,5,3,4,2},第一次降序为5->3,所以第一个错误节点为5,第二次降序为4->2,所以第二个错误节点为2,把5和2换过来就可以恢复。

如果在中序遍历时节点值只出现了一次降序,第一个错误的节点为这次降序时较大的节点,第二个错误的节点为这次降序时较小的节点。

比如,原来的搜索二叉树在中序遍历时节点值依次出现{1,2,3,4,5},如果因为两个节点位置错了而出现{1,2,4,3,5},只有一次降序为4->3,所以第一个错误节点为4,第二个错误节点为3,把4和3换过来就可以恢复。

寻找两个错误节点的过程可以总结为:第一个错误节点为第一次降序时较大的节点,第二个错误节点为最后一次降序时较小的节点。

所以,只要改写一个基本的中序遍历,就可以完成原问题的要求,改写递归、非递归或者Morris遍历都可以。

找到两个错误节点的过程请参看如下代码中的getTwoErrNodes方法。

          public class Node {
                  public int value;
                  public Node left;
                  public Node right;
                  public Node(int data) {
                          this.value = data;
                  }
          }

          public Node[] getTwoErrNodes(Node head) {
                  Node[] errs = new Node[2];
                  if (head == null) {
                          return errs;
                  }
                  Stack<Node> stack = new Stack<Node>();
                  Node pre = null;
                  while (! stack.isEmpty() || head ! = null) {
                          if (head ! = null) {
                                  stack.push(head);
                                  head = head.left;
                          } else {
                                  head = stack.pop();
                                  if (pre ! = null && pre.value > head.value) {
                                          errs[0] = errs[0] == null ? pre : errs[0];
                                          errs[1] = head;
                                  }
                                  pre = head;
                                  head = head.right;
                          }
                  }
                  return errs;
          }

进阶问题——在结构上交换这两个错误节点。若要在结构上交换两个错误节点,首先应该找到两个错误节点各自的父节点,随便改写一个二叉树的遍历即可。

找到两个错误节点各自父节点的过程请参看如下代码中的getTwoErrParents方法,该方法返回长度为2的Node类型的数组parents,parents[0]表示第一个错误节点的父节点,parents[1]表示第二个错误节点的父节点。

          public Node[] getTwoErrParents(Node head, Node e1, Node e2) {
                  Node[] parents = new Node[2];
                  if (head == null) {
                          return parents;
                  }
                  Stack<Node> stack = new Stack<Node>();
                  while (! stack.isEmpty() || head ! = null) {
                          if (head ! = null) {
                                  stack.push(head);
                                  head = head.left;
                          } else {
                                  head = stack.pop();
                                  if (head.left == e1 || head.right == e1) {
                                          parents[0] = head;
                                  }
                                  if (head.left == e2 || head.right == e2) {
                                          parents[1] = head;
                                  }
                                  head = head.right;
                          }
                  }
                  return parents;
          }

找到两个错误节点的父节点之后,第一个错误节点记为e1,e1的父节点记为e1P,e1的左孩子记为e1L,e1的右孩子记为e1R。第二个错误节点记为e2,e2的父节点记为e2P,e2的左孩子记为e2L,e2的右孩子记为e2R。

在结构上交换两个节点,实际上就是把两个节点互换环境。粗略地说,就是让e2成为e1P的孩子节点,让e1L和e1R成为e2的孩子节点;让e1成为e2P的孩子节点,让e2L和e2R成为e1的孩子节点。但这只是粗略的理解,在实际交换的过程中有很多情况需要我们做特殊处理。比如,如果e1是头节点,意味着e1P为null,那么让e2成为e1P的孩子节点时,关于e1P的任何left指针或right指针操作都会发生错误,因为e1P为null根本没有Node类型节点的结构。再如,如果e1本身就是e2的左孩子,即e1==e2L,那么让e2L成为e1的左孩子时,e1的left指针将指向e2L,将会指向自己,这会让整棵二叉树发生严重的结构错误。

换句话说,我们必须理清楚e1及其上下环境之间的关系、e2及其上下环境之间的关系,以及两个环境之间是否有联系。有以下三个问题和一个特别注意是必须关注的。

问题一:e1和e2是否有一个是头节点?如果有,谁是头?

问题二:e1和e2是否相邻?如果相邻,谁是谁的父节点?

问题三:e1和e2分别是各自父节点的左孩子还是右孩子?

特别注意:因为是在中序遍历时先找到e1,后找到e2,所以e1一定不是e2的右孩子,e2也一定不是e1的左孩子。

以上三个问题与特别注意之间相互影响,情况非常复杂。经过仔细整理,情况共有14种,每一种情况在调整e1和e2各自的拓扑关系时都有特殊处理。

1.e1是头,e1是e2的父,此时e2只可能是e1的右孩子。

2.e1是头,e1不是e2的父,e2是e2P的左孩子。

3.e1是头,e1不是e2的父,e2是e2P的右孩子。

4.e2是头,e2是e1的父,此时e1只可能是e2的左孩子。

5.e2是头,e2不是e1的父,e1是e1P的左孩子。

6.e2是头,e2不是e1的父,e1是e1P的右孩子。

7.e1和e2都不是头,e1是e2的父,此时e2只可能是e1的右孩子,e1是e1P的左孩子。

8.e1和e2都不是头,e1是e2的父,此时e2只可能是e1的右孩子,e1是e1P的右孩子。

9.e1和e2都不是头,e2是e1的父,此时e1只可能是e2的左孩子,e2是e2P的左孩子。

10.e1和e2都不是头,e2是e1的父,此时e1只可能是e2的左孩子,e2是e2P的右孩子。

11.e1和e2都不是头,谁也不是谁的父节点,e1是e1P的左孩子,e2是e2P的左孩子。

12.e1和e2都不是头,谁也不是谁的父节点,e1是e1P的左孩子,e2是e2P的右孩子。

13.e1和e2都不是头,谁也不是谁的父节点,e1是e1P的右孩子,e2是e2P的左孩子。

14.e1和e2都不是头,谁也不是谁的父节点,e1是e1P的右孩子,e2是e2P的右孩子。

当情况1至情况3发生时,二叉树新的头节点应该为e2,当情况4至情况6发生时,二叉树新的头节点应该为e1,其他情况发生时,二叉树的头节点不用发生变化。

从结构上调整两个错误节点的全部过程请参看如下代码中的recoverTree方法。

          public Node recoverTree(Node head) {
                  Node[] errs = getTwoErrNodes(head);
                  Node[] parents = getTwoErrParents(head, errs[0], errs[1]);
                  Node e1 = errs[0];
                  Node e1P = parents[0];
                  Node e1L = e1.left;
                  Node e1R = e1.right;
                  Node e2 = errs[1];
                  Node e2P = parents[1];
                  Node e2L = e2.left;
                  Node e2R = e2.right;
                  if (e1 == head) {
                          if (e1 == e2P) { // 情况1
                                  e1.left = e2L;
                                  e1.right = e2R;
                                  e2.right = e1;
                                  e2.left = e1L;
                          } else if (e2P.left == e2) { // 情况2
                                  e2P.left = e1;
                                  e2.left = e1L;
                                  e2.right = e1R;
                                  e1.left = e2L;
                                  e1.right = e2R;
                          } else { // 情况3
                                  e2P.right = e1;
                                  e2.left = e1L;
                                  e2.right = e1R;
                                  e1.left = e2L;
                                  e1.right = e2R;
                          }
                          head = e2;
                  } else if (e2 == head) {
                          if (e2 == e1P) { // 情况4
                                  e2.left = e1L;
                                  e2.right = e1R;
                                  e1.left = e2;
                                  e1.right = e2R;
                          } else if (e1P.left == e1) { // 情况5
                                  e1P.left = e2;
                                  e1.left = e2L;
                                  e1.right = e2R;
                                  e2.left = e1L;
                                  e2.right = e1R;
                          } else { // 情况6
                                  e1P.right = e2;
                                  e1.left = e2L;
                                  e1.right = e2R;
                                  e2.left = e1L;
                                  e2.right = e1R;
                          }
                          head = e1;
                  } else {
                          if (e1 == e2P) {
                                  if (e1P.left == e1) { // 情况7
                                          e1P.left = e2;
                                          e1.left = e2L;
                                          e1.right = e2R;
                                          e2.left = e1L;
                                          e2.right = e1;
                                  } else { // 情况8
                                          e1P.right = e2;
                                          e1.left = e2L;
                                          e1.right = e2R;
                                          e2.left = e1L;
                                          e2.right = e1;
                                  }
                          } else if (e2 == e1P) {
                                  if (e2P.left == e2) { // 情况9
                                          e2P.left = e1;
                                          e2.left = e1L;
                                          e2.right = e1R;
                                          e1.left = e2;
                                          e1.right = e2R;
                                  } else { // 情况10
                                          e2P.right = e1;
                                          e2.left = e1L;
                                          e2.right = e1R;
                                          e1.left = e2;
                                          e1.right = e2R;
                                  }
                          } else {
                                  if (e1P.left == e1) {
                                          if (e2P.left == e2) { // 情况11
                                                  e1.left = e2L;
                                                  e1.right = e2R;
                                                  e2.left = e1L;
                                                  e2.right = e1R;
                                                  e1P.left = e2;
                                                  e2P.left = e1;
                                          } else { // 情况12
                                                  e1.left = e2L;
                                                  e1.right = e2R;
                                                  e2.left = e1L;
                                                  e2.right = e1R;
                                                  e1P.left = e2;
                                                  e2P.right = e1;
                                          }
                                  } else {
                                          if (e2P.left == e2) { // 情况13
                                                  e1.left = e2L;
                                                  e1.right = e2R;
                                                  e2.left = e1L;
                                                  e2.right = e1R;
                                                  e1P.right = e2;
                                                  e2P.left = e1;
                                          } else { // 情况14
                                                  e1.left = e2L;
                                                  e1.right = e2R;
                                                  e2.left = e1L;
                                                  e2.right = e1R;
                                                  e1P.right = e2;
                                                  e2P.right = e1;
                                          }
                                  }
                          }
                  }
                  return head;
          }

判断t1树是否包含t2树全部的拓扑结构

【题目】

给定彼此独立的两棵树头节点分别为t1和t2,判断t1树是否包含t2树全部的拓扑结构。

例如,图3-34所示的t1树和图3-35所示的t2树。

image

图3-34

image

图3-35

t1树包含t2树全部的拓扑结构,所以返回true。

【难度】

士 ★☆☆☆

【解答】

如果t1中某棵子树头节点的值与t2头节点的值一样,则从这两个头节点开始匹配,匹配的每一步都让t1上的节点跟着t2的先序遍历移动,每移动一步,都检查t1的当前节点是否与t2当前节点的值一样。比如,题目中的例子,t1中的节点2与t2中的节点2匹配,然后t1跟着t2向左,发现t1中的节点4与t2中的节点4匹配,t1跟着t2继续向左,发现t1中的节点8与t2中的节点8匹配,此时t2回到t2中的节点2,t1也回到t1中的节点2,然后t1跟着t2向右,发现t1中的节点5与t2中的节点5匹配。t2匹配完毕,结果返回true。如果匹配的过程中发现有不匹配的情况,直接返回false,说明t1的当前子树从头节点开始,无法与t2匹配,那么再去寻找t1的下一棵子树。t1的每棵子树上都有可能匹配出t2,所以都要检查一遍。

所以,如果t1的节点数为N ,t2的节点数为M ,该方法的时间复杂度为O (N ×M )。

具体过程请参看如下代码中的contains方法,

          public class Node {
                  public int value;
                  public Node left;
                  public Node right;
                  public Node(int data) {
                          this.value = data;
                  }
          }

          public boolean contains(Node t1, Node t2) {
                  return check(t1, t2) || contains(t1.left, t2) || contains(t1.right, t2);
          }

          public boolean check(Node h, Node t2) {
                  if (t2 == null) {
                          return true;
                  }
                  if (h == null || h.value ! = t2.value) {
                          return false;
                  }
                  return check(h.left, t2.left) && check(h.right, t2.right);
          }

判断t1树中是否有与t2树拓扑结构完全相同的子树

【题目】

给定彼此独立的两棵树头节点分别为t1和t2,判断t1中是否有与t2树拓扑结构完全相同的子树。

例如,图3-36所示的t1树和图3-37所示t2树。

image

图3-36

image

图3-37

t1树有与t2树拓扑结构完全相同的子树,所以返回true。但如果t1树和t2树分别如图3-38和图3-39所示,则t1树就没有与t2树拓扑结构完全相同的子树,所以返回false。

image

图3-38

image

图3-39

【难度】

校 ★★★☆

【解答】

如果t1的节点数为N ,t2的节点数为M ,本题最优解是时间复杂度为O (N +M )的方法。先简单介绍一个时间复杂度为O (N ×M )的方法,对于t1的每棵子树,都去判断是否与t2树的拓扑结构完全一样,这个过程的复杂度为O (M ),t1的子树一共有N 棵,所以时间复杂度为O (N ×M ),这种方法本书不再详述。

下面重点介绍一下时间复杂度为O (N +M )的方法,首先是把t1树和t2树按照先序遍历的方式序列化,关于这个内容,请阅读本书“二叉树的序列化和反序列化”问题。以题目的例子来说,t1树序列化后的结果为“1!2!4! #!8! #! #!5!9! #! #! #!3!6! #! #!7! #! #! ”,记为t1Str。t2树序列化后的结果为“2!4! #!8! #! #!5!9! #! #! #! ”,记为t2Str。接下来只要验证t2Str是否是t1Str的子串即可,这个用KMP算法可以在线性时间内解决。所以t1序列化的过程为O (N ),t2序列化的过程为O (M ),KMP解决t1Str和t2Str的匹配问题O (M +N ),所以时间复杂度为O (M +N )。有关KMP算法的内容,请读者阅读本书“KMP算法”问题,关于这个算法非常清晰的解释,这里不再详述。

本题最优解的全部过程请参看如下代码中的isSubtree方法。

          public boolean isSubtree(Node t1, Node t2) {
                  String t1Str = serialByPre(t1);
                  String t2Str = serialByPre(t2);
                  return getIndexOf(t1Str, t2Str) ! = -1;
          }

          public String serialByPre(Node head) {
                  if (head == null) {
                          return "#! ";
                  }
                  String res = head.value + "! ";
                  res += serialByPre(head.left);
                  res += serialByPre(head.right);
                  return res;
          }
          // KMP

          public int getIndexOf(String s, String m) {
                  if (s == null || m == null || m.length() < 1 || s.length() < m.length())
                  {
                          return -1;
                  }
                  char[] ss = s.toCharArray();
                  char[] ms = m.toCharArray();
                  int si = 0;
                  int mi = 0;
                  int[] next = getNextArray(ms);
                  while (si < ss.length && mi < ms.length) {
                          if (ss[si] == ms[mi]) {
                                  si++;
                                  mi++;
                          } else if (next[mi] == -1) {
                                  si++;
                          } else {
                                  mi = next[mi];
                          }
                  }
                  return mi == ms.length ? si - mi : -1;
          }

          public int[] getNextArray(char[] ms) {
                  if (ms.length == 1) {
                          return new int[] { -1 };
                  }
                  int[] next = new int[ms.length];
                  next[0] = -1;
                  next[1] = 0;
                  int pos = 2;
                  int cn = 0;
                  while (pos < next.length) {
                          if (ms[pos - 1] == ms[cn]) {
                                  next[pos++] = ++cn;
                          } else if (cn > 0) {
                                  cn = next[cn];
                          } else {
                                  next[pos++] = 0;
                          }
                  }
                  return next;
          }

判断二叉树是否为平衡二叉树

【题目】

平衡二叉树的性质为:要么是一棵空树,要么任何一个节点的左右子树高度差的绝对值不超过1。给定一棵二叉树的头节点head,判断这棵二叉树是否为平衡二叉树。

【要求】

如果二叉树的节点数为N ,要求时间复杂度为O (N )。

【难度】

士 ★☆☆☆

【解答】

解法的整体过程为二叉树的后序遍历,对任何一个节点node来说,先遍历node的左子树,遍历过程中收集两个信息,node的左子树是否为平衡二叉树,node的左子树最深到哪一层记为lH。如果发现node的左子树不是平衡二叉树,无须进行任何后续过程,此时返回什么已不重要,因为已经发现整棵树不是平衡二叉树,退出遍历过程;如果node的左子树是平衡二叉树,再遍历node的右子树,遍历过程中再收集两个信息,node的右子树是否为平衡二叉树,node的右子树最深到哪一层记为rH。如果发现node的右子树不是平衡二叉树,无须进行任何后续过程,返回什么也不重要,因为已经发现整棵树不是平衡二叉树,退出遍历过程;如果node的右子树也是平衡二叉树,就看lH和rH差的绝对值是否大于1,如果大于1,说明已经发现整棵树不是平衡二叉树,如果不大于1,则返回lH和rH较大的一个。

判断的全部过程请参看如下代码中的isBalance方法。在递归函数getHeight中,一旦发现不符合平衡二叉树的性质,递归过程会迅速退出,此时返回什么根本不重要。boolean[] res长度为1,其功能相当于一个全局的boolean变量。

          public boolean isBalance(Node head) {
                  boolean[] res = new boolean[1];
                  res[0] = true;
                  getHeight(head, 1, res);
                  return res[0];
          }

          public int getHeight(Node head, int level, boolean[] res) {
                  if (head == null) {
                          return level;
                  }
                  int lH = getHeight(head.left, level + 1, res);
                  if (! res[0]) {
                          return level;
                  }
                  int rH = getHeight(head.right, level + 1, res);
                  if (! res[0]) {
                          return level;
                  }
                  if (Math.abs(lH - rH) > 1) {
                          res[0] = false;
                  }
                  return Math.max(lH, rH);
          }

整个后序遍历的过程中,每个节点最多遍历一次,如果中途发现不满足平衡二叉树的性质,整个过程会迅速退出,没遍历到的节点也不用遍历了,所以时间复杂度为O (N )。

根据后序数组重建搜索二叉树

【题目】

给定一个整型数组arr,已知其中没有重复值,判断arr是否可能是节点值类型为整型的搜索二叉树后序遍历的结果。

进阶:如果整型数组arr中没有重复值,且已知是一棵搜索二叉树的后序遍历结果,通过数组arr重构二叉树。

【难度】

士 ★☆☆☆

【解答】

原问题的解法。二叉树的后序遍历为先左、再右、最后根的顺序,所以,如果一个数组是二叉树后序遍历的结果,那么头节点的值一定会是数组的最后一个元素。搜索二叉树的性质,所以比后序数组最后一个元素值小的数组会在数组的左边,比数组最后一个元素值大的数组会在数组的右边。比如arr=[2,1,3,6,5,7,4],比4小的部分为[2,1,3],比4大的部分为[6,5,7]。如果不满足这种情况,说明这个数组一定不可能是搜索二叉树后序遍历的结果。接下来数组划分成左边数组和右边数组,相当于二叉树分出了左子树和右子树,只要递归地进行如上判断即可。

具体过程请参看如下代码中的isPostArray方法。

          public boolean isPostArray(int[] arr) {
                  if (arr == null || arr.length == 0) {
                          return false;
                  }
                  return isPost(arr, 0, arr.length - 1);
          }

          public boolean isPost(int[] arr, int start, int end) {
                  if (start == end) {
                          return true;
                  }
                  int less = -1;
                  int more = end;
                  for (int i = start; i < end; i++) {
                          if (arr[end] > arr[i]) {
                                  less = i;
                          } else {
                                  more = more == end ? i : more;
                          }
                  }
                  if (less == -1 || more == end) {
                          return isPost(arr, start, end - 1);
                  }
                  if (less ! = more - 1) {
                          return false;
                  }
                  return isPost(arr, start, less) && isPost(arr, more, end - 1);
          }

进阶问题的分析与原问题同理,一棵树的后序数组中最后一个值为二叉树头节点的值,数组左部分都比头节点的值小,用来生成头节点的左子树,剩下的部分用来生成右子树。

具体过程请参看如下代码中的posArrayToBST方法。

          public class Node {

                  public int value;

                  public Node left;

                  public Node right;

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

          public Node posArrayToBST(int[] posArr) {
                  if (posArr == null) {
                          return null;
                  }
                  return posToBST(posArr, 0, posArr.length - 1);
          }

          public Node posToBST(int[] posArr, int start, int end) {
                  if (start > end) {
                          return null;
                  }
                  Node head = new Node(posArr[end]);
                  int less = -1;
                  int more = end;
                  for (int i = start; i < end; i++) {
                          if (posArr[end] > posArr[i]) {
                                  less = i;
                          } else {
                                  more = more == end ? i : more;
                          }
                  }
                  head.left = posToBST(posArr, start, less);
                  head.right = posToBST(posArr, more, end - 1);
                  return head;
          }

判断一棵二叉树是否为搜索二叉树和完全二叉树

【题目】

给定一个二叉树的头节点head,已知其中没有重复值的节点,实现两个函数分别判断这棵二叉树是否是搜索二叉树和完全二叉树。

【难度】

士 ★☆☆☆

【解答】

判断一棵二叉树是否是搜索二叉树,只要改写一个二叉树中序遍历,在遍历的过程中看节点值是否都是递增的即可。本书改写的是Morris中序遍历,所以时间复杂度为O (N ),额外空间复杂度为O (1)。有关Morris中序遍历的介绍,请读者阅读本书“遍历二叉树的神级方法”问题。需要注意的是,Morris遍历分调整二叉树结构和恢复二叉树结构两个阶段,所以,当发现节点值降序时,不能直接返回false,这么做可能会跳过恢复阶段,从而破坏二叉树的结构。

通过改写Morris中序遍历来判断搜索二叉树的过程请参看如下代码中的isBST方法。

          public class Node {
                  public int value;
                  public Node left;
                  public Node right;
                  public Node(int data) {
                          this.value = data;
                  }
          }

          public boolean isBST(Node head) {
                  if (head == null) {
                          return true;
                  }
                  boolean res = true;
                  Node pre = null;
                  Node cur1 = head;
                  Node cur2 = null;
                  while (cur1 ! = null) {
                          cur2 = cur1.left;
                          if (cur2 ! = null) {
                                  while (cur2.right ! = null && cur2.right ! = cur1) {
                                          cur2 = cur2.right;
                                  }
                                  if (cur2.right == null) {
                                          cur2.right = cur1;
                                          cur1 = cur1.left;
                                          continue;
                                  } else {
                                          cur2.right = null;
                                  }
                          }
                          if (pre ! = null && pre.value > cur1.value) {
                                  res = false;
                          }
                          pre = cur1;
                          cur1 = cur1.right;
                  }
                  return res;
          }

判断一棵二叉树是否是完全二叉树,依据以下标准会使判断过程变得简单且易实现:

1.按层遍历二叉树,从每层的左边向右边依次遍历所有的节点。

2.如果当前节点有右孩子,但没有左孩子,直接返回false。

3.如果当前节点并不是左右孩子全有,那之后的节点必须都为叶节点,否则返回false。

4.遍历过程中如果不返回false,遍历结束后返回true。

判断是否是完全二叉树的全部过程请参看如下代码中的isCBT方法。

          public boolean isCBT(Node head) {
                  if (head == null) {
                          return true;
                  }
                  Queue<Node> queue = new LinkedList<Node>();
                  boolean leaf = false;
                  Node l = null;
                  Node r = null;
                  queue.offer(head);
                  while (! queue.isEmpty()) {
                          head = queue.poll();
                          l = head.left;
                          r = head.right;
                          if ((leaf&&(l! =null||r! =null)) || (l==null&&r! =null)) {
                                  return false;
                          }
                          if (l ! = null) {
                                  queue.offer(l);
                          }
                          if (r ! = null) {
                                  queue.offer(r);
                          } else {
                                  leaf = true;
                          }
                  }
                  return true;
          }

通过有序数组生成平衡搜索二叉树

【题目】

给定一个有序数组sortArr,已知其中没有重复值,用这个有序数组生成一棵平衡搜索二叉树,并且该搜索二叉树中序遍历的结果与sortArr一致。

【难度】

士 ★☆☆☆

【解答】

本题的递归过程比较简单,用有序数组中最中间的数生成搜索二叉树的头节点,然后用这个数左边的数生成左子树,用右边的数生成右子树即可。

全部过程请参看如下代码中的generateTree方法。

          public class Node {
                  public int value;
                  public Node left;
                  public Node right;
                  public Node(int data) {
                          this.value = data;
                  }
          }

          public Node generateTree(int[] sortArr) {
                  if (sortArr == null) {
                          return null;
                  }
                  return generate(sortArr, 0, sortArr.length - 1);
          }

          public Node generate(int[] sortArr, int start, int end) {
                  if (start > end) {
                          return null;
                  }
                  int mid = (start + end) / 2;
                  Node head = new Node(sortArr[mid]);
                  head.left = generate(sortArr, start, mid - 1);
                  head.right = generate(sortArr, mid + 1, end);
                  return head;
          }

在二叉树中找到一个节点的后继节点

【题目】

现在有一种新的二叉树节点类型如下:

          public class Node {
                  public int value;
                  public Node left;
                  public Node right;
                  public Node parent;
                  public Node(int data) {
                          this.value = data;
                  }
          }

该结构比普通二叉树节点结构多了一个指向父节点的parent指针。假设有一棵Node类型的节点组成的二叉树,树中每个节点的parent指针都正确地指向自己的父节点,头节点的parent指向null。只给一个在二叉树中的某个节点node,请实现返回node的后继节点的函数。在二叉树的中序遍历的序列中,node的下一个节点叫作node的后继节点。

例如,图3-40所示的二叉树。

image

图3-40

中序遍历的结果为:1,2,3,4,5,6,7,8,9,10

所以节点1的后继为节点2,节点2的后继为节点3,……,节点10的后继为null。

【难度】

尉 ★★☆☆

【解答】

先简单介绍一种时间复杂度和空间复杂度较高但易于理解的方法。既然新类型的二叉树节点有指向父节点的指针,那么一直往上移动,自然可以找到头节点。找到头节点之后,再进行二叉树的中序遍历,生成中序遍历序列,然后在这个序列中找到node节点的下一个节点返回即可。如果二叉树的节点数为N ,这种方法要把二叉树的所有节点至少遍历一遍,生成中序遍历的序列还需要大小为N 的空间,所以该方法的时间复杂度与额外空间复杂度都为O (N )。本书不再详述。

最优解法不必遍历所有的节点,如果node节点和node后继节点之间的实际距离为L ,最优解法只用走过L 个节点,时间复杂度为O (L ),额外空间复杂度为O (1 )。接下来详细说明最优解法是如何找到node的后继节点的。

情况1:如果node有右子树,那么后继节点就是右子树上最左边的节点。

例如,题目所示的二叉树中,当node为节点1、3、4、6或9时,就是这种情况。

情况2:如果node没有右子树,那么先看node是不是node父节点的左孩子,如果是左孩子,那么此时node的父节点就是node的后继节点;如果是右孩子,就向上寻找node的后继节点,假设向上移动到的节点记为s,s的父节点记为p,如果发现s是p的左孩子,那么节点p就是node节点的后继节点,否则就一直向上移动。

例如,题目所示的二叉树中,当node为节点7时,节点7的父节点是节点8,同时节点7是节点8的左孩子,此时节点8就是节点7的后继节点。

再如,题目所示的二叉树中,当node为节点5时,节点5的父节点是节点4,但是节点5是节点4的右孩子,所以向上寻找node的后继节点。当向上移动到节点4,节点4的父节点是节点3,但是节点4还是节点3的右孩子,继续向上移动。当向上移动到节点3时,节点3的父节点是节点6,此时终于发现节点3是节点6的左孩子,移动停止,节点6就是node(节点5)的后继节点。

情况3:如果在情况2中一直向上寻找,都移动到空节点时还是没有发现node的后继节点,说明node根本不存在后继节点。

比如,题目所示的二叉树中,当node为节点10时,一直向上移动到节点6,此时发现节点6的父节点已经为空,说明node没有后继节点。

情况1和情况2遍历的节点就是node到node后继节点这条路径上的节点;情况3遍历的节点数也不会超过二叉树的高度。

最优解的具体过程请参看如下代码中的getNextNode方法。

          public Node getNextNode(Node node) {
                  if (node == null) {
                          return node;
                  }
                  if (node.right ! = null) {
                          return getLeftMost(node.right);
                  } else {
                          Node parent = node.parent;
                          while (parent ! = null && parent.left ! = node) {
                                  node = parent;
                                  parent = node.parent;
                          }
                          return parent;
                  }
          }

          public Node getLeftMost(Node node) {
                  if (node == null) {
                          return node;
                  }
                  while (node.left ! = null) {
                          node = node.left;
                  }
                  return node;
          }

在二叉树中找到两个节点的最近公共祖先

【题目】

给定一棵二叉树的头节点head,以及这棵树中的两个节点o1和o2,请返回o1和o2的最近公共祖先节点。

例如,图3-41所示的二叉树。

image

图3-41

节点4和节点5的最近公共祖先节点为节点2,节点5和节点2的最近公共祖先节点为节点2,节点6和节点8的最近公共祖先节点为节点3,节点5和节点8的最近公共祖先节点为节点1。

进阶:如果查询两个节点的最近公共祖先的操作十分频繁,想法让单条查询的查询时间减少。

再进阶:给定二叉树的头节点head,同时给定所有想要进行的查询。二叉树的节点数量为N ,查询条数为M ,请在时间复杂度为O (N +M )内返回所有查询的结果。

【难度】

原问题:士 ★☆☆☆

进阶问题:尉 ★★☆☆

再进阶问题:校 ★★★☆

【解答】

先来解决原问题。后序遍历二叉树,假设遍历到的当前节点为cur。因为是后序遍历,所以先处理cur的两棵子树。假设处理cur左子树时返回节点为left,处理右子树时返回right。

1.如果发现cur等于null,或者o1、o2,则返回cur。

2.如果left和right都为空,说明cur整棵子树上没有发现过o1或o2,返回null。

3.如果left和right都不为空,说明左子树上发现过o1或o2,右子树上也发现过o2或o1,说明o1向上与o2向上的过程中,首次在cur相遇,返回cur。

4.如果left和right有一个为空,另一个不为空,假设不为空的那个记为node,此时node到底是什么?有两种可能,要么node是o1或o2中的一个,要么node已经是o1和o2的最近公共祖先。不管是哪种情况,直接返回node即可。

以题目二叉树的例子来说明一下,假设o1为节点6,o2为节点8,过程为后序遍历。

● 依次遍历节点4、节点5、节点2,都没有发现o1或o2,所以节点1的左子树返回为null;

● 遍历节点6,发现节点6等于o1,返回节点6,所以节点3左子树的返回值为节点6;

● 遍历节点8,发现节点8等于o2,返回节点8,所以节点7左子树的返回值为节点8;

● 节点7的右子树为null,所以节点7右子树的返回值为null;

● 遍历节点7,左子树返回节点8,右子树返回null,根据步骤4,此时返回节点8,所以节点3的右子树的返回值为节点8;

● 遍历节点3,左子树返回节点6,右子树返回节点8,根据步骤3,此时返回节点3,所以节点1的右子树的返回值为节点3;

● 遍历节点1,左子树返回null,右子树返回节点3,根据步骤4,最终返回节点3。

找到两个节点最近公共祖先的详细过程请参看如下代码中的lowestAncestor方法。

          public Node lowestAncestor(Node head, Node o1, Node o2) {
                  if (head == null || head == o1 || head == o2) {
                          return head;
                  }
                  Node left = lowestAncestor(head.left, o1, o2);
                  Node right = lowestAncestor(head.right, o1, o2);
                  if (left ! = null && right ! = null) {
                          return head;
                  }
                  return left ! = null ? left : right;
          }

进阶问题其实是先花较大的力气建立一种记录,以后执行每次查询时就可以完全根据记录进行查询。记录的方式可以有很多种,本书提供两种记录结构供读者参考,两种记录各有优缺点。

结构一:建立二叉树中每个节点对应的父节点信息,是一张哈希表。

如果对题目中的二叉树建立这种哈希表,哈希表中的信息如下:

key value
节点1 null
节点2 节点1
节点3 节点1
节点4 节点2
节点5 节点2
节点6 节点3
节点7 节点3
节点8 节点7

key代表二叉树中的一个节点,value代表其对应的父节点。只用遍历一次二叉树,这张表就可以创建好,以后每次查询都可以根据这张哈希表进行。

假设想查节点4和节点8的最近公共祖先,方法是使用如上的哈希表,把包括节点4在内的所有节点4的祖先节点放进另一个哈希表A中,A表示节点4到头节点这条路径上所有节点的集合。所以A={节点4,节点2,节点1}。然后使用如上的哈希表,从节点8开始往上逐渐移动到头节点。首先是节点8,发现不在A中,然后是节点7,发现也不在A中,接下来是节点3,依然不在A中,最后是节点1,发现在A中,那么节点1就是节点4和节点8的最近公共祖先。只要在移动过程中发现某个节点在A中,这个节点就是要求的公共祖先节点。

结构一的具体实现请参看如下代码中Record1类的实现,构造函数是创建记录过程,方法query是查询操作。

          public class Record1 {
                  private HashMap<Node, Node> map;

                  public Record1(Node head) {
                          map = new HashMap<Node, Node>();
                          if (head ! = null) {
                                  map.put(head, null);
                          }
                          setMap(head);
                  }

                  private void setMap(Node head) {
                          if (head == null) {
                                  return;
                          }
                          if (head.left ! = null) {
                                  map.put(head.left, head);
                          }
                          if (head.right ! = null) {
                                  map.put(head.right, head);
                          }
                          setMap(head.left);
                          setMap(head.right);
                  }

                  public Node query(Node o1, Node o2) {
                          HashSet<Node> path = new HashSet<Node>();
                          while (map.containsKey(o1)) {
                                  path.add(o1);
                                  o1 = map.get(o1);
                          }
                          while (! path.contains(o2)) {
                                  o2 = map.get(o2);
                          }
                          return o2;
                  }
          }

很明显,结构一建立记录的过程时间复杂度为O (N )、额外空间复杂度为O (N )。查询操作时,时间复杂度为O (h ),其中,h 为二叉树的高度。

结构二:直接建立任意两个节点之间的最近公共祖先记录,便于以后查询时直接查。

建立记录的具体过程如下:

1.对二叉树中的每棵子树(一共N 棵)都进行步骤2。

2.假设子树的头节点为h,h所有的后代节点和h节点的最近公共祖先都是h,记录下来。h左子树的每个节点和h右子树的每个节点的最近公共祖先都是h,记录下来。

为了保证记录不重复,设计一种好的实现方式是这种结构实现的重点。

结构二的具体实现请参看如下代码中Record2类的实现。

          public class Record2 {
                  private HashMap<Node, HashMap<Node, Node>> map;

                  public Record2(Node head) {
                          map = new HashMap<Node, HashMap<Node, Node>>();
                          initMap(head);
                          setMap(head);
                  }

                  private void initMap(Node head) {
                          if (head == null) {
                                  return;
                          }
                          map.put(head, new HashMap<Node, Node>());
                          initMap(head.left);
                          initMap(head.right);
                  }

                  private void setMap(Node head) {
                          if (head == null) {
                                  return;
                          }
                          headRecord(head.left, head);
                          headRecord(head.right, head);
                          subRecord(head);
                          setMap(head.left);
                          setMap(head.right);
                  }

                  private void headRecord(Node n, Node h) {
                          if (n == null) {
                                  return;
                          }
                          map.get(n).put(h, h);
                          headRecord(n.left, h);
                          headRecord(n.right, h);
                  }

                  private void subRecord(Node head) {
                          if (head == null) {
                                  return;
                          }
                          preLeft(head.left, head.right, head);
                          subRecord(head.left);
                          subRecord(head.right);
                  }

                  private void preLeft(Node l, Node r, Node h) {
                          if (l == null) {
                                  return;
                          }
                          preRight(l, r, h);
                          preLeft(l.left, r, h);
                          preLeft(l.right, r, h);
                  }

                  private void preRight(Node l, Node r, Node h) {
                          if (r == null) {
                                  return;
                          }
                          map.get(l).put(r, h);
                          preRight(l, r.left, h);
                          preRight(l, r.right, h);
                  }

                  public Node query(Node o1, Node o2) {
                          if (o1 == o2) {
                                  return o1;
                          }
                          if (map.containsKey(o1)) {
                                  return map.get(o1).get(o2);
                          }
                          if (map.containsKey(o2)) {
                                  return map.get(o2).get(o1);
                          }
                          return null;
                  }
          }

如果二叉树的节点数为N ,想要记录每两个节点之间的信息,信息的条数为((N -1)×N )/2。所以建立结构二的过程的额外空间复杂度为O (N 2 ),时间复杂度为O (N 2 ),单次查询的时间复杂度为O (1)。

再进阶的问题:请参看下一题“Tarjan算法与并查集解决二叉树节点间最近公共祖先的批量查询问题”。

Tarjan算法与并查集解决二叉树节点间最近公共祖先的批量查询问题

【题目】

如下的Node类是标准的二叉树节点结构:

          public class Node {
                  public int value;
                  public Node left;
                  public Node right;
                  public Node(int data) {
                          this.value = data;
                  }
          }

再定义Query类如下:

          public class Query {
                  public Node o1;
                  public Node o2;
                  public Query(Node o1, Node o2) {
                          this.o1 = o1;
                          this.o2 = o2;
                  }
          }

一个Query类的实例表示一条查询语句,表示想要查询o1节点和o2节点的最近公共祖先节点。

给定一棵二叉树的头节点head,并给定所有的查询语句,即一个Query类型的数组Query[] ques,请返回Node类型的数组Node[] ans,ans[i]代表ques[i]这条查询的答案,即ques[i].o1和ques[i].o2的最近公共祖先。

【要求】

如果二叉树的节点数为N ,查询语句的条数为M ,整个处理过程的时间复杂度要求达到O (N +M )。

【难度】

校 ★★★☆

【解答】

本题的解法利用了Tarjan算法与并查集结构的结合。二叉树如图3-42所示,假设想要进行的查询为ques[0]=(节点4和节点7),ques[1]=(节点7和节点8),ques[2]=(节点8和节点9),ques[3]=(节点9和节点3),ques[4]=(节点6和节点6),ques[5]=(null和节点5),ques[6]=(null和null)。

image

图3-42

首先生成和ques长度一样的ans数组,如下三种情况的查询是可以直接得到答案的:

1.如果o1等于o2,答案为o1。例如,ques[4],令ans[4]=节点6。

2.如果o1和o2只有一个为null,答案是不为空的那个。例如,ques[5],令ans[5]=节点5。

3.如果o1和o2都为null,答案为null。例如ques[6],令ans[6]=null。

对不能直接得到答案的查询,我们把查询的格式转换一下,具体过程如下:

1.生成两张哈希表queryMap和indexMap。queryMap类似于邻接表,key表示查询涉及的某个节点,value是一个链表类型,表示key与那些节点之间有查询任务。indexMap的key也表示查询涉及的某个节点,value也是链表类型,表示如果依次解决有关key节点的每个问题,该把答案放在ans的什么位置。也就是说,如果一个节点为node,node与哪些节点之间有查询任务呢?都放在queryMap中;获得的答案该放在ans的什么位置呢?都放在indexMap中。

比如,根据ques[0~3],queryMap和indexMap生成记录如下:

Key Value
节点4 queryMap中节点4的链表:{节点7}
indexMap中节点4的链表:{ 0 }
节点7 queryMap中节点7的链表:{节点4,节点8}
indexMap中节点7的链表:{ 0 ,1 }
节点8 queryMap中节点8的链表:{节点7,节点9}
indexMap中节点8的链表:{ 1 ,2 }
节点9 queryMap中节点9的链表:{节点8,节点3}
indexMap中节点9的链表:{ 2 ,3 }
节点3 queryMap中节点3的链表:{节点9}
indexMap中节点3的链表:{ 3 }

读者应该会发现一条(o1,o2)的查询语句在上面的两个表中其实生成了两次。这么做的目的是为了处理时方便找到关于每个节点的查询任务,也方便设置答案,介绍完整个流程之后,会有进一步说明。

接下来是Tarjan算法处理M 条查询的过程,整个过程是二叉树的先左、再根、再右、最后再回到根的遍历。以图3-42的二叉树来说明。

1)对每个节点生成各自的集合,{1},{2},…,{9},开始时每个集合的祖先节点设为空。

2)遍历节点4,发现它属于集合{4},设置集合{4}的祖先为节点4,发现有关于节点4和节点7的查询任务,发现节点7属于集合{7},但集合{7}的祖先节点为空,说明还没遍历到,所以暂时不执行这个查询任务。

2.遍历节点2,发现它属于集合{2},设置集合{2}的祖先为节点2,此时左孩子节点4属于集合{4},将集合{4}与集合{2}合并,两个集合一旦合并,小的不再存在,而是生成更大的集合{4,2},并设置集合{4,2}的祖先为当前节点2。

3.遍历节点7,发现它属于集合{7},设置集合{7}的祖先为节点7,发现有关节点7和节点4的查询任务,发现节点4属于集合{4,2},集合{4,2}的祖先节点为节点2,说明节点4和节点7都已经遍历到,根据indexMap知道答案应放在0位置,所以设置ans[0]=节点2;又发现有节点7和节点8的查询任务,发现节点8属于集合{8},但集合{8}的祖先节点为空,说明还没遍历到,忽略。

4.遍历节点5,发现它属于集合{5},设置集合{5}的祖先为节点5,此时左孩子节点7属于集合{7},两集合合并为{7,5},并设置集合{7,5}的祖先为当前节点5。

5.遍历节点8,发现它属于集合{8},设置集合{8}的祖先为节点8,发现有节点8和节点7的查询任务,发现节点7属于集合{7,5},集合{7,5}的祖先节点为节点5,设置ans[1]=节点5;发现有节点8和节点9的查询任务,忽略。

6.从节点5的右子树重新回到节点5,节点5属于{7,5},节点5的右孩子节点8属于{8},两个集合合并为{7,5,8},并设置{7,5,8}的祖先节点为当前的节点5。

7.从节点2的右子树重新回到节点2,节点2属于集合{2,4},节点2的右孩子节点5属于集合{7,5,8},合并为{2,4,7,5,8},并设置这个集合的祖先节点为当前的节点2。

8.遍历节点1,{2,4,7,5,8}与{1}合并为{2,4,7,5,8,1},这个集合祖先节点为当前的节点1;

9.遍历节点3,发现属于集合{3},集合{3}祖先节点设为节点3,发现有节点3和节点9的查询任务,但节点9没遍历到,忽略。

10.遍历节点6,发现属于集合{6},集合{6}祖先节点设为节点6。

11.遍历节点9,发现属于集合{9},集合{9}祖先节点设为节点9;发现有节点9和节点8的查询任务,节点8属于{2,4,7,5,8,1},这个集合的祖先节点为节点1,根据indexMap知道答案应放在2位置,所以设置ans[2]=节点1;发现有节点9和节点3的查询任务,节点3属于{3},这个集合的祖先节点为节点3,根据indexMap,答案应放在3位置,所以设置ans[3]=节点1。

12.回到节点6,合并{6}和{9}为{6,9},{6,9}的祖先节点设为节点6。

13.回到节点3,合并{3}和{6,9}为{3,6,9},{3,6,9}的祖先节点设为节点3。

14.回到节点1,合并{2,4,7,5,8,1}和{3,6,9}为{1,2,3,4,5,6,7,8,9},祖先节点设为节点1。

15.过程结束,所有的答案都已得到。

现在我们可以解释生成queryMap和indexMap的意义了,遍历到一个节点时记为a,queryMap可以让我们迅速查到有哪些节点和a之间有查询任务,如果能够得到答案,indexMap还能告诉我们把答案放在ans的什么位置。假设a和节点b之间有查询任务,如果此时b已经遍历过,自然可以取得答案,然后在有关a的链表中,删除这个查询任务;如果此时b没有遍历过,依然在属于a的链表中删除这个查询任务,这个任务会在遍历到b的时候重新被发现,因为同样的任务b也存了一份。所以遍历到一个节点,有关这个节点的任务列表会被完全清空,可能有些任务已被解决,有些则没有也不要紧,一定会在后序的过程中被发现并得以解决。这就是queryMap和indexMap生成两遍查询任务信息的意义。

上述流程很好理解,但大量出现生成集合、合并集合和根据节点找到所在集合的操作,如果二叉树的节点数为N ,那么生成集合操作O (N )次,合并集合操作O (N )次,根据节点找到所在集合O (N +M )次。所以,如果上述整个过程想达到O (N +M )的时间复杂度,那就要求有关集合的单次操作,平均时间复杂度要求为O (1),请注意这里说的是平均。存在这么好的集合结构吗?存在。这种集合结构就是接下来要介绍的并查集结构。

并查集结构由Bernard A. Galler和Michael J. Fischer在1964年发明,但证明时间复杂度的工作却持续了数年之久,直到1989才彻底证明完毕。有兴趣的读者请阅读《算法导论》一书来了解整个证明过程,本书由于篇幅所限,不再详述证明过程,这里只重点介绍并查集的结构和各种操作的细节,并实现针对二叉树结构的并查集,这是一种经常使用的高级数据结构。

请读者注意,上述流程中提到一个集合祖先节点的概念与接下来介绍并查集时提到的一个集合代表节点(父节点)的概念不是一回事。本题的流程中有关设置一个集合祖先节点的操作也不属于并查集自身的操作,关于这个操作,我们在介绍完并查集结构之后再详细说明。

并查集由一群集合构成,比如步骤1中对每个节点都生成各自的集合,所有集合的全体构成一个并查集={ {1},{2},…,{9} }。这些集合可以合并,如果最终合并成一个大集合(步骤14),那么此时并查集中有一个元素,这个元素是这个大集合,即并查集={ {1,2,…,9} }。其实主要是想说明并查集是集合的集合这个概念。

并查集先经历初始化的过程,就向流程中的步骤1一样,把每个节点都生成一个只含有自己的集合。那么并查集中的单个集合是什么结构呢?如果集合中只有一个元素,记为节点a时,如图3-43所示。

image

图3-43

当集合中只有一个元素时,这个元素的father为自己,也就意味着这个集合的代表节点就是唯一的元素。实现记录节点father信息的方式有很多,本书使用哈希表来保存所有并查集中所有集合的所有元素的father信息,记为fatherMap。比如,对于这个集合,在fatherMap中肯定有某一条记录为(节点a(key),节点a(value)),表示key节点的father为value节点。每个元素除了father信息,还有另一个信息叫rank,rank为整数代表一个节点的秩,秩的概念可以粗略地理解为一个节点下面还有多少层节点,但是并查集结构对每个节点秩的更新并不严格,所以每个节点的秩只能粗略描述该节点下面的深度,正是由于秩在更新上的不严格,换来了极好的时间复杂度,而也正是因为这种不严格增加了并查集时间复杂度证明的难度。集合中只有一个元素时,这个元素的rank初始化为0。所有节点的秩信息保存在rankMap中。

对二叉树结构并查集初始化的具体过程请参看如下DisjointSets类中的makeSets方法。

当集合有多个节点时,下层节点的father为上层节点,最上层的节点father指向自己,最上层的节点又叫集合的代表节点,如图3-44所示。

image

图3-44

在并查集中,若要查一个节点属于哪个集合,就是在查这个节点所在集合的代表节点是什么,一个节点通过father信息逐渐找到最上面的节点,这个节点的father是自己,代表整个集合。比如图3-44中,任何一个节点最终都找到节点a,比如节点g。如果另外一个节点假设为z,找到的代表节点不是节点a,那么可以肯定节点g和节点z不在一个集合中。通过一个节点找到所在集合代表节点的过程叫作findFather过程。findFather最终会返回代表节点,但过程并不仅是单纯的查找过程,还会把整个查找路径压缩。比如,执行findFather(g),通过father逐渐向上,找到最上层节点a之后,会把从a到g这条路径上所有节点的father都设置为a,则集合变成图3-45的样子。

image

图3-45

经过路径压缩之后,路径上每个节点下次在找代表节点的时候都只需经过一次移动的过程。这也是整个并查集结构的设计中最重要的优化。

根据一个节点查找所在集合代表节点的过程请参看如下DisjointSets类中的findFather方法。

前面已经展示了并查集中的集合如何初始化,如何根据某一个节点查找所在集合的代表元素以及如何做路径压缩的过程,接下来介绍集合如何合并。首先,两个集合进行合并操作时,参数并不是两个集合,而是并查集中任意的两个节点,记为a和b。所以集合的合并更准确的说法是,根据a找到a所在集合的代表节点是findFather(a),记为aF,根据b找到b所在集合的代表节点是findFather(b),记为bF,然后用如下策略决定由哪个代表节点作为合并后大集合的代表节点。

1.如果aF==bF,说明a和b本身就在一个集合里,不用合并。

2.如果aF! =bF,那么假设aF的rank值记为aFrank,bF的rank值记为bFrank。根据对rank的解释,rank可以粗描一个节点下面的层数,而aF和bF本身又是各自集合中最上面的节点,所以aFrank粗描a所在集合的总层数,bFrank粗描b所在集合的总层数。如果aFrank<bFrank,那么把aF的father设为bF,表示a所在集合因为层数较少,所在挂在了b所在集合的下面,这样合并之后的大集合rank不会有变化。如果aFrank>bFrank,就把bF的father设为aF。如果aFrank==bFrank,那么aF和bF谁做大集合的代表都可以,本文的实现是用aF作为代表,即把bF的father设为aF,此时aF的rank值增加1。

合并过程如图3-46和图3-47所示。

image

图3-46

image

图3-47

根据两个节点合并两个集合的过程请参看如下DisjointSets类中的union方法。

          public class DisjointSets {
                  public HashMap<Node, Node> fatherMap;
                  public HashMap<Node, Integer> rankMap;
                  public DisjointSets() {
                          fatherMap = new HashMap<Node, Node>();
                          rankMap = new HashMap<Node, Integer>();
                  }

                  public void makeSets(Node head) {
                          fatherMap.clear();
                          rankMap.clear();
                          preOrderMake(head);
                  }

                  private void preOrderMake(Node head) {
                          if (head == null) {
                                  return;
                          }
                          fatherMap.put(head, head);
                          rankMap.put(head, 0);
                          preOrderMake(head.left);
                          preOrderMake(head.right);
                  }

                  public Node findFather(Node n) {
                          Node father = fatherMap.get(n);
                          if (father ! = n) {
                                  father = findFather(father);
                          }
                          fatherMap.put(n, father);
                          return father;
                  }

                  public void union(Node a, Node b) {
                          if (a == null || b == null) {
                                  return;
                          }
                          Node aFather = findFather(a);
                          Node bFather = findFather(b);
                          if (aFather ! = bFather) {
                                  int aFrank = rankMap.get(aFather);
                                  int bFrank = rankMap.get(bFather);
                                  if (aFrank < bFrank) {
                                          fatherMap.put(aFather, bFather);
                                  } else if (aFrank > bFrank) {
                                          fatherMap.put(bFather, aFather);
                                  } else {
                                          fatherMap.put(bFather, aFather);
                                          rankMap.put(aFather, aFrank + 1);
                                  }
                          }
                  }
          }

介绍完并查集的结构之后,最后解释一下在总流程中如何设置一个集合的祖先节点,如上流程中的每一步都有把当前点node所在集合的祖先节点设置为node的操作。在整个流程开始之前,建立一张哈希表,参看如下Tarjan类中的ancestorMap,我们知道在并查集中,每个集合都是用该集合的代表节点来表示的。所以,如果想把node所在集合的祖先节点设为node,只用把记录( findFather(node) ,node )放入ancestorMap中即可。同理,如果想得到一个节点a所在集合的祖先节点,令key为findFather(a),然后从ancestorMap中取出相应的记录即可。ancestorMap同时还可以表示一个节点是否被访问过。

全部的处理流程请参看如下代码中的tarJanQuery方法。

          // 主方法
          public Node[] tarJanQuery(Node head, Query[] quries) {
                  Node[] ans = new Tarjan().query(head, quries);
                  return ans;
          }

          // Tarjan类实现处理流程
          public class Tarjan {
                  private HashMap<Node, LinkedList<Node>> queryMap;
                  private HashMap<Node, LinkedList<Integer>> indexMap;
                  private HashMap<Node, Node> ancestorMap;
                  private DisjointSets sets;

                  public Tarjan() {
                          queryMap = new HashMap<Node, LinkedList<Node>>();
                          indexMap = new HashMap<Node, LinkedList<Integer>>();
                          ancestorMap = new HashMap<Node, Node>();
                          sets = new DisjointSets();
                  }

                  public Node[] query(Node head, Query[] ques) {
                          Node[] ans = new Node[ques.length];
                          setQueries(ques, ans);
                          sets.makeSets(head);
                          setAnswers(head, ans);
                          return ans;
                  }

                  private void setQueries(Query[] ques, Node[] ans) {
                          Node o1 = null;
                          Node o2 = null;
                          for (int i = 0; i ! = ans.length; i++) {
                                  o1 = ques[i].o1;
                                  o2 = ques[i].o2;
                                  if (o1 == o2 || o1 == null || o2 == null) {
                                          ans[i] = o1 ! = null ? o1 : o2;
                                  } else {
                                          if (! queryMap.containsKey(o1)) {
                                            queryMap.put(o1, new LinkedList<Node>());
                                            indexMap.put(o1, new LinkedList<Integer>());
                                          }
                                          if (! queryMap.containsKey(o2)) {
                                            queryMap.put(o2, new LinkedList<Node>());
                                            indexMap.put(o2, new LinkedList<Integer>());
                                          }
                                          queryMap.get(o1).add(o2);
                                          indexMap.get(o1).add(i);
                                          queryMap.get(o2).add(o1);
                                          indexMap.get(o2).add(i);
                                  }
                          }
                  }

                  private void setAnswers(Node head, Node[] ans) {
                          if (head == null) {
                                  return;
                          }
                          setAnswers(head.left, ans);
                          sets.union(head.left, head);
                          ancestorMap.put(sets.findFather(head), head);
                          setAnswers(head.right, ans);
                          sets.union(head.right, head);
                          ancestorMap.put(sets.findFather(head), head);
                          LinkedList<Node> nList = queryMap.get(head);
                          LinkedList<Integer> iList = indexMap.get(head);
                          Node node = null;
                          Node nodeFather = null;
                          int index = 0;
                          while (nList ! = null && ! nList.isEmpty()) {
                                  node = nList.poll();
                                  index = iList.poll();
                                  nodeFather = sets.findFather(node);
                                  if (ancestorMap.containsKey(nodeFather)) {
                                          ans[index] = ancestorMap.get(nodeFather);
                                  }
                          }
                  }
          }

二叉树节点间的最大距离问题

【题目】

从二叉树的节点A出发,可以向上或者向下走,但沿途的节点只能经过一次,当到达节点B时,路径上的节点数叫作A到B的距离。

比如,图3-48所示的二叉树,节点4和节点2的距离为2,节点5和节点6的距离为5。给定一棵二叉树的头节点head,求整棵树上节点间的最大距离。

image

图3-48

【要求】

如果二叉树的节点数为N ,时间复杂度要求为O (N )。

【难度】

尉 ★★☆☆

【解答】

一个以h为头的树上,最大距离只可能来自以下三种情况:

● h的左子树上的最大距离。

● h的右子树上的最大距离。

● h左子树上离h.left最远的距离+1(h)+h右子树上离h.right最远的距离。

三个值中最大的那个就是整棵h树中最远的距离。

根据如上分析,设计解法的过程如下:

1.整个过程为后序遍历,在二叉树的每棵子树上执行步骤2。

2.假设子树头为h,处理h左子树,得到两个信息,左子树上的最大距离记为lMax,左子树上距离h左孩子的最远距离记为maxfromLeft。同理,处理h右子树得到右子树上的最大距离记为rMax和距离h右孩子的最远距离记为maxFromRight。那么maxfromLeft + 1 +maxFromRight就是跨h节点情况下的最大距离,再与lMax和rMax比较,把三者中的最值作为h树上的最大距离返回,maxfromLeft+1就是h左子树上离h最远的点到h的距离,maxFromRight+1就是h右子树上离h最远的点到h的距离,选两者中最大的一个作为h树上距离h最远的距离返回。如何返回两个值?一个正常返回,另一个用全局变量表示。

具体过程请参看如下代码中的maxDistance方法,其中,record[0]就表示另一个返回值。

          public int maxDistance(Node head) {
                  int[] record = new int[1];
                  return posOrder(head, record);
          }

          public int posOrder(Node head, int[] record) {
                  if (head == null) {
                          record[0] = 0;
                          return 0;
                  }
                  int lMax = posOrder(head.left, record);
                  int maxfromLeft = record[0];
                  int rMax = posOrder(head.right, record);
                  int maxFromRight = record[0];
                  int curNodeMax = maxfromLeft + maxFromRight + 1;
                  record[0] = Math.max(maxfromLeft, maxFromRight) + 1;
                  return Math.max(Math.max(lMax, rMax), curNodeMax);
          }

先序、中序和后序数组两两结合重构二叉树

【题目】

已知一棵二叉树的所有节点值都不同,给定这棵二叉树正确的先序、中序和后序数组。请分别用三个函数实现任意两种数组结合重构原来的二叉树,并返回重构二叉树的头节点。

【难度】

先序与中序结合 士 ★☆☆☆

中序与后序结合 士 ★☆☆☆

先序与后序结合 尉 ★★☆☆

【解答】

先序与中序结合重构二叉树的过程如下:

1.先序数组中最左边的值就是树的头节点值,记为h,并用h生成头节点,记为head。然后在中序数组中找到h,假设位置是i 。那么在中序数组中,i 左边的数组就是头节点左子树的中序数组,假设长度为l ,则左子树的先序数组就是先序数组中h往右长度也为l 的数组。

比如:先序数组为[1,2,4,5,8,9,3,6,7],中序数组为[4,2,8,5,9,1,6,3,7],二叉树头节点的值是1,在中序数组中找到1的位置,1左边的数组为[4,2,8,5,9],是头节点左子树的中序数组,长度为5;先序数组中1的右边长度也为5的数组为[2,4,5,8,9],就是左子树的先序数组。

2.用左子树的先序和中序数组,递归整个过程建立左子树,返回的头节点记为left。

3.i 右边的数组就是头节点右子树的中序数组,假设长度为r 。先序数组中右侧等长的部分就是头节点右子树的先序数组。

比如步骤1的例子,中序数组中1右边的数组为[6,3,7],长度为3;先序数组右侧等长的部分为[3,6,7],它们分别为头节点右子树的中序和先序数组。

4.用右子树的先序和中序数组,递归整个过程建立右子树,返回的头节点记为right。

5.把head的左孩子和右孩子分别设为left和right,返回head,过程结束。

如果二叉树的节点数为N ,在中序数组中找到位置i 的过程可以用哈希表来实现,这样整个过程时间复杂度为O (N )。

具体过程请参看如下代码中的preInToTree方法。

          public Node preInToTree(int[] pre, int[] in) {
                  if (pre == null || in == null) {
                          return null;
                  }
                  HashMap<Integer, Integer> map = new HashMap<Integer, Integer>();
                  for (int i = 0; i < in.length; i++) {
                          map.put(in[i], i);
                  }
                  return preIn(pre, 0, pre.length - 1, in, 0, in.length - 1, map);
          }

          public Node preIn(int[] p, int pi, int pj, int[] n, int ni, int nj,
                          HashMap<Integer, Integer> map) {
                  if (pi > pj) {
                          return null;
                  }
                  Node head = new Node(p[pi]);
                  int index = map.get(p[pi]);
                  head.left = preIn(p, pi + 1, pi + index - ni, n, ni, index - 1, map);
                  head.right = preIn(p, pi + index - ni + 1, pj, n, index + 1, nj, map);
                  return head;
          }

中序和后序重构的过程与先序和中序的过程类似。先序和中序的过程是用先序数组最左的值来对中序数组进行划分,因为这是头节点的值。后序数组中头节点的值是后序数组最右的值,所以用后序最右的值来划分中序数组即可。

具体过程请参看如下代码中的inPosToTree方法。

          public Node inPosToTree(int[] in, int[] pos) {
                  if (in == null || pos == null) {
                          return null;
                  }
                  HashMap<Integer, Integer> map = new HashMap<Integer, Integer>();
                  for (int i = 0; i < in.length; i++) {
                          map.put(in[i], i);
                  }
                  return inPos(in, 0, in.length - 1, pos, 0, pos.length - 1, map);
          }

          public Node inPos(int[] n, int ni, int nj, int[] s, int si, int sj,
                          HashMap<Integer, Integer> map) {
                  if (si > sj) {
                          return null;
                  }
                  Node head = new Node(s[sj]);
                  int index = map.get(s[sj]);
                  head.left = inPos(n, ni, index - 1, s, si, si + index - ni - 1, map);
                  head.right = inPos(n, index + 1, nj, s, si + index - ni, sj - 1, map);
                  return head;
          }

先序和后序结合重构二叉树。要求面试者首先分析出节点值都不同的二叉树,即便得到了正确的先序与后序数组,在大多数情况下也不能通过这两个数组把原来的树重构出来。这是因为很多结构不同的树中,先序与后序数组是一样的,比如,头节点为1、左孩子为2、右孩子为null的树,先序数组为[1,2],后序数组为[2,1]。而头节点为1、左孩子为null、右孩子为2的树也是同样的结果。然后需要分析出什么样的树可以被先序和后序数组重建,如果一棵二叉树除叶节点之外,其他所有的节点都有左孩子和右孩子,只有这样的树才可以被先序和后序数组重构出来。最后才是通过划分左右子树各自的先序与后序数组的方式重建整棵树,具体过程请参看如下代码中的prePosToTree方法。

          // 每个节点的孩子数都为0或2的二叉树才能被先序与后序重构出来
          public Node prePosToTree(int[] pre, int[] pos) {
                  if (pre == null || pos == null) {
                          return null;
                  }
                  HashMap<Integer, Integer> map = new HashMap<Integer, Integer>();
                  for (int i = 0; i < pos.length; i++) {
                          map.put(pos[i], i);
                  }
                  return prePos(pre, 0, pre.length - 1, pos, 0, pos.length - 1, map);
          }

          public Node prePos(int[] p, int pi, int pj, int[] s, int si, int sj,
                          HashMap<Integer, Integer> map) {
                  Node head = new Node(s[sj--]);
                  if (pi == pj) {
                          return head;
                  }
                  int index = map.get(p[++pi]);
                  head.left = prePos(p, pi, pi + index - si, s, si, index, map);
                  head.right = prePos(p, pi + index - si + 1, pj, s, index + 1, sj, map);
                  return head;
          }

通过先序和中序数组生成后序数组

【题目】

已知一棵二叉树所有的节点值都不同,给定这棵树正确的先序和中序数组,不要重建整棵树,而是通过这两个数组直接生成正确的后序数组。

【难度】

士 ★☆☆☆

【解答】

举例说明生成后序数组的过程,假设pre=[1,2,4,5,3,6,7],in=[4,2,5,1,6,3,7]。

1.根据pre和in的长度,生成长度为7的后序数组pos,按以下规则从右到左填满pos。

2.根据[1,2,4,5,3,6,7]和[4,2,5,1,6,3,7],设置pos[6]=1,即先序数组最左边的值。根据1把in划分成[4,2,5]和[6,3,7],pre中1的右边部分根据这两部分等长划分出[2,4,5]和[3,6,7]。[2,4,5]和[4,2,5]一组,[3,6,7]和[6,3,7]一组。

3.根据[3,6,7]和[6,3,7],设置pos[5]=3,再次划分出[6(]来自[3,6,7])和[6(]来自[6,3,7])一组,[7](来自[3,6,7])和[7](来自[6,3,7])一组。

4.根据[7]和[7]设置pos[4]=7。

5.根据[6]和[6]设置pos[3]=6。

6.根据[2,4,5]和[4,2,5],设置pos[2]=2,再次划分出[4(]来自[2,4,5])和[4(]来自[4,2,5])一组,[5](来自[[2,4,5])和[5](来自[4,2,5])一组。

7.根据[5]和[5]设置pos[1]=5。

8.根据[4]和[4]设置pos[0]=4。

如上过程简单总结为:根据当前的先序和中序数组,设置后序数组最右边的值,然后划分出左子树的先序、中序数组,以及右子树的先序、中序数组,先根据右子树的划分设置好后序数组,再根据左子树的划分,从右边到左边依次设置好后序数组的全部位置。

具体过程请参看如下代码中的getPosArray方法。

          public int[] getPosArray(int[] pre, int[] in) {
                  if (pre == null || in == null) {
                          return null;
                  }
                  int len = pre.length;
                  int[] pos = new int[len];
                  HashMap<Integer, Integer> map = new HashMap<Integer, Integer>();
                  for (int i = 0; i < len; i++) {
                          map.put(in[i], i);
                  }
                  setPos(pre, 0, len - 1, in, 0, len - 1, pos, len - 1, map);
                  return pos;
          }

          // 从右往左依次填好后序数组s
          // si为后序数组s该填的位置
          // 返回值为s该填的下一个位置
          public int setPos(int[] p, int pi, int pj, int[] n, int ni, int nj,
                          int[] s, int si, HashMap<Integer, Integer> map) {
                  if (pi > pj) {
                          return si;
                  }
                  s[si--] = p[pi];
                  int i = map.get(p[pi]);
                  si = setPos(p, pj - nj + i + 1, pj, n, i + 1, nj, s, si, map);
                  return setPos(p, pi + 1, pi + i - ni, n, ni, i - 1, s, si, map);
          }

统计和生成所有不同的二叉树

【题目】

给定一个整数N ,如果N <1,代表空树结构,否则代表中序遍历的结果为{1,2,3,…,N }。请返回可能的二叉树结构有多少。

例如,N =-1时,代表空树结构,返回1; N =2时,满足中序遍历为{1,2}的二叉树结构只有如图3-49所示的两种,所以返回结果为2。

image

图3-49

进阶:N 的含义不变,假设可能的二叉树结构有M 种,请返回M 个二叉树的头节点,每一棵二叉树代表一种可能的结构。

【难度】

尉 ★★☆☆

【解答】

如果中序遍历有序且无重复值,则二叉树必为搜索二叉树。假设num(a)代表a 个节点的搜索二叉树有多少种可能,再假设序列为{1,…,i ,…,N },如果以1作为头节点,1不可能有左子树,故以1作为头节点有多少种可能的结构,完全取决于1的右子树有多少种可能结构,1的右子树有N -1个节点,所以有num(N-1)种可能。

如果以i 作为头节点,i 的左子树有i -1个节点,所以可能的结构有num(i-1)种,右子树有N -i 个节点,所以有num(N-i)种可能。故以i 为头节点的可能结构有num(i-1)×num(N-i)种。

如果以N 作为头节点,N 不可能有右子树,故以N 作为头节点有多少种可能,完全取决于N 的左子树有多少种可能,N 的左子树有N -1个节点,所以有num(N-1)种。

把从1到N 分别作为头节点时,所有可能的结构加起来就是答案,可以利用动态规划来加速计算的过程,从而做到O (N 2 )的时间复杂度。

具体请参看如下代码中的numTrees方法。

          public int numTrees(int n) {
                  if (n < 2) {
                          return 1;
                  }
                  int[] num = new int[n + 1];
                  num[0] = 1;
                  for (int i = 1; i < n + 1; i++) {
                          for (int j = 1; j < i + 1; j++) {
                                  num[i] += num[j - 1] * num[i - j];
                          }
                  }
                  return num[n];
          }

进阶问题与原问题的过程其实是很类似的。如果要生成中序遍历是{a…b}的所有结构,就从a开始一直到b,枚举每一个值作为头节点,把每次生成的二叉树结构的头节点都保存下来即可。假设其中一次是以i 值为头节点的(aib ),以i 头节点的所有结构按如下步骤生成:

1.用{a…i -1}递归生成左子树的所有结构,假设所有结构的头节点保存在listLeft链表中。

2.用{a…i +1}递归生成右子树的所有结构,假设所有结构的头节点保存在listRight链表中。

3.在以i 为头节点的前提下,listLeft中的每一种结构都可以与listRight中的每一种结构构成单独的结构,且和其他任何结构都不同。为了保证所有的结构之间不互相交叉,所以对每一种结构都复制出新的树,并记录在总的链表res中。

具体过程请参看如下代码中的generateTrees方法。

          public List<Node> generateTrees(int n) {
                  return generate(1, n);
          }

          public List<Node> generate(int start, int end) {
                  List<Node> res = new LinkedList<Node>();
                  if (start > end) {
                          res.add(null);
                  }
                  Node head = null;
                  for (int i = start; i < end + 1; i++) {
                          head = new Node(i);
                          List<Node> lSubs = generate(start, i - 1);
                          List<Node> rSubs = generate(i + 1, end);
                          for (Node l : lSubs) {
                                  for (Node r : rSubs) {
                                          head.left = l;
                                          head.right = r;
                                          res.add(cloneTree(head));
                                  }
                          }
                  }
                  return res;
          }

          public Node cloneTree(Node head) {
                  if (head == null) {
                          return null;
                  }
                  Node res = new Node(head.value);
                  res.left = cloneTree(head.left);
                  res.right = cloneTree(head.right);
                  return res;
          }

统计完全二叉树的节点数

【题目】

给定一棵完全二叉树的头节点head,返回这棵树的节点个数。

【要求】

如果完全二叉树的节点数为N ,请实现时间复杂度低于O (N )的解法。

【难度】

尉 ★★☆☆

【解答】

遍历整棵树当然可以求出节点数,但这肯定不是最优解法,本书不再详述。

如果完全二叉树的层数为h ,本书的解法可以做到时间复杂度为O (h 2 ),具体过程如下:

1.如果head==null,说明是空树,直接返回0。

2.如果不是空树,就求树的高度,求法是找到树的最左节点看能到哪一层,层数记为h

3.这一步是求解的主要逻辑,也是一个递归过程记为bs(node,l,h),node表示当前节点,l 表示node所在的层数,h 表示整棵树的层数是始终不变的。bs(node,l,h)的返回值表示以node为头的完全二叉树的节点数是多少。初始时node为头节点head,l 为1,因为head在第1层,一共有h 层始终不变。那么这个递归的过程可以用两个例子来说明,如图3-50和图3-51所示。

image

图3-50

image

图3-51

找到node右子树的最左节点,如果像图3-51的例子一样,发现它能到达最后一层,即h==4层。此时说明node的整棵左子树都是满二叉树,并且层数为h -l层,一棵层数为h -l的满二叉树,其节点数为2h- 1 -1个。如果加上node节点自己,那么节点数为2^(h-1)-1+1==2^(h-1)个。此时如果再知道node右子树的节点数,那么以node为头的完全二叉树上到底有多少个节点就求出来了。那么node右子树的节点数到底是多少呢?就是bs(node.right,l+1,h)的结果,递归去求即可。最后整体返回2^(h-1)+bs(node.right,l+1,h)。

找到node右子树的最左节点,如果像图3-51的例子一样,发现它没有到达最后一层,说明node的整棵右子树都是满二叉树,并且层数为h -l -1层,一棵层数为h -l -1的满二叉树,其节点数为2h- l- 1 -1个。如果加上node节点自己,那么节点数为2^(h-l-1)-1+1==2^(h-l-1)个。此时如果再知道node左子树的节点数,那么以node为头的完全二叉树上到底有多少个节点就求出来了。node左子树的节点数到底是多少呢?就是bs(node.left,l+1,h)的结果,递归去求即可,最后整体返回2^(h-l-1)+bs(node.left,l+1,h)。

全部过程请参看如下代码中的nodeNum方法。

          public int nodeNum(Node head) {
                  if (head == null) {
                          return 0;
                  }
                  return bs(head, 1, mostLeftLevel(head, 1));
          }

          public int bs(Node node, int l, int h) {
                  if (l == h) {
                          return 1;
                  }
                  if (mostLeftLevel(node.right, l + 1) == h) {
                          return (1 << (h - l)) + bs(node.right, l + 1, h);
                  } else {
                          return (1 << (h - l - 1)) + bs(node.left, l + 1, h);
                  }
          }

          public int mostLeftLevel(Node node, int level) {
                  while (node ! = null) {
                          level++;
                          node = node.left;
                  }
                  return level - 1;
          }

每一层只会选择一个节点node进行bs的递归过程,所以调用bs函数的次数为O (h )。每次调用bs函数时,都会查看node右子树的最左节点,所以会遍历O (h )个节点,整个过程的时间复杂度为O (h 2 )。