分数树 Stern-Brocot Tree

5086 字
25 分钟
分数树 Stern-Brocot Tree
AIGC 通告

本文用于测试 Deepseek Harness 接管博客的文章生成效果,如有疏漏烦请指出。

书接上回:伸展树 Splay 是这条树形数据结构流水线的最后一篇。从 到 ,再到 ,我们一直在和“具有单调性的值序列”打交道。今天我们换一种元素——不存整数,存分数。于是有了这篇分数树。

分数树简介A Brief Introduction to Stern-Brocot#

分数树Stern-Brocot Tree,也译作斯特恩-布罗科特树Stern-Brocot,是德国数学家 与法国钟表匠 先后独立提出的一种结构,其中 是在研究齿轮传动比的时候把它捣鼓出来的——可以用它把所有可能的齿轮齿数比按由小到大的顺序列出来,从而挑选出近似程度最好的一对齿轮。所以说这玩意儿一开始是给钟表匠用的,不是给 OIer 用的。

它做的事情可以用一句话概括:

把全体非负既约分数,按照大小关系排成一棵二叉搜索树。

树里的每个元素都是一个分数 ,且强调一下——树中出现的所有分数都是既约的,即 。这一点由构造方式自动保证,后文会顺带提到。

分数树的构造Construction of Stern-Brocot Tree#

从两个边界开始Starting from Two Bounds#

取两个“分数”作为初始边界:

其中 严格来说不是分数,我们在这里把它理解为 的一个占位符(这样写纯属为了方便叙述,程序中谁也不会去真的拿它当分母)。

每一轮迭代中,我们取出序列里所有相邻的两个分数 与 ,在它们中间插入中位分数Mediant:

第一轮之后序列变成:

第二轮:

第三轮:

如果把这些“插入”看成树上的节点,就得到了分数树的结构。以 为根节点,左子树由 与 之间的所有分数递归生成,右子树由 与 之间的分数递归生成,便得到:

graph TD A["1/1"] --> B["1/2"] A --> C["2/1"] B --> D["1/3"] B --> E["2/3"] C --> F["3/2"] C --> G["3/1"] D --> H["1/4"] D --> I["2/5"] E --> J["3/5"] E --> K["3/4"]
Note

上图只画了三层(深度指从根出发的边数)。 与 永远只是边界,不会出现在树中,它们的作用相当于 里那两个正、负无穷节点——不错,我说的就是那篇 里提到的“人体的阑尾”。

三条基本性质Three Basic Properties#

这个构造之所以成立,靠的是下面三条性质(证明在 OI Wiki 上很详细,这里只讲结论):

  1. 单调性:每一轮迭代结束后,序列都是严格递增的。
  2. 最简性:每一轮插入的分数都是既约分数。
  3. 完全性:任意一个非负既约分数 ,都会在某一轮恰好出现一次。

单调性与最简性的证明是同一套路的双重归纳。先定义两个分数 、 的行列式Determinant:

那么初始的两项 与 之间满足

也就是说 。这个性质在插入中位分数时是可以传递的:对相邻三项 ,有

于是“相邻两项的行列式绝对值为 ”在每一步迭代后都成立。而由

立刻得到 ,即每一项都是既约分数——最简性就保住了。 这一项需要特殊处理(分母为 时上述约定照旧成立),这就是为什么它只是一个占位符。

Tip

记 的绝对值而非 本身,是因为随着构造向右推进, 会在 与 之间规律地摆动。判断既约性只需要 这一条。

为什么它是二叉搜索树Why Is It a Binary Search Tree#

分数树和 的对应关系非常直白:

  • 左子树的所有分数都小于当前节点,右子树都大于当前节点;
  • 当前节点总等于左右两条边界的中位分数;
  • 中序遍历得到的就是整个分数序列,与前面逐轮构造出来的序列完全一致。

因此在树上做二分查找是天然成立的,这就是后文所有算法的出发点。

分数树的代码实现In-Code Practice#

我们照例沿用 那篇的行文顺序:先明确结构,再实现操作。

结构定义Structure Definition#

分数树不同于前面三棵树的地方在于,它几乎不需要显式建树。绝大多数题目只需要维护“当前所在区间的两个边界”以及“当前访问到的节点”,三个分数即可完整描述一次搜索,属于是数据结构里罕见的“用三个变量就能跑”的选手。为了统一写法,我们还是给分数定义一个结构体:

struct Frac {
long long u, v; // Numerator and denominator
};

其中 v 始终为正。由于 作为上边界参与运算,程序里所有涉及它的地方都要单独处理,不能真的去拿它做除法或取模——下文所有代码都遵守这一约定。

分数比较Fraction Comparison#

一切操作的基础是两个分数的大小比较。直接除肯定不行(精度不够,而且 直接就炸了),正确做法是交叉相乘再比较:

实现时为了避免乘法溢出,统一强制转换成 __int128 再做减法:

// Compare a/b with c/d, return 1 if greater, -1 if smaller, 0 if equal
int compare(long long a, long long b, long long c, long long d) {
__int128 x = (__int128)a * d - (__int128)c * b;
if (x > 0) return 1;
if (x < 0) return -1;
return 0;
}
Tip

用减法而不是直接比 >,是因为 a * d 与 c * b 各自都可能达到 量级(值域 时),直接比会把 __int128 也顶到危险区,而它们的差在本题的实际数据里始终在安全范围内。当然,这只是经验之谈,遇到更险的数据仍然建议上高精度或手写比较。

树上遍历Tree Traversal#

分数树虽然不用真的建树,但遍历是必要的。最典型的用法是:输出所有分母不超过 的既约分数(洛谷 P1458)。做法就是一次中序遍历,只不过“剪枝”的门槛换成了分母大小:

int n;
// Output every reduced fraction strictly inside (au/av, bu/bv) with denominator <= n
void dfs(int au, int av, int bu, int bv) {
int mu = au + bu, mv = av + bv; // Mediant of the two bounds
if (mv > n) return; // Denominator exceeds n, prune the whole subtree
dfs(au, av, mu, mv); // Walk into the left subtree
printf("%d/%d\n", mu, mv); // Visit the current node
dfs(mu, mv, bu, bv); // Walk into the right subtree
}

调用时以 与 为初始边界(两端的 、 单独输出),就能按递增顺序得到全部答案。因为中序遍历的顺序就是分数递增的顺序,所以代码里完全不需要排序——这就是分数树最优雅的地方。

Note

由于分母在递归中单调不减(中位分数的分母是两个边界分母之和),一旦 mv > n 就可以直接 return,不需要担心“子树深处还会冒出分母更小的分数”。这个剪枝的正确性同样由单调性保证。

树上查找Searching on the Tree#

有了 的性质,查找一个给定分数 的思路是显而易见的:从根 出发,比当前节点小就往左,大就往右,一路走到底。

用“边界 + 节点”的写法描述就是:初始边界为 与 ,每步取中位分数 与目标比较,据此把左边界或右边界替换成中位分数,直到中位分数恰好等于目标。

// Find p/q in the tree, return the number of steps to reach it
int search(long long p, long long q) {
long long au = 0, bu = 1; // Lower bound
long long av = 1, bv = 0; // Upper bound
int step = 0;
while (true) {
long long cu = au + av, cv = bu + bv; // Mediant, the node we are visiting
if (compare(cu, cv, p, q) == 0) return step;
if (compare(cu, cv, p, q) < 0) {
au = cu, bu = cv; // Target is bigger, move the lower bound up
} else {
av = cu, bv = cv; // Target is smaller, move the upper bound down
}
step++;
}
return -1;
}

这个做法的复杂度是 的——最坏情况是查找 ,从根 开始要连续向左走 步才能到它。对于 还能勉强接受,再大就不行了。

Warning

注意 search() 只是在演示“怎么在树上找路”,它不能直接用来求“区间内分母最小的分数”——后者要比较的是当前节点与整个区间的关系,而不是与单个分数的大小关系。区间版本见后文“区间查找”一节。

跳跃优化Jump Optimization#

观察一下就会发现,朴素查找的慢不在于走了多远,而在于连续做了多少次同方向的无效判断。查找 时,从 出发要连续向左走 步,而这 步里每一步做的事情其实是同一件。

于是很自然地想到:连续同向的若干步,能不能一次走完?

答案是肯定的。设当前所在节点为 ,左、右边界分别为 、。若下一步要向右跳 步,那么走完之后右侧界会变成

令它“刚好越过”目标分数,即可解出 :

因此最小的合法 就是 。向左跳的推导完全对称,只需要把分子分母的角色对调。

而一旦“跳一次”成功越过目标,上一步所在的位置就一定是我们想要的答案——因为分数树的深度随分母单调增加,第一次进入目标区间的那一层就是所有答案中深度最浅的一层。这个性质在下一节会被用到。

区间查找Interval Query#

真正有意思的问题往往是:给定一个区间 ,求其中分母最小的既约分数。

这正好是刚才那条性质的直接应用——分母最小的分数,就是分数树中第一个落进该区间的节点,也就是深度最浅的那个节点。于是算法就变成了:一边向下走,一边判断当前所在区间的中位分数是否已经进入目标区间,进去了就立即停下。

// Find the fraction with the minimum denominator strictly inside (A/B, C/D)
void solve(long long A, long long B, long long C, long long D) {
long long lu = 0, lv = 1; // Lower bound 0/1
long long ru = 1, rv = 0; // Upper bound 1/0
long long cu = 1, cv = 1; // Node we are visiting, initially the root 1/1
while (true) {
// Step 1: has the current node fallen into the target interval?
if (compare(cu, cv, A, B) > 0 && compare(cu, cv, C, D) < 0) break;
// Step 2: otherwise move towards the interval
if (compare(cu, cv, A, B) <= 0) {
// Current node <= A/B, so we have to go right. Work out the jump length first
long long t = (long long)(((__int128)A * cv - (__int128)cu * B) / ((__int128)ru * B - (__int128)A * rv));
if (t < 1) t = 1;
lu = cu + (t - 1) * ru; // The last node not exceeding A/B becomes the lower bound
lv = cv + (t - 1) * rv;
} else {
// Current node >= C/D, so we have to go left
long long t = (long long)(((__int128)cu * D - (__int128)C * cv) / ((__int128)C * lv - (__int128)lu * D));
if (t < 1) t = 1;
ru = cu + (t - 1) * lu; // The last node not going below C/D becomes the upper bound
rv = cv + (t - 1) * lv;
}
cu = lu + ru; // The new node is always the mediant of the two bounds
cv = lv + rv;
}
printf("%lld/%lld\n", cu, cv);
}

这段代码里有几个点需要单独交代:

  1. t = 1 的兜底不能省。当 t 算出来是 时,意味着当前节点已经“贴”在边界上,此时至少要走一步,否则 lu/lv 不会更新,循环就会原地打转——这是手写跳跃时最容易踩的坑。
  2. lu 的取值是 cu + (t - 1) * ru 而不是 cu + t * ru。因为第 步正好跨过目标,所以“最后一个没越过目标的节点”是第 步的位置,它才应该是新的边界。
  3. 中途乘法的溢出。A * cv 这类的量在数值上可能达到 ,必须用 __int128 计算;而 本身通常不大,转回 long long 是安全的。这也是为什么整个算法强烈建议在 64 位环境下配合 __int128 使用。

典例演练Pratical Examples#

洛谷 P1458 [USACO2.1] 顺序的分数 Ordered Fractions#

题目地址:P1458

题目难度:普及-

输入一个自然数 ,对于一个最简分数 (分子和分母互质的分数),满足 ,请找出所有满足条件的分数。

这有一个例子,当 时,所有解为:

给定一个自然数 ,请编程按分数值递增的顺序输出所有解。

输入格式:

单独的一行一个自然数

输出格式:

每个分数单独占一行,按照大小次序排列

数据范围:

对于 的数据,。

这就是标准的“分母不超过 的分数遍历”,直接把上面那个 dfs 抄下来就能过。唯一需要注意的是首尾两个数 与 要单独输出,因为它们是边界而不是中位分数。

完整代码:

#include <bits/stdc++.h>
using namespace std;
int n;
void dfs(int au, int av, int bu, int bv) {
int mu = au + bu, mv = av + bv;
if (mv > n) return;
dfs(au, av, mu, mv);
printf("%d/%d\n", mu, mv);
dfs(mu, mv, bu, bv);
}
int main() {
scanf("%d", &n);
printf("0/1\n");
dfs(0, 1, 1, 1);
printf("1/1\n");
return 0;
}

由于 ,分母沿递归单调递增,递归深度只有 量级,完全不用担心爆栈。复杂度是输出量级别的 ,而 ,对 来说就是前面提过的七千八百多个分数,跑起来非常轻松。

顺便一提,这道题用普通的“枚举 + 排序”也能过,但既然是分数树专题,总得给分数树一点面子。

AtCoder ABC408 G - A/B < p/q < C/D#

题目地址:ABC408 G

题目难度:省选/NOI-

给定满足 的正整数 ,求最小的正整数 ,使得存在正整数 满足:

共 组数据。

输入格式:

第一行一个整数 ,接下来 行每行四个整数 。

输出格式:

输出 行,每行一个整数表示答案。

数据范围:

,,保证 。

正儿八经的区间查找模板题。题意就是求区间内分母最小的分数,而且区间端点本身可能不是既约分数(题目没有保证),但这并不影响算法——分数树上只有既约分数,我们的比较全程使用交叉相乘,不做任何约分,因此结果天然既约。

直接套用上面的 solve() 即可,只需注意读入用 long long、中间运算用 __int128、并且由于 高达 ,输出建议用 printf 或手写快写。完整代码:

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int compare(ll a, ll b, ll c, ll d) {
__int128 x = (__int128)a * d - (__int128)c * b;
return x > 0 ? 1 : (x < 0 ? -1 : 0);
}
int main() {
int T;
scanf("%d", &T);
while (T--) {
ll A, B, C, D;
scanf("%lld%lld%lld%lld", &A, &B, &C, &D);
ll lu = 0, lv = 1, ru = 1, rv = 0, cu = 1, cv = 1;
while (true) {
if (compare(cu, cv, A, B) > 0 && compare(cu, cv, C, D) < 0) break;
if (compare(cu, cv, A, B) <= 0) {
ll t = (ll)(((__int128)A * cv - (__int128)cu * B) / ((__int128)ru * B - (__int128)A * rv));
if (t < 1) t = 1;
lu = cu + (t - 1) * ru, lv = cv + (t - 1) * rv;
} else {
ll t = (ll)(((__int128)cu * D - (__int128)C * cv) / ((__int128)C * lv - (__int128)lu * D));
if (t < 1) t = 1;
ru = cu + (t - 1) * lu, rv = cv + (t - 1) * lv;
}
cu = lu + ru, cv = lv + rv;
}
printf("%lld\n", cv);
}
return 0;
}

这份代码在样例上的输出为 3 5 1 226 4,与题目给出的答案一致。由于每次换向都会让分子或分母至少翻倍,循环次数是 级别的, 组数据可以轻松跑完。

至于我是怎么确认这份代码不会因为 t = 0 死循环的……对拍机告诉我,别问。

常见问题Q & A#

Q:分数树和 Farey 序列是什么关系?

Farey 序列Farey Sequence 就是分母不超过 的全部既约分数按序排成的序列。换句话说, 恰好是分数树中所有分母 的节点按中序遍历得到的序列。前面的 P1458 输出的正是限制在 内的 : 时共有 个分数,而 的长度有熟知的估计 ,代入 得约 ,与实际值相当接近(误差来自 求和公式的截断)。

Q:分数树的深度是多少?会不会退化?

分数树不会出现 那种“形态随插入顺序变化”的退化问题——它的形态是完全确定的,跟什么顺序插进来毫无关系,因为“插入顺序”这个概念在这里根本不存在。

但要说它像平衡树一样矮,那也是不对的。对于 这类分数,它位于树的一条纯左链上,深度恰好是 :实测 的深度就是 ,而 同样需要 步。因此最坏深度是 级别的,朴素查找也因此是 ——这和后文“朴素查找”的复杂度是完全吻合的。真正让复杂度降下来的是跳跃优化:每次换向都会让分母至少翻倍,于是压缩后的步数是 。

值得一提的是,深度最大的“密集”路径出现在 Fibonacci 分数上: 的深度是 ,这些分数也正是连分数展开全为 的经典例子。

Q:为什么所有分数都保证既约?

因为构造时相邻两项始终满足 ,而对任意分数 ,有 ,于是 。这也是为什么分数树可以“免费”做到最简性,不需要任何约分操作。

Q:跳跃优化里的 会不会算错方向?

会——如果算出来 ,说明当前节点已经贴在边界上,此时必须至少走一步。这就是代码里 if (t < 1) t = 1; 的作用,也是手写跳跃时最容易漏掉的一行。

后记 & 鸣谢Epilogue & Special Thanks#

写这篇文章的过程比前几篇都要折腾。、、 至少还有现成的平衡树模板可以对着抄,而分数树在网上能找到的完整实现少得可怜,很多题解只讲了思路没有给代码,或者给了代码但没讲清楚 lu 与 ru 到底该取哪一步的位置。我为了确认那两个 (t - 1) 究竟是 t 还是 t - 1,前后把 ABC408G 的对拍程序重写了三遍……

最后想说的是,分数树大概是这一系列里最“数学”的一棵树:它不像平衡树那样需要维护旋转,也不像伸展树那样需要考虑势能,它把所有的复杂度都藏在了性质里——只要证明了单调性、最简性和完全性,剩下的实现几乎是顺手写出来的。这种“想清楚了就没什么可写”的感觉,还挺让人上瘾的。

参考资料:

[1] OI Wiki. Stern–Brocot 树与 Farey 序列 [EB/OL]. https://oi-wiki.org/math/number-theory/stern-brocot/

[2] KumaTachiRen. AtCoder Beginner Contest 408 G 题解 [EB/OL]. https://atcoder.jp/contests/abc408/editorial/13196

[3] arrow_king. ABC408G sol: stern-brocot [EB/OL]. https://www.luogu.com.cn/article/7nfgwxco

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

分数树 Stern-Brocot Tree
https://justpureh2o.cn/articles/51342/
作者
JustPureH2O
发布于
2026-10-18
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
JustPureH2O
穷方圆平直之情,尽规矩准绳之用
公告
JustPureH2O 的博客现已正式迁移至 Astro!原 Hexo 网站将移至 https://hexo.justpureh2o.cn/
音乐
封面

音乐

暂未播放

0:000:00
暂无歌词
分类
标签
站点统计
文章
108
分类
17
标签
64
总字数
418,721
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
Vercel
博客版本
Firefly vunknown
文章许可
CC BY-NC-SA 4.0

文章目录