目录
从前序遍历序列生成二叉搜索树 (Generating from Preorder)
[巧妙的 O(N) 解法](#巧妙的 O(N) 解法)
从前序遍历序列生成二叉搜索树 (Generating from Preorder)
回到两种遍历的核心特性
在动手之前,我们必须从第一性原理出发,搞清楚两种遍历的本质:
-
二叉搜索树 (BST) 的性质:
左 < 根 < 右
。这个性质定义了节点值的空间关系。 -
前序遍历 (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。
- 确定总根节点 :根据我们的关键洞察,数组的第一个元素
15
就是整棵树的根。我们创建这个节点。
cpp
15
/ \
? ?
- 划分左右子树 :现在,我们需要知道剩下的数组
[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
的右子树的前序遍历。
- 递归求解:我们现在面临两个规模更小、但性质完全相同的问题:
-
问题A:根据前序序列
[8, 3, 12]
重建一棵树,并把它链接到15
的left
指针。 -
问题B:根据前序序列
[20, 18, 25]
重建一棵树,并把它链接到15
的right
指针。
这天然就是一个递归的过程。
对问题 A [8, 3, 12]
进行递归:
-
根是
8
。 -
在
[3, 12]
中找分割点。3 < 8
,12 > 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
的右孩子...
推导出的关键要素:
-
我们需要一个全局的索引,来告诉我们当前正在处理数组中的哪个数字。这个索引必须在所有递归调用中同步前进,绝不后退。
-
我们的递归函数,除了需要知道数组本身,还必须知道当前正在构建的节点所允许的值的下界 (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²) 解法的性能瓶颈(重复扫描)来寻找优化的突破口。
-
核心思路转变:从"为每个根划分左右数组"转变为"为每个位置约束其合法值范围"。
-
实现关键:
-
使用一个全局索引(通过指针传递)来确保数组只被遍历一次。
-
递归函数携带值的上下界
(min_val, max_val)
作为参数。 -
每创建一个节点,就将这个节点的值作为新的约束,传递给构建其左右子树的递归调用中,从而不断收紧范围。
-
-
性能 :由于数组中的每个元素都只被访问和处理一次(在它成为
root
节点时),所以这个算法的时间复杂度是 O(N),空间复杂度是 O(h)(h是树的高度,用于递归栈),这是一个巨大的提升。