一、什么是笛卡尔树?
笛卡尔树是一种二叉树,它同时满足两个性质:
- 二叉搜索树 (BST) 性质 :对于树中的任意节点,其左子树所有节点的 下标 (index) 都小于它,右子树所有节点的 下标 都大于它。(即:中序遍历 = 原数组下标顺序)
- 堆 (Heap) 性质 :对于树中的任意节点,其 权值 (value) 都满足堆性质(例如小根堆:父节点值 ≤\le≤ 子节点值)。
想象一个直方图(柱状图)。
- 找到全图最矮的那根柱子,它是根节点。
- 它把数组切成了左右两半。
- 左边的最矮柱子是左孩子,右边的最矮柱子是右孩子。
- 递归下去...
这就是笛卡尔树。
二、它到底有什么用?(核心价值)
笛卡尔树最大的作用是: 建立"下标范围"与"数值大小"的拓扑映射。
2.1 价值一:RMQ ⇔\Leftrightarrow⇔ LCA 的等价性
这是笛卡尔树最著名的性质:
原数组区间 [L,R][L, R][L,R] 的最小值,就是笛卡尔树上节点 LLL 和节点 RRR 的最近公共祖先 (LCA)。
-
为什么有用?
通常我们用 ST 表解决 RMQ (区间最值),用倍增解决 LCA。
但在某些复杂的树形 DP 或特定问题中,我们需要反过来思考:把一个复杂的区间最值查询,转化为树上的节点关系。
例如: 标准的 RMQ 算法(Farach-Colton & Bender 算法)可以在 O(N)O(N)O(N) 预处理、O(1)O(1)O(1) 查询下解决 RMQ。它的核心步骤就是:先把数组建成笛卡尔树,转化为 LCA 问题,再通过欧拉序列转化为 ±1\pm 1±1 RMQ 问题。
2.2 价值二:解决"左右延伸"类问题 (直方图最大矩形)
经典问题 :给定 NNN 个柱子,求柱子能勾勒出的最大矩形面积。
- 暴力解法:对每个柱子,向左向右找第一个比它矮的柱子,确定宽度。
- 笛卡尔树解法 :
在笛卡尔树上,对于任意节点 uuu:- 它的整个子树(包括自己)对应的下标范围 [Lsub,Rsub][L_{sub}, R_{sub}][Lsub,Rsub],就是 以 uuu 为最小值所能扩展的最大范围。
- 矩形面积 = val[u]×(Rsub−Lsub+1)val[u] \times (R_{sub} - L_{sub} + 1)val[u]×(Rsub−Lsub+1)。
- 一次树形 DFS (O(N)O(N)O(N)) 即可求出所有答案。
2.3 价值三:Treap (树堆) 的本质
平衡树 Treap 本质上就是一颗 隐式的笛卡尔树。
- Treap 的节点有两个值:
key(BST 性质) 和priority(堆性质,随机生成)。 - 正因为
priority是随机的,这棵笛卡尔树的期望高度才是 O(logN)O(\log N)O(logN),从而保证了平衡。 - 理解笛卡尔树,你就理解了 Treap 为什么能平衡,以及
Split和Merge操作的本质。
三、O(N)O(N)O(N) 线性构建算法
如果用普通的插入法,最坏是 O(N2)O(N^2)O(N2)。但我们可以用 单调栈 思想在 O(N)O(N)O(N) 内构建。
3.1 算法流程 (右链维护法)
我们按照下标 1∼N1 \sim N1∼N 顺序插入节点。由于新节点的下标总是最大的,根据 BST 性质,新节点一定在当前树的"最右侧路径"上。
我们维护一条 右链(从根节点一直往右儿子走形成的链)。这条链上的元素下标递增。
当插入新节点 uuu 时:
- 向上回溯 :在右链上从下往上找,找到第一个权值比 uuu 小的节点 ppp(满足堆性质)。
- 挂接 :
- ppp 的原本右儿子(权值比 ppp 大,但比 uuu 大)变成 uuu 的 左儿子。
- uuu 变成 ppp 的 右儿子。
- 入栈 :uuu 成为右链的新末端。
这个过程本质上就是 单调栈 的过程。每个节点最多进栈出栈一次,复杂度 O(N)O(N)O(N)。
3.2 代码实现 (Java)
java
import java.util.Stack;
class CartesianTree {
int[] left, right; // 左右子节点
int root;
// arr: 原数组
public CartesianTree(int[] arr) {
int n = arr.length;
left = new int[n];
right = new int[n];
// 初始化为 -1
for(int i=0; i<n; i++) {
left[i] = -1;
right[i] = -1;
}
build(arr);
}
private void build(int[] arr) {
// 单调栈存储右链上的节点下标
// 栈底是根方向,栈顶是右链末端
Stack<Integer> stack = new Stack<>();
for (int i = 0; i < arr.length; i++) {
int lastPop = -1;
// 维护小根堆性质:栈顶元素必须小于当前元素
// 如果栈顶 > 当前元素,说明栈顶应该在当前元素的左子树中
while (!stack.isEmpty() && arr[stack.peek()] > arr[i]) {
lastPop = stack.pop();
}
// 刚刚弹出的节点(比当前大),成为当前节点的左儿子
left[i] = lastPop;
// 当前节点成为栈顶节点(比当前小)的右儿子
if (!stack.isEmpty()) {
right[stack.peek()] = i;
}
stack.push(i);
}
// 栈底元素即为树根
if (!stack.isEmpty()) {
root = stack.get(0);
}
}
}
四、灵魂拷问:为什么有了线段树/单调栈,还需要笛卡尔树?
这是一个非常深刻的问题。
- 求区间最小值 :线段树 O(logN)O(\log N)O(logN),ST 表 O(1)O(1)O(1),都很好。
- 求左右边界 :单调栈 O(N)O(N)O(N) 扫描一次就行。
那笛卡尔树是不是"多余"的?
答案:如果你只需要结果,单调栈够了;但如果你需要"结构",笛卡尔树是必须的。
4.1 单调栈是"过程",笛卡尔树是"结果"
单调栈本质上是构建笛卡尔树的 过程。
- 单调栈在扫描过程中,确实找到了每个元素"左边/右边第一个比它小"的位置。
- 但单调栈扫完就"扔"了,它没有把这种关系 持久化 下来。
笛卡尔树把单调栈扫描过程中产生的这种"大小覆盖关系"固化成了一棵树。
一旦建成,你就可以在这棵树上做任何树能做的事:树形 DP、DFS、LCA。
4.2 降维打击:树形 DP vs 组合数学
场景 :给定一个数组,求所有子区间的最小值之和。(∑i=1N∑j=iNmin(A[i..j])\sum_{i=1}^N \sum_{j=i}^N \min(A[i..j])∑i=1N∑j=iNmin(A[i..j]))
- 单调栈做法 :找到每个 A[k]A[k]A[k] 能作为最小值的范围 [Lk,Rk][L_k, R_k][Lk,Rk],贡献是 A[k]×(k−Lk+1)×(Rk−k+1)A[k] \times (k - L_k + 1) \times (R_k - k + 1)A[k]×(k−Lk+1)×(Rk−k+1)。这其实就是隐式地利用了笛卡尔树的性质。
- 同一类问题(仍然是单调栈强项) :只要答案能写成 ∑f(min)\sum f(\min)∑f(min),单调栈这套边界 [Lk,Rk][L_k, R_k][Lk,Rk] 仍然直接好用。
- 最小值平方和 :把 A[k]A[k]A[k] 换成 A[k]2A[k]^2A[k]2 即可,贡献是 A[k]2×(k−Lk+1)×(Rk−k+1)A[k]^2 \times (k - L_k + 1) \times (R_k - k + 1)A[k]2×(k−Lk+1)×(Rk−k+1)。
- 最小值为偶数的区间个数 :只对 A[k]A[k]A[k] 为偶数的下标累加,贡献是 (k−Lk+1)×(Rk−k+1)(k - L_k + 1) \times (R_k - k + 1)(k−Lk+1)×(Rk−k+1)。
- 重复值要用一严一松的比较(例如左边找 "<",右边找 "<="),保证每个区间只归属到一个 kkk。
- 真正麻烦的问题(依赖区间内部结构) :目标不仅依赖最小值本身,还依赖区间内部的其他信息时,用单调栈往往会变成一堆分类讨论。
- 例子 :求 ∑1≤l≤r≤nmin(A[l..r])⋅∑(A[l..r])\sum_{1\le l\le r\le n} \min(A[l..r]) \cdot \sum(A[l..r])∑1≤l≤r≤nmin(A[l..r])⋅∑(A[l..r])。
- 这已经不是 ∑f(min)\sum f(\min)∑f(min) 了,因为每个最小值节点 uuu 还需要把 "所有以 uuu 为最小值的区间" 的区间和统统加起来。
- 用笛卡尔树做树形 DP :以节点 uuu 为最小值的区间,等价于 "左子树选一个后缀" + uuu + "右子树选一个前缀"。
- 维护子树中序段的可合并信息:szszsz(大小)、SSS(总和)、PPP(所有非空前缀和之和)、QQQ(所有非空后缀和之和)。
- 在 uuu 处合并后,令 cL=sz(left)+1c_L=sz(left)+1cL=sz(left)+1,cR=sz(right)+1c_R=sz(right)+1cR=sz(right)+1,则 "以 uuu 为最小值的区间和总和" 为:
cL⋅cR⋅val[u]+cR⋅Q(left)+cL⋅P(right)c_L \cdot c_R \cdot val[u] + c_R \cdot Q(left) + c_L \cdot P(right)cL⋅cR⋅val[u]+cR⋅Q(left)+cL⋅P(right)。 - 所以该节点对答案的贡献是:val[u]val[u]val[u] 乘以上式。
- 例子 :求 ∑1≤l≤r≤nmin(A[l..r])⋅∑(A[l..r])\sum_{1\le l\le r\le n} \min(A[l..r]) \cdot \sum(A[l..r])∑1≤l≤r≤nmin(A[l..r])⋅∑(A[l..r])。
- Codeforces 例题(需要结构信息) :CF 1290E "Cartesian Tree"(https://codeforces.com/problemset/problem/1290/E)。
- 题目把 "逐步插入" 的过程和笛卡尔树的子树统计绑在一起:你不仅要知道相邻更小/更大关系,还要能动态维护并查询树上子树规模相关的全局统计。
- 这类题的关键不在于一次性算出边界,而在于把单调栈产生的关系固化成结构,并在结构上做维护与统计。
4.3 理论极限:O(N)−O(1)O(N) - O(1)O(N)−O(1) 的 RMQ
这是理论计算机科学中的一个重要结论。
标准 RMQ 问题(ST 表)预处理是 O(NlogN)O(N \log N)O(NlogN)。
能否做到 O(N)O(N)O(N) 预处理,O(1)O(1)O(1) 查询?
可以,且 必须 经过笛卡尔树:
- 数组 →\to→ 笛卡尔树 (O(N)O(N)O(N))。
- 笛卡尔树 RMQ →\to→ LCA 问题。
- LCA →\to→ 欧拉序列 ±1\pm 1±1 RMQ 问题 (O(N)O(N)O(N))。
- 分块查表 (O(N)O(N)O(N))。
没有笛卡尔树作为中间桥梁,这个理论下界无法达成。
五、进阶:从笛卡尔树到标准 RMQ 算法 (Farach-Colton & Bender)
这是一个理论计算机科学中的经典算法,它实现了 RMQ 问题的理论下界: O(N)O(N)O(N) 预处理, O(1)O(1)O(1) 查询 。
(普通的 ST 表需要 O(NlogN)O(N \log N)O(NlogN) 预处理)。
这个算法的流程非常精妙,它利用笛卡尔树将问题一步步转化,最终消解掉。
5.1 第一步:RMQ →\to→ LCA
核心思想:利用笛卡尔树性质。
- 输入 :数组 AAA。
- 转化 :构建 AAA 的笛卡尔树 TTT。
- 结论 :RMQA(i,j)=LCAT(i,j)RMQ_A(i, j) = LCA_T(i, j)RMQA(i,j)=LCAT(i,j)。
- 代价 :构建笛卡尔树是 O(N)O(N)O(N)。
现在问题变成了:如何在树上做 O(1)O(1)O(1) LCA?(倍增法是 O(logN)O(\log N)O(logN),Tarjan 是离线的,都不行)。
5.2 第二步:LCA →\to→ ±1\pm 1±1 RMQ
核心思想:利用欧拉序列(Euler Tour)将树拍平回数组。
- 欧拉序列 :对树进行 DFS,每经过一个节点(无论是首次进入还是回溯)都记录下来,并记录深度。
- 得到序列 EEE(节点编号)和 DDD(节点深度)。序列长度为 2N−12N-12N−1。
- 转化 :
- uuu 和 vvv 的 LCA,就是在欧拉序列 EEE 中,uuu 首次出现位置和 vvv 首次出现位置之间,深度 DDD 最小 的那个节点。
- 结论 :LCA 问题又变回了 DDD 数组上的 RMQ 问题!
- 关键性质 :DDD 数组中相邻两个元素的深度差必然是 ±1\pm 1±1(因为 DFS 每次只走一步,要么下到儿子+1,要么回溯父亲-1)。
现在问题变成了:如何在一个 相邻差为 ±1\pm 1±1 的数组上做 O(1)O(1)O(1) RMQ?
5.3 第三步:±1\pm 1±1 RMQ 的分块与查表
对于普通 RMQ,我们无法做到 O(N)O(N)O(N) 预处理。但对于 ±1\pm 1±1 RMQ,我们可以利用这个约束来压缩状态。
-
分块 :
将数组切分成长度为 B=logN2B = \frac{\log N}{2}B=2logN 的微小块。
-
块间 (Macro) RMQ:
- 取出每个块的最小值,组成一个新数组 A′A'A′,长度为 N/B≈2N/logNN/B \approx 2N / \log NN/B≈2N/logN。
- 对 A′A'A′ 建立普通的 ST 表。
- 预处理时间:O(NlogN⋅log(NlogN))=O(N)O(\frac{N}{\log N} \cdot \log(\frac{N}{\log N})) = O(N)O(logNN⋅log(logNN))=O(N)。
- 查询时间:O(1)O(1)O(1)。
-
块内 (Micro) RMQ:
- 关键点来了:由于相邻差是 ±1\pm 1±1,一个长度为 BBB 的块,其形状(升降趋势)完全由 B−1B-1B−1 个 ±1\pm 1±1 决定。
- 本质不同的块形状只有 2B−12^{B-1}2B−1 种。
- 因为 B=logN2B = \frac{\log N}{2}B=2logN,所以 2B−1≈N2^{B-1} \approx \sqrt{N}2B−1≈N 。这是极小的!
- 查表法 :我们要预处理这 N\sqrt{N}N 种形状中,任意子区间的最小值位置。
- 预处理时间:O(N⋅B2)=O(Nlog2N)≪O(N)O(\sqrt{N} \cdot B^2) = O(\sqrt{N} \log^2 N) \ll O(N)O(N ⋅B2)=O(N log2N)≪O(N)。
5.4 最终查询
查询 [L,R][L, R][L,R] 时:
- 如果在同一个微小块内 →\to→ 查表 O(1)O(1)O(1)。
- 如果跨块 →\to→
- 左端残余部分:查表 O(1)O(1)O(1)。
- 右端残余部分:查表 O(1)O(1)O(1)。
- 中间完整块:查块间 ST 表 O(1)O(1)O(1)。
- 取三者最小值。
总结 :正是因为笛卡尔树将任意数组转化为了 ±1\pm 1±1 约束的欧拉序列,我们才能利用"本质不同形状很少"这一特性进行状态压缩,从而达成理论下界。
六、更多经典应用:笛卡尔树的降维打击
除了 RMQ 和直方图最大矩形,笛卡尔树在解决某些特定类型的 区间计数/最值贡献 问题时,具有降维打击般的直观性。
6.1 区间最值贡献求和 (Sum of Subarray Minimums)
问题 :给定数组 AAA,求 ∑i=1N∑j=iNmin(A[i..j])\sum_{i=1}^N \sum_{j=i}^N \min(A[i..j])∑i=1N∑j=iNmin(A[i..j])。
(即:所有连续子数组的最小值之和)
- 普通视角 :枚举 i,ji, ji,j 是 O(N2)O(N^2)O(N2)。优化需要单调栈推导左右边界,容易搞错 i−L+1i-L+1i−L+1 这种下标细节。
- 笛卡尔树视角 :
这个问题等价于:计算树上每个节点的权值 ×\times× 它被穿过的次数 。- 对于节点 uuu,它管辖的区间是 [Lsub,Rsub][L_{sub}, R_{sub}][Lsub,Rsub]。
- 它左子树大小为 szLsz_LszL,右子树大小为 szRsz_RszR。
- 核心逻辑 :任何跨越 uuu 且不超出 [Lsub,Rsub][L_{sub}, R_{sub}][Lsub,Rsub] 的区间,其最小值必然是 val[u]val[u]val[u]。
- 这样的区间有多少个?左边可选起点数 (szL+1)(sz_L + 1)(szL+1),右边可选终点数 (szR+1)(sz_R + 1)(szR+1)。
- 贡献 = val[u]×(szL+1)×(szR+1)val[u] \times (sz_L + 1) \times (sz_R + 1)val[u]×(szL+1)×(szR+1)。
- 算法 :建树 O(N)O(N)O(N),DFS 统计子树大小 O(N)O(N)O(N)。完事。
6.2 字符串界的黑科技:后缀树的"平替"
后缀树 (Suffix Tree) 是解决字符串问题的神器,但它的构建算法(Ukkonen)极其复杂,代码量大且容易写错。
而 后缀数组 (Suffix Array) + Height 数组 + 笛卡尔树 的组合,可以完美模拟后缀树的结构,且代码极其短小。
-
原理:
- 先求出字符串的 后缀数组 (SA) 和 LCP 数组 (Height)。
- 对 Height 数组 构建 笛卡尔树。
- 结论 :这棵笛卡尔树的结构,本质上就是 后缀树 的结构(即后缀树的虚树形式)。
-
应用场景:
- 求任意两个后缀的最长公共前缀 (LCP) :
- 转化为笛卡尔树上的 LCA (最近公共祖先)。
- LCP(suffixi,suffixj)=value(LCA(rank[i],rank[j]))LCP(suffix_i, suffix_j) = value(LCA(rank[i], rank[j]))LCP(suffixi,suffixj)=value(LCA(rank[i],rank[j]))。
- 求所有后缀对的 LCP 之和 (或"子串出现次数 ×\times× 长度"的最大值):
- 转化为统计笛卡尔树上每个节点贡献的 "矩形面积"。
- 即:Height[u]×(u 的管辖宽度)Height[u] \times (u \text{ 的管辖宽度})Height[u]×(u 的管辖宽度)。
- (注:"本质不同子串个数"通常直接用公式 ∑(N−SA[i]−Height[i])\sum (N - SA[i] - Height[i])∑(N−SA[i]−Height[i]) 求解,不需要笛卡尔树)
- 最长公共子串 (LCS) :
- 多串拼接后,在笛卡尔树上寻找 "子树内包含所有原串后缀" 的最深节点。
- 求任意两个后缀的最长公共前缀 (LCP) :
-
价值 :
这一转化让字符串问题回归到了数组区间问题。你不需要懂复杂的自动机理论,只需要会写后缀排序和单调栈,就能解决绝大多数后缀树能解决的问题。
6.3 图论中的"笛卡尔树":Kruskal 重构树
这是处理 "带阈值的连通性" 问题的终极武器。
疑问 :如果是查询"只经过边权 ≤K\le K≤K 的边能到哪",直接把 ≤K\le K≤K 的边加入并查集不就行了?
解答 :如果是 单次查询 ,并查集确实够了。但如果给你 10510^5105 次查询 ,每次的 KKK 都不一样呢?
- 每次都重新跑一遍并查集?O(M⋅Q)O(M \cdot Q)O(M⋅Q) 会超时。
- 离线排序查询?可以,但如果是 强制在线 呢?
- Kruskal 重构树 可以在 O(NlogN)O(N \log N)O(NlogN) 预处理后,以 O(logN)O(\log N)O(logN) 回答每次在线查询。
6.3.1 什么是重构树?
在运行 Kruskal 算法求最小生成树时,我们通常的操作是:
如果 find(u) != find(v),则 union(u, v)。
重构树的改动 :
当我们要合并两个集合(根分别为 rootu,rootvroot_u, root_vrootu,rootv)时,通过边权 www 连接:
- 不直接 让 rooturoot_urootu 指向 rootvroot_vrootv。
- 新建一个虚拟节点 new_rootnew\_rootnew_root,点权设为 www。
- 让 rooturoot_urootu 和 rootvroot_vrootv 成为 new_rootnew\_rootnew_root 的左右儿子。
- 现在 new_rootnew\_rootnew_root 成了这个新集合的根。
6.3.2 为什么它有效?(核心性质)
这样构建出来的树有几个神奇性质:
- 二叉树结构:原图的节点都是叶子,新建的虚拟节点是内部节点。
- 大根堆性质 :父节点的权值(合并发生的晚,边权大)一定 ≥\ge≥ 子节点的权值。
- LCA 性质 :原图中两个点 u,vu, vu,v 路径上的 最大边权 ,就是重构树上 LCA(u,v)LCA(u, v)LCA(u,v) 的权值。
6.3.3 如何解决问题?
问题 :从 SSS 出发,只经过边权 ≤K\le K≤K 的边,能到达哪些点?
转化逻辑:
-
在重构树上,从叶子节点 SSS 开始,向上倍增跳跃。
-
跳到一个 最高的祖先 AAA ,满足 val[A]≤Kval[A] \le Kval[A]≤K。
- 关键点:为什么不用担心旁边的兄弟节点?
- 在重构树中,节点代表的是 边(连接两个连通块的操作)。
- 除了向上通过父亲,没有别的路可以走。
- 如果你想去兄弟子树(原来的另一个连通块),唯一的路径就是通过你的父亲(那条连接你们的边)。
- 如果父亲的权值 >K> K>K,说明连接这两个连通块的 最小边权 都 >K> K>K。
- 既然最小边都过不去,那这两个连通块在 ≤K\le K≤K 的限制下就是 不连通 的。
- 所以,只要被父亲挡住了,你就绝对去不了兄弟那里。
-
结论 :AAA 的整个子树中的所有叶子节点,就是 SSS 能到达的所有点。
-
进阶 :结合 DFS 序 ,子树就是一个连续区间。问题转化为:"在这个区间内,有多少个点满足..." →\to→ 主席树/线段树 解决。
- 价值 :
它将图上的 "路径限制可达性" 问题,转化为了树上的 "子树查询" 问题。结合 DFS 序和线段树,可以处理极高难度的图论综合题(如 IOI 2018 Werewolf)。
七、总结
笛卡尔树并不是用来替代线段树或平衡树来处理"动态修改"的。它的核心价值在于 结构转化 ------ 它能将一个 线性数组的区间最值问题 完美地转化为 树上的祖先关系问题 。
而且,它的构建是 O(N)O(N)O(N) 的,比线段树的 O(NlogN)O(N \log N)O(NlogN) 建树更快,且结构更紧凑。
笛卡尔树并不是一种"更强"的数据结构,而是一种 "转化视角"的工具。
| 特性 | 线段树 | 笛卡尔树 |
|---|---|---|
| 构建时间 | O(NlogN)O(N \log N)O(NlogN) | O(N)O(N)O(N) |
| 主要能力 | 动态区间修改/查询 | 静态数组结构分析 |
| 核心逻辑 | 二分法 | 极值分治 |
| 适用场景 | 区间求和、修改 | LCA ⇔\Leftrightarrow⇔ RMQ 转化、直方图问题 |
一句话记住笛卡尔树 :
它是将"数值大小"转化为"树形父子关系"的桥梁,让我们可以用处理树(Tree DP、LCA)的方法来处理数组区间问题。