在 Python 中构建森林系列:单向二叉搜索树






4.43/5 (3投票s)
使用 Python 构建线程二叉搜索树。
引言
本文是“Python 系列:构建森林”系列的第三篇文章。在上篇文章《Python 系列:二叉树遍历》中,我们讨论了使用递归和辅助栈来实现二叉树的遍历。本文将构建二叉搜索树的一个变种——线索二叉搜索树。线索二叉搜索树利用空指针(左或右子节点为空)的属性来实现某些特定顺序的遍历,而无需使用栈或递归。
项目设置
与“Python 系列:构建二叉搜索树”系列中的其他文章一样,本文的实现假定使用 Python 3.9 或更高版本。本文为我们的项目增加了两个模块:用于线索二叉搜索树实现的 `single_threaded_binary_trees.py` 和用于单元测试的 `test_single_threaded_binary_trees.py`。添加这两个文件后,我们的项目结构如下:
forest-python
├── forest
│ ├── __init__.py
│ ├── binary_trees
│ │ ├── __init__.py
│ │ ├── binary_search_tree.py
│ │ ├── single_threaded_binary_trees.py
│ │ └── traversal.py
│ └── tree_exceptions.py
└── tests
├── __init__.py
├── conftest.py
├── test_binary_search_tree.py
├── test_single_threaded_binary_trees.py
└── test_traversal.py
(完整代码可在 forest-python 获取。)
什么是线索二叉树?
线索二叉树是二叉树的一种变种,它通过利用节点的空左指针或右指针来优化特定顺序的遍历。线索二叉树有两种类型:
- 单线索二叉树 (Single-threaded binary tree):对于节点中空的左指针或右指针,它会被“线索化”,指向该节点的中序前驱或后继。换句话说,左指针或右指针不再为空,而是指向节点的(前驱或后继)。单线索二叉树有两种实现方式:左单线索二叉树和右单线索二叉树。
- 右单线索二叉树 (Right single-threaded binary tree):节点的空右指针指向该节点的后继,如果节点有空的左指针,则该指针保持为空。
- 左单线索二叉树 (Left single-threaded binary tree):节点的空左指针指向该节点的前驱,如果节点有空的右指针,则该指针保持为空。
- 双线索二叉树 (Double-threaded binary tree):对于任何空的左指针或右指针,这些空指针都会被线索化,指向中序前驱或后继:如果左指针为空,则指向前驱;如果右指针为空,则指向后继。
虽然线索可以添加到任何二叉树中,但最适合将其添加到二叉搜索树或其变种(即满足二叉搜索树性质的树)中。因此,在本项目中,我们将实现的线索二叉树是带有线索的二叉搜索树(即线索二叉搜索树)。在文章的其余部分,为了避免术语冗余,我们提及的线索二叉树都将特指二叉搜索树。
此外,线索不一定需要指向中序前驱或后继,也可以指向前序或后序。但是,中序是二叉搜索树的有序序列,所以指向中序前驱或后继的线索是最常见的。
我们为什么需要线索?
为二叉树添加线索会增加复杂性,那么我们为什么需要线索呢?有几个原因:
- 快速访问后继或前驱
- 某些遍历方式无需辅助栈或递归
- 由于无需辅助栈或递归,减少了执行遍历时的内存消耗
- 利用了被浪费的空间。由于节点中空的左指针或右指针不存储任何信息,我们可以将其用作线索。
对于每种类型的线索二叉树,我们可以总结添加线索的好处如下:
- 右单线索二叉树无需栈或递归即可执行中序和前序遍历。它还提供了快速访问后继的功能。
- 左单线索二叉树无需栈或递归即可执行反中序遍历,并提供快速访问前驱的功能。
- 双线索二叉树兼具两种单线索二叉树的优点。
构建单线索二叉搜索树
正如我们在“构建二叉搜索树”部分所做的那样,本节将逐步介绍实现过程,并讨论一些实现选择背后的想法。
节点
节点结构与二叉搜索树节点类似,但增加了一个额外的字段 `is_thread`。`is_thread` 属性的目的是区分左指针或右指针是普通指针还是线索。
`is_thread` 属性是一个布尔变量:如果该指针(根据单线索二叉树的类型,为左指针或右指针)是线索,则为 `True`;否则为 `False`。
@dataclasses.dataclass
class Node:
key: Any
data: Any
left: Optional["Node"] = None
right: Optional["Node"] = None
parent: Optional["Node"] = None
is_thread: bool = False
与二叉搜索树节点一样,我们将线索二叉树的节点类定义为数据类 (dataclass)。
右单线索二叉搜索树
顾名思义,我们可以通过下图来形象地理解右单线索二叉树:
每个节点的空右指针都指向其中序后继,并且其 `is_thread` 变量设置为 `True`,除了最右边的节点。最右边节点的右指针仍然为空,其 `is_thread` 为 `False`,这样我们就知道它是最右边的节点(即没有后继了)。
与二叉搜索树一样,右单线索二叉树也拥有核心功能(`insert`、`delete` 和 `search`)用于构建和修改,以及其他不与特定树绑定的辅助功能,例如获取最左节点和获取树的高度。我们在二叉搜索树中实现的 `__repr__()` 函数也可以用于调试目的。
这里的主要区别在于,我们实现了无需栈或递归即可进行中序和前序遍历的方法。
class RightThreadedBinaryTree:
def __init__(self) -> None:
self.root: Optional[Node] = None
def __repr__(self) -> str:
"""Provie the tree representation to visualize its layout."""
if self.root:
return (
f"{type(self)}, root={self.root}, "
f"tree_height={str(self.get_height(self.root))}"
)
return "empty tree"
def search(self, key: Any) -> Optional[Node]:
…
def insert(self, key: Any, data: Any) -> None:
…
def delete(self, key: Any) -> None:
…
@staticmethod
def get_leftmost(node: Node) -> Node:
…
@staticmethod
def get_rightmost(node: Node) -> Node:
…
@staticmethod
def get_successor(node: Node) -> Optional[Node]:
…
@staticmethod
def get_predecessor(node: Node) -> Optional[Node]:
…
@staticmethod
def get_height(node: Optional[Node]) -> int:
…
def inorder_traverse(self) -> traversal.Pairs:
…
def preorder_traverse(self) -> traversal.Pairs:
…
Insert
`insert` 操作类似于二叉搜索树的插入。区别在于线索二叉树需要考虑线索的更新。因此,插入步骤如下:
- 通过从根节点开始遍历树,并将新节点键与沿途每个节点的键进行比较,找到插入新节点的合适位置(即新节点的父节点)。向右子树遍历时,还要检查 `is_thread` 变量。如果该变量为 `True`,则表示我们已到达叶子节点,该叶子节点就是新节点的父节点。
- 更新新节点的 parent 属性以指向父节点。
- 如果新节点是父节点的左子节点,则将新节点的右指针指向父节点,并将 `is_thread` 变量设置为 `True`。更新父节点的左指针指向新节点。
- 如果新节点是其父节点的右子节点,则将父节点的右指针(在插入之前,父节点的右指针必须是线索)复制到新节点的右指针,并将 `is_thread` 变量设置为 `True`。更新父节点的右指针指向新节点,并将父节点的 `is_thread` 设置为 `False`。
下图演示了节点插入的步骤。
我们可以按以下方式实现插入:
def insert(self, key: Any, data: Any) -> None:
new_node = Node(key=key, data=data)
parent: Optional[Node] = None
current: Optional[Node] = self.root
while current:
parent = current
if new_node.key < current.key:
current = current.left
elif new_node.key > current.key:
# If the node is thread, meaning it's a leaf node.
if current.is_thread:
current = None
else:
current = current.right
else:
raise tree_exceptions.DuplicateKeyError(key=new_node.key)
new_node.parent = parent
# If the tree is empty
if parent is None:
self.root = new_node
elif new_node.key < parent.key:
parent.left = new_node
# Update thread
new_node.right = parent
new_node.is_thread = True
else:
# Update thread
new_node.is_thread = parent.is_thread
new_node.right = parent.right
parent.is_thread = False
# Parent's right must be set after thread update
parent.right = new_node
搜索
搜索操作也类似于二叉搜索树的搜索,但需要检查 `is_thread` 变量来确定我们是否到达了叶子节点。
- 从根节点开始遍历树,并将要查找的键与沿途每个节点的键进行比较。
- 如果键匹配,则找到了该节点。
- 如果在到达叶子节点后(如果 `is_thread` 为 `True`,则表示该节点是叶子节点)仍未找到匹配的键,则表示该键不存在于树中。
实现与我们在二叉搜索树中进行的实现相似,只需进行一个简单的修改——检查 `is_thread`。
def search(self, key: Any) -> Optional[Node]:
current = self.root
while current:
if key == current.key:
return current
elif key < current.key:
current = current.left
else: # key > current.key
if current.is_thread:
break
current = current.right
return None
删除
与二叉搜索树一样,要删除的右单线索二叉树节点有三种情况:无子节点、只有一个子节点、有两个子节点。我们还使用在“Python 系列:二叉搜索树 - 删除”中使用的替换(transplant)技术来用要删除的节点替换子树。虽然基本思想相同,但 `transplant` 函数和 `delete` 函数都需要考虑右线索。我们最需要记住的是,在删除节点时,如果其他节点的右指针指向要删除的节点,我们需要更新该节点的线索(即右指针)。
情况 1:无子节点
如果待删除节点没有子节点,则其左指针为空,`is_thread` 为 `True`。关于线索,我们需要考虑两种情况。请看下图:
情况 2a:只有一个左子节点
如果待删除节点只有一个左子节点,这意味着节点的 `is_thread` 为 `True`。(例外情况是当待删除节点是根节点且只有一个左子节点时;在这种情况下,节点的 `is_thread` 为 `False` 且其右指针为 `None`)。需要更新线索的情况如下图所示:
情况 2b:只有一个右子节点
如果待删除节点只有一个右子节点,这意味着节点的左指针为空,`is_thread` 为 `False`。由于待删除节点没有左子节点,这意味着没有人指向该节点。
情况 3:有两个子节点
与二叉搜索树的删除类似,待删除节点有两个子节点的情况可以分解为两种子情况:
3.a 要删除节点的右子节点也是右子树中最左边的节点。
在这种情况下,右子节点必须只有一个右子节点。因此,我们可以用右子节点替换被删除的节点,如下图所示:
3.b 要删除节点的右子节点也有两个子节点。
在这种情况下,我们从右子树中找到最左边的节点来替换被删除的节点。请注意,当我们从右子树中移除最左边的节点时,它也属于删除情况:情况 1(无子节点)或情况 2(只有一个右子节点)。否则,它就不可能是最左边的节点。
因此,我们两次使用 `transplant` 函数:一次移除最左边的节点,另一次用原来的最左边的节点替换被删除的节点。下图演示了在执行删除时需要考虑线索的情况。
被替换节点没有子节点
被替换节点只有一个右子节点:
根据上图,我们可以实现 `delete` 和 `transplant` 函数如下:
def delete(self, key: Any) -> None:
if self.root and (deleting_node := self.search(key=key)):
# Case 1: no child
if deleting_node.left is None and (
deleting_node.right is None or deleting_node.is_thread
):
self._transplant(deleting_node=deleting_node, replacing_node=None)
# Case 2a: only one left child
elif deleting_node.left and (
deleting_node.is_thread or deleting_node.right is None
# deleting_node.right is None means the deleting node is the root.
):
predecessor = self.get_predecessor(node=deleting_node)
if predecessor:
predecessor.right = deleting_node.right
self._transplant(
deleting_node=deleting_node, replacing_node=deleting_node.left
)
# Case 2b: only one right child
elif deleting_node.left is None and deleting_node.is_thread is False:
self._transplant(
deleting_node=deleting_node, replacing_node=deleting_node.right
)
# Case 3: two children
elif (
deleting_node.left
and deleting_node.right
and deleting_node.is_thread is False
):
predecessor = self.get_predecessor(node=deleting_node)
replacing_node: Node = self.get_leftmost(node=deleting_node.right)
# the leftmost node is not the direct child of the deleting node
if replacing_node.parent != deleting_node:
if replacing_node.is_thread:
self._transplant(
deleting_node=replacing_node, replacing_node=None
)
else:
self._transplant(
deleting_node=replacing_node,
replacing_node=replacing_node.right,
)
replacing_node.right = deleting_node.right
replacing_node.right.parent = replacing_node
replacing_node.is_thread = False
self._transplant(
deleting_node=deleting_node, replacing_node=replacing_node
)
replacing_node.left = deleting_node.left
replacing_node.left.parent = replacing_node
if predecessor and predecessor.is_thread:
predecessor.right = replacing_node
else:
raise RuntimeError("Invalid case. Should never happened")
def _transplant(self, deleting_node: Node, replacing_node: Optional[Node]) -> None:
if deleting_node.parent is None:
self.root = replacing_node
if self.root:
self.root.is_thread = False
elif deleting_node == deleting_node.parent.left:
deleting_node.parent.left = replacing_node
if replacing_node:
if deleting_node.is_thread:
if replacing_node.is_thread:
replacing_node.right = replacing_node.right
else: # deleting_node == deleting_node.parent.right
deleting_node.parent.right = replacing_node
if replacing_node:
if deleting_node.is_thread:
if replacing_node.is_thread:
replacing_node.right = replacing_node.right
else:
deleting_node.parent.right = deleting_node.right
deleting_node.parent.is_thread = True
if replacing_node:
replacing_node.parent = deleting_node.parent
获取高度
要计算线索二叉树的高度,我们可以像在二叉搜索树中那样,递归地为每个子节点的高度加一。如果一个节点有两个子节点,我们使用 max 函数获取子节点中较高的那个高度,然后加一。主要区别在于我们使用 `is_thread` 来检查一个节点是否有右子节点。
@staticmethod def get_height(node: Optional[Node]) -> int: if node: if node.left and node.is_thread is False: return ( max( RightThreadedBinaryTree.get_height(node.left), RightThreadedBinaryTree.get_height(node.right), ) + 1 ) if node.left: return RightThreadedBinaryTree.get_height(node=node.left) + 1 if node.is_thread is False: return RightThreadedBinaryTree.get_height(node=node.right) + 1 return 0
获取最左侧和最右侧节点
获取最左节点和最右节点的实现与二叉搜索树的实现类似。要获取最右节点,除了检查右指针是否为空外,我们还需要检查 `is_thread` 是否为 `True`。因此,我们可以修改 `get_rightmost` 函数如下:
@staticmethod
def get_rightmost(node: Node) -> Node:
current_node = node
while current_node.is_thread is False and current_node.right:
current_node = current_node.right
return current_node
`get_leftmost` 的实现与二叉搜索树中的 `get_leftmost` 完全相同。
@staticmethod
def get_leftmost(node: Node) -> Node:
current_node = node
while current_node.left:
current_node = current_node.left
return current_node
前驱和后继
由于右单线索二叉树提供快速访问中序后继(如果节点的右指针是线索,则指向其后继),我们可以通过跟随其右指针来获取节点的后继,如果右指针是线索。否则,该节点的后继是其右子树中最左边的节点。
@staticmethod
def get_successor(node: Node) -> Optional[Node]:
if node.is_thread:
return node.right
else:
if node.right:
return RightThreadedBinaryTree.get_leftmost(node=node.right)
# if node.right is None, it means no successor of the given node.
return None
`get_predecessor` 的实现与二叉搜索树的前驱函数完全相同。
@staticmethod
def get_predecessor(node: Node) -> Optional[Node]:
if node.left:
return RightThreadedBinaryTree.get_rightmost(node=node.left)
parent = node.parent
while parent and node == parent.left:
node = parent
parent = parent.parent
return parent
中序遍历
右单线索二叉树提供的一个好处是,我们无需辅助栈或递归即可进行中序遍历。算法如下:
- 从整个树的最左边节点开始。
- 如果右指针是线索,则跟随右指针;如果右指针不是线索,则转到子树的最左边节点。
- 重复步骤 2,直到右指针为空。
下图中的红色箭头演示了带有线索的中序遍历。
并且实现该函数时无需使用辅助栈或递归。
def inorder_traverse(self) -> traversal.Pairs:
if self.root:
current: Optional[Node] = self.get_leftmost(node=self.root)
while current:
yield (current.key, current.data)
if current.is_thread:
current = current.right
else:
if current.right is None:
break
current = self.get_leftmost(current.right)
前序遍历
右单线索二叉树还提供了一种更简单的前序遍历方式,比中序遍历更简单。
- 从根节点开始。
- 如果左指针不为空,则转到左子节点。
- 如果左指针为空,则跟随线索转向右边。
- 重复步骤 2 和 3,直到右指针为空。
下图中的红色箭头显示了线索化前序遍历的路径。
前序遍历可以实现如下:
def preorder_traverse(self) -> traversal.Pairs:
current = self.root
while current:
yield (current.key, current.data)
if current.is_thread:
# If a node is thread, it must have a right child.
current = current.right.right # type: ignore
else:
current = current.left
左单线索二叉搜索树
左单线索二叉树与右单线索二叉树是对称的。在左单线索二叉树中,如果任何节点的左指针为空,则该左指针是线索,指向该节点的中序前驱,并且 `is_thread` 变量设置为 `True`。左单线索二叉树的形象化如下图所示。
左单线索二叉树的类结构与右单线索二叉树几乎相同。唯一的区别是,左单线索二叉树提供简单的反中序遍历,而不是中序和前序遍历。
class LeftThreadedBinaryTree:
def __init__(self) -> None:
self.root: Optional[Node] = None
def __repr__(self) -> str:
"""Provie the tree representation to visualize its layout."""
if self.root:
return (
f"{type(self)}, root={self.root}, "
f"tree_height={str(self.get_height(self.root))}"
)
return "empty tree"
def search(self, key: Any) -> Optional[Node]:
…
def insert(self, key: Any, data: Any) -> None:
…
def delete(self, key: Any) -> None:
…
@staticmethod
def get_leftmost(node: Node) -> Node:
…
@staticmethod
def get_rightmost(node: Node) -> Node:
…
@staticmethod
def get_successor(node: Node) -> Optional[Node]:
…
@staticmethod
def get_predecessor(node: Node) -> Optional[Node]:
…
@staticmethod
def get_height(node: Optional[Node]) -> int:
…
def reverse_inorder_traverse(self) -> traversal.Pairs:
…
Insert
`insert` 操作类似于右单线索二叉树。区别在于线索在左侧。
- 通过从根节点开始遍历树,并将新节点键与沿途每个节点的键进行比较,找到插入新节点的合适位置(即新节点的父节点)。向左子树遍历时,还要检查 `is_thread` 变量。如果该变量为 `True`,则表示我们已到达叶子节点,该叶子节点就是父节点。
- 找到父节点后,更新父节点的左指针(或右指针,取决于键值)指向新节点。
- 更新新节点的 parent 属性以指向父节点。
- 如果新节点是父节点的右子节点,则将新节点的左指针指向父节点,并将 `is_thread` 变量设置为 `True`。
- 如果新节点是父节点的左子节点,则将父节点的左指针(在插入之前,父节点的左指针必须是线索)复制到新节点的左指针,并将 `is_thread` 设置为 `True`。更新父节点的左指针指向新节点。
下图演示了节点插入的步骤。
实现如下:
def insert(self, key: Any, data: Any) -> None:
new_node = Node(key=key, data=data)
parent: Optional[Node] = None
current: Optional[Node] = self.root
while current:
parent = current
if new_node.key < current.key:
# If the node is thread, meaning it's a leaf node.
if current.is_thread:
current = None
else:
current = current.left
elif new_node.key > current.key:
current = current.right
else:
raise tree_exceptions.DuplicateKeyError(key=new_node.key)
new_node.parent = parent
# If the tree is empty
if parent is None:
self.root = new_node
elif new_node.key > parent.key:
parent.right = new_node
# Update thread
new_node.left = parent
new_node.is_thread = True
else:
# Update thread
new_node.is_thread = parent.is_thread
new_node.left = parent.left
parent.is_thread = False
# Parent's left must be set after thread update
parent.left = new_node
搜索
搜索操作与右单线索二叉树类似,因此我们需要检查 `is_thread` 变量来确定我们是否到达了叶子节点。
- 从根节点开始遍历树,并将要查找的键与沿途每个节点的键进行比较。
- 如果键匹配,则找到了该节点。
- 如果在到达叶子节点后(如果 `is_thread` 为 `True`,也表示该节点是叶子节点)仍未找到匹配的键,则表示该键不存在于树中。
实现与右单线索二叉树的搜索非常相似。
def search(self, key: Any) -> Optional[Node]:
current = self.root
while current:
if key == current.key:
return current
elif key < current.key:
if current.is_thread is False:
current = current.left
else:
break
else: # key > current.key:
current = current.right
return None
删除
同样,左单线索二叉树的删除与右单线索二叉树是对称的,也分为三种情况:无子节点、只有一个子节点、有两个子节点。
情况 1:无子节点
情况 2a:只有一个左子节点
情况 2b:只有一个右子节点
情况 3:有两个子节点
3.a 要删除节点的右子节点也是右子树中最左边的节点。
3.b 要删除节点的右子节点也有两个子节点。
被替换节点没有子节点
被替换节点只有一个右子节点:
与右单线索二叉树一样,我们也使用在二叉搜索树删除中使用的替换(transplant)技术来用要删除的节点替换子树。
def delete(self, key: Any) -> None:
if self.root and (deleting_node := self.search(key=key)):
# Case 1: no child
if deleting_node.right is None and (
deleting_node.left is None or deleting_node.is_thread
):
self._transplant(deleting_node=deleting_node, replacing_node=None)
# Case 2a: only one left child
elif (deleting_node.right is None) and (deleting_node.is_thread is False):
self._transplant(
deleting_node=deleting_node, replacing_node=deleting_node.left
)
# Case 2b: only one right child
elif deleting_node.right and (
deleting_node.is_thread or deleting_node.left is None
# deleting_node.left is None means the deleting node is the root.
):
successor = self.get_successor(node=deleting_node)
if successor:
successor.left = deleting_node.left
self._transplant(
deleting_node=deleting_node, replacing_node=deleting_node.right
)
# Case 3: two children
elif deleting_node.right and deleting_node.left:
replacing_node: Node = self.get_leftmost(node=deleting_node.right)
successor = self.get_successor(node=replacing_node)
# the leftmost node is not the direct child of the deleting node
if replacing_node.parent != deleting_node:
self._transplant(
deleting_node=replacing_node,
replacing_node=replacing_node.right,
)
replacing_node.right = deleting_node.right
replacing_node.right.parent = replacing_node
self._transplant(
deleting_node=deleting_node, replacing_node=replacing_node
)
replacing_node.left = deleting_node.left
replacing_node.left.parent = replacing_node
replacing_node.is_thread = False
if successor and successor.is_thread:
successor.left = replacing_node
else:
raise RuntimeError("Invalid case. Should never happened")
def _transplant(self, deleting_node: Node, replacing_node: Optional[Node]) -> None:
if deleting_node.parent is None:
self.root = replacing_node
if self.root:
self.root.is_thread = False
elif deleting_node == deleting_node.parent.left:
deleting_node.parent.left = replacing_node
if replacing_node:
if deleting_node.is_thread:
if replacing_node.is_thread:
replacing_node.left = deleting_node.left
else:
deleting_node.parent.left = deleting_node.left
deleting_node.parent.is_thread = True
else: # deleting_node == deleting_node.parent.right
deleting_node.parent.right = replacing_node
if replacing_node:
if deleting_node.is_thread:
if replacing_node.is_thread:
replacing_node.left = deleting_node.left
if replacing_node:
replacing_node.parent = deleting_node.parent
获取高度
`get_height` 函数与右单线索二叉树的实现是对称的。
@staticmethod def get_height(node: Optional[Node]) -> int: if node: if node.right and node.is_thread is False: return ( max( LeftThreadedBinaryTree.get_height(node.left), LeftThreadedBinaryTree.get_height(node.right), ) + 1 ) if node.right: return LeftThreadedBinaryTree.get_height(node=node.right) + 1 if node.is_thread is False: return LeftThreadedBinaryTree.get_height(node=node.left) + 1 return 0
获取最左侧和最右侧节点
由于左单线索二叉树与右单线索二叉树是对称的,因此在尝试获取最左节点时,我们需要检查 `is_thread` 是否为 `True` 并检查左指针是否为空。
@staticmethod
def get_leftmost(node: Node) -> Node:
current_node = node
while current_node.left and current_node.is_thread is False:
current_node = current_node.left
return current_node
`get_rightmost` 的实现与二叉搜索树中的 `get_rightmost` 完全相同。
@staticmethod
def get_rightmost(node: Node) -> Node:
current_node = node
while current_node.right:
current_node = current_node.right
return current_node
前驱和后继
根据左单线索二叉树的定义:节点的空左指针指向其中序前驱。我们可以通过跟随线索来获取节点的“前驱”,如果节点的 `is_thread` 为 `True`。
@staticmethod
def get_predecessor(node: Node) -> Optional[Node]:
if node.is_thread:
return node.left
else:
if node.left:
return LeftThreadedBinaryTree.get_rightmost(node=node.left)
# if node.left is None, it means no predecessor of the given node.
return None
`get_successor` 的实现与二叉搜索树的后继函数完全相同。
@staticmethod
def get_successor(node: Node) -> Optional[Node]:
if node.right:
return LeftThreadedBinaryTree.get_leftmost(node=node.right)
parent = node.parent
while parent and node == parent.right:
node = parent
parent = parent.parent
return parent
反中序遍历
通过左线索,左单线索二叉树在执行反中序遍历时既不需要递归也不需要辅助栈。
- 从整个树的最右边节点开始。
- 如果左指针是线索,则跟随线索;如果左指针不是线索,则转到子树的最右边节点。
- 重复步骤 2,直到左指针为空。
下图中的红色箭头演示了带有线索的反中序遍历。
以下是实现:
def reverse_inorder_traverse(self) -> traversal.Pairs:
if self.root:
current: Optional[Node] = self.get_rightmost(node=self.root)
while current:
yield (current.key, current.data)
if current.is_thread:
current = current.left
else:
if current.left is None:
break
current = self.get_rightmost(current.left)
测试
一如既往,我们应该尽可能为我们的代码编写单元测试。在这里,我们使用在“Python 系列:构建二叉搜索树”中创建的 `conftest.py` 文件中的 `basic_tree` 函数来测试我们的单线索二叉树。完整的单元测试请参见 test_single_threaded_binary_trees.py。
分析
线索二叉树操作的运行时间与普通二叉搜索树的操作相同。
尽管右单线索二叉树提供了更简便的方式来获取节点的后继,左单线索二叉树提供了更直接的方式来获取节点的前驱,但线索并未在运行时间上节省太多。主要原因是这些函数仍然需要调用 `get_leftmost` 和 `get_rightmost` 函数,它们的平均运行时间为 O(lg n),最坏情况下的运行时间为 O(n)。
然而,线索确实在特定遍历方式上节省了空间复杂度。
示例
为二叉搜索树添加线索会使其实现更加复杂,但当遍历是关键需求,同时又需要考虑空间消耗时,它们可以提供一种解决方案。例如,我们想构建一个数据库,用户需要频繁地按升序和降序访问数据,但我们内存有限(即由于空间消耗考虑,无法使用辅助栈或递归)。在这种情况下,我们可以使用线索二叉树来实现用于升序和降序访问的索引。
from typing import Any
from forest.binary_trees import single_threaded_binary_trees
from forest.binary_trees import traversal
class MyDatabase:
"""Example using threaded binary trees to build an index."""
def __init__(self) -> None:
self._left_bst = single_threaded_binary_trees.LeftThreadedBinaryTree()
self._right_bst = single_threaded_binary_trees.RightThreadedBinaryTree()
def _persist(self, payload: Any) -> str:
"""Fake function pretent storing data to file system.
Returns
-------
str
Path to the payload.
"""
return f"path_to_{payload}"
def insert_data(self, key: Any, payload: Any) -> None:
"""Insert data.
Parameters
----------
key: Any
Unique key for the payload
payload: Any
Any data
"""
path = self._persist(payload=payload)
self._left_bst.insert(key=key, data=path)
self._right_bst.insert(key=key, data=path)
def dump(self, ascending: bool = True) -> traversal.Pairs:
"""Dump the data.
Parameters
----------
ascending: bool
The order of data.
Yields
------
Pairs
The next (key, data) pair.
"""
if ascending:
return self._right_bst.inorder_traverse()
else:
return self._left_bst.reverse_inorder_traverse()
if __name__ == "__main__":
# Initialize the database.
my_database = MyDatabase()
# Add some items.
my_database.insert_data("Adam", "adam_data")
my_database.insert_data("Bob", "bob_data")
my_database.insert_data("Peter", "peter_data")
my_database.insert_data("David", "david_data")
# Dump the items in ascending order.
print("Ascending...")
for contact in my_database.dump():
print(contact)
print("\nDescending...")
# Dump the data in decending order.
for contact in my_database.dump(ascending=False):
print(contact)
(完整示例可在 single_tbst_database.py 中找到。)
输出将如下所示:
Ascending...
('Adam', 'path_to_adam_data')
('Bob', 'path_to_bob_data')
('David', 'path_to_david_data')
('Peter', 'path_to_peter_data')
Descending...
('Peter', 'path_to_peter_data')
('David', 'path_to_david_data')
('Bob', 'path_to_bob_data')
('Adam', 'path_to_adam_data')
摘要
尽管添加线索会增加复杂性且不会提高运行时间性能,但线索二叉树利用了左指针或右指针的空闲空间,并提供了一种简单的方式来检索前驱或后继,以及一种无需使用栈或递归即可执行某些遍历的解决方案。当考虑空间复杂度且特定遍历(例如中序遍历)至关重要时,线索二叉树可以成为一种选择。
下一篇文章将构建双线索二叉树,它将结合单线索二叉树的优点,但也会更加复杂。
历史
- 2021年4月3日:初始版本