分数树 Stern-Brocot Tree

本文用于测试 Deepseek Harness 接管博客的文章生成效果,如有疏漏烦请指出。
书接上回:伸展树 Splay 是这条树形数据结构流水线的最后一篇。从
分数树简介
分数树,也译作斯特恩-布罗科特树,是德国数学家 所以说这玩意儿一开始是给钟表匠用的,不是给 OIer 用的。
它做的事情可以用一句话概括:
把全体非负既约分数,按照大小关系排成一棵二叉搜索树。
树里的每个元素都是一个分数
分数树的构造
从两个边界开始
取两个“分数”作为初始边界:
其中
每一轮迭代中,我们取出序列里所有相邻的两个分数
第一轮之后序列变成:
第二轮:
第三轮:
如果把这些“插入”看成树上的节点,就得到了分数树的结构。以
上图只画了三层(深度指从根出发的边数)。
三条基本性质
这个构造之所以成立,靠的是下面三条性质(证明在 OI Wiki 上很详细,这里只讲结论):
- 单调性:每一轮迭代结束后,序列都是严格递增的。
- 最简性:每一轮插入的分数都是既约分数。
- 完全性:任意一个非负既约分数
,都会在某一轮恰好出现一次。
单调性与最简性的证明是同一套路的双重归纳。先定义两个分数
那么初始的两项
也就是说
于是“相邻两项的行列式绝对值为
立刻得到
记
为什么它是二叉搜索树
分数树和
- 左子树的所有分数都小于当前节点,右子树都大于当前节点;
- 当前节点总等于左右两条边界的中位分数;
- 中序遍历得到的就是整个分数序列,与前面逐轮构造出来的序列完全一致。
因此在树上做二分查找是天然成立的,这就是后文所有算法的出发点。
分数树的代码实现
我们照例沿用
结构定义
分数树不同于前面三棵树的地方在于,它几乎不需要显式建树。绝大多数题目只需要维护“当前所在区间的两个边界”以及“当前访问到的节点”,三个分数即可完整描述一次搜索,属于是数据结构里罕见的“用三个变量就能跑”的选手。为了统一写法,我们还是给分数定义一个结构体:
struct Frac { long long u, v; // Numerator and denominator};其中 v 始终为正。由于
分数比较
一切操作的基础是两个分数的大小比较。直接除肯定不行(精度不够,而且
实现时为了避免乘法溢出,统一强制转换成 __int128 再做减法:
// Compare a/b with c/d, return 1 if greater, -1 if smaller, 0 if equalint 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;}用减法而不是直接比 >,是因为 a * d 与 c * b 各自都可能达到 __int128 也顶到危险区,而它们的差在本题的实际数据里始终在安全范围内。当然,这只是经验之谈,遇到更险的数据仍然建议上高精度或手写比较。
树上遍历
分数树虽然不用真的建树,但遍历是必要的。最典型的用法是:输出所有分母不超过
int n;
// Output every reduced fraction strictly inside (au/av, bu/bv) with denominator <= nvoid 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}调用时以
由于分母在递归中单调不减(中位分数的分母是两个边界分母之和),一旦 mv > n 就可以直接 return,不需要担心“子树深处还会冒出分母更小的分数”。这个剪枝的正确性同样由单调性保证。
树上查找
朴素查找
有了
用“边界 + 节点”的写法描述就是:初始边界为
// Find p/q in the tree, return the number of steps to reach itint 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;}这个做法的复杂度是
注意 search() 只是在演示“怎么在树上找路”,它不能直接用来求“区间内分母最小的分数”——后者要比较的是当前节点与整个区间的关系,而不是与单个分数的大小关系。区间版本见后文“区间查找”一节。
跳跃优化
观察一下就会发现,朴素查找的慢不在于走了多远,而在于连续做了多少次同方向的无效判断。查找
于是很自然地想到:连续同向的若干步,能不能一次走完?
答案是肯定的。设当前所在节点为
令它“刚好越过”目标分数,即可解出
因此最小的合法
而一旦“跳一次”成功越过目标,上一步所在的位置就一定是我们想要的答案——因为分数树的深度随分母单调增加,第一次进入目标区间的那一层就是所有答案中深度最浅的一层。这个性质在下一节会被用到。
区间查找
真正有意思的问题往往是:给定一个区间
这正好是刚才那条性质的直接应用——分母最小的分数,就是分数树中第一个落进该区间的节点,也就是深度最浅的那个节点。于是算法就变成了:一边向下走,一边判断当前所在区间的中位分数是否已经进入目标区间,进去了就立即停下。
// 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);}这段代码里有几个点需要单独交代:
t = 1的兜底不能省。当t算出来是时,意味着当前节点已经“贴”在边界上,此时至少要走一步,否则 lu/lv不会更新,循环就会原地打转——这是手写跳跃时最容易踩的坑。lu的取值是cu + (t - 1) * ru而不是cu + t * ru。因为第步正好跨过目标,所以“最后一个没越过目标的节点”是第 步的位置,它才应该是新的边界。 - 中途乘法的溢出。
A * cv这类的量在数值上可能达到,必须用 __int128计算;而本身通常不大,转回 long long是安全的。这也是为什么整个算法强烈建议在 64 位环境下配合__int128使用。
典例演练
洛谷 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:分数树和 Farey 序列是什么关系?
Farey 序列
Q:分数树的深度是多少?会不会退化?
分数树不会出现
但要说它像平衡树一样矮,那也是不对的。对于
值得一提的是,深度最大的“密集”路径出现在 Fibonacci 分数上:
Q:为什么所有分数都保证既约?
因为构造时相邻两项始终满足
Q:跳跃优化里的
会不会算错方向?
会——如果算出来 if (t < 1) t = 1; 的作用,也是手写跳跃时最容易漏掉的一行。
后记 & 鸣谢
写这篇文章的过程比前几篇都要折腾。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
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!


































































































































































