二叉树从基础概念到LeetCode实战

二叉树:从基础概念到 LeetCode 实战

如果你正在准备算法面试,或者刚学完链表、栈、队列这些线性结构,想往更复杂的数据结构迈进------二叉树几乎是绕不过去的一道坎。

说实话,二叉树这个东西,概念本身不难理解。但一旦到了递归遍历、堆调整、LeetCode 刷题的环节,很多人就开始头大了。我自己当初学的时候也是这样:看懂了定义,一到写代码就懵。

所以这篇文章,我打算把二叉树从头到尾捋一遍。从最基础的定义开始,到四种遍历方式,再到堆和堆排序,最后用几道 LeetCode 经典题收尾。由浅入深,尽量把每一步的"为什么"讲清楚,而不只是"怎么做"。


第一部分:二叉树的基础概念

1.1 树的定义

先说树。这里的"树"不是外面种的那棵,而是一种层次化的数据结构

树是由 n n n( n ≥ 0 n \geq 0 n≥0)个节点组成的有限集合。当 n = 0 n = 0 n=0 时,称为空树 ;当 n > 0 n > 0 n>0 时,有一个特殊的节点称为根节点(root) ,其余节点可以分为 m m m( m > 0 m > 0 m>0)个互不相交的有限集合,每个集合本身又是一棵树,称为根的子树

这个定义是递归的------树由子树构成,子树又是一棵树。这个递归特性贯穿了二叉树的整个学习过程,后面写遍历代码的时候你会深有体会。

1.2 二叉树的定义

二叉树是树的一种特例。它的约束很简单:每个节点最多有两个子节点 ,分别称为左孩子右孩子

复制代码
    A
   / \
  B   C
 / \
D   E

上面就是一棵二叉树。A 是根节点,B 是 A 的左孩子,C 是 A 的右孩子。D 和 E 是 B 的孩子。

图片说明:一棵标准的二叉树,根节点标为 A,位于图的最上方。A 的左孩子是 B,右孩子是 C,用线段连接。B 的左孩子是 D,右孩子是 E。C 没有子节点(叶子节点)。图中标注了 root 指向 A,B 和 C 分别为"左孩子"和"右孩子",D 和 E 为"叶子节点"。

1.3 满二叉树与完全二叉树

这两个概念面试常考,也容易混淆。

满二叉树(Full Binary Tree) :每一层的节点数都达到最大值。也就是说,一棵深度为 h h h 的满二叉树有 2 h − 1 2^h - 1 2h−1 个节点,所有叶子都在同一层,每个非叶子节点都有两个孩子。

完全二叉树(Complete Binary Tree):除了最后一层,其他每一层都被完全填满,并且最后一层的节点从左到右连续排列,中间不能有空缺。

两者的区别在哪?满二叉树要求所有层都满,完全二叉树允许最后一层不满,但必须从左往右依次排。满二叉树一定是完全二叉树,但完全二叉树不一定是满二叉树。

图片说明:左右并排两棵树。左边标题"满二叉树":3 层 7 个节点,第 1 层 1 个(根),第 2 层 2 个,第 3 层 4 个,全部填满。右边标题"完全二叉树":3 层 6 个节点,第 1 层 1 个,第 2 层 2 个,第 3 层从左到右 3 个节点,最右侧缺一个。

1.4 关键术语

术语 含义
节点的度(Degree) 节点拥有的子树数。二叉树中度的取值范围是 0、1、2
叶子节点(Leaf) 度为 0 的节点,没有孩子
分支节点 度不为 0 的节点
节点的层次(Level) 根为第 1 层,根的孩子为第 2 层,以此类推
树的深度(Depth) 树中节点的最大层次
父节点 / 子节点 直接相连的上下两级节点
兄弟节点 同一个父节点的子节点

1.5 二叉树的性质

这几条性质看起来简单,但推导过程能帮你理解二叉树的内部结构。

性质 1 :第 i i i 层上最多有 2 i − 1 2^{i-1} 2i−1 个节点( i ≥ 1 i \geq 1 i≥1)。

第 1 层只有根节点,1 个。第 2 层最多 2 个,第 3 层最多 4 个......每层翻倍,所以第 i i i 层最多 2 i − 1 2^{i-1} 2i−1 个。

性质 2 :深度为 h h h 的二叉树最多有 2 h − 1 2^h - 1 2h−1 个节点。

把每层的最大节点数加起来: 2 0 + 2 1 + 2 2 + ⋯ + 2 h − 1 = 2 h − 1 2^0 + 2^1 + 2^2 + \cdots + 2^{h-1} = 2^h - 1 20+21+22+⋯+2h−1=2h−1。等比数列求和公式直接得出。

性质 3 :对任何一棵二叉树,如果叶子节点数为 n 0 n_0 n0,度为 2 的节点数为 n 2 n_2 n2,则 n 0 = n 2 + 1 n_0 = n_2 + 1 n0=n2+1。

这条性质面试经常考。推导过程:设 n 0 n_0 n0、 n 1 n_1 n1、 n 2 n_2 n2 分别是度为 0、1、2 的节点数,总节点数 n = n 0 + n 1 + n 2 n = n_0 + n_1 + n_2 n=n0+n1+n2。另一方面,树的边数等于 n − 1 n - 1 n−1(每个节点除了根都有一条边连向父节点),也等于 n 1 + 2 n 2 n_1 + 2n_2 n1+2n2(度为 1 的节点贡献 1 条边,度为 2 的贡献 2 条)。所以 n − 1 = n 1 + 2 n 2 n - 1 = n_1 + 2n_2 n−1=n1+2n2,代入 n = n 0 + n 1 + n 2 n = n_0 + n_1 + n_2 n=n0+n1+n2 得到 n 0 = n 2 + 1 n_0 = n_2 + 1 n0=n2+1。

性质 4 :具有 n n n 个节点的完全二叉树的深度为 ⌊ log ⁡ 2 n ⌋ + 1 \lfloor \log_2 n \rfloor + 1 ⌊log2n⌋+1。

完全二叉树深度为 h h h 时,节点数 n n n 满足 2 h − 1 ≤ n < 2 h 2^{h-1} \leq n < 2^h 2h−1≤n<2h,取对数即可得到。

1.6 存储方式

二叉树有两种主要的存储方式。

顺序存储(数组)

把二叉树的节点按层序遍历的顺序存入数组。对于完全二叉树来说,这种存储方式很自然,也不浪费空间。

假设节点存在数组 arr 中,下标从 0 开始:

  • 节点 i 的左孩子下标:2 * i + 1
  • 节点 i 的右孩子下标:2 * i + 2
  • 节点 i 的父节点下标:(i - 1) / 2

但如果树很不平衡(比如退化成链表),数组中间会有大量空位,浪费严重。所以顺序存储主要用于完全二叉树场景,比如后面要讲的堆。

链式存储(二叉链表)

更通用的方式。每个节点包含数据域和两个指针域:

cpp 复制代码
typedef struct TreeNode {
    int val;                    // 数据域
    struct TreeNode *left;      // 左孩子指针
    struct TreeNode *right;     // 右孩子指针
} TreeNode;

创建一个新节点:

cpp 复制代码
TreeNode* createNode(int val) {
    TreeNode* node = (TreeNode*)malloc(sizeof(TreeNode));
    node->val = val;
    node->left = NULL;
    node->right = NULL;
    return node;
}

链式存储灵活,不会因为树形不平衡而浪费空间,是实际编程中最常用的方式。


第二部分:二叉树的遍历

遍历是二叉树最核心的操作。所谓遍历,就是按照某种规则访问树的每个节点一次且仅一次。

二叉树的遍历有四种主要方式:前序遍历中序遍历后序遍历层序遍历。前三种属于深度优先(DFS),层序遍历属于广度优先(BFS)。

图片说明:一棵 3 层二叉树,根节点 1,左孩子 2,右孩子 3。2 的左右孩子为 4 和 5,3 的左右孩子为 6 和 7。图中用三种颜色的箭头分别标出三种遍历路径:红色表示前序遍历路径(1→2→4→5→3→6→7),绿色表示中序遍历路径(4→2→5→1→6→3→7),蓝色表示后序遍历路径(4→5→2→6→7→3→1)。

2.1 前序遍历

访问顺序:根 → 左 → 右

先访问根节点,再递归遍历左子树,最后递归遍历右子树。

cpp 复制代码
void preorderTraversal(TreeNode* root) {
    if (root == NULL) return;
    printf("%d ", root->val);        // 先访问根节点
    preorderTraversal(root->left);   // 递归遍历左子树
    preorderTraversal(root->right);  // 递归遍历右子树
}

对于上面那棵树(1-2-3-4-5-6-7),前序遍历的结果是:1 2 4 5 3 6 7

2.2 中序遍历

访问顺序:左 → 根 → 右

先递归遍历左子树,再访问根节点,最后递归遍历右子树。对于二叉搜索树(BST),中序遍历的结果是有序的。

cpp 复制代码
void inorderTraversal(TreeNode* root) {
    if (root == NULL) return;
    inorderTraversal(root->left);    // 先遍历左子树
    printf("%d ", root->val);        // 再访问根节点
    inorderTraversal(root->right);   // 最后遍历右子树
}

结果是:4 2 5 1 6 3 7

2.3 后序遍历

访问顺序:左 → 右 → 根

先递归遍历左子树,再递归遍历右子树,最后访问根节点。后序遍历的一个实际用途是释放二叉树的内存------必须先释放子节点,再释放父节点。

cpp 复制代码
void postorderTraversal(TreeNode* root) {
    if (root == NULL) return;
    postorderTraversal(root->left);   // 先遍历左子树
    postorderTraversal(root->right);  // 再遍历右子树
    printf("%d ", root->val);         // 最后访问根节点
}

结果是:4 5 2 6 7 3 1

2.4 层序遍历

从上到下、从左到右逐层访问。需要借助队列来实现。

cpp 复制代码
void levelOrderTraversal(TreeNode* root) {
    if (root == NULL) return;
    
    // 用数组模拟队列
    TreeNode* queue[1000];
    int front = 0, rear = 0;
    queue[rear++] = root;  // 根节点入队
    
    while (front < rear) {
        TreeNode* node = queue[front++];  // 出队
        printf("%d ", node->val);
        
        if (node->left != NULL)
            queue[rear++] = node->left;   // 左孩子入队
        if (node->right != NULL)
            queue[rear++] = node->right;  // 右孩子入队
    }
}

结果是:1 2 3 4 5 6 7

2.5 非递归实现(迭代法)

递归写法简洁,但面试官经常追问"能不能用迭代写"。其实,递归的本质是系统帮你维护了一个栈,我们手动用栈模拟就行了。

以前序遍历为例:

cpp 复制代码
void preorderIterative(TreeNode* root) {
    if (root == NULL) return;
    
    TreeNode* stack[1000];
    int top = -1;
    stack[++top] = root;  // 根节点入栈
    
    while (top >= 0) {
        TreeNode* node = stack[top--];  // 出栈并访问
        printf("%d ", node->val);
        
        // 注意:先右后左,因为栈是后进先出
        if (node->right != NULL)
            stack[++top] = node->right;
        if (node->left != NULL)
            stack[++top] = node->left;
    }
}

中序遍历的迭代写法稍有不同,需要一路向左走到底:

cpp 复制代码
void inorderIterative(TreeNode* root) {
    TreeNode* stack[1000];
    int top = -1;
    TreeNode* cur = root;
    
    while (cur != NULL || top >= 0) {
        // 一路向左,全部入栈
        while (cur != NULL) {
            stack[++top] = cur;
            cur = cur->left;
        }
        // 弹出栈顶并访问
        cur = stack[top--];
        printf("%d ", cur->val);
        // 转向右子树
        cur = cur->right;
    }
}

图片说明(前序遍历迭代过程):展示一个栈的变化过程。初始状态栈中只有根节点 1。第一步:弹出 1,访问 1,将右孩子 3 和左孩子 2 依次入栈(栈中:2, 3)。第二步:弹出 2,访问 2,将右孩子 5 和左孩子 4 入栈(栈中:4, 5, 3)。第三步:弹出 4,访问 4,无子节点(栈中:5, 3)。以此类推,直到栈空。


第三部分:堆(优先队列)

堆是一种特殊的完全二叉树,常用于实现优先队列。

3.1 堆的定义

堆分为两种:

  • 大堆(大根堆) :每个节点的值都大于等于其子节点的值。根节点是最大值。
  • 小堆(小根堆) :每个节点的值都小于等于其子节点的值。根节点是最小值。

关键点:堆是一棵完全二叉树,所以用数组存储非常合适。

图片说明:左侧是一棵完全二叉树形式的堆,根节点值为 9,左右孩子为 8 和 7。8 的左右孩子为 3 和 5,7 的左右孩子为 6 和 1。每个父节点都大于其子节点。右侧是对应的数组表示,下标 0-6 分别对应值 9, 8, 7, 3, 5, 6, 1

3.2 向下调整算法

当一个节点的值可能破坏堆的性质时(比如建堆时从最后一个非叶子节点往前调整),需要把它"下沉"到合适的位置。

cpp 复制代码
// 假设构建大堆
// n 是数组大小,i 是需要向下调整的节点下标
void siftDown(int arr[], int n, int i) {
    int parent = i;
    int child = 2 * parent + 1;  // 左孩子下标
    
    while (child < n) {
        // 找出左右孩子中较大的那个
        if (child + 1 < n && arr[child + 1] > arr[child])
            child = child + 1;
        
        // 如果父节点已经大于等于最大的孩子,调整结束
        if (arr[parent] >= arr[child])
            break;
        
        // 否则交换,继续向下调整
        int temp = arr[parent];
        arr[parent] = arr[child];
        arr[child] = temp;
        
        parent = child;
        child = 2 * parent + 1;
    }
}

3.3 向上调整算法

当在堆末尾插入一个新元素时,可能破坏堆的性质,需要把它"上浮"到合适位置。

cpp 复制代码
// i 是新插入元素的下标
void siftUp(int arr[], int i) {
    int child = i;
    int parent = (child - 1) / 2;
    
    while (child > 0) {
        // 大堆:如果子节点大于父节点,交换
        if (arr[child] > arr[parent]) {
            int temp = arr[parent];
            arr[parent] = arr[child];
            arr[child] = temp;
            
            child = parent;
            parent = (child - 1) / 2;
        } else {
            break;
        }
    }
}

3.4 建堆

给定一个数组,怎么把它调整成堆?从最后一个非叶子节点开始,从后往前依次执行向下调整。

最后一个非叶子节点的下标是 n / 2 − 1 n/2 - 1 n/2−1( n n n 是数组长度)。

cpp 复制代码
void buildHeap(int arr[], int n) {
    // 从最后一个非叶子节点开始,从后往前向下调整
    for (int i = n / 2 - 1; i >= 0; i--) {
        siftDown(arr, n, i);
    }
}
建堆时间复杂度为什么是 O(N)?

很多人第一反应觉得建堆是 O ( N log ⁡ N ) O(N \log N) O(NlogN),因为每次 siftDown 最多走 log ⁡ N \log N logN 层,一共 N N N 个节点。但实际上不是这样。

关键在于:不是每个节点都会调整 log ⁡ N \log N logN 层。底层的节点(叶子节点附近)几乎不需要调整,只有靠近根的节点才需要调整较多层。

精确计算:

  • 第 h h h 层(倒数第 1 层)有约 n / 2 n/2 n/2 个节点,每个最多调整 0 层(已经是叶子)
  • 第 h − 1 h-1 h−1 层有约 n / 4 n/4 n/4 个节点,每个最多调整 1 层
  • 第 h − 2 h-2 h−2 层有约 n / 8 n/8 n/8 个节点,每个最多调整 2 层
  • ...
  • 第 1 层有 1 个节点,最多调整 h − 1 h-1 h−1 层

总调整次数:

S = ∑ k = 1 h ( k − 1 ) ⋅ n 2 k = n ∑ k = 1 h k − 1 2 k S = \sum_{k=1}^{h} (k-1) \cdot \frac{n}{2^k} = n \sum_{k=1}^{h} \frac{k-1}{2^k} S=k=1∑h(k−1)⋅2kn=nk=1∑h2kk−1

这个级数收敛到一个常数(约等于 1),所以 S = O ( n ) S = O(n) S=O(n)。

3.5 堆排序

堆排序的思路分两步:

  1. 建堆:升序排列建大堆,降序排列建小堆
  2. 反复交换 + 调整 :把堆顶元素(最大/最小值)和末尾元素交换,然后对堆顶执行 siftDown,缩小堆的范围
cpp 复制代码
void heapSort(int arr[], int n) {
    // 第一步:建大堆
    buildHeap(arr, n);
    
    // 第二步:反复交换堆顶和末尾,然后调整
    for (int i = n - 1; i > 0; i--) {
        // 交换堆顶和当前末尾
        int temp = arr[0];
        arr[0] = arr[i];
        arr[i] = temp;
        
        // 对堆顶执行向下调整,范围缩小到 i
        siftDown(arr, i, 0);
    }
}

为什么升序用大堆?因为大堆的堆顶是最大值,把它放到数组末尾,然后缩小堆的范围继续找次大值,最终数组就是升序排列。

图片说明(堆排序过程):展示堆排序的几个关键步骤。步骤1:建好大堆 9,8,7,3,5,6,1。步骤2:交换堆顶 9 和末尾 1,数组变为 1,8,7,3,5,6,\|9,9 已到位。步骤3:对 1 执行 siftDown,恢复大堆 8,5,7,3,1,6,\|9。步骤4:交换 8 和 6,继续调整。

3.6 TOP-K 问题

TOP-K 问题:从 N N N 个数据中找出最大(或最小)的 K K K 个。

一种朴素思路是全部排序,取前 K K K 个,时间复杂度 O ( N log ⁡ N ) O(N \log N) O(NlogN)。但如果 N N N 很大、 K K K 很小,这就太浪费了。

更高效的做法是用堆:

  • 找最大的 K 个 :建一个大小为 K K K 的小堆 。遍历数据,比堆顶大的就替换堆顶并调整。遍历完后,堆里就是最大的 K K K 个。
  • 找最小的 K 个 :建一个大小为 K K K 的大堆,道理一样。
cpp 复制代码
// 找数组中最大的 K 个元素
// 使用小堆,大小为 K
void topK(int arr[], int n, int k) {
    // 先建一个大小为 k 的小堆
    // 小堆的 siftDown:父节点小于子节点时不动
    // 这里省略小堆的 siftDown 实现,逻辑和大堆对称
    
    int* heap = (int*)malloc(k * sizeof(int));
    
    // 前 k 个元素先入堆
    for (int i = 0; i < k; i++)
        heap[i] = arr[i];
    
    // 建小堆
    for (int i = k / 2 - 1; i >= 0; i--)
        siftDownMin(heap, k, i);  // 小堆版本的 siftDown
    
    // 遍历剩余元素
    for (int i = k; i < n; i++) {
        if (arr[i] > heap[0]) {  // 比堆顶大,替换
            heap[0] = arr[i];
            siftDownMin(heap, k, 0);  // 调整
        }
    }
    
    // heap 中就是最大的 k 个元素
    for (int i = 0; i < k; i++)
        printf("%d ", heap[i]);
    
    free(heap);
}

时间复杂度: O ( N log ⁡ K ) O(N \log K) O(NlogK),空间复杂度: O ( K ) O(K) O(K)。当 K ≪ N K \ll N K≪N 时,比排序快得多。


第四部分:LeetCode 实战

理论讲完了,来刷几道题。选的题目从易到难,覆盖了前面讲的遍历、深度、翻转和堆。

题目一:144. 二叉树的前序遍历

题目链接LeetCode 144

题意 :给你二叉树的根节点 root,返回它节点值的前序遍历

解题思路

前序遍历的顺序是根→左→右。递归写法最直观,但面试官可能要求迭代写法,所以两种都给出。

递归解法
cpp 复制代码
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     struct TreeNode *left;
 *     struct TreeNode *right;
 * };
 */

void preorder(struct TreeNode* root, int* result, int* returnSize) {
    if (root == NULL) return;
    result[(*returnSize)++] = root->val;  // 根
    preorder(root->left, result, returnSize);   // 左
    preorder(root->right, result, returnSize);  // 右
}

int* preorderTraversal(struct TreeNode* root, int* returnSize) {
    int* result = (int*)malloc(sizeof(int) * 200);  // 题目保证节点数 <= 100
    *returnSize = 0;
    preorder(root, result, returnSize);
    return result;
}
迭代解法
cpp 复制代码
int* preorderTraversal(struct TreeNode* root, int* returnSize) {
    int* result = (int*)malloc(sizeof(int) * 200);
    *returnSize = 0;
    
    if (root == NULL) return result;
    
    struct TreeNode* stack[200];
    int top = -1;
    stack[++top] = root;
    
    while (top >= 0) {
        struct TreeNode* node = stack[top--];
        result[(*returnSize)++] = node->val;  // 访问节点
        
        // 先右后左,因为栈是后进先出
        if (node->right != NULL)
            stack[++top] = node->right;
        if (node->left != NULL)
            stack[++top] = node->left;
    }
    
    return result;
}
复杂度分析
  • 时间复杂度 : O ( n ) O(n) O(n),每个节点访问一次
  • 空间复杂度 : O ( n ) O(n) O(n),最坏情况(树退化为链表)栈深度为 n n n

题目二:104. 二叉树的最大深度

题目链接LeetCode 104

题意:给定一个二叉树,找出其最大深度。二叉树的深度为根节点到最远叶子节点的最长路径上的节点数。

解题思路

最大深度 = max(左子树深度, 右子树深度) + 1。这是一个非常自然的递归结构。

递归解法
cpp 复制代码
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     struct TreeNode *left;
 *     struct TreeNode *right;
 * };
 */
int maxDepth(struct TreeNode* root) {
    if (root == NULL) return 0;  // 空树深度为 0
    
    int leftDepth = maxDepth(root->left);    // 左子树深度
    int rightDepth = maxDepth(root->right);  // 右子树深度
    
    // 当前节点深度 = 较深子树 + 1
    return (leftDepth > rightDepth ? leftDepth : rightDepth) + 1;
}
BFS 解法(层序遍历)

也可以用层序遍历,走完几层深度就是几:

cpp 复制代码
int maxDepth(struct TreeNode* root) {
    if (root == NULL) return 0;
    
    struct TreeNode* queue[10000];
    int front = 0, rear = 0;
    queue[rear++] = root;
    int depth = 0;
    
    while (front < rear) {
        int levelSize = rear - front;  // 当前层节点数
        depth++;  // 每处理一层,深度 +1
        
        for (int i = 0; i < levelSize; i++) {
            struct TreeNode* node = queue[front++];
            if (node->left) queue[rear++] = node->left;
            if (node->right) queue[rear++] = node->right;
        }
    }
    
    return depth;
}
复杂度分析
  • 时间复杂度 : O ( n ) O(n) O(n),每个节点访问一次
  • 空间复杂度 :递归 O ( h ) O(h) O(h)( h h h 为树高),BFS O ( w ) O(w) O(w)( w w w 为最大层宽)

题目三:226. 翻转二叉树

题目链接LeetCode 226

题意 :给你一棵二叉树的根节点 root,翻转这棵二叉树,并返回其根节点。翻转是指交换每个节点的左右子树。

解题思路

这道题的关键是想到:翻转一棵树 = 交换当前节点的左右子树 + 递归翻转左子树 + 递归翻转右子树。顺序无所谓,先交换再递归,或者先递归再交换都行。

有个趣事:Max Howell(Homebrew 的作者)曾在 Google 面试中被问到这道题,没写出来。所以别觉得简单题就掉以轻心。

递归解法
cpp 复制代码
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     struct TreeNode *left;
 *     struct TreeNode *right;
 * };
 */
struct TreeNode* invertTree(struct TreeNode* root) {
    if (root == NULL) return NULL;
    
    // 交换左右子树
    struct TreeNode* temp = root->left;
    root->left = root->right;
    root->right = temp;
    
    // 递归翻转左右子树
    invertTree(root->left);
    invertTree(root->right);
    
    return root;
}
复杂度分析
  • 时间复杂度 : O ( n ) O(n) O(n),每个节点访问一次
  • 空间复杂度 : O ( h ) O(h) O(h),递归栈深度等于树高,最坏 O ( n ) O(n) O(n)

题目四:215. 数组中的第K个最大元素

题目链接LeetCode 215

题意 :给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。

解题思路

这道题正好用前面讲的堆。两种堆解法:

解法 A:建大堆,弹出 K-1 次,堆顶就是第 K 大

建大堆 O ( N ) O(N) O(N),每次弹出 O ( log ⁡ N ) O(\log N) O(logN),一共弹 K − 1 K-1 K−1 次,总复杂度 O ( N + K log ⁡ N ) O(N + K \log N) O(N+KlogN)。

解法 B:维护大小为 K 的小堆(TOP-K 思路)

遍历数组,维护一个大小为 K K K 的小堆。遍历完后堆顶就是第 K K K 大。时间复杂度 O ( N log ⁡ K ) O(N \log K) O(NlogK),空间 O ( K ) O(K) O(K)。

当 K K K 很小时,解法 B 更优。这里给出解法 B:

cpp 复制代码
// 小堆的向下调整
void siftDownMin(int* heap, int n, int i) {
    int parent = i;
    int child = 2 * parent + 1;
    
    while (child < n) {
        // 找左右孩子中较小的
        if (child + 1 < n && heap[child + 1] < heap[child])
            child = child + 1;
        
        if (heap[parent] <= heap[child])
            break;
        
        int temp = heap[parent];
        heap[parent] = heap[child];
        heap[child] = temp;
        
        parent = child;
        child = 2 * parent + 1;
    }
}

int findKthLargest(int* nums, int numsSize, int k) {
    int* heap = (int*)malloc(sizeof(int) * k);
    
    // 前 k 个元素建小堆
    for (int i = 0; i < k; i++)
        heap[i] = nums[i];
    for (int i = k / 2 - 1; i >= 0; i--)
        siftDownMin(heap, k, i);
    
    // 遍历剩余元素
    for (int i = k; i < numsSize; i++) {
        if (nums[i] > heap[0]) {  // 比堆顶大就替换
            heap[0] = nums[i];
            siftDownMin(heap, k, 0);
        }
    }
    
    // 堆顶就是第 k 大的元素
    int result = heap[0];
    free(heap);
    return result;
}
复杂度分析
  • 时间复杂度 : O ( N log ⁡ K ) O(N \log K) O(NlogK),遍历 N N N 个元素,每次调整最多 log ⁡ K \log K logK
  • 空间复杂度 : O ( K ) O(K) O(K),维护大小为 K K K 的堆

扩展题目:236. 二叉树的最近公共祖先

题目链接LeetCode 236

题意:给定一个二叉树,找到该树中两个指定节点的最近公共祖先(LCA)。

解题思路

这道题比前面几道难一些,需要理解递归的返回值含义。

对每个节点,递归问三个问题:

  1. 左子树里有没有 p 或 q?
  2. 右子树里有没有 p 或 q?
  3. 当前节点是不是 p 或 q?

如果其中两个为真,当前节点就是 LCA。具体来说:

  • 当前节点等于 p 或 q → 当前节点可能就是答案
  • 左子树和右子树各找到一个 → 当前节点是 LCA
  • 只有一边找到 → 答案在那一边
cpp 复制代码
/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     struct TreeNode *left;
 *     struct TreeNode *right;
 * };
 */
struct TreeNode* lowestCommonAncestor(struct TreeNode* root, 
                                       struct TreeNode* p, 
                                       struct TreeNode* q) {
    // 递归终止条件:走到空节点,或者找到 p 或 q
    if (root == NULL || root == p || root == q)
        return root;
    
    // 在左右子树中分别查找
    struct TreeNode* left = lowestCommonAncestor(root->left, p, q);
    struct TreeNode* right = lowestCommonAncestor(root->right, p, q);
    
    // 两侧都找到了:当前节点就是 LCA
    if (left != NULL && right != NULL)
        return root;
    
    // 只有一侧找到:返回那一侧的结果
    return left != NULL ? left : right;
}
复杂度分析
  • 时间复杂度 : O ( n ) O(n) O(n),最坏情况要遍历所有节点
  • 空间复杂度 : O ( h ) O(h) O(h),递归栈深度等于树高

参考资料

相关推荐
凯瑟琳.奥古斯特1 小时前
二分查找解力扣1011最优运载能力
开发语言·c++·算法·leetcode·职场和发展
学计算机的计算基2 小时前
二叉树算法下篇:递归核心技巧与高频面试题详解
java·笔记·算法
水龙吟啸2 小时前
华为2026.6.24机考选择题+编程题【速刷敲黑板】
人工智能·深度学习·算法·机器学习·华为
Eloudy2 小时前
全文 - Evolution Strategies as a Scalable Alternative to Reinforcement Learning
人工智能·算法
net3m332 小时前
可以加大adc滑动滤波的间隔时间,比如定时每20ms采集一次滤波输入,且用滑动窗口均值滤波
算法
卷福同学9 小时前
不用服务器,不用配环境,我10分钟上线了一个AI Agent
人工智能·后端·算法
至乐活着11 小时前
深入解析跳表SkipList:原理、实现与性能优化实战
数据结构·算法·跳表·skiplist·java实现
Jerry12 小时前
LeetCode 383. 赎金信
算法
ai产品老杨12 小时前
H264 H265视频分析常见问题和排查清单
人工智能·算法·音视频