全栈开发知识库

87.叉搜索树中第K小的元素

题目

给定一个二叉搜索树的根节点 root ,和一个整数 k ,请你设计一个算法查找其中第 k 小的元素(k 从 1 开始计数)。

示例 1:

Image

  • 输入:root = [3,1,4,null,2], k = 1
  • 输出:1

示例 2:

  • 输入:root = [5,3,6,2,4,null,null,1], k = 3
  • 输出:3

提示:

  • 树中的节点数为 n 。
  • 1 <= k <= n <= 104
  • 0 <= Node.val <= 104

进阶:如果二叉搜索树经常被修改(插入/删除操作)并且你需要频繁地查找第 k 小的值,你将如何优化算法?

思路

上一题(530 最小绝对差)我们确立了一条铁律:

▎ BST 的中序遍历(左 → 根 → 右)产出的序列必然是升序的。

那"第 k 小"就是中序序列里的第 k 个元素。问题瞬间从"在树里找"变成"在一个升序数组里取第 k 项"。

第 0 步:最直白的做法

中序遍历,把所有值倒进一个数组,返回 values[k - 1]。

return values[k - 1] // k 是从 1 开始的,数组下标从 0 开始

这样一定对,O(n) 时间 O(n) 空间。但它做了两件多余的事:遍历了整棵树,还存下了全部值。

第 1 步:不用存数组,边走边数

既然只要第 k 个,那就用一个计数器 count:每次中序访问到一个节点就 count++,数到 k 就是它。

第 2 步:数到了就立刻停

这是本题相比 530 的关键增量。530 必须看完所有相邻对才能确定最小差,但这题——第 k 个一拿到,后面的节点全都不用看了。

于是复杂度从 O(n) 变成 O(H + k): - H 是树高,代表"从根一路向左下到最小元素"的路程 - k 是从最小元素往后数 k 步

当 k 很小时(比如 k = 1),几乎瞬间返回,根本不碰右子树。

class Solution {

    // 状态放在类上,所有递归层共享同一份
    private var count = 0        // 已经数过几个节点
    private var result = 0       // 答案

    /**
     * 递归中序版
     *
     * 时间复杂度 O(H + k)(靠剪枝实现;不剪枝就是 O(n))
     * 空间复杂度 O(H):递归栈
     */
    fun kthSmallest(root: TreeNode?, k: Int): Int {
        // ⚠️ 用成员变量必须在入口重置,否则同一实例调用两次会带上次的残留
        count = 0
        result = 0

        inorder(root, k)
        return result
    }

    private fun inorder(node: TreeNode?, k: Int) {
        if (node == null) return

        inorder(node.left, k)                   // ① 左

        // ② 根
        // 剪枝 1:左子树里已经找到答案了(count 已达 k),当前节点不能再计数,直接回退。
        //         这一句会让所有还没返回的递归层像多米诺一样连续 return。
        if (count == k) return

        count++
        if (count == k) {
            result = node.`val`                 // 我就是第 k 小
            return                              // 剪枝 2:不必再进右子树
        }

        inorder(node.right, k)                  // ③ 右
    }
}

LeetCode 这题带一个进阶问题:

▎ 如果 BST 经常被修改(插入/删除),并且你需要频繁查找第 k 小的值,如何优化?

上面的做法每次查询都是 O(H + k),查一万次就重复走一万遍。答案是给节点增加一个字段:

class AugmentedNode(var val: Int) { var left: AugmentedNode? = null var right: AugmentedNode? = null var size = 1 // 🔑 以自己为根的子树中,节点的总个数(含自己) }

有了 size,查询就变成在树上做一次二分下降,完全不用遍历:

站在任意节点,设 leftSize = 左子树的节点数。那么这个节点在它自己这棵子树的中序序列里,排第 leftSize + 1 位。于是三种情况:

/** * 借助子树大小信息,O(h) 查询第 k 小 —— 不需要任何遍历 */ fun kthSmallest(root: AugmentedNode?, k: Int): Int { var node = root var target = k

  while (node != null) {
      val leftSize = node.left?.size ?: 0     // 左子树有多少个节点

      when {
          // 情况 1:我自己就排第 leftSize + 1 位,正好是要找的
          target == leftSize + 1 -> return node.`val`

          // 情况 2:要找的名次比我靠前 → 一定在左子树里,名次不变
          target <= leftSize -> node = node.left

          // 情况 3:要找的名次比我靠后 → 在右子树里。
          //         左子树的 leftSize 个 + 我自己 1 个都比它小,要从名次里扣掉
          else -> {
              target -= leftSize + 1
              node = node.right
          }
      }
  }
  return -1

}

代价: 每次插入/删除时,要顺着路径更新沿途所有节点的 size,这也只是 O(h)。

效果: 查询从 O(H + k) 降到 O(h),且和 k 的大小无关(k = n 时也一样快)。这种"节点上挂统计信息"的树叫 Order Statistic Tree(顺序统计树),配合 AVL/红黑树保持平衡后,h = O(log n),插入、删除、查第 k 小全都是 O(log n)。

复杂度

  • 时间复杂度:
  • 空间复杂度:


  • 邮箱 :charon.chui@gmail.com
  • Good Luck!
本页由 Markdown 自动渲染 · 共 628 篇笔记