BST
Why BST ?
BST全称Binary Search Tree,可见这个数据结构和搜索有着密切的关系,为什么要有这种数据结构呢?每一种新技术的出现必然是能够满足旧技术的功能,并且进一步补上一些旧技术的短板。回顾一下线性结构链表和数组,在这两种结构中进行某个值的搜索只能顺序遍历,效率较低;后来由于算法的助力,对于一个有序的数组,可以使用二分查找来大幅提升查找效率为。二分查找之所以快,是因为其并没有遍历每一个元素,而是利用整体有序的性质不断通过计算中位值来将搜索的区间折半。
为了将二分查找的高效附加在数据结构上,让其天然具有高效的搜索性质,大神发明了BST。BST就是二分查找在数据结构上的体现,不断将一个有序数组的中值上拉为根,根的左侧为值比其小的区间,根的右侧为值比其大的区间,简而言之,左小右大,就变成了一棵BST了。
你看,此时如果我们要在树上查找某个结点,根据左小右大搜索路径查找,是不是本质和二分查找一模一样?
BST的性质
更加准确地来看看二叉搜索树,二叉搜索树是满足以下条件的特殊的二叉树:
- 存在可比较的关键码key。
- 左子树的任意结点key不大于根的key;右子树的任意结点key不小于根的key。简而言之,左小右大。
- 左右子树也是二叉搜索树。
可以发现,bst的定义是一种递归的定义,所以跟bst相关的==大部分问题都可以很方便地使用递归解决==。
二叉树的高度 & 深度
某个结点的高度,是指该结点到其最远叶子结点的路径长度;
某个结点的深度,是指该结点到根节点的路径长度。
而对于树本身而言,高度和深度分别就是所有结点中高度的最大值、深度的最大值。所以对于树而言,高度和深度的值是一样的;但对于某个结点而言,其高度和深度就不一定相同。
实操体会
在真正的使用中,我们并不可能完全看见存储在计算机中的树结构,因为说到底树只是我们构想出来的逻辑结构,而计算机中只有一个个物理地址单元来存储数据,即树上的每一个结点,故结点之间的联系就是通过地址来相连,也就是结点对象的引用。
同时由于结点的地址并不是连续的,不像数组,我们不能够随机访问某个结点,访问一棵树的唯一切入点就是根结点,有了根节点就相当于有了一棵树。
记住那句话:大部分树的问题都可以使用递归解决,因为树本身就是递归定义的。递归的分析的注意点:
- 找出同类型的子问题
- 找出递归的基例
开始吧,先定义结点。
class Node {
Node left, right;
K key;
public Node(K key) {
this.key = key;
}
}
Node root; // 整棵树的唯一根结点
前序、中序、后序遍历二叉树
所谓的这三种遍历分别对应的遍历顺序为根左右、左根右、左右根,根就是指根结点,左右就是指左右子树。以中序遍历为例子,中序遍历左子树,访问根,中序遍历右子树。可以发现这就是一个递归的过程。
可以发现,由于bst左小右大的性质,中序遍历的结果就是一个递增的序列。前边提到过,二叉树相关的问题大部分都可以使用递归来解决,从递归的角度分析就是:
- 中序遍历树(左根右),就是中序遍历根的左子树(同类子问题),访问根节点,中序遍历根的右子树(同类子问题)。
- 基例:子树为空。
//------------------ 中序遍历 ---------------------//
public void inOrder() {
inOrder(root);
}
public void inOrder(Node r) {
if (r == null) return;
inOrder(r.left); // 中序遍历左子树
System.out.println(r.key); // 访问根
inOrder(r.right); // 中序遍历右子树
}
- 前序遍历树(根左右),就是访问根,前序遍历左子树,前序遍历右子树。
- 基例:子树为空。
//------------------ 前序遍历 ----------------------//
public void preOrder() {
preOrder(root);
}
public void preOrder(Node r) {
if (r == null) return;
System.out.println(r.key); // 访问根
preOrder(r.left); // 前序遍历左子树
preOrder(r.right); // 前序遍历右子树
}
- 后序遍历树(左右根),就是后序遍历左子树,后续遍历右子树,访问根。
- 基例:子树为空。
//----------------- 后序遍历 ---------------------//
public void postOrder() {
postOrder(root);
}
public void postOrder(Node r) {
if (r == null) return;
postOrder(r.left); // 后序遍历左子树
postOrder(r.right); // 后序遍历右子树
System.out.println(r.key); // 访问根
}
层次遍历二叉树
层次遍历意思是从上到下,每一层从左到右遍历整个二叉树。利用队列先进先出的特性就可以很方便地实现。
//----------------- 层次遍历 ---------------------//
public void levelTraversal() {
if (root == null) return;
Node tmp;
Queue q = new LinkedList<>();
q.add(root);while (!q.isEmpty()) {
System.out.println((tmp = q.remove()).key);if (tmp.left != null) q.add(tmp.left);if (tmp.right != null) q.add(tmp.right);
}
}
搜索
作为二分查找在数据结构上的体现,二叉树的查找就是二分的思想,要查找的关键码比根小则在左侧子树继续查找,否则在右侧子树上查找,利用递归的方式分析:
- 在二叉搜索树上查找key,若key小于根结点关键码,则在左子树上查找(同类型子问题);否则在右子树上查找(同类型子问题)
- 基例:树为空(失败),key值匹配(成功)
public Node search(K key) {
}
private Node search(Node n, K key) {
if (n == null) return null;
int tmp;
if ((tmp = key.compareTo(n.key)) 0) // 左小
return search(n.left, key);
else if (tmp > 0) // 右大
return search(n.right, key);
else return n; // 成功匹配
}
插入
要插入一个结点,就要通过查找找到位置,然后插入新节点。为了更易维护二叉搜索树的性质,插入总是尽量作为一个新的叶子,除非关键码匹配的结点已经在树中(此时替换值即可)。仍然以递归的视角来分析:
- 要在bst插入一个关键码为key的结点
- 若key小于bst的根结点,则在左子树上插入(同类型子问题);否则在右子树上插入(同类型子问题)
- 基例:当前位置为空;关键码刚好匹配。
public void insert(K key) {
root = insert(root, key);
}
private Node insert(Node n, K key) {
if (n == null) return new Node(key); // 基例
int tmp;
if ((tmp = key.compareTo(n.key)) 0) {
n.left = insert(n.left, key);
} else if (tmp > 0) {
n.right = insert(n.right, key);
} else {
// 刚好匹配,结点的数据进行更新即可
// 定义的结点忽略了具体的数据,所以空操作即可
}
return n;
}
删除
bst的删除可以分为三种情况。
① 要删除的结点就是叶子结点:直接删除即可,因为不会影响bst的任何性质。
② 要删除的结点有一个孩子:将当前结点替换成孩子结点即可。
③ 要删除的结点有两个孩子:这个删除就比较巧妙,把中序遍历中的后继结点覆盖要删除的结点即可。其实就是将右子树中的最小值覆盖当前要删除的结点,接着在右子树中删除最小值。
public void delete(K key) {
root = delete(root, key);
}
private Node delete(Node n, K key) {
if (n == null) return n;
int tmp;
if ((tmp = key.compareTo(n.key)) 0) {
n.left = delete(n.left, key);
} else if (tmp > 0) {
n.right = delete(n.right, key);
} else {
if (n.left == null) // 只有一个孩子的情况 + 叶子的情况
return n.right;
else if (n.right == null)
return n.left;
Node min = minNode(n.right); // 有两个孩子的情况
n.key = min.key; // 右子树中最小点,覆盖当前删除的点
n.right = delete(n.right, n.key); // 在右子树中继续执行删除操作
}
return n;
}
// n为根的子树中的最小结点
private Node minNode(Node n) {
Node tmp = null;
while (n != null) {
tmp = n;
n = n.left;
}
return tmp;
}
根据前序遍历、中序遍历构建二叉树
根据遍历序列构建二叉树关键在于找到根,仍然从递归的角度分析:
- 找到当前的根(前序序列的第一个元素)。
- 根据找到的根元素,可以划分出左子树的前序、中序序列,右子树的前序、中序序列。
- 在左子树中找根(同类型子问题),在右子树中找根(同类型子问题)……以此类推。
- 基例:前序序列为空。
/**
* 根据前序遍历和中序遍历构造bst
* @param pre 前序遍历key值
* @param in 中序遍历key值
* @return 构建完的bst的根
*/
public Node createBST(List pre, List in) {
if (pre == null || pre.size() == 0) return null;
K k = pre.get(0); // 前序遍历的第一个点key就是根节点key
Node rt = new Node(k); // 构建出根结点
int leftSize = in.indexOf(k); // 根节点在中序遍历中的位置,也是左子树的大小
rt.left = createBST(
pre.subList(1, 1 + leftSize),
in.subList(0, leftSize)); // 找出左子树的根结点
rt.right = createBST(
pre.subList(1 + leftSize, pre.size()),
in.subList(leftSize + 1, pre.size())); // 找出右子树的根结点
return rt;
}
求二叉树的结点个数
对于二叉树这种递归定义的数据结构,利用递归解决问题真的是屡试不爽:
二叉树的结点个数
= 左子树的结点个数(同类型子问题) + 右子树的结点个数(同类型子问题) + 1(根)
基例:树为空。
/**
* 求二叉树的节点数
* @return 二叉树的结点数
*/
public int size() {
return size(root);
}
/**
* @param r 当前子树的根结点
* @return r为根的子树的结点数
*/
public int size(Node r) {
if (r == null) return 0;
return size(r.left) + size(r.right) + 1;
}
求二叉树第k层的结点个数
嗯,没错还是递归来看:
二叉树第k层的结点个数
= 左子树第k-1层的结点个数(同类子问题) + 右子树第k-1层的结点个数(同类子问题)
基例:当前所在的层次 >= k
/**
* @param k 层次(从1开始)
* @return 第k层的结点个数
*/
public int levelSize(int k) {
return levelSize(k, 1, root);
}
private int levelSize(int k, int currentLevel, Node n) {
if (n == null) return 0;
else if (currentLevel == k) return 1;
else if (currentLevel > k) return 0;
return levelSize(k, currentLevel + 1, n.left) // 左子树
+ levelSize(k, currentLevel + 1, n.right); // 右子树
}
求二叉树的叶子结点个数
递归角度分析:
二叉树的叶子结点个数
= 左子树的叶子结点个数 + 右子树的叶子结点个数
基例:当前就是叶子结点
/**
* @return bst的叶子数
*/
public int leaves() {
return leaves(root);
}
private int leaves(Node n) {
if (n == null) return 0;
else if (n.right == null && n.left == null) return 1;
else
return leaves(n.right) // 左子树的叶子结点个数
+ leaves(n.left); // 右子树的叶子结点个数
}
求二叉树的深度&高度
之前提到过,对于树而言深度和高度数值上是没有区别的,这里就分析一下高度(到叶子的距离)。递归分析走起:
二叉树的高度
= max(左子树的高度,右子树的高度)+ 1
基例:树为空。
/**
* @return bst的高度(最远的叶子到跟的路径长度)
*/
public int height() {
if (root == null) return 0;
return height(root);
}
/**
* @param n 当前子树的根结点
* @return 结点n的高度
*/
private int height(Node n) {
if (n == null) return -1;
return Math.max(height(n.left), height(n.right)) + 1;
}
求二叉树中结点的最大距离(树直径)
递归分析走起,树直径可以分为三种情况:只存在左子树中、只存在右子树中、横跨根结点,故:
二叉树的直径
= max(左子树直径,右子树直径,左子树的高度 + 右子树的高度 + 2)
基例:树为空
/**
* @return 二叉树的直径
*/
public int diameter() {
return diameter(root);
}
private int diameter(Node n) {
if (n == null) return 0;
int leftHeight = height(n.left);
int rightHeight = height(n.right);
int leftDiameter = diameter(n.left);
int rightDiameter = diameter(n.right);
return Math.max(
leftHeight + rightHeight + 2, // 横跨根结点
Math.max(leftDiameter, rightDiameter) // 只存在于左子树或者右子树
);
}
判断二叉树是不是完全二叉树
首先完全二叉树是指这样一棵二叉树:
- 除了最后一层,其余层结点都铺满。
- 最后一层的结点从左往右开始出现。
仍然先尝试使用递归,但是对于这个问题使用递归来解决似乎就有些棘手,因为没有办法找到同类型的子问题。转变策略,再观察一下完全二叉树的结构,可以发现它整体是从左往右,从上到下,非常符合层次遍历的逻辑。那么,如果是完全二叉树,则层次遍历从左往右,从上到下一路遍历下来都不应该在完全遍历完之前遇到null
空结点,否则不是完全二叉树。
/**
* 判断当前二叉树是否是完全二叉树
*
* @return true:是完全二叉树
*/
public boolean isComplete() {
if (root == null) return true;
Queue q = new LinkedList<>();
q.add(root);
Node n;while (!q.isEmpty()) {
n = q.remove();if (n == null) { // 一旦遇到空结点就跳出循环break;
} else {
q.add(n.left);
q.add(n.right);
}
}while (!q.isEmpty()) { // 检查遇到了空结点时,是否还存在未遍历到的结点if (q.remove() != null) // 存在未遍历到的结点,不是完全二叉树return false;
}return true;
}
二叉树镜像
当你照着一面镜子,你的左手对于镜子里的它就是右手,你的右手对于镜子里的它就是左手。对于二叉树同理,二叉树照镜子,其左子树就是镜子里的它的右子树,其右子树就是镜子里的它的左子树,即左右子树交换。而左右子树也在照镜子呀,所以对于左右子树是同类型的子问题,使用递归解决:
- 二叉树镜像 = 左右子树镜像 then 交换左右子树的位置
- 基例:树为空
/**
* 将二叉树镜像
*/
public void mirror() {
root = mirror(root);
}
private Node mirror(Node n) {
if (n == null) return null;
n.left = mirror(n.left);
n.right = mirror(n.right);
Node tmp = n.left;
n.left = n.right; n.right = tmp;
return n;
}
小结
这么一路下来,对二叉树终于有那么些感觉了吧~一个递归的产物,所以其大部分问题都可以递归解决,在分析递归的时候主要找到递归的同类型子问题和递归的最终基例,此路不通再寻它路。