二叉树的遍历、哈希表的冲突处理,这两个东西基本是Java面试里绕不开的“送命题”。我当年面过不少公司,几乎每一轮技术面都会从这里抽一道题,要么让你手写二叉树的前中后序遍历,要么问HashMap在JDK 8里为什么要引入红黑树。这篇文章不打算讲教科书式的定义,而是从实际面试和开发角度出发,把二叉树和哈希表这两块内容拆开揉碎,把原理、代码、坑点一次说清楚。
这篇文章适合三类人:准备Java面试、正在复习数据结构的在校生,工作中经常处理集合类但没深究过底层原理的开发者,以及想把手写代码能力补一补的转行朋友。看完之后,你不仅能应付常见的面试手写题,还能理解HashMap源码里那些“看似奇怪的设计”到底是为了解决什么问题。
1. 二叉树:从结构到遍历一次说透
1.1 为什么面试官总爱问二叉树
二叉树在面试中出现频率极高,不是因为它本身多复杂,而是因为它同时考了三个能力:递归思维、指针/引用操作、边界条件处理。这三种能力恰好是写业务代码时最常用到的,所以面试官用二叉树来快速判断一个候选人的代码功底,性价比非常高。
二叉树本身是一种树形结构,每个节点最多有两个子节点,分别叫左孩子和右孩子。这个限制让它的结构足够简单,但又保留了树的核心特性:层次关系、递归定义、非线性存储。你可以把二叉树理解成“链表的分叉版本”——链表只有一个next指针,二叉树有两个方向可以走,这就让遍历和搜索有了多种策略。
在实际开发中,二叉树也不是摆设。编译器里的表达式树、数据库索引里的B+树(B树是二叉树的多路扩展)、文件系统的目录结构,底层都离不开树形组织的思想。掌握了二叉树,你再去看这些系统会轻松很多。
1.2 二叉树的节点定义与三种递归遍历
在Java里定义二叉树节点非常简单,通常就下面这几行:
java复制public class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode() {}
TreeNode(int val) { this.val = val; }
TreeNode(int val, TreeNode left, TreeNode right) {
this.val = val;
this.left = left;
this.right = right;
}
}
这个结构和链表节点的定义极其相似,只是把单个next指针换成了left和right两个指针。理解这一点很重要,很多初学者觉得二叉树难,其实就是没意识到它本质上是链表的推广。
三种递归遍历是二叉树的基础操作。前序遍历先访问根节点,再遍历左子树,最后遍历右子树;中序遍历先遍历左子树,再访问根节点,最后遍历右子树;后序遍历先遍历左子树,再遍历右子树,最后访问根节点。代码写起来就是调整三行代码的顺序:
java复制// 前序遍历:根 -> 左 -> 右
public void preorder(TreeNode root) {
if (root == null) return;
System.out.print(root.val + " ");
preorder(root.left);
preorder(root.right);
}
// 中序遍历:左 -> 根 -> 右
public void inorder(TreeNode root) {
if (root == null) return;
inorder(root.left);
System.out.print(root.val + " ");
inorder(root.right);
}
// 后序遍历:左 -> 右 -> 根
public void postorder(TreeNode root) {
if (root == null) return;
postorder(root.left);
postorder(root.right);
System.out.print(root.val + " ");
}
递归代码很简洁,但很多面试官会追问一句“递归的底层是怎么执行的”。这里要能答得上来:每次递归调用都会在JVM栈上压入一个栈帧,保存当前方法的局部变量和返回地址。所以递归深度过大会导致栈溢出,这就是后面要讲的StackOverflowError的根源。
注意:面试手写遍历时,很多人都能背出递归代码,但被问到“中序遍历二叉搜索树的结果有什么特点”时会卡住。这里记住一个结论:对二叉搜索树做中序遍历,结果是递增有序的。这几乎是必考点。
1.3 层序遍历与迭代写法
递归遍历本质上是深度优先搜索(DFS),而层序遍历则是典型的广度优先搜索(BFS)。层序遍历要求从上到下、从左到右逐层访问节点,实现时需要借助队列:
java复制public void levelOrder(TreeNode root) {
if (root == null) return;
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
while (!queue.isEmpty()) {
TreeNode node = queue.poll();
System.out.print(node.val + " ");
if (node.left != null) queue.offer(node.left);
if (node.right != null) queue.offer(node.right);
}
}
这里的核心思路是:每从队列头部弹出一个节点,就把它的左右孩子加到队列尾部。这样先进先出的特性保证了节点按照层级顺序被处理。很多面试题,比如“求二叉树的最大宽度”“二叉树的右视图”,都是在层序遍历框架上加一点变量统计。
迭代写法考察的是手动模拟递归栈的能力。以先序遍历为例,用栈实现时先压右孩子、再压左孩子,这样出栈的时候才能先访问左孩子:
java复制public void preorderIterative(TreeNode root) {
if (root == null) return;
Deque<TreeNode> stack = new ArrayDeque<>();
stack.push(root);
while (!stack.isEmpty()) {
TreeNode node = stack.pop();
System.out.print(node.val + " ");
if (node.right != null) stack.push(node.right);
if (node.left != null) stack.push(node.left);
}
}
中序和后序的迭代写法稍微绕一点,中序需要一路往左走到头再开始处理,后序可以用“前序改写+反转结果”的技巧。面试时如果时间紧张,优先掌握前序和中序的迭代写法,后序用双栈法或反转法兜底。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 进阶二叉树:搜索树、深度与线索化
2.1 搜索二叉树的核心性质与插入删除
搜索二叉树的英文是Binary Search Tree,简称BST。它有一个关键性质:对于任意节点,左子树所有节点的值都小于它,右子树所有节点的值都大于它。这个性质决定了它在查找场景下的效率:理想情况下每次比较都能排除一半的子树,查找时间复杂度是O(log n)。
插入操作就是不断比较然后向下走的过程:
java复制public TreeNode insertIntoBST(TreeNode root, int val) {
if (root == null) return new TreeNode(val);
if (val < root.val) {
root.left = insertIntoBST(root.left, val);
} else if (val > root.val) {
root.right = insertIntoBST(root.right, val);
}
return root;
}
删除操作是BST里最容易写错的。删除一个节点分三种情况:叶子节点直接删;只有一个孩子就让孩子顶上来;有两个孩子,就要在右子树里找到最小节点(或者左子树里的最大节点)来替换被删节点。很多人面试时能讲清楚思路,但手写代码就容易漏掉情况,建议多练几遍。
BST有个天然的缺陷:如果按顺序插入1到n,它会退化成一个链表,查找复杂度变成O(n)。这引出后面的平衡二叉树概念,这也是HashMap树化时选择红黑树的原因——红黑树是近似平衡的BST,能保证最坏情况下也有O(log n)的查找效率。
2.2 二叉树深度、平衡判断与常见题型
“二叉树的深度”是热词里出现过的题目,也是LeetCode上的经典题。深度就是根节点到最远叶子节点的最长路径上的节点数。代码极其简洁:
java复制public int maxDepth(TreeNode root) {
if (root == null) return 0;
return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1;
}
这行代码的关键在于理解“+1”的含义:当前节点的深度等于左右子树中较深的那个再加一。递归的终止条件是空节点返回0。类似的还有“最小深度”,但最小深度有个坑:如果某个节点只有一个孩子,不能简单地取Math.min,否则会出现空子树深度为0导致的错误结果。正确写法是分情况讨论,只有左右孩子都为空时才返回1,否则返回有孩子的那一侧的深度加一。
平衡二叉树的判断是另一道高频题。一棵二叉树是平衡的,当且仅当任意节点的左右子树高度差不超过1。这里如果跟着“求深度”的思路写一个自顶向下的递归,会重复计算很多次。更优的做法是自底向上,在求高度的同时判断平衡性:
java复制public boolean isBalanced(TreeNode root) {
return height(root) != -1;
}
private int height(TreeNode node) {
if (node == null) return 0;
int leftH = height(node.left);
if (leftH == -1) return -1;
int rightH = height(node.right);
if (rightH == -1) return -1;
if (Math.abs(leftH - rightH) > 1) return -1;
return Math.max(leftH, rightH) + 1;
}
2.3 线索二叉树:为什么需要它
线索二叉树在热词里出现了,它不算高频面试题,但属于“懂原理就加分”的内容。传统的二叉树遍历需要递归或栈,而线索二叉树通过在空指针域中存储前驱和后继节点的信息,让遍历可以像链表一样线性完成,不再需要递归和栈。
具体做法是:将节点的空左孩子指针指向中序遍历序列中的前驱节点,空右孩子指针指向后继节点。为了区分正常孩子和线索,需要额外两个布尔标志位。这种思路在Morris遍历里体现得最明显——Morris遍历用O(1)额外空间实现二叉树遍历,核心就是借助线索思想临时修改树的结构,遍历完再恢复。
我平时不怎么建议面试的时候主动聊线索二叉树,除非你已经熟练掌握递归和迭代遍历。但如果你想展现深度,可以简单说一句“我用Morris遍历实现过O(1)空间的中序遍历”,如果面试官感兴趣,再展开讲。
3. 哈希表:从原理到HashMap源码
3.1 哈希函数与哈希冲突
哈希表的核心思想用一个生活化类比就能讲清楚:你去图书馆还书,管理员不是一本书一本书翻书架找位置,而是按照书的编号直接算出它该放在哪一排,放进去就行。哈希函数就是把“书的编号”映射到“书架位置”的算法。
在Java里,哈希表就是HashMap。它内部维护一个数组(称为桶数组),put操作时先计算key的哈希值,再通过哈希值定位到数组下标,把键值对存进去。get操作时用同样的方式定位,就能在近似O(1)的时间里拿到值。
问题来了:不同的key算出来的哈希值可能落到同一个数组下标上,这就是哈希冲突。处理哈希冲突的常见方案有两种:开放地址法和链地址法。把这两种方案对比一下:
| 维度 | 开放地址法 | 链地址法 |
|---|---|---|
| 冲突解决思路 | 在表内寻找下一个空位 | 在同一个桶下挂链表 |
| 存储密度 | 表不能装太满,负载因子受限 | 链表可较长,容忍更高负载 |
| 缓存友好性 | 数组连续内存,缓存友好 | 链表跳转,缓存不友好 |
| Java中的应用 | ThreadLocalMap | HashMap |
Open addressing在Java里的代表是ThreadLocalMap,而HashMap用的是链地址法。JDK 8之后,当链表长度超过8且数组长度大于等于64时,链表会转成红黑树,这是为了抵抗哈希攻击——当哈希函数被恶意构造、大量key都落到同一个桶时,链表查询会退化成O(n),红黑树能保证O(log n)。
3.2 HashMap源码里的三个关键参数
看HashMap源码时抓住三个参数就够了:初始容量16、负载因子0.75、树化阈值8。这三个参数背后都有讲究。
初始容量是16,这是2的幂次。HashMap计算数组下标时用(n - 1) & hash代替取模运算,因为当容量n是2的幂时,hash % n和hash & (n - 1)的结果等价,而位运算比取模快得多。这也是为什么HashMap扩容时总是翻倍而不是随便加长。
负载因子0.75是一个时间与空间的折中。负载因子越大,能装的元素越多、空间利用率越高,但冲突概率也增大,查询变慢;负载因子越小,空间浪费越多,但冲突少、查询快。0.75是大量实验统计出来的一个平衡点。当元素数量超过容量 * 负载因子时,HashMap会触发扩容,容量翻倍,所有元素重新散列到新数组里。
树化阈值8的选择也值得说。理想情况下,随机哈希码的分布遵循泊松分布,链表长度达到8的概率已经非常低(约千万分之一)。设置8作为阈值,是为了在“几乎不可能出现”的极端情况下提供兜底保护。而反树化阈值是6,留了一个缓冲区间,避免元素在7和8之间频繁震荡导致反复转换。
3.3 HashMap的put流程与红黑树
put操作的完整流程是:先对key的hashCode做一次扰动(高16位异或低16位),然后定位桶位置;如果桶为空,直接放新节点;如果桶不为空,遍历桶内元素,有相同key就覆盖旧值;否则把新节点追加到链表末尾或红黑树中。JDK 8之前链表用头插法,JDK 8之后改成尾插法,这是为了避免多线程扩容时产生循环链表的问题。
红黑树的引入让HashMap源码复杂度上了一个台阶。红黑树本质上是一棵自平衡的二叉搜索树,它通过节点颜色(红/黑)和旋转操作保证最长路径不超过最短路径的两倍。这保证了即使在最坏情况下,查找、插入、删除都是O(log n)。对开发者来说,最直观的理解就是:同样的key落到同一个桶里,JDK 8的最坏查询时间从O(n)变成了O(log n)。
这里要补充一个面试高频题:HashMap为什么线程不安全?因为多线程并发put时,两个线程同时检测到某个位置为空,然后都往里写,最后只有一个线程的数据能保留;更严重的是扩容阶段,多个线程同时操作链表或树,可能导致数据丢失甚至形成死循环。日常开发里并发场景要用ConcurrentHashMap,而不是给HashMap加锁。
4. 面试高频对比与手写实现
4.1 二叉树和哈希表到底该怎么选
二叉树和哈希表都能快速查找,但适用场景差别很大。面试官常问“为什么有了HashMap还需要TreeMap”,本质上就是在考察这两种数据结构的差异。
哈希表的优势是平均时间复杂度低,插入、查找、删除都是O(1),但它有几个明显的短板:遍历时无序;查找范围(比如小于某个值的所有key)效率极低;扩容有性能损耗。而二叉搜索树(尤其是平衡树)虽然单次操作是O(log n),但它天然有序,可以高效地做范围查询、按序遍历,还可以方便地找最大最小值。
我个人的选型建议是:只按key精确查找就选HashMap;需要按顺序遍历key就选TreeMap;既要快速查找又要范围查询,可以考虑跳表或者对有序性要求不高的场景用ConcurrentSkipListMap。在面试里如果能说清楚这个权衡,比单纯背源码加分很多。
4.2 数据结构设计不当导致的OutOfMemoryError
热词里有一条“java: OutOfMemoryError: insufficient memory”,这是个值得展开的话题。很多人以为OOM只是内存不够,但很多时候是数据结构用得不对导致的。
最常见的例子是:用HashMap做缓存,但key是自定义对象,却没有重写equals和hashCode。这会导致每次put都生成新的key对象,hashCode不同就存到不同桶里,旧数据永远不会被覆盖,缓存无限膨胀,最终OOM。重写equals和hashCode之后,相同业务意义的key才能正确落到同一个桶里,put操作才能覆盖旧值,内存才能被控制住。
另一个常见问题是set集合里存了可变对象。HashSet底层是HashMap,如果对象加入HashSet后修改了参与hashCode计算的字段,对象的哈希值就变了,但它在哈希表中的位置还是老的,这样既找不到它,也无法删除,成了“内存垃圾”。所以放进HashSet或者作为HashMap的key的对象,最好设计成不可变对象,比如用record或把字段设为final。
4.3 手写一个简化版HashMap
面试时手写HashMap虽然不常见,但能加深理解。我建议平时练习时写一个简化版,实现put和get,用链地址法处理冲突,不涉及扩容:
java复制public class SimpleHashMap<K, V> {
private static final int CAPACITY = 16;
private Node<K, V>[] table;
static class Node<K, V> {
K key;
V value;
Node<K, V> next;
Node(K key, V value) {
this.key = key;
this.value = value;
}
}
@SuppressWarnings("unchecked")
public SimpleHashMap() {
table = new Node[CAPACITY];
}
private int hash(K key) {
return (key == null) ? 0 : key.hashCode() & (CAPACITY - 1);
}
public V put(K key, V value) {
int index = hash(key);
if (table[index] == null) {
table[index] = new Node<>(key, value);
return null;
}
Node<K, V> cur = table[index];
while (cur != null) {
if (cur.key.equals(key)) {
V oldValue = cur.value;
cur.value = value;
return oldValue;
}
if (cur.next == null) break;
cur = cur.next;
}
cur.next = new Node<>(key, value);
return null;
}
public V get(K key) {
int index = hash(key);
Node<K, V> cur = table[index];
while (cur != null) {
if (cur.key.equals(key)) {
return cur.value;
}
cur = cur.next;
}
return null;
}
}
这段代码把HashMap最核心的逻辑浓缩了:通过hashCode定位桶,通过equals判断key是否相同,冲突时挂在链表上。写一遍之后,再看真实源码会顺畅很多。
5. 常见问题与排查技巧实录
5.1 递归遍历时StackOverflowError
二叉树递归遍历在树特别深的时候会抛StackOverflowError。这个问题的根源是JVM的调用栈深度有限,默认情况下大约能承受数千到数万层的递归调用。解决办法有三个:把递归改成显式栈的迭代写法;使用Morris遍历降低空间复杂度;如果是数据规模确实大,就要考虑改用平衡树来保证树的深度是O(log n)。
我遇到过不少人把这个报错当成JVM配置问题,去调-Xss参数增大栈空间。这个方向治标不治本,因为栈空间设再大,递归深度达到千万级别还是会爆。更好的做法是从数据结构层面控制树的深度,或者换非递归实现。
5.2 Lombok编译报错:不受支持的编译器
热词里有一条“java: You aren't using a compiler supported by lombok, so lombok will not work”。这个问题在本地跑的时候经常遇到,尤其是IDEA自带的编译器版本和Lombok版本不兼容时。解决方案一般是升级Lombok版本到最新,或者在Maven/Gradle的编译参数里明确指定编译器版本,让Lombok的注解处理器能找到匹配的编译器接口。
排查步骤我一般按这个顺序来:先看IDE的编译日志,确认实际使用的JDK版本;再对比pom.xml里的Lombok版本和JDK版本;最后检查是不是有帮助模块和项目用了不同的JDK。如果版本都对不上,直接更新IDEA和Lombok插件通常能解决。
5.3 源发行版17需要目标发行版17
热词“java: 警告: 源发行版17需要目标发行版17”是典型的JDK版本不一致问题。项目编译用的JDK是17,但IDE里配置的字节码目标版本还是8或者11,就会出现这个警告。解决方法是到IDEA的Project Structure里把Project SDK和Project language level统一,再到Settings里把Java Compiler的Target bytecode version改成一致。
这个问题和前面的Lombok问题都属于环境类问题,但它们很真实地反映了一个情况:很多人的数据结构代码没问题,反而被环境配置搞得焦头烂额。所以我建议在准备面试算法题的同时,也把自己的本地Java环境梳理一遍,避免在关键时刻因为环境问题浪费时间。
聊到这里,想起一个实用的小技巧:排查二叉树相关代码问题时,我习惯在本地写一个工具方法,把树的结构用括号表达式打印出来,比如1(2(4,5),3(,6))这种格式,对比起来非常直观。哈希表的问题排查则靠重写toString,把table数组里每个桶的链表长度打出来,看看有没有严重的哈希倾斜。这两个小方法帮我省了不少时间,也分享给正在刷题的各位。
