数据结构之二叉树

二叉树的起源

说起二叉树,我们不能直接从 “树” 开始,而是要回到数据结构的两大 “原始部落”:数组和链表。二叉树(尤其是二叉搜索树 BST)的出现,本质上是一场为了终结 “性能偏科” 而发起的架构革命。


原始部落的“偏科”困境

在二叉树诞生前,程序员只能在两种极端的存储方式中挣扎:

数组的痛:如果你在 100 万长度数组开头插入一个数,你得把后面 99.9 万个数往后挪一位。这实在 是不可接受的。
链表的痛:即便链表是排好序的,你也无法跳跃。想找中间的数?对不起,请从头开始数。


二叉树的起源

二叉搜索树(BST)的灵感来源极其天才:如果我们将二分查找的 “逻辑过程” 直接变成数据的 “存储物理结构” 呢?

  • 二分查找的逻辑:先看中间的数,比它小往左看,比它大往右看。
  • 二叉树的结构:把中间那个数作为根节点,左边连小的,右边连大的。

二叉树 = 具有二分查找能力的链表:它通过增加一个维度的指针(从 next 变成 left / right),成功实现了到二维的跨越。


解决的问题

二叉树存在的终极任务只有四个字:动态平衡,即在数据不断变动(动态插入/删除)的情况下,依然保持极高的查找速度。

  • 它既像链表,节点分散存储,插入时只需改动几个指针,不需要挪动整块内存。
  • 它又像数组:通过父子节点的逻辑关系,每次比较都能砍掉一半的搜索范围。


从理想到现实的修补

虽然二叉树(BST)理论上解决了数组和链表的矛盾,但它在现实中遇到了一个致命伤:退化。如果数据是有序存入的(比如 1, 2, 3, 4, 5),二叉树会 “长歪”,变成一根长长的棍子(链表),这时它所有的优势都荡然无存。于是从二叉树在20世纪60年代被正式提出,到后续几十年的算法史,其实都是在为二叉树做 “整形手术”:

  • AVL 树:为了解决退化,发明了“旋转”,强迫树必须长得左右对称。
  • 红黑树:觉得 AVL 旋转太频繁浪费性能,于是发明了 “颜色规则”,在平衡和效率间找平衡(用于 Java HashMap)。
  • B/B+ 树:觉得内存太小,要把树存到硬盘里,于是把 “二叉” 变成了 “多叉”,减少翻页次数(用于数据库索引)。


代码实现

下面是 BST 核心代码及其效果演示。你可看到它是如何通过递归实现 “自动导航” 的。

🔍 准备就绪。尝试搜索 15 或 35。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
/**
* @author KJ
* @description 最基本的二叉树
*/
public class MyBST {

private TreeNode root;
public static class TreeNode {
int value;
TreeNode left;
TreeNode right;

public TreeNode(int value) {
this.value = value;
}
}

/**
* 插入
*/
public void insert(int value) {
// 必须接收并设置root,否则 root 永远是空
this.root = insert(this.root, value);
}

private TreeNode insert(TreeNode curRoot, int value) {
if (curRoot == null) {
return new TreeNode(value);
}
if (value < curRoot.value) {
curRoot.left = insert(curRoot.left, value);
} else if (value > curRoot.value) {
curRoot.right = insert(curRoot.right, value);
}
return curRoot;
}

/**
* 查找
*/
public boolean search(int value) {
TreeNode curRoot = this.root;
while (curRoot != null) {
if (value == curRoot.value) return true;
curRoot = value < curRoot.value ? curRoot.left : curRoot.right;
}
return false;
}

/**
* 中序遍历打印树结构
*/
public void printTree() {
printTree(root, 0);
}

private void printTree(TreeNode node, int level) {
// 如果需要分别对左右子树做分别处理,可以增加入参标识
if (node == null) return;

// 1. 先打印右子树(因为在视觉上右子树在上方)
printTree(node.right, level + 1);

// 2. 打印当前节点:根据层级进行缩进
if (level == 0) {
System.out.println(curRoot.value);
} else {
String subPrefix = "| ".repeat(level - 1);
System.out.println(subPrefix + "|----" + curRoot.value);
}

// 3. 最后打印左子树
printTree(node.left, level + 1);
}

public static void main(String[] args) {
MyBST bst = new MyBST();

// 1. 模拟数据存入
int[] nodes = {25, 15, 35, 10, 20, 30, 40};
Arrays.stream(nodes).forEach(bst::insert);

// 2. 遍历展示树的结构
bst.printTree();
System.out.println();

// 3. 测试搜索功能
System.out.println(bst.search(20) ? "存在" : "不存在");
System.out.println(bst.search(99) ? "存在" : "不存在");
}
}

测试效果:

1
2
3
4
5
6
7
8
9
10
|    |---40
|---35
| |---30
25
| |---20
|---15
| |---10

存在
不存在


二叉树和堆的关系

简单来说,堆(Heap) 就是一个非常有 “纪律” 的完全二叉树。完全满足两个条件就可以称之为“堆”:

  • 条件 A:结构必须是 “完全二叉树”:普通的二叉树可以长得歪七拧八,左边一长串,右边空荡荡。但堆要求树的每一层必须是满的。如果最后一层不满,也必须从左到右依次排开,不能跳着长。就像排队报数,必须先站满第一排,再站第二排,且第二排的人必须靠左站齐,不能中间留位子。
  • 条件 B:必须满足 “堆序性”:在二叉树里,节点之间可能没啥大小逻辑。但在堆里,必须满足 父节点 > 子节点(大顶堆)或 父节点 < 子节点(小顶堆)。

有了树,为什么要搞出个 “堆” 来?主要还是为了解决 “找极值” 的问题。

  • 效率极高:如果你想从100万个数字里不停地找出最大的那个,用普通数组你得翻遍全身;但用大顶堆,最大值就在最顶端,一眼就能看到。
  • 动态调整:当你拿走最大的数,或者新进来一个数,堆能通过一套简单的 “比武招亲” 规则(上浮或下沉),快速重新排好顺序。

说白了,堆的最大用处就是 “动态管理最值”。只要你的需求符合以下描述,选堆那就准没错:

  • 数据一直在变动(新元素加入或旧元素删除)。
  • 你永远只想关注这群数据里最大、最小或排在最前面的那几个。