线索二叉树与Morris遍历

分手后的思念是犯贱 2023-09-23 14:42 198阅读 0赞

一、二叉树线索化

对于一棵普通的二叉树,它的节点结构需要由两个指针域和一个数据域构成。而一棵树中必定存在一些指针域没有被使用到,这就造成了空间的浪费。
3d677723d8c8bd5963af4d30d93039c0.png

另一方面,我们经常用到二叉树的前、中、后序遍历,如果想求某种遍历中某个节点的前驱和后继节点,那就需要重新进行遍历。这无疑会造成时间的浪费。

所以为什么不把空闲的指针域利用起来,让其指向节点的前驱或后继呢?其实这就是线索二叉树的核心思想。

我们以中序遍历为例进行讲解。对于前面那棵二叉树,我们很容易得出它的中序遍历结果为:42513。但对于计算机来说,它只知道当前遍历到的节点cur,以及我们可以缓存下来的前一个遍历到的节点pre。

于是,计算机首先遍历到「4」这个节点
0425b629ba989a5df592e306a4bfda49.png

显然,这个节点的左右指针是空的,这两个指针域可以利用起来。但这个节点并没有前驱节点,而后继节点计算机并不知道是谁,所以这两个指针域还是只能指向空。

接下来遍历到「2」这个节点
543b020c28298936fe9b8881d10b7b03.png

虽然「2」节点的左右指针都不是空的,但它的前驱节点「4」的右指针还没有指向后继节点。而「4」的后继节点正是「2」节点。所以我们将「4」的右指针指向「2」
c7d5a03cd9a1e451e11d18c2ae9e8464.png

接下来指针遍历到了「5」节点
4f3a2c56859b703dc21f661f7906bce4.png

此时「5」节点的左指针为空,而且它的前驱我们知道是「2」,所以将左指针指向「2」
ac6a6349bd5c2c32836a6d538696a40e.png

接下来指针遍历到「1」节点,它的前驱节点「5」的右指针为空,所以将「5」的右指针指向「1」
4fcadbc0d5a05ab014eb0a5fab70aa3c.png

最后,指针遍历到「3」,它的左指针为空,所以将左指针指向「1」
6072097a106536a37b0db8c9834e40fa.png

至此,线索二叉树就构建完成了。但对于计算机来说,并不知道哪些指针是指向前驱或后继的指针,哪些指针是指向左右孩子的指针。所以我们还需要在二叉树节点的结构中引入两个bool变量,区分该指针是否是线索。

  1. public class ThreadedBinaryTreeNode<T>
  2. {
  3. public T data;
  4. public ThreadedBinaryTreeNode<T> left;
  5. public ThreadedBinaryTreeNode<T> right;
  6. public bool leftTag;
  7. public bool rightTag;
  8. }

线索化的代码如下:

  1. public void InThreading()
  2. {
  3. InThreadingMethod(Head);
  4. // 处理遍历的最后一个节点
  5. if (_pre != null) _pre.RightTag = true;
  6. }
  7. private ThreadedBinaryTreeNode<T>? _pre;
  8. private void InThreadingMethod(ThreadedBinaryTreeNode<T>? head)
  9. {
  10. if(head == null) return;
  11. InThreadingMethod(head.Left);
  12. // 前驱线索
  13. if (head.Left == null)
  14. {
  15. head.Left = _pre;
  16. head.LeftTag = true;
  17. }
  18. // 后继线索
  19. if (_pre != null && _pre.Right == null)
  20. {
  21. _pre.Right = head;
  22. _pre.RightTag = true;
  23. }
  24. _pre = head;
  25. InThreadingMethod(head.Right);
  26. }

完成线索化后,当我们需要查询某个节点的后继时,如果它有后继指针,那就可以直接返回后继指针指向的节点;否则就返回其右子树按中序遍历的第一个节点(最左节点)

  1. public ThreadedBinaryTreeNode<T>? GetNext(ThreadedBinaryTreeNode<T> node)
  2. {
  3. // 有后继指针,直接返回
  4. if (node.RightTag) return node.Right;
  5. // 否则返回右子树按中序遍历的第一个节点
  6. var root = node.Right;
  7. while (root != null && !root.LeftTag)
  8. {
  9. root = root.Left;
  10. }
  11. return root;
  12. }

当需要查询某个节点的前驱时,如果它有前驱指针,则直接返回前驱指针指向的节点;否则就返回其左子树按中序遍历的最后一个节点(最右节点)

  1. public ThreadedBinaryTreeNode<T>? GetPre(ThreadedBinaryTreeNode<T> node)
  2. {
  3. // 有前驱指针,直接返回
  4. if (node.LeftTag) return node.Left;
  5. // 否则返回左子树按中序遍历的最后一个节点
  6. var root = node.Left;
  7. while (root != null && !root.RightTag)
  8. {
  9. root = root.Right;
  10. }
  11. return root;
  12. }

明白了如何寻找前驱后继,那么中序遍历整棵树也就非常简单了

  1. public void Traverse()
  2. {
  3. // 先找到中序遍历的起始节点
  4. var cur = Head;
  5. while (cur != null && !cur.LeftTag)cur = cur.Left;
  6. // 依次寻找后继节点
  7. while (cur!=null)
  8. {
  9. Console.Write(cur.Data);
  10. cur = GetNext(cur);
  11. }
  12. }

线索二叉树的优点是遍历过程不再需要依靠堆栈,相对来讲速度会快一点,且比较省空间。最主要的一点是寻找任意节点的前驱和后继节点变得容易,不需要从头开始遍历。

二、Morris遍历

Morris遍历是对线索二叉树的一种巧妙的利用。它并不是事先进行线索化,而是一边遍历一边进行线索化。这使它的空间复杂度可以降低到 O ( 1 ) O(1) O(1)级别,时间复杂度为 O ( N ) O(N) O(N)。

Morris遍历的基本原理是利用叶子节点空闲的指针,构成回到上层节点的通路,从而避免使用额外的存储结构实现遍历。

Morris遍历的过程如下:
从根节点开始遍历,假设当前节点为cur
(1)如果cur没有左孩子,则前往cur的右孩子(即cur = cur.right)
(2)如果cur有左孩子,则寻找左子树上的最右节点pre
①如果pre的右孩子为空,则将其右指针指向cur,cur向左移动
②如果pre的右孩子为cur,则将其右指针指向空,cur向右移动

下面通过一个例子来演示Morris遍历的过程
2b004b3de965c2719483646853da0389.png

首先,cur位于1节点,存在左孩子,所以要寻找左子树上的最右节点,也就是5。5节点的右孩子为空,所以将其右指针指向cur。然后cur左移来到2的位置。
9670db87c3dbc4d91464ce0cea258f93.png

2节点存在左孩子,所以要寻找左子树上的最右节点,也就是4节点。4节点的右孩子为空,所以将其右指针指向cur。cur左移来到4的位置
a0123ebd3da25b9baf6a02e880056487.png

由于4节点没有左孩子,所以cur挪动到右孩子的位置,也就是回到2节点
0c395b093b70a2911778056cfeaae574.png

2节点存在左孩子,所以继续寻找左子树的最右节点,也就是4。但4节点的右孩子就是cur,所以将其右指针指向空,然后cur右移来到5节点
20550a51d24bb28560936f055057a841.png

5节点没有左孩子,所以cur右移来到1节点。
26314c78e66d9ebf7e13b4e849332d26.png

1节点存在左孩子,所以寻找左子树上的最右节点,也就是5。但5节点的右孩子就是cur,所以将其右指针指向空,cur右移来到3节点
696877eb696668541e0b2b2963e11c0c.png

3节点没有左孩子,所以cur右移来到空,遍历结束。

我们将遍历过程中经过的节点一一列出来
1->2->4->(2)->5->(1)->3
可以发现其中一些节点经过了2次。

如果我们将第一次经过视为有效,则遍历结果为12453,正是前序遍历的顺序;
如果将第二次经过视为有效,则遍历结果为42513,正是中序遍历的顺序。

至于后序遍历就有些复杂了。常规的Morris遍历只能保证根节点一定在右节点之前遍历到,而后序遍历则需要先遍历到右节点,再遍历到根。所以只能中序遍历的基础上,将根->右的遍历顺序进行反转,成为右->根。操作方法是,在执行到(2)②步骤,将cur右移之前,先将cur左子树的右边界进行逆转,然后遍历,然后再逆转回来。具体可以参考代码。
ded6015113a6b8af8e8fd1559224acb4.png

前序遍历

  1. // 寻找左子树最右节点
  2. private TreeNode<T> FindMostRightParentInLeftTree<T>(TreeNode<T> head)
  3. {
  4. if (head?.Left == null) throw new NullReferenceException();
  5. TreeNode<T> cur = head.Left;
  6. while (cur != null && cur.Right != null && cur.Right != head)
  7. {
  8. cur = cur.Right;
  9. }
  10. return cur;
  11. }
  12. /// <summary>
  13. /// Morris前序遍历
  14. /// </summary>
  15. /// <param name="head"></param>
  16. /// <typeparam name="T"></typeparam>
  17. public void Morris_Preorder<T>(TreeNode<T> head)
  18. {
  19. TreeNode<T>? cur = head;
  20. while (cur != null)
  21. {
  22. // 如果cur有左孩子
  23. if (cur.Left != null)
  24. {
  25. // 寻找左子树最右节点的父节点
  26. TreeNode<T> rightParent = FindMostRightParentInLeftTree(cur);
  27. // 如果最右节点为空,则右指针指向cur,cur左移
  28. if (rightParent.Right == null)
  29. {
  30. Console.Write(cur.Data+" ");
  31. rightParent.Right = cur;
  32. cur = cur.Left;
  33. }
  34. // 如果最右节点为cur,则右指针指向空,cur右移
  35. else if (rightParent.Right == cur)
  36. {
  37. rightParent.Right = null;
  38. cur = cur.Right;
  39. }
  40. }
  41. // cur没有左孩子,右移
  42. else
  43. {
  44. Console.Write(cur.Data+" ");
  45. cur = cur.Right;
  46. }
  47. }
  48. }

中序遍历(只是换一下打印的位置)

  1. /// <summary>
  2. /// Morris中序遍历
  3. /// </summary>
  4. /// <param name="head"></param>
  5. /// <typeparam name="T"></typeparam>
  6. public void Morris_Inorder<T>(TreeNode<T> head)
  7. {
  8. TreeNode<T>? cur = head;
  9. while (cur != null)
  10. {
  11. // 如果cur有左孩子
  12. if (cur.Left != null)
  13. {
  14. // 寻找左子树最右节点的父节点
  15. TreeNode<T> rightParent = FindMostRightParentInLeftTree(cur);
  16. // 如果最右节点为空,则右指针指向cur,cur左移
  17. if (rightParent.Right == null)
  18. {
  19. rightParent.Right = cur;
  20. cur = cur.Left;
  21. }
  22. // 如果最右节点为cur,则右指针指向空,cur右移
  23. else if (rightParent.Right == cur)
  24. {
  25. Console.Write(cur.Data+" ");
  26. rightParent.Right = null;
  27. cur = cur.Right;
  28. }
  29. }
  30. // cur没有左孩子,右移
  31. else
  32. {
  33. Console.Write(cur.Data+" ");
  34. cur = cur.Right;
  35. }
  36. }
  37. }

后序遍历

  1. // 逆序右边界
  2. private TreeNode<T> ReverseRightBorder<T>(TreeNode<T> head)
  3. {
  4. TreeNode<T>? pre = null;
  5. while (head != null)
  6. {
  7. TreeNode<T>? next = head.Right;
  8. head.Right = pre;
  9. pre = head;
  10. head = next;
  11. }
  12. return pre;
  13. }
  14. // 逆序打印右边界
  15. private void ReversePrintRightBorder<T>(TreeNode<T> head)
  16. {
  17. // 反转右边界
  18. var tail = ReverseRightBorder(head);
  19. TreeNode<T> cur = tail;
  20. // 遍历
  21. while (cur != null)
  22. {
  23. Console.Write(cur.Data+" ");
  24. cur = cur.Right;
  25. }
  26. // 再反转回来
  27. ReverseRightBorder(tail);
  28. }
  29. /// <summary>
  30. /// Morris后序遍历
  31. /// </summary>
  32. /// <param name="head"></param>
  33. /// <typeparam name="T"></typeparam>
  34. public void Morris_Postorder<T>(TreeNode<T> head)
  35. {
  36. TreeNode<T>? cur = head;
  37. while (cur != null)
  38. {
  39. // 如果cur有左孩子
  40. if (cur.Left != null)
  41. {
  42. // 寻找左子树最右节点的父节点
  43. TreeNode<T> rightParent = FindMostRightParentInLeftTree(cur);
  44. // 如果最右节点为空,则右指针指向cur,cur左移
  45. if (rightParent.Right == null)
  46. {
  47. rightParent.Right = cur;
  48. cur = cur.Left;
  49. }
  50. // 如果最右节点为cur,则右指针指向空,cur右移
  51. else if (rightParent.Right == cur)
  52. {
  53. rightParent.Right = null;
  54. // 逆序打印左树右边界
  55. ReversePrintRightBorder(cur.Left);
  56. cur = cur.Right;
  57. }
  58. }
  59. // cur没有左孩子,右移
  60. else
  61. {
  62. cur = cur.Right;
  63. }
  64. }
  65. // 逆序打印头结点的右边界
  66. ReversePrintRightBorder(head);
  67. }

三、参考资料

[1]. https://zhuanlan.zhihu.com/p/348381217
[2]. https://zhuanlan.zhihu.com/p/101321696
[3]. https://www.bilibili.com/video/BV13g41157hK

发表评论

表情:
评论列表 (有 0 条评论,198人围观)

还没有评论,来说两句吧...

相关阅读

    相关 Morris算法进行

    二叉树作为计算机中的一个重要数据结构,在很多领域都会涉及到,而提到二叉树,我们首先想到的就是其3种遍历方式--前序、中序和后序,对于这三种遍历方式,我们很容易通过使用递归或者迭