力扣算法解析按专题吃透,不做题海 速查 90 天路线 ⌂ 全部教程

专题 18二叉搜索树:有序性带来的降维打击

在专题 07里你已经学会了用「分解式」思考普通的二叉树:让左右子树干完活把结果递上来。 但普通二叉树结构太自由,想找某个值只能老老实实遍历整棵树。 二叉搜索树(Binary Search Tree,简称 BST)给二叉树加了一条铁律,换来了「任意查找只要走一条单边」的超级能力。 这条铁律就是「左 ≤ 根 ≤ 右」。本章先讲清楚 BST 的两大武器,再用 5 道高频题把「验证 / 搜索 / 构建 / 计数 / 删除」五套动作练熟——你会发现, 它们背后都是同一句话:别辜负这棵已经排好序的树。

学习目标
掌握 BST 两大武器与 5 道经典题
前置要求
Java 基础 · 专题 07 二叉树(分解式)
本章产出
5 道 Hot 题 + 验证/删除套路
建议学时
5–7 小时

18.1BST 的性质:中序遍历即有序

很多同学第一次听说 BST 会想:「不就是棵树吗,凭什么它比普通二叉树高级?」答案就在它的一条定义上。一棵合法的二叉搜索树要求:对于树上任意一个节点,它的左子树里所有节点的值都严格小于它,右子树里所有节点的值都严格大于它(有些教材允许等于,力扣默认不允许重复值,用严格不等)。换句话说,整棵树满足「左 ≤ 根 ≤ 右」的全局秩序。

一棵合法的 BST 长这样(数值已标好):
        8
      /   \
     3     10
    / \      \
   1   6      14
      / \     /
     4   7   13

随手抽查几个节点都满足「左边全小、右边全大」:
  - 根 8:左边 3/1/6/4/7 全 < 8;右边 10/14/13 全 > 8 ✓
  - 节点 3:左边 1 < 3;右边 6/4/7 全 > 3 ✓
  - 节点 10:右边 14/13 全 > 10;左边没有,默认满足 ✓

两大武器,记住这八个字就够

BST 之所以好用,是因为这条秩序带来了两件普通二叉树没有的「外挂」:

用武器二在上面的树里找 7:
  8 → 7<8 走左 → 到 3
  3 → 7>3 走右 → 到 6
  6 → 7>6 走右 → 到 7,命中!
只走了 4 步,根本没碰右边的 10/14/13。这就是「走单边」的威力。
一句话记忆:BST 是一棵「已经替你排好序的树」。凡是题目说「给你一棵二叉搜索树」,你第一反应就应该是——能不能借「中序有序」或「走单边」把复杂度从 O(n) 降下来。后面 5 道题全是这两个想法的变体。

节点定义(后面所有题都基于它)

力扣里树节点就长这样,和专题 07完全一致,这里再贴一遍方便对照:

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;
    }
}

18.2验证二叉搜索树(中等,力扣 98)

中等 核心:递归时把「上下界」一路传下去

题意:给你一棵二叉树,判断它是不是一棵有效的二叉搜索树。有效 BST 的定义前面讲过:每个节点的左子树全部小于它、右子树全部大于它,且左右子树自身也得是 BST;空树算合法。例如 [2,1,3] 是 BST,[5,1,4,null,null,3,6] 不是。

思路推导:为什么「只比较父子」是错的

最直觉的写法:遍历每个节点,只要它比左孩子大、比右孩子小就放心。代码大概是:

错误思路(伪代码):
boolean check(TreeNode node):
    if node == null: return true
    if node.left  != null && node.left.val  >= node.val: return false
    if node.right != null && node.right.val <= node.val: return false
    return check(node.left) && check(node.right)

这个写法只看节点和它的直接孩子,漏掉了对「祖先」的约束。力扣 98 官方给的经典反例 [5,1,4,null,null,3,6] 在根节点就露馅(右孩子 4 比根 5 还小);但更隐蔽的是下面这棵,父子比较全部通过,却仍然不是 BST:

        10
       /  \
      5   15
          /
         6
父子检查:10 比 5 大、比 15 小 ✓;15 比 6 大 ✓;5、6 都没孩子 ✓
看似全过,但 6 在 10 的右子树里,按 BST 铁律必须 > 10,而 6 < 10 → 非法!
「只比父子」完全没发现这个矛盾,因为它忘了:6 身上还压着「祖先 10 定下的下界」。

所以正确的思路是:每个节点都必须落在一个由祖先不断收窄的区间 (lower, upper) 里。当你从左孩子下来,说明当前节点必须比父节点小,于是它的上界 upper 收紧成父节点的值;从右孩子下来,下界 lower 抬升成父节点的值。把这对上下界一路递归传下去,任何一次越界就说明不合法。

把 (lower, upper) 传下去的过程(用上面 6 的反例):
  根 10:区间 (-∞, +∞),10 在里 ✓
    左 5:区间 (-∞, 10),5 在里 ✓
    右 15:区间 (10, +∞),15 在里 ✓
      15 的左 6:区间 (10, +∞)(继承了祖先 10 定的下界!),但 6 < 10 → 越界,判非法 ✓
看,把祖先的界传下来,6 立刻被抓住。

参考代码

class Solution {
    public boolean isValidBST(TreeNode root) {
        // 初始区间用 Long 的边界,因为节点值可能正好踩到 Integer 的边界(详见 18.7)
        return dfs(root, Long.MIN_VALUE, Long.MAX_VALUE);
    }

    // lower / upper 是「当前节点允许取值的开区间」
    private boolean dfs(TreeNode node, long lower, long upper) {
        if (node == null) return true;          // 空子树永远是合法 BST
        // 当前节点必须严格落在 (lower, upper),等于也不行(力扣默认无重复值)
        if (node.val <= lower || node.val >= upper) return false;
        // 左子树:上界收紧为 node.val;右子树:下界抬升为 node.val
        return dfs(node.left, lower, node.val)
            && dfs(node.right, node.val, upper);
    }
}

复杂度

每个节点只访问一次,每次 O(1) 比较,时间 O(n),空间是递归栈 O(h)(h 为树高,最坏退化 O(n))。这比「先中序遍历再检查是否升序」的 O(n) 额外数组空间更省,且能提前剪枝。

另一种经典写法:跑一遍中序遍历,把节点值依次存进列表,最后检查列表是否严格递增。思路也对,但要多开一个数组。面试时两种都能讲,但「传上下界」更能体现你对 BST 定义的理解,且能提前返回,更受面试官青睐。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 恢复二叉搜索树(力扣 99,困难)——与本题共用「BST 中序必有序」的认知;差异在已知恰好有两个节点被交换,你要从中序序列里找出这两个逆序对并换回来(提示:中序遍历时记录第一次下降的 prev、val 和最后一次下降的节点,交换二者即可,不用完全重建)。
  • 二叉搜索树迭代器(力扣 173,中等)——与本题共用「受控中序」思想;差异在题目要求你实现一个 next() 每次返回下一个最小元素、hasNext() 判断是否有,且要求均摊 O(1)(提示:用栈模拟中序,每次只压到「最左」就停,弹出时再处理右子树)。
  • 二叉搜索树中的插入操作(力扣 701,中等)——与本题共用「走单边定位」动作;差异在插入不用动老结构,沿 BST 性质找到空位挂上即可(提示:递归或循环,比 val 小走左、大走右,遇到 null 就 new 一个节点接上)。

18.3二叉搜索树中的搜索(简单,力扣 700)

简单 核心:利用有序性,每步砍掉一半

题意:给定一棵二叉搜索树的根 root 和一个目标值 val,请你在树里找到值为 val 的节点并返回它的子树;找不到就返回 null。例如树 [4,2,7,1,3] 找 2,返回以 2 为根的子树(包含 1 和 3)。

思路推导:别遍历,要「二分」

如果这是一棵普通二叉树,你只能老老实实遍历所有节点,O(n)。但这是 BST!回忆武器二:val 比当前节点小,目标一定在左子树;大,一定在右子树。所以每一步都直接决定往哪边走,另一半根本不用看——这就是树上的「二分查找」。

用递归还是循环?两者都行。递归就是「把走单边」这句话翻译成代码;循环则更省栈空间、更好写。下面用循环写,思路更直白:从根出发,比大小决定下一脚踩左边还是右边,碰到 null 说明这条路走到头了,树里没有 val。

在 [4,2,7,1,3] 里找 7:
  4 → 7>4 走右 → 到 7,命中,返回以 7 为根的子树
在 [4,2,7,1,3] 里找 5:
  4 → 5>4 走右 → 7 → 5<7 走左 → 左是 null → 没找到,返回 null

参考代码

class Solution {
    public TreeNode searchBST(TreeNode root, int val) {
        TreeNode cur = root;
        while (cur != null) {
            if (cur.val == val) return cur;     // 找到了,返回整棵子树
            if (val < cur.val) cur = cur.left;  // 目标更小,只走左边
            else cur = cur.right;               // 目标更大,只走右边
        }
        return null;                            // 走到叶子还没碰到,说明不存在
    }
}

复杂度

每步砍掉一半子树,最多走树高 h 步,时间 O(h)(平衡树 O(log n),最坏退化 O(n)),空间 O(1)。这就是 BST 查找相比普通树的降维优势。

对比普通二叉树找值(必须遍历 O(n)),这道题的「简单」全靠 BST 的有序性。记住:只要题目明确说「二叉搜索树」,先想能不能走单边,别一上来就无脑 DFS。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 二叉搜索树中的插入操作(力扣 701,中等)——与本题共用「走单边定位」;差异在找到空位后要把新节点挂上去,且需要把路径上的父指针接回(提示:递归写法最省事,root.left = insert(root.left, val) 一路把子树接回)。
  • 二叉搜索树的最近公共祖先(力扣 235,简单)——与本题共用「利用有序性剪枝」;差异在给定两节点 p、q,利用 BST 性质:若 p、q 分居当前节点两侧则当前节点即 LCA,否则往同一侧继续走(提示:不用像普通树那样后序汇报,直接边走边判断)。
  • 二叉搜索树与双向链表(剑指 Offer 36,中等)——与本题共用「中序有序」;差异在把中序遍历得到的升序序列改成循环双向链表(提示:中序遍历时维护一个 prev 指针,边遍历边把 prev.right=cur、cur.left=prev 串起来,首尾再相连)。

18.4将有序数组转换为二叉搜索树(简单,力扣 108)

简单 核心:取中点当根,左右半边各建子树

题意:给你一个升序排列的整数数组 nums,请你把它转换成一棵「高度平衡」的二叉搜索树,并返回根节点。高度平衡指每个节点的左右子树高度差不超过 1。例如 nums = [-10,-3,0,5,9],一种合法答案是 [0,-3,9,-10,null,5]。

思路推导:从「数组有序」想到「BST 中序有序」

已知两件事:(1) 数组是升序的;(2) BST 的中序遍历也是升序的。那是不是「数组就是某棵 BST 的中序序列」?是的!问题于是变成:已知中序序列,怎么还原出一棵树,而且还要平衡?

中序序列决定了「谁在谁左边、谁在谁右边」,但没决定「谁是根」。根选哪里,整棵树的形状就由哪里切开。要让树平衡,最自然的切法就是每次取中点当根:

nums = [-10, -3, 0, 5, 9],长度 5,中点下标 2 = 0 当根:
        0
      /   \
  左半[-10,-3]  右半[5,9]
  左半中点 -3 当根,左挂 -10 →   右半中点 9 当根,左挂 5 →
        0
      /   \
    -3     9
    /     /
  -10    5
中序走一遍:-10,-3,0,5,9 正好是原数组,且左右高度差 ≤ 1 ✓

为什么中点能保证平衡?因为每次把区间从正中间劈成两半,左右子树分到的元素数量最多差 1,递归深度自然就是 log n 级别,不会退化成链。注意中点可以「偏左」或「偏右」(比如区间长度偶数时有两个候选中点),两种都合法,题目也接受任意一种。

参考代码

class Solution {
    public TreeNode sortedArrayToBST(int[] nums) {
        return build(nums, 0, nums.length - 1);
    }

    // 用闭区间 [lo, hi] 表示「当前要建的子树对应的数组片段」
    private TreeNode build(int[] nums, int lo, int hi) {
        if (lo > hi) return null;                 // 区间为空,返回空树
        int mid = lo + (hi - lo) / 2;             // 中点当根(这样写防 (lo+hi) 溢出)
        TreeNode root = new TreeNode(nums[mid]);
        root.left = build(nums, lo, mid - 1);     // 左半边是左子树
        root.right = build(nums, mid + 1, hi);    // 右半边是右子树
        return root;
    }
}

复杂度

每个数组元素恰好当一次根,建树 O(n);递归栈深度为树高 O(log n)(平衡)。返回的就是高度平衡的 BST,满足题目要求。

如果题目给的是普通二叉树要变平衡(力扣 1382),思路完全对称:先中序遍历把节点收集成有序数组,再用本题的「取中点建树」还原——本质是「中序展平 → 中点重建」。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 有序链表转换二叉搜索树(力扣 109,中等)——与本题共用「取中点当根」;差异在输入是单向链表不能随机访问下标,得用快慢指针找中点,或先转成数组再复用本题(提示:快慢指针找到中点后断开链表,递归左右;注意断开前记录 mid.next 再置 null)。
  • 将二叉搜索树变平衡(力扣 1382,中等)——与本题共用「中序展平 + 中点重建」;差异在先对原 BST 中序遍历得到有序节点列表,再调用本题的 build 逻辑重建(提示:中序收集时直接存 TreeNode 引用,重建时重新连 left/right)。

18.5二叉搜索树中第 K 小的元素(中等,力扣 230)

中等 核心:中序遍历数到第 K 个

题意:给定一棵二叉搜索树的根和一个整数 k,返回树中第 k 小的元素(1-based)。例如树 [3,1,4,null,2],中序是 [1,2,3,4],k=1 返回 1,k=3 返回 3。

思路推导:中序第 K 个 = 第 K 小

普通无序数组找第 k 小,得排序或快选(见专题 16)。但这是 BST!武器一告诉我们:BST 的中序遍历天然升序。升序序列里的第 k 个,不就是第 k 小吗?所以这条路简直是量身定做——

顺着中序顺序(左 → 根 → 右)走,每访问一个节点就计数 +1,数到 k 的瞬间,那个节点的值就是答案。不需要把整棵树都遍历完,数到了就可以停(提前返回能省时间,尤其 k 很小时)。

树 [3,1,4,null,2],中序访问顺序:1(计数1) → 2(计数2) → 3(计数3) → 4(计数4)
k=3:数到第三个节点是 3,直接返回,不用再访问 4。

为什么不用「先收集成数组再取 arr[k-1]」?那样空间 O(n)。用递归计数 + 提前返回,空间只有栈 O(h),更优雅。两种都能过,下面给「计数 + 提前停」的写法。

参考代码

class Solution {
    private int count;     // 已经中序访问了多少个节点
    private int ans;       // 第 k 小的答案

    public int kthSmallest(TreeNode root, int k) {
        count = 0;
        inorder(root, k);
        return ans;
    }

    private void inorder(TreeNode node, int k) {
        if (node == null || count >= k) return;   // 空 或 已经数够就别再走了
        inorder(node.left, k);                    // 先左
        count++;                                  // 访问当前(根)
        if (count == k) { ans = node.val; return; } // 正好第 k 个,记下并停
        inorder(node.right, k);                   // 再右
    }
}

复杂度

平均访问 k 个节点就停,时间约 O(h + k)(h 是到目标节点的深度);最坏 k=n 时 O(n)。空间 O(h) 递归栈。比「全收集再取」省空间,且提前返回更快。

想问「第 k 大」怎么办?对称地做逆中序遍历(右 → 根 → 左),数到第 k 个就是从大到小第 k 个。剑指 Offer 54 就是这题的另一面。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 两数之和 IV - 输入 BST(力扣 653,简单)——与本题共用「BST 中序有序」;差异在问是否存在两个节点值之和等于 k,可中序收集成有序数组后双指针,或边遍历边用 HashSet 查 complement(提示:遍历时看 k - node.val 是否已在集合里,在就找到)。
  • 二叉搜索树的第 k 大节点(剑指 Offer 54,中等)——与本题共用「中序数数」;差异在用逆中序(右→根→左)数到第 k 个(提示:把 18.5 的左右访问顺序反过来即可)。
  • 恢复二叉搜索树(力扣 99,困难)——与本题共用「中序有序」视角;差异在中序里找两处逆序(prev > cur)定位被交换的两个节点再交换值(提示:第一次下降记 prev,第二次下降记 cur,交换这两个节点的 val)。

18.6删除二叉搜索树中的节点(中等,力扣 450)

中等 核心:删完用「中序后继」顶替,保住 BST 性质

题意:给定一棵 BST 的根和一个值 key,删掉值为 key 的节点,并且保证删除后仍然是一棵合法的 BST,返回新的根。例如 [5,3,6,2,4,null,7] 删 3,返回 [5,4,6,2,null,null,7] 之类(删法不唯一但都合法)。

思路推导:删除的难处在「别破坏秩序」

查找很简单(走单边,见 18.3)。真正的麻烦是:删掉一个节点后,它下面的子树怎么接回去,才能不破坏「左小右大」?按「要删节点的孩子情况」分三种:

删 5(左右都有)的例子:
        5
      /   \
     3     8
    / \   / \
   2   4 6   9
右子树最左下是 6(右子树里最小的),用 6 顶替 5:
        6
      /   \
     3     8
    / \   / \
   2   4 7   9      (原 6 被抽走,6 的右孩子 7 接回 8 的左)
为什么是 6?因为 6 是「大于左子树(2,3,4)、小于右子树其余(7,8,9)」的唯一值,顶上后仍合法。

为什么「用中序后继顶替」能保持 BST 性质?因为中序后继是右子树的最小值,它满足:大于当前节点的所有左后代、小于右子树里除它以外的所有节点。把它的值复制到当前节点,再递归地把「后继」从右子树里删掉(后继至多只有一个右孩子,落回情况一/二很好处理),整棵树秩序纹丝不动。

参考代码

class Solution {
    public TreeNode deleteNode(TreeNode root, int key) {
        if (root == null) return null;            // 没找到,原样返回
        if (key < root.val) {
            root.left = deleteNode(root.left, key);     // 去左子树删
        } else if (key > root.val) {
            root.right = deleteNode(root.right, key);   // 去右子树删
        } else {
            // 找到要删的节点就是 root,分三种情况
            if (root.left == null) return root.right;   // 情况一:无左,右孩子顶
            if (root.right == null) return root.left;   // 情况二:无右,左孩子顶
            // 情况三:左右都有,找中序后继(右子树最左节点)
            TreeNode succ = root.right;
            while (succ.left != null) succ = succ.left;
            root.val = succ.val;                        // 用后继的值覆盖当前
            // 删掉后继本身(它至多有一个右孩子,递归会落回情况一/二)
            root.right = deleteNode(root.right, succ.val);
        }
        return root;                                    // 把接好的子树交回上层
    }
}

复杂度

查找要删节点走单边 O(h);找后继沿右子树最左走也是 O(h);总复杂度 O(h)(h 为树高)。空间 O(h) 递归栈。关键点在于「递归地把子树重新接回」——这正是专题 07分解式思想的体现:左右子树干完删除,把结果交回给我。

最容易写错的点:情况三里,有人用「前驱」(左子树最右节点)顶替也完全可以,但你必须删掉被复制走的那个后继/前驱节点本身,否则树上会出现两个相同值、且结构混乱。代码里 root.right = deleteNode(root.right, succ.val) 这一步就是在删后继,绝不能漏。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 二叉搜索树中的插入操作(力扣 701,中等)——与本题共用「走单边定位 + 递归接回子树」;差异在插入不用处理三种情况,找到空位 new 一个节点挂上即可(提示:root.left = insert(root.left, val) 把新子树接回)。
  • 修剪二叉搜索树(力扣 669,中等)——与本题共用「递归重建 BST」;差异在给定区间 [low, high],把不在区间里的节点整棵剪掉(提示:当前节点 < low 就丢掉它、返回修剪后的右子树;> high 同理返回左子树;在区间内则递归修剪左右)。
  • 不同的二叉搜索树 II(力扣 95,中等)——与本题共用「BST 由中序区间决定形状」;差异在给定 1~n,枚举所有合法的 BST 形态(提示:对每个候选根 i,左用 [1,i-1]、右用 [i+1,n] 递归生成,左右结果两两组合)。

18.7本专题常见坑

  • 验证 BST 时「只比较父子」。如 18.2 所讲,必须沿祖先把上下界 (lower, upper) 一路传下去。只比当前节点和左右孩子,会放过「孙辈越界」的非法树(例如 10 的右子树里藏着 6)。
  • Integer 边界值误伤(最隐蔽)。验证 BST 时若用 Integer.MIN_VALUE / Integer.MAX_VALUE 当初始上下界,一旦节点值正好等于 Integer.MIN_VALUE,node.val >= lower 这个「等于」判断会把它误判成非法(因为 MIN_VALUE 不允许再小,但你的下界就是它自己)。解决:初始界用 Long.MIN_VALUE / Long.MAX_VALUE,或比较时用更谨慎的写法。
  • 删除节点后忘了把子树接回去。18.6 三种情况里,每一次 root.left = deleteNode(...) / root.right = ... 都是在「把处理好的子树重新连上」。漏写赋值、或只写 deleteNode(root.left, key) 不接回,树就断了。
  • 把 BST 当普通树无脑遍历。题目白纸黑字写了「二叉搜索树」,还写个 BFS/DFS 全扫来找值、计数,白白浪费 O(log n) 的武器。先想「走单边」和「中序有序」。
  • 中序遍历的「第 k 小」没提前停。数到 k 应立刻返回,而不是傻傻遍历完整棵树再取 —— 尤其 k 很小或树很大时差别明显。
  • 构建平衡 BST 时中点下标写法溢出。mid = (lo + hi) / 2 在 lo+hi 很大时可能整数溢出,写成 lo + (hi - lo) / 2 更安全。

18.8自测题

Q1.(概念题)为什么验证 BST 不能只比较「每个节点和它的左右孩子」,而要递归传递上下界?请举一个父子和睦但整体非法的例子。

因为 BST 要求「某节点的所有左后代都小、所有右后代都大」,约束来自整条祖先链,不只是直接父亲。例子:根 10,左 5,右 15,而 15 的左孩子是 6。父子比较全过(10>5、10<15、15>6),但 6 位于 10 的右子树却小于 10,违反 BST 铁律。只有把祖先 10 定的下界 (10, +∞) 一路传给 6,才能发现 6 < 10 越界。

Q2.(概念题)删除 BST 节点时,为什么用「中序后继(右子树最左节点)」顶替能保持 BST 性质?能不能用「前驱」?

中序后继是右子树里最小的值,它必然大于被删节点的全部左后代、且小于右子树里其余所有节点,所以复制到被删位置后整棵树仍满足左小右大。完全可以用「前驱」(左子树最右节点,即左子树最大值),效果对称。无论用哪个,记得最后要把那个被复制走的继任节点本身从树中删掉(它至多有一个孩子,递归落回情况一/二)。

Q3.(动手题)不用额外数组,写一个函数:输入一棵 BST 的根,返回它的第 k 大元素(注意是第 k 大,不是第 k 小)。先说思路再写核心递归。

思路:BST 升序中序是「小→大」,要做「第 k 大」就走逆中序(右→根→左),这样访问顺序是「大→小」,数到第 k 个就是第 k 大。核心:void rev(TreeNode n, int k){ if(n==null||c>=k) return; rev(n.right,k); c++; if(c==k){ans=n.val;return;} rev(n.left,k); },初始 c=0,调用 rev(root, k) 后返回 ans。与 18.5 的写法仅左右访问顺序相反。
学完这章,你应该能回答:BST 的两大武器是什么;为什么验证 BST 要传上下界、初始界为什么用 Long;搜索/构建如何借「走单边」和「中序有序」;删除节点的三种情况尤其是「中序后继顶替」为什么保秩序;以及第 k 小为什么等于中序第 k 个。