LeetCode 面试题 04.02. 最小高度树
题目描述
题意分析
给定一个元素各不相同、且已按升序排列的整数数组,要求用它构造一棵二叉搜索树,并且这棵树的高度要尽可能小。题目允许返回任意一棵满足条件的树,判题用的是"高度是否最小 + 中序遍历是否等于原数组"的校验,而不是逐节点比对某棵标准答案。
约束里给了两个关键信号。第一,输入已经有序——这等于把 BST 最重要的性质(中序遍历升序)直接摆在面前,说明不需要做任何比较或插入排序,节点的相对顺序已经定死了。第二,要求高度最小——
n个节点的二叉树高度下界是 $\lceil \log_2(n+1) \rceil$,要达到这个下界,每一层都必须尽量填满,也就是任意节点的左右子树规模差不超过 1。
把这两条合起来:中序序列固定为原数组,同时要求左右子树尽量均衡。选定根之后,中序序列被根切成左右两段,这两段的长度就是左右子树的节点数;要让它们尽量均衡,根就必须取在(近似)中点。这一步推理是本题的全部难点,剩下的都是递归的机械展开。
边界上要覆盖:空数组(返回空树);单元素;偶数长度(中点有两个候选,取哪个都行,只要一致);以及递归区间收缩到
lo > hi时必须终止。
解法:分治选中点构造 BST
核心思路
先想暴力:把数组元素一个个插进一棵普通 BST。瓶颈在于插入顺序完全决定形态——按升序插入会得到一条向右退化的链,高度是 $n$,是最差而非最优;即使随机打乱插入,期望高度是 $O(\log n)$ 但没有最坏保证,而且改变了"用给定数组构造"的语义。
关键观察分两步。其一,BST 的中序遍历必须升序,而输入数组本身就是升序的,所以最终树的中序遍历必然等于这个数组——节点的中序位置是被输入锁死的,我们唯一能选的只有"谁当根"。其二,一旦选定
nums[mid]当根,中序序列nums[lo..hi]就被切成nums[lo..mid-1](左子树)和nums[mid+1..hi](右子树),左右子树的节点数分别是mid - lo和hi - mid。要让树高最小,就要让这两个数尽量接近,于是mid必须取区间中点。
由此得到递归定义:
build(lo, hi)返回"用nums[lo..hi]这段升序子数组构造出的、高度最小的 BST 的根"。它满足两条性质——中序遍历恰为nums[lo..hi];高度为 $\lceil \log_2(hi - lo + 2) \rceil$。递推关系是build(lo, hi) = 以 nums[mid] 为根,左孩子接 build(lo, mid-1),右孩子接 build(mid+1, hi),终止条件是lo > hi时返回空。
为什么"每层取中点"就能达到高度下界?因为每次递归都把区间长度砍成两半(相差至多 1),区间长度
len经过 $\lceil \log_2(len+1) \rceil$ 层就必然收缩到空,而这正是len个节点的二叉树高度的理论下界,所以取中点既是可行解也是最优解。同时,取中点为根天然满足 BST 性质:左段全部小于nums[mid]、右段全部大于它,这是输入有序直接给的,不需要额外校验。
解题步骤
- 入口调用
build(nums, 0, nums.length - 1),用闭区间[lo, hi]表示"当前要处理的这段子数组"。选闭区间是因为它和"mid属于当前段"的语义最贴合;用左闭右开也行,但终止条件和mid的计算都要跟着改,混用是本题最常见的越界来源。
- 递归第一步:
lo > hi时返回null。这对应"这段子数组为空,不需要节点"。注意判断是>而不是>=:lo == hi时区间里还有一个元素,必须建出一个叶子节点;写成>=会把所有叶子都丢掉,构造出的树只有一半的节点。
- 取
mid = lo + (hi - lo) / 2。写成这个形式而不是(lo + hi) / 2,是为了避免lo + hi在大数组上溢出 int(虽然本题规模小不会触发,但这是必须养成的习惯)。除法向下取整意味着偶数长度时取左中点,这不影响正确性——右中点同样能得到最小高度,只是形态不同,判题两者都接受。
- 用
nums[mid]建根节点。这是"选中点当根"的落地,也是保证均衡的唯一动作。
- 左孩子递归
build(lo, mid - 1),右孩子递归build(mid + 1, hi)。两个区间必须排除mid本身,否则元素会被重复建成节点,中序遍历不再等于原数组,同时区间不收缩会导致无限递归直至爆栈。
- 返回根节点。递归自底向上把子树逐层挂上,最终得到整棵树。
以
nums = [-10, -3, 0, 5, 9]走一遍(下标 0..4):
build(0, 4):mid = 0 + (4 - 0) / 2 = 2,根节点是nums[2] = 0。左子树处理[0, 1],右子树处理[3, 4]。
build(0, 1):mid = 0 + (1 - 0) / 2 = 0,节点值nums[0] = -10。左子树build(0, -1):lo > hi返回空。右子树build(1, 1):mid = 1,节点值nums[1] = -3;它的左右分别是build(1, 0)和build(2, 1),都因lo > hi返回空,所以-3是叶子。于是左子树是"根-10,右孩子-3"。
build(3, 4):mid = 3 + (4 - 3) / 2 = 3,节点值nums[3] = 5。左子树build(3, 2)为空;右子树build(4, 4)建出叶子nums[4] = 9。于是右子树是"根5,右孩子9"。最终形态:根
0,左孩子-10(其右孩子-3),右孩子5(其右孩子9)。高度是 3,而 5 个节点的高度下界是 $\lceil \log_2 6 \rceil = 3$,达到下界。中序遍历依次访问-10, -3, 0, 5, 9,与原数组一致,BST 性质成立。顺带验证
lo > hi这个终止条件的必要性:build(0, -1)中lo = 0、hi = -1,若误写成lo >= hi才返回空,那么build(1, 1)会被直接判空,-3这个节点根本不会被创建,中序遍历少了一个元素。
代码实现
class Solution {
public TreeNode sortedArrayToBST(int[] nums) {
return build(nums, 0, nums.length - 1);
}
private TreeNode build(int[] nums, int lo, int hi) {
if (lo > hi) {
return null;
}
int mid = lo + (hi - lo) / 2;
TreeNode root = new TreeNode(nums[mid]);
root.left = build(nums, lo, mid - 1);
root.right = build(nums, mid + 1, hi);
return root;
}
}
func sortedArrayToBST(nums []int) *TreeNode {
return build(nums, 0, len(nums)-1)
}
func build(nums []int, lo, hi int) *TreeNode {
if lo > hi {
return nil
}
mid := lo + (hi-lo)/2
root := &TreeNode{Val: nums[mid]}
root.Left = build(nums, lo, mid-1)
root.Right = build(nums, mid+1, hi)
return root
}
复杂度分析
- 时间复杂度:$O(n)$。每个数组元素恰好被用来创建一个节点、且只被访问一次;递归调用次数是节点数的常数倍(每个节点带来两次子调用,其中空区间的调用也是常数次),没有任何重复计算或回溯。
- 空间复杂度:$O(\log n)$(不计返回的树本身)。递归栈深度等于树高,而取中点保证了树是平衡的,树高为 $O(\log n)$。如果把构造出的
n个节点也算进去,则是 $O(n)$。
关键点总结
- 有序输入 + BST,等价于"中序序列已被锁定"。看到这两个条件同时出现,就该立刻意识到唯一的自由度是"谁当根",问题从"怎么构造"变成"怎么切分",难度直接降一个档次。
- 要高度最小,就让每次切分尽量均衡。这是分治类构造题的通用准则:递归深度由"每层规模缩减的比例"决定,只有对半砍才能拿到 $O(\log n)$ 的深度;偏一点点(比如总是取
lo)就退化成 $O(n)$ 的链。- 闭区间递归的三件套要配套写:终止条件
lo > hi、中点lo + (hi - lo) / 2、子区间[lo, mid-1]与[mid+1, hi]。三者中任意一处按另一种区间约定来写,都会导致漏节点、重复节点或无限递归。- 子区间必须严格收缩且排除已用元素。
mid已经被消费成根节点,两侧区间都要跳过它。凡是递归分治,写完子调用后应立刻检查"子问题规模是否严格小于父问题",这是防止爆栈的机械检查。- 面试视角:主动说明"答案不唯一"和高度下界。面试官常追问"取右中点行不行""为什么这样就是最小高度"。标准答法是:偶数长度时左右中点都可行,判题只校验高度与中序;最小高度等于 $\lceil \log_2(n+1) \rceil$,均衡切分每层规模减半,恰好达到这个下界。能顺带补一句"若输入换成有序链表(
109题),因为不能 $O(1)$ 随机访问,要么先转数组,要么用快慢指针找中点、或用中序模拟递归",展示对同一模型不同载体的迁移能力。
易错点总结
- 错误写法:终止条件写成
if (lo >= hi) return null;→ 用例nums = [1, 2]:build(0, 1)取mid = 0建出节点1,右子树build(1, 1)被直接判空,节点2丢失,中序遍历只有[1],判题失败。- 错误写法:终止条件写成
if (lo > hi) return new TreeNode(0);(返回哨兵节点而非空) → 用例nums = [1]:叶子节点1的左右各挂上一个值为 0 的假节点,中序遍历变成[0, 1, 0],既破坏 BST 性质又多出节点。- 错误写法:
mid = lo(总取左端点当根) → 用例nums = [1, 2, 3, 4, 5]:每层只砍掉一个元素,构造出一条全右链,高度是 5 而不是 3,虽然中序遍历正确,但不满足"高度最小",判题直接失败。- 错误写法:左右递归写成
build(lo, mid)和build(mid, hi)(没排除 mid) → 用例nums = [1, 2]:build(0, 1)取mid = 0,左子树递归build(0, 0)又建一个1,右子树build(0, 1)与父调用完全相同,区间不收缩,无限递归直至StackOverflowError。- 错误写法:
mid = (lo + hi) / 2→ 本题数据规模小不会出事,但在lo、hi接近Integer.MAX_VALUE的同类二分题上,lo + hi溢出成负数,mid变成负下标,直接数组越界。应统一写成lo + (hi - lo) / 2。- 错误写法:入口写成
build(nums, 0, nums.length)→ 用例nums = [1]:build(0, 1)中mid = 0正常,但右子树build(1, 1)会访问nums[1]越界抛异常。闭区间的右端必须是length - 1。- 错误写法:先对
nums调一次Arrays.sort保险 → 用例nums = [-10, -3, 0, 5, 9]:结果虽然正确,但白白付出 $O(n \log n)$ 的代价,并且暴露了"没读出输入已有序这个关键条件",面试里会被直接指出。- 错误写法:把根的左右孩子接反,写成
root.left = build(mid + 1, hi)→ 用例nums = [1, 2, 3]:根是2,左孩子挂上3、右孩子挂上1,中序遍历变成[3, 2, 1],BST 性质完全破坏。- 错误写法:Go 里写成
root := &TreeNode{Val: mid}(把下标当成了值) → 用例nums = [-10, -3, 0, 5, 9]:根节点的值变成2而不是0,整棵树的值域和原数组毫无关系。构造节点时要用nums[mid]而非mid。- 错误写法:为了"避免递归"改成用一个队列层序建树,但按数组顺序依次取值 → 用例
nums = [1, 2, 3, 4, 5]:层序填充得到根1、左2、右3……虽然形态平衡、高度是 3,但中序遍历是[4, 2, 5, 1, 3],完全不是升序,BST 性质不成立。形态平衡和中序有序必须同时满足,只顾一头就会错。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 108. 将有序数组转换为二叉搜索树 | 简单 | 完全同题的主站版本,可拿来对照两种取中点方式的不同形态 |
| 109. 有序链表转换二叉搜索树 | 中等 | 载体换成链表后无法 $O(1)$ 取中点,需快慢指针或中序模拟递归 |
| 105. 从前序与中序遍历序列构造二叉树 | 中等 | 根由前序首元素给定而非自选,重点在中序里定位根来切分左右规模 |
| 106. 从中序与后序遍历序列构造二叉树 | 中等 | 根取自后序末元素,递归时要倒着消费后序序列 |
| 889. 根据前序和后序遍历构造二叉树 | 中等 | 缺少中序导致答案不唯一,要靠前序次元素定位左子树规模 |
| 剑指 Offer 07. 重建二叉树 | 中等 | 与 105 同模型,重点是用哈希表把中序定位从 $O(n)$ 降到 $O(1)$ |