这是我的第一篇 算法导论(Introduction to Algorithms, Third Edition) 读书笔记. 旨在记录重点, 强化自己对算法的学习效果.
这里不会像书本那样详细, 给出复杂的推理证明, 这里只是一点学习心得.
我看到是 算法导论(中文版) 的书, 这里有原著(提取码:cz4v)(英文版PDF) 可以对照阅读.
1 什么是二叉搜索树
一棵 二叉搜索树(BST) 是以一棵 二叉树 来组织的.
二叉搜索树 可以使用一个 链表数据结构(linked data structure ) 表示, 其中每个 结点(node) 就是一个对象. 除了关键字
(key) 和 卫星数据
(satellite data) 之外, 每个结点还包含属性 left
(指向左孩子), right
(指向右孩子) 和 p
(指向双亲).
如果某个孩子结点和父结点不存在, 则响应属性的值为 NIL
, 根结点
是树中唯一 父指针(p
) 为 NIL
的结点.
备注:
我们存储在结点里的数据, 可以称为记录(record), 每个记录包含一/多个关键字(key), key
是结点之间比较大小时用的值; 记录的剩余部分称为 卫星数据(satellite data), 卫星数据和关键字是一同存取的.
下面是两棵 二叉搜索树:
(a) 图: 一个具有 6 个结点, 高度为 2 的 二叉搜索树.
(b) 图: 一个有和(a)图相同的 6 个结点, 高度为 4 的 二叉搜索树.
因为 二叉搜索树 的基本操作所花费的时间和 树的高度 成正比. 所以, (b) 是一个比 (a) 低效的 二叉搜索树.
1.1 二叉搜索树的性质
二叉搜索树的性质(binary-search-tree property):
设 x
是 二叉搜索树 中的一个结点.
如果 y
是 x
的 左子树 的一个结点, 那么 y.key <= x.key
.
如果 y
是 x
的 右子树 中的一个结点, 那么 y.key >= x.key
.
根据 二叉搜索树 的性质, 给出其数据结构:
public class Node {
private int key;
private Node left;
private Node right;
private Node p;
public Node(int key, Node p) {
this.key = key;
this.p = p;
}
// 省略 getter 和 setter
}
1.2 中序遍历
我们可以通过 一个简单的递归算法(a simple recursive algorithm) 输出 二叉搜索树 中的所有 key
.
输出的 根的key
位于其 左子树的key
和 右子树的key
之间, 这种算法称为 中序遍历(inorder tree walk)算法.
类似的, 先序遍历(preorder tree walk)输出的 根的key
在其 左右子树的key
之前;
后序遍历(postorder tree walk)输出的 根的key
在其 左右子树的key
之后.
中序遍历算法INORDER-TREE-WALK(x)
:
输入BST的根结点, 输出中序BST的所有结点.
INORDER-TREE-WALK(x)
if x != NIL
INORDER-TREE-WALK(x.left)
print x.key
INORDER-TREE-WALK(x.right)
遍历一棵有 n
个结点的 BST 的时间复杂度是 O(n).
对上图(a) 中的BST 中序遍历 实现:
public class Bst {
public List<Integer> inorderTreeWalk(Node root) {
List<Integer> result = new ArrayList<>();
doInorderTreeWalk(root, result);
return result;
}
public void doInorderTreeWalk(Node root, List<Integer> result) {
if (root != null) {
doInorderTreeWalk(root.getLeft(), result);
// System.out.println(root.getKey());
result.add(root.getKey());
doInorderTreeWalk(root.getRight(), result);
}
}
public static void main(String[] args) {
Node root = new Node(6, null);
Node n51 = new Node(5, root);
root.setLeft(n51);
Node n52 = new Node(5, n51);
n51.setRight(n52);
Node n2 = new Node(2, n51);
n51.setLeft(n2);
Node n7 = new Node(7, root);
root.setRight(n7);
Node n8 = new Node(8, n7);
n7.setRight(n8);
Bst bst = new Bst();
List<Integer> r = bst.inorderTreeWalk(root);
r.forEach(i -> System.out.print(i + " "));
}
}
输出:
2 5 5 6 7 8
2 查询二叉搜索树
我们经常需要 查找 一个存储在 BST 中的 key.
除了 search
以外, BST 还支持 minimum
, maximum
, successor
和 predecessor
的查询操作.
在任何高度为 h
的 BST 上, 这些查询操作的时间复杂度都是 O(h)
.
2.1 查找(searching)
二叉搜索树查找(searching) 算法 TREE-SEARCH(x, k)
:
输入一个 指向根结点的指针x
和一个 关键字k
, 如果 存在结点的关键字等于k
, 则返回这个结点的指针, 否则返回 NIL.
TREE-SEARCH(x, k)
if x == NIL or k == x.key
return x
if k < x.key
return TREE-SEARCH(x.left, k)
else
return TREE-SEARCH(x.right, k)
BST 查找算法 TREE-SEARCH(x, k)
的运行时间是 O(h)
, 其中 h
是 BST 的高度.
我们可以采用 while
循环展开 递归, 用 迭代方式 重写 查找算法.
ITERATIVE-TREE-SEARCH(x, k)
while x != NIL and k != x.key
if k < x.key
x = x.left
else x = x.right
return x
2.2 最小和最大关键字(minimum and maximum)
BST 的最小 key 结点(minimum):
从树根沿着 左孩子指针往下走, 直到遇到 NIL
, 走到的最后一个结点就是 最小关键字结点.
TREE-MINIMUM(x)
while x.left != NIL
x = x.left
return x
BST 的最大 key 元素(maximum):
从树根沿着 右孩子指针往下走, 直到遇到 NIL
, 走到的最后一个结点就是 最大关键字结点.
TREE-MAXIMUM(x)
while x.right != NIL
x = x.right
return x
查找 BST 的最小/大结点 都需要花费 O(h)
时间, h
为 BST 的高度.
2.3 后继和前驱(successor and predecessor)
后继结点(successor):
给定一棵 BST 的一个结点, 按照中序遍历的次序 查找它的后继.
TREE-SUCCESSOR(x)
if x.right != NIL
return TREE-MINIMUM(x.right)
y = x.p
while y != NIL and x == y.right
x = y
y = y.p
return y
查找 x
结点的 后继结点, 分两种情况:
x
的右子树非空, 那么x
的后继就是 右子树中的最小结点.x
的右子树为空, 那么x
的后继只可能在其 祖先 之中. 假设x
的后继是y
, 那么y
一定是x
在向上查找路径中出现右拐的第一个节点.
关于情况2可以参考下图理解:
前驱结点(predecessor):
给定一棵 BST 的一个结点, 按照中序遍历的次序 查找它的前驱.
TREE-PREDECESSOR(x)
if x.left != NIL
return TREE-MAXIMUM(x.left)
y = x.p
while y != NIL and x == y.left
x = y
y = y.p
return y
TREE-PREDECESSOR(x)
和 TREE-SUCCESSOR(x)
是对称的, 它们的时间复杂度都是 O(h)
.
3 插入和删除
插入和删除操作会引起 BST 的集合结构的变化, 需要修改数据以保持 BST 的性质成立.
3.1 插入
要将一个新值 v
插入一棵 二叉搜索树T
中, 假设 输入时 z
结点(z.key = v, z.left=NIL, z.right=NIL), 把 z
插入 T
的相应位置上:
TREE-INSERT(T, z)
y = NIL
x = T.root
while x != NIL
y = x
if z.key < x.key
x = x.left
else x = x.right
z.p = y
if y == NIL
T.root = z // tree T was empty
elseif z.key < y.key
y.left = z
else y.right = z
插入算法的过程很简单:
保持 跟踪指针(trailing pointer)(中文版翻译成遍历指针) y
作为 x
的双亲.
x
由根结点向下走(z.key < x.key时 x
往左走, 否则 x
往右走), 最终 x
变为 NIL
时停止, 最终 y
指向叶子结点. 让 z
成为 叶子结点的一个孩子即可.
示例, 把 13 插入 以下BST:
TREE-INSERT
在一棵高度为 h
的 BST 上的运行时间为 O(h)
.
3.2 删除
从 一棵 BST T
中删除一个结点 z
需要考虑以下四种情况:
- 如果
z
没有左孩子, 那么用其 右孩子 替换z
. 这个右孩子是不是NIL
都行. - 如果
z
没有右孩子(只有一个孩子, 且是左孩子), 那么用 左孩子 替换z
.
除了上述两种情况外,z
有左和右两个孩子. 我们需要查找z
的后继y
.y
是z
的后继, 所以y
是没有左孩子的. - 如果
y
是z
的右孩子, 那么用y
替换z
. - 否则,
y
位于z
的右子树中, 但并不是z
的右孩子. 这种情况下, 先用y
的右孩子 替换y
, 然后再用y
替换z
.
4 种 删除 z
的情况如下图所示:
对于 3 和 4 这两种情况, 需要注意:
删除z
, 只能用 z
的后继结点放在 z
的位置. 因为 BST 要求 当前结点
的右子树的任一结点都小于 当前结点
.
为了实现上图中的替换操作, 先定义一个 移植(transplant)过程.
TRANSPLANT
用一棵以 v
为根的子树 替换 一棵以 u
为根的子树时, u
的双亲就变为 v
的双亲, 并且 u
会成为 v
的双亲的响应的孩子.
TRANSPLANT(T, u, v)
if u.p == NIL
T.root = v
elseif u == u.p.left
u.p.left = v
else u.p.right = v
if v != NIL
v.p = u.p
从 BST T
中删除结点 z
的过程:
TREE-DELETE(T, z)
if z.left == NIL
TRANSPLANT(T, z, z.right)
elseif z.right == NIL
TRANSPLANT(T, z, z.left)
else y = TREE-MINIMUM(z.right) // y是z的后继
if y.p != z
TRANSPLANT(T, y, y.right)
// 把y的右子树换成z的右子树
y.right = z.right
y.right.p = y
TRANSPLANT(T, z, y)
// 把y的左子树换成z的左子树
y.left = z.left
y.left.p = y
除了 TREE-MINIMUM
之外, TREE-DELETE
每一行都是花费常数时间, 因此, 在一棵高度为 h
的BST上, TREE-DELETE
运行时间为 O(h)
.