树(Tree)
高度和深度都是边数:
二叉树(Binary Tree)
每个节点最多有两个“叉”,也就是两个子节点的树,分别是左子节点和右子节点。
满二叉树: 叶子节点全都在最底层,除了叶子节点之外,每个节点都有左右两个子节点,这种二叉树就叫做满二叉树(编号2);
完全二叉树: 叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最大。(编号 3 )
链式存储法
大部分二叉树代码都是通过这种结构来实现的。
完全二叉树不就是“芸芸众树”啊,为神马还要单独提出来?
因为完全二叉树有这种存储规律:节点 X 存储在数组中下标为 i 的位置,下标为 2 * i 的位置存储的就是左子节点,下标为 2 * i + 1 的位置存储的就是右子节点。
这种规律完全可以用数组存储。因为数组的存储方式并不需要像链式存储法那样,要存储额外的左右子节点的指针。所以,堆是一种完全二叉树,最常用数组存储。(这也就是为什么会单独定义完全二叉树)
二叉树遍历
每个节点最多会被访问两次,所以遍历操作的时间复杂度2n,时间复杂度是 O(n)。
//中序遍历递归函数
private void inOrder(StringBuilder sb, Node root) {
if (root == null) return;
inOrder(sb,root.left);
sb.append(root.data + ",");
inOrder(sb,root.right);
}
//前序遍历
private void preOrder(StringBuilder sb,Node root){
if (root == null) return;
sb.append(root.data + ",");
preOrder(sb,root.left);
preOrder(sb,root.right);
}
//后序遍历
private void postOrder(StringBuilder sb,Node root){
if (root == null) return;
preOrder(sb,root.left);
preOrder(sb,root.right);
sb.append(root.data + ",");
}
/**
* 难点:按层遍历
* @return
*/
public String tierOrder(){
if (root == null) return null;
//保存同一级的结点
LinkQueue queue = new LinkQueue(20);
//按序保存输出值
ArrayList list = new ArrayList();
//先将根节点入队
queue.enqueue(root);
tierOrder(queue,list);
return list.toString();
}
private void tierOrder(LinkQueue queue,ArrayList list) {
//队为空说明该层没有结点了
if (queue.isEmpty()) return;
//获取当前层结点个数
int size = queue.getSize();
//只依次取出当前层结点
//并且每取出一个结点将此结点的子节点入队
while (0<size){
Node root = (Node)queue.dequeue();
list.add(root);
--size;
if (root.left != null) queue.enqueue(root.left);
if (root.right != null) queue.enqueue(root.right);
}
//递归
tierOrder(queue,list);
}
二叉查找树(Binary Search Tree)
就是排序后的树:二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。(排序才能查找嘛,按照查找值与结点值比较大小,判断是往左还是往右)
查找
public Node find(int data) {
Node p = tree;
while (p != null) {
if (data < p.data)
p = p.left;
else if (data > p.data)
p = p.right;
else return p;
}
return null;
}
插入
public void insert(int data) {
if (tree == null) {
tree = new Node(data);
return;
}
Node p = tree;
while (p != null) {
if (data > p.data) {
if (p.right == null) {
p.right = new Node(data);
return;
}
p = p.right;
} else { // data < p.data
if (p.left == null) {
p.left = new Node(data);
return;
}
p = p.left;
}
}
}
删除
- 第一种情况是,如果要删除的节点没有子节点,我们只需要直接将父节点中,指向要删除节点的指针置为 null。
- 第二种情况是,如果要删除的节点只有一个子节点(只有左子节点或者右子节点),那么直接让该子结点(无论左右)代替该结点就可。
- 第三种情况是,如果要删除的节点有两个子节点。我们需要找到这个节点的右子树中的最小节点,把它替换到要删除的节点上。 (此替换一般采用直接赋值,将该最小结点的值直接赋该删除结点)然后再删除掉这个最小节点,因为最小节点肯定没有左子节点(如果有左子结点,那就不是最小节点了),所以,我们可以应用上面两条规则来删除这个最小节点。比如图中的删除节点 18。
public void delete(int data) {
Node p = tree; // p指向要删除的节点,初始化指向根节点
Node pp = null; // pp记录的是p的父节点
while (p != null && p.data != data) {
pp = p;
if (data > p.data) p = p.right;
else p = p.left;
}
if (p == null) return; // 没有找到
// 要删除的节点有两个子节点
if (p.left != null && p.right != null) { // 查找右子树中最小节点
Node minP = p.right;
Node minPP = p; // minPP表示minP的父节点
while (minP.left != null) {
minPP = minP;
minP = minP.left;
}
p.data = minP.data; // 将minP的数据替换到p中
p = minP; // 下面就变成了删除minP了
pp = minPP;
}
// 删除节点是叶子节点或者仅有一个子节点
Node child; // p的子节点
if (p.left != null) child = p.left;
else if (p.right != null) child = p.right;
else child = null;
if (pp == null) tree = child; // 删除的是根节点
else if (pp.left == p) pp.left = child;
else pp.right = child;
}
支持重复数据的二叉查找树
- 第一种方法比较容易。二叉查找树中每一个节点不仅会存储一个数据,因此我们通过链表和支持动态扩容的数组等数据结构,把值相同的数据都存储在同一个节点上。
- 第二种方法:
大于等于的值都放在右子树上:
查找数据的时候,遇到值相同的节点,我们并不停止查找操作,而是继续在右子树中查找,直到遇到叶子节点,才停止。
删除操作,我们也需要先查找到每个要删除的节点,然后再按前面讲的删除操作的方法,依次删除。
二叉查找树的时间复杂度分析
二叉树插入、删除、查找时间复杂度[O(logn),O(n)]之间,越平衡越靠近O(logn)。(其实都跟树的高度成正比,也就是 O(height)(log2n < height < n))
平衡二叉查找树:插入、删除、查找操作的时间复杂度比较稳定,是 O(logn)。
二叉树相对散列表的优势
- 第一,散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序。而对于二叉查找树来说,我们只需要中序遍历,就可以在 O(n) 的时间复杂度内,输出有序的数据序列。
- 第二,散列表扩容耗时很多,而且当遇到散列冲突时,性能不稳定(特别情况可能退化为O(n))尽管二叉查找树的性能不稳定,但是在工程中,我们最常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在 O(logn)。(普通的二叉树也可能退化为链表O(n))
- 第三,尽管散列表的查找等操作的时间复杂度是常量级的,但因为哈希冲突的存在,这个常量不一定比 logn 小(比如,拉了很长的链),所以实际的查找速度可能不一定比 O(logn) 快。加上哈希函数的耗时,也不一定就比平衡二叉查找树的效率高。
- 第四,散列表的构造比二叉查找树要复杂,需要考虑的东西很多。比如散列函数的设计、冲突解决办法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性这一个问题。最后,为了避免过多的散列冲突,散列表装载因子不能太大,特别是基于开放寻址法解决冲突的散列表。