数据结构:从前序遍历序列重建一棵二叉搜索树 (Generating from Preorder)

目录

从前序遍历序列生成二叉搜索树 (Generating from Preorder)

回到两种遍历的核心特性

推导重建算法

代码实现

[巧妙的 O(N) 解法](#巧妙的 O(N) 解法)

转变思路------从"划分"到"约束"

代码实现

总结


从前序遍历序列生成二叉搜索树 (Generating from Preorder)

回到两种遍历的核心特性

在动手之前,我们必须从第一性原理出发,搞清楚两种遍历的本质:

  1. 二叉搜索树 (BST) 的性质:左 < 根 < 右。这个性质定义了节点值的空间关系。

  2. 前序遍历 (Preorder Traversal) 的性质:根 -> 左 -> 右。这个性质定义了节点在序列中的出现顺序。

将这两个性质结合起来,我们会发现一个惊人的事实:

在任何一段 BST 的前序遍历序列中,第一个元素永远是这段序列所能构成的树的根节点。

例如,对于我们之前创建的树,其前序遍历序列是 [15, 8, 3, 12, 20, 18, 25]

cpp 复制代码
      15
     /  \
    8    20
   / \   / \
  3  12 18  25
  • 对于整个序列,15 是第一个元素,所以 15 是整棵树的根。

  • 15 的左子树的前序遍历是 [8, 3, 12],其中 8 是第一个,所以 8 是左子树的根。

  • 15 的右子树的前序遍历是 [20, 18, 25],其中 20 是第一个,所以 20 是右子树的根。

这个规律递归地适用于所有子树。


推导重建算法

我们手里只有一个前序遍历数组,比如 preorder = [15, 8, 3, 12, 20, 18, 25]。我们的目标是重建出唯一的 BST。

  1. 确定总根节点 :根据我们的关键洞察,数组的第一个元素 15 就是整棵树的根。我们创建这个节点。
cpp 复制代码
   15
  /  \
 ?    ?
  1. 划分左右子树 :现在,我们需要知道剩下的数组 [8, 3, 12, 20, 18, 25] 中,哪些属于左子树,哪些属于右子树。
  • 根据 BST 的性质:所有小于 15 的节点都属于左子树,所有大于 15 的节点都属于右子树。

  • 根据前序遍历的性质:左子树的所有节点会紧跟在根节点之后,然后才是右子树的所有节点。

所以,我们可以在 [8, 3, 12, 20, 18, 25] 这个子数组中找到一个"分割点 ":这个点是第一个大于根节点 15 的数。

  • 8 < 15

  • 3 < 15

  • 12 < 15

  • 20 > 15 <-- 啊哈!分割点找到了。

这意味着:

  • [8, 3, 12] 这部分构成了 15 的左子树的前序遍历。

  • [20, 18, 25] 这部分构成了 15 的右子树的前序遍历。

  1. 递归求解:我们现在面临两个规模更小、但性质完全相同的问题:
  • 问题A:根据前序序列 [8, 3, 12] 重建一棵树,并把它链接到 15left 指针。

  • 问题B:根据前序序列 [20, 18, 25] 重建一棵树,并把它链接到 15right 指针。

这天然就是一个递归的过程。

对问题 A [8, 3, 12] 进行递归:

  • 根是 8

  • [3, 12] 中找分割点。3 < 812 > 8

  • 左子树序列是 [3],右子树序列是 [12]

  • 继续递归... 直到处理的序列为空。


代码实现

根据上述推导,我们可以编写一个递归函数。这个函数需要知道它当前正在处理原数组的哪一个片段。因此,我们需要传递数组的起始索引结束索引

函数签名和基本情况

cpp 复制代码
// Node 结构体定义...

/*
 * 功能:根据 preorder 数组中从 startIndex 到 endIndex 的片段,构建一棵 BST
 * 返回值:构建好的子树的根节点
 */
Node* buildFromPreorderHelper(int preorder[], int startIndex, int endIndex) {
    // 基本情况:如果起始索引大于结束索引,说明这是一个空片段,无法构成节点
    if (startIndex > endIndex) {
        return NULL;
    }
    
    // ... 递归构建的逻辑 ...
}

创建根节点并寻找分割点

cpp 复制代码
Node* buildFromPreorderHelper(int preorder[], int startIndex, int endIndex) {
    if (startIndex > endIndex) {
        return NULL;
    }

    // 1. 片段的第一个元素就是根
    Node* root = new Node(preorder[startIndex]);

    // 2. 寻找分割点:找到第一个大于根值的元素的索引
    int splitIndex = startIndex + 1;
    while (splitIndex <= endIndex && preorder[splitIndex] < root->data) {
        splitIndex++;
    }
    
    // ... 递归调用 ...
    return root;
}

递归构建左右子树

找到分割点 splitIndex 后,我们就知道了左右子树各自对应的数组片段。

  • 左子树的片段是 [startIndex + 1, splitIndex - 1]

  • 右子树的片段是 [splitIndex, endIndex]

cpp 复制代码
Node* buildFromPreorderHelper(int preorder[], int startIndex, int endIndex) {
    if (startIndex > endIndex) {
        return NULL;
    }

    Node* root = new Node(preorder[startIndex]);

    int splitIndex = startIndex + 1;
    while (splitIndex <= endIndex && preorder[splitIndex] < root->data) {
        splitIndex++;
    }

    // 3. 递归构建左右子树
    root->left = buildFromPreorderHelper(preorder, startIndex + 1, splitIndex - 1);
    root->right = buildFromPreorderHelper(preorder, splitIndex, endIndex);

    return root;
}

主调函数

我们需要一个启动函数来开始整个过程。

cpp 复制代码
Node* buildBstFromPreorder(int preorder[], int n) {
    if (n == 0) {
        return NULL;
    }
    return buildFromPreorderHelper(preorder, 0, n - 1);
}

这个方法虽然直观,但由于每次都需要循环查找分割点,在最坏情况下(树退化成链表),时间复杂度会达到 O(N2)。存在一种更优化的 O(N) 解法,它通过维护一个值的"上下界"来避免重复扫描,但从第一性原理推导,上述方法是最自然的。


巧妙的 O(N) 解法

首先,我们要回到第一性原理,问一个问题:

O(N²) 解法慢在哪里?

它慢在重复劳动 。对于树中的每一个节点,我们都回头去扫描了一遍数组的剩余部分,只为了找到那个"分割点"。

例如,在处理根节点 15 时,我们扫描了 [8, 3, 12] 来确认它们都小于 15。接着,在处理子节点 8 时,我们又扫描了 [3, 12] 来确认它们的大小关系。这种重复扫描就是性能瓶颈。

**优化的核心思想:**我们能不能只遍历数组一次,每当我们遇到一个数字时,就能立刻知道它应该被放在树的哪个位置?


转变思路------从"划分"到"约束"

O(N²) 解法的思路是"划分":对于一个根节点,它把剩余的数组划分为左、右两部分。

O(N) 解法的思路是"约束":我们从头到尾处理前序遍历序列,每创建一个节点,就为后面可能成为其子孙的节点设定一个值的约束范围 (value range)

我们来手动模拟一下这个"约束"的过程,并思考需要哪些信息。 假设前序序列是 [15, 8, 3, 12, 20, 18, 25]

1. 开始处理 15

  • 15 是第一个,所以它肯定是根。我们创建它。

  • 15 对它的孩子们施加了什么约束?

    • 它的左孩子(以及左子树所有节点)的值必须在 (-∞, 15) 这个区间内。

    • 它的右孩子(以及右子树所有节点)的值必须在 (15, +∞) 这个区间内。

2. 继续处理下一个数字 8

  • 我们现在尝试为 15 构建左子树。

  • 8 是否满足成为 15 左子树一部分的条件?是的,8(-∞, 15) 的区间内。

  • 所以 8 成为 15 的左孩子。我们创建它。

  • 现在,8 又对它的孩子们施加了更严格的约束:

    • 8 的左孩子的值必须在 (-∞, 8) 区间内。(它既要小于父亲8,也要小于祖父15

    • 8 的右孩子的值必须在 (8, 15) 区间内。(它既要大于父亲8,也要小于祖父15

3. 继续处理下一个数字 3

  • 我们现在尝试为 8 构建左子树。

  • 3 是否满足成为 8 左子树一部分的条件?是的,3(-∞, 8) 区间内。

  • 所以 3 成为 8 的左孩子。我们创建它。

  • 3 没有任何孩子,所以它不施加新的约束,我们继续。

4. 继续处理下一个数字 12

  • 我们尝试为 3 构建左子树。12 不在 (-∞, 3) 区间,失败。

  • 我们尝试为 3 构建右子树。12 不在 (3, 8) 区间(注意上界是8),失败。

  • 这意味着 3 的子树构建完毕。我们回溯到 8

  • 我们尝试为 8 构建右子树。12 是否满足条件?是的,12(8, 15) 区间内!

  • 所以 12 成为 8 的右孩子。我们创建它。

5. 继续处理下一个数字 20

  • 我们继续上面的过程,最终会回溯到 15

  • 我们尝试为 15 构建右子树。20 是否满足条件?是的,20(15, +∞) 区间内。

  • 所以 20 成为 15 的右孩子...

推导出的关键要素:

  1. 我们需要一个全局的索引,来告诉我们当前正在处理数组中的哪个数字。这个索引必须在所有递归调用中同步前进,绝不后退。

  2. 我们的递归函数,除了需要知道数组本身,还必须知道当前正在构建的节点所允许的值的下界 (lower bound)上界 (upper bound)


代码实现

基于这个新的"约束"思想,我们来重写递归函数。

新的函数签名

我们需要一个辅助函数,它带有我们刚才推导出的关键要素。

cpp 复制代码
#include <climits> // 为了使用 INT_MIN 和 INT_MAX

// Node 结构体...

/*
 * 功能: 根据 preorder 数组构建 BST
 * preIndex: 一个指向当前处理位置索引的指针,确保全局同步
 * min_val: 当前节点允许的最小值 (下界)
 * max_val: 当前节点允许的最大值 (上界)
 */
Node* buildHelper(int preorder[], int* preIndex, int min_val, int max_val);

为什么 preIndex 要用指针 int*?因为我们希望在任何一层递归中对它做的 ++ 操作,能够被所有其他层级的递归调用"看到"。如果只传值 int,每个函数只会修改自己的副本。

基本情况与合法性检查

函数的第一件事,就是检查当前 preorder[*preIndex] 的值是否在 (min_val, max_val) 这个合法的区间内。

cpp 复制代码
Node* buildHelper(int preorder[], int* preIndex, int n, int min_val, int max_val) {
    // 基本情况1: 数组已经处理完毕
    if (*preIndex >= n) {
        return NULL;
    }

    Node* root = NULL;
    int current_val = preorder[*preIndex];

    // 关键的合法性检查:当前值是否在父节点设定的约束范围内
    if (current_val > min_val && current_val < max_val) {
        // 合法,可以创建节点
        // ... 创建和递归的逻辑 ...
    }
    
    // 如果不合法,说明这个数字不属于当前子树,直接返回 NULL,
    // preIndex 保持不变,留给上层的其他分支去处理。
    return root; 
}

创建节点与递归构建

如果当前值合法,我们就创建节点,消耗掉这个值(将索引++),然后递归地为左右子树设定新的、更严格的约束。

cpp 复制代码
Node* buildHelper(int preorder[], int* preIndex, int n, int min_val, int max_val) {
    if (*preIndex >= n) {
        return NULL;
    }

    Node* root = NULL;
    int current_val = preorder[*preIndex];

    if (current_val > min_val && current_val < max_val) {
        // 1. 创建根节点
        root = new Node(current_val);
        
        // 2. 消耗掉当前值 (这是关键一步,索引向前移动)
        (*preIndex)++;

        // 3. 递归构建左子树
        // 左子树的约束:下界不变,上界变为当前节点的值
        root->left = buildHelper(preorder, preIndex, n, min_val, current_val);

        // 4. 递归构建右子树
        // 右子树的约束:下界变为当前节点的值,上界不变
        // 注意:此时 preIndex 可能已经被左子树的构建过程推进了很多
        root->right = buildHelper(preorder, preIndex, n, max_val, current_val);
    }
    
    return root;
}

修正一个小错误 :右子树的buildHelper调用中,参数顺序应该是(..., n, current_val, max_val),下界是current_val,上界不变。

最终的完整代码

cpp 复制代码
#include <iostream>
#include <climits> // for INT_MIN, INT_MAX

// (Node and inorderTraversal function definitions here...)
struct Node { /* ... */ };
void inorderTraversal(Node* root) { /* ... */ };


Node* buildHelper(int preorder[], int* preIndex, int n, int min_val, int max_val) {
    if (*preIndex >= n) {
        return NULL;
    }

    int current_val = preorder[*preIndex];

    // 如果当前值不满足约束,它就不属于这个子树
    if (current_val <= min_val || current_val >= max_val) {
        return NULL;
    }

    // 值是合法的,创建节点
    Node* root = new Node(current_val);
    
    // 消耗当前值
    (*preIndex)++;

    // 递归构建左子树,新的上界是当前节点的值
    root->left = buildHelper(preorder, preIndex, n, min_val, current_val);
    
    // 递归构建右子树,新的下界是当前节点的值
    root->right = buildHelper(preorder, preIndex, n, current_val, max_val);

    return root;
}

Node* buildBstFromPreorder_Optimized(int preorder[], int n) {
    if (n == 0) return NULL;
    int preIndex = 0;
    // 初始调用,约束范围是无穷小到无穷大
    return buildHelper(preorder, &preIndex, n, INT_MIN, INT_MAX);
}

int main() {
    int preorder[] = {15, 8, 3, 12, 20, 18, 25};
    int n = sizeof(preorder) / sizeof(preorder[0]);

    std::cout << "使用 O(N) 方法从前序遍历创建 BST..." << std::endl;
    Node* root = buildBstFromPreorder_Optimized(preorder, n);

    std::cout << "中序遍历验证结果: ";
    inorderTraversal(root); // 应该输出: 3 8 12 15 18 20 25 
    std::cout << std::endl;

    return 0;
}

总结

  • 第一性原理:我们通过识别出 O(N²) 解法的性能瓶颈(重复扫描)来寻找优化的突破口。

  • 核心思路转变:从"为每个根划分左右数组"转变为"为每个位置约束其合法值范围"。

  • 实现关键

    1. 使用一个全局索引(通过指针传递)来确保数组只被遍历一次。

    2. 递归函数携带值的上下界 (min_val, max_val) 作为参数。

    3. 每创建一个节点,就将这个节点的值作为新的约束,传递给构建其左右子树的递归调用中,从而不断收紧范围。

  • 性能 :由于数组中的每个元素都只被访问和处理一次(在它成为 root 节点时),所以这个算法的时间复杂度是 O(N),空间复杂度是 O(h)(h是树的高度,用于递归栈),这是一个巨大的提升。

相关推荐
养成系小王1 小时前
四大常用排序算法
数据结构·算法·排序算法
闪电麦坤953 小时前
数据结构:二叉树的遍历 (Binary Tree Traversals)
数据结构·二叉树·
球king3 小时前
数据结构中邻接矩阵中的无向图和有向图
数据结构
野渡拾光5 小时前
【考研408数据结构-05】 串与KMP算法:模式匹配的艺术
数据结构·考研·算法
pusue_the_sun12 小时前
数据结构:二叉树oj练习
c语言·数据结构·算法·二叉树
liang_jy18 小时前
数组(Array)
数据结构·面试·trae
要做朋鱼燕19 小时前
【数据结构】用堆解决TOPK问题
数据结构·算法
秋难降20 小时前
LRU缓存算法(最近最少使用算法)——工业界缓存淘汰策略的 “默认选择”
数据结构·python·算法
Jayyih1 天前
嵌入式系统学习Day19(数据结构)
数据结构·学习