Stern–Brocot 树与 Farey 序列
引入
本文介绍存储最简分数的数据结构以及其它相关概念。它们与 连分数 紧密相关,在算法竞赛中可以用于解决一系列数论问题,并可能作为某些题目的隐含背景出现。
Stern–Brocot 树
Stern–Brocot 树是一种维护分数的优雅的结构,包含所有不同的正有理数。这个结构分别由 Moritz Stern 在 1858 年和 Achille Brocot 在 1861 年独立发现。
构造
逐层构造
Stern–Borcot 树可以在迭代构造第
此处的
在第
将每次迭代中新添加的分数连结成树状结构,就得到了 Stern–Brocot 树。如下图所示:
第
三元组构造
另一种等价的构造方式是以三元组
作为根节点,且在每个节点
后都分别添加
为左右子节点,就可以构造出整个 Stern–Brocot 树。Stern–Brocot 树的每个节点记录的三元组中,实际存储的分数是位于三元组中间的分数
矩阵表示与 Stern–Brocot 数系
三元组构造意味着每个 Stern–Brocot 树上的节点都对应着一个矩阵
Stern–Brocot 树的根节点是单位矩阵
每个节点实际对应的分数就是
建树实现
建树算法只需要模拟上述过程即可。下面是对前
建树
// In-Order Transversal of Stern-Brocot Tree till Layer N.
void build(int n, int a = 0, int b = 1, int c = 1, int d = 0, int level = 1) {
if (level > n) return; // Only first n layers.
int x = a + c, y = b + d;
build(n, a, b, x, y, level + 1);
std::cout << x << '/' << y << ' '; // Output the current fraction.
build(n, x, y, c, d, level + 1);
}
建树算法的复杂度是
性质
接下来讨论 Stern–Brocot 树的性质。简而言之,Stern–Brocot 树是包含所有正的最简有理分数的 二叉搜索树,它也是分子和分母的 堆,也是分母和分子构成的二元组的 笛卡尔树。如果考虑前文的三元组构造中的左右端点形成的区间,则 Stern–Brocot 树也可以看作是
单调性
在上面的构造中,每一层的分数都是单调递增的。为此只需要归纳证明。因为如果
这一点可以通过消去不等式的分母得出。归纳起点是
最简性
在上面的构造中,每个分数都是最简分数。为此同样需要归纳证明上面的构造中,每一层相邻的分数
根节点处是单位矩阵,这显然成立。向下移动时,乘以的矩阵
对于归纳起点
完全性
最后,需要说明 Stern–Brocot 树包括了所有正的最简分数。因为前两条性质已经说明 Stern–Brocot 树是二叉搜索树,而任何正的最简分数
假设现在已经知道
那么必然有
将两个不等式分别乘以
利用前面已经说明的等式
每次搜索更深入一层的时候,等式右侧都严格地增加,而左侧保持不变,因此搜索必然在有限步内停止。
查找分数
在实际应用 Stern–Brocot 树的过程中,经常需要查询给定分数在 Stern–Brocot 树上的位置。
朴素算法
因为 Stern–Brocot 是二叉搜索树,只需要通过比较当前分数和要寻找的分数的大小关系,就可以确定从根节点到给定分数的路径。将路径上向左子节点移动和向右子节点移动分别记作
朴素的分数查找算法的实现如下:
朴素分数查找
算法的复杂度是
在 Stern–Brocot 数系中,每个正的无理数都对应着唯一的无限长的字符串。可以使用同样的算法构造出这个字符串。这个无限长的字符串的每个前缀都对应着一个有理的最简分数。将这些最简分数排成一列,数列中的分数的分母是严格递增的,而数列的极限就是该无理数。因此,Stern-Brocot 树可以用于找到某个无理数的任意精度的有理逼近。但是,应当注意的是,这个有理数数列和无理数之间的差距并非严格递减的。对于有理逼近的严格理论,应当参考连分数页面的 丢番图逼近 一节。在实际应用 Stern-Brocot 树寻找某个实数在分母不超过某范围的最佳逼近时,最后应当注意比较左右区间端点到该实数的距离。
快速算法
查找分数的朴素算法效率并不高,但是经过简单的优化就可以得到
如果要查找的分数
快速分数查找
// Locate a given fraction in the Stern-Brocot tree.
auto find(int x, int y) {
std::vector<std::pair<int, char>> res;
int a = 0, b = 1, c = 1, d = 0;
bool right = true;
while (x != a + c || y != b + d) {
if (right) {
auto t = (b * x - a * y - 1) / (y * c - x * d);
res.emplace_back(t, 'R');
a += t * c;
b += t * d;
} else {
auto t = (y * c - x * d - 1) / (b * x - a * y);
res.emplace_back(t, 'L');
c += t * a;
d += t * b;
}
right ^= 1;
}
return res;
}
# Locate a given fraction in the Stern-Brocot tree.
def find(x, y):
res = []
a, b, c, d = 0, 1, 1, 0
right = True
while x != a + c or y != b + d:
if right:
t = (b * x - a * y - 1) // (y * c - x * d)
res.append((t, "R"))
a += t * c
b += t * d
else:
t = (y * c - x * d - 1) // (b * x - a * y)
res.append((t, "L"))
c += t * a
d += t * b
right ^= 1
return res
当前查找算法需要在分数
基于连分数的算法
对于分数已知的情形,可以利用连分数给出更为简单的算法。不妨假设首组移动是向右的;如果不然,则设首组向右移动的次数为零。向右、向左交替移动端点,将每组移动后的端点位置排列如下:
其中,偶数组移动是向右的,故而记录的是左端点的位置;奇数组移动是向左的,故而记录的是右端点的位置。在这一列端点前面再添加两个端点
设第
根据连分数的 递推关系 就可以知道,端点
最后得到的连分数是
因此,在目标分数的末尾为一的 连分数表示 中,不计最后的一,前面的项就编码了 Stern–Brocot 树上自根节点到当前节点的路径。其中,偶数项(下标自
有理数的连分数表示可以通过辗转相除法求得,因此基于连分数表示的分数查找算法的复杂度是
基于连分数的分数查找
利用连分数表示,可以简单地表达出某个节点的父节点和子节点。对于节点
Calkin–Wilf 树
另外一种更为简单的存储正有理分数的结构是 Calkin–Wilf 树。它通常如下所示:
树的根节点为
与连分数的关系
与 Stern–Brocot 树不同,Calkin–Wilf 树不是二叉搜索树,因此不能用于二分查找有理数。
在 Calkin–Wilf 树中,当
利用连分数的语言,设当前的节点存储的是某个连分数的余项
对于分数
- 当
时,它的父节点为 ; - 当
且 时,它的父节点为 ; - 当
且 时,它的父节点为 。
反过来,它的子节点则分别是
与 Stern–Brocot 树的关系
同样和连分数建立起联系,Stern–Brocot 树中路径上节点呈现的是渐近分数的递归关系,而 Calkin–Wilf 树中路径上节点呈现的是余项的递归关系。同一个分数的连分数表示是一定的,因此它在 Calkin–Wilf 树上到达根节点的路径的编码和 Stern–Brocot 树的根节点到它的路径的编码是完全一样的。但是,由于路径的方向相反,所以虽然 Stern–Brocot 树和 Calkin–Wilf 树同一层存储的分数是一样的,但是位置并不相同。
如果对两个树分别做 广度优先搜索 并依次对节点编号,根节点编号为
相应地,Stern–Brocot 树上,连分数
删去初始的
正因如此,Stern–Brocot 树上的节点有时会按照对应的 Calkin–Wilf 树上的节点编号,由此得到的编号如下图所示:
这个编号可以递归地构造:根节点编号为
Stern 双原子序列
将 Calkin–Wilf 树中的所有分数按照广度优先搜索的编号排列,或将 Stern–Brocot 树中的所有分数按照上图所示的编号排列,就得到如下序列:
利用 Calkin–Wilf 树的构造过程,可以证明,该序列中相邻的两个分数,前一个的分母必然等于后一个的分子。将分子单独列出,就得到 Stern 双原子序列(Stern diatomic sequence,OEIS A002487),也称为 Stern–Brocot 序列(Stern–Brocot sequence)。上述序列的编号从
设
递归起点是
Farey 序列
Farey 序列与 Stern–Brocot 树有着极其相似的特征。记 第
根据 Farey 序列的定义,它自然满足单调性、最简性和完全性。如上图所示,
上文中构建 Stern–Brocot 树的算法同样适用于构建 Farey 序列。因为 Stern–Brocot 树中包括所有最简分数,因此只要将边界条件修改为对分母的限制就可以得到构造 Farey 序列的代码。可以将第
构建 Farey 序列
// Farey Sequence of Order N.
void build(int n, int a = 0, int b = 1, int c = 1, int d = 1,
bool init = true) {
int x = a + c, y = b + d;
if (y > n) return; // Only first n layers.
if (init) std::cout << "0/1 "; // Output 0/1;
build(n, a, b, x, y, false);
std::cout << x << '/' << y << ' '; // Output the current fraction.
build(n, x, y, c, d, false);
if (init) std::cout << "1/1 "; // Output 1/1;
}
直接构建 Farey 序列的复杂度是
序列长度与分数查找
Farey 序列的长度可以递归求出。相较于
此处的
相较于直接求出序列的长度,更为常见的情景是需要求出序列
要得到右式,应用了 莫比乌斯反演。线性筛和枚举因子结合,可以做到
反过来,已知编号求解分数,需要对
Farey 邻项
如果分数
设
反过来,这也是两个最简真分数成为 Farey 邻项的充分条件。现在说明这一点。不妨设
其实,因为下一个会出现在两者之间的最简分数必然是
分母不超过
图中的圆称为 Ford 圆:对于每个
要验证相切的圆总是对应着 Farey 邻项,可以直接计算两圆心的距离:
因为两个最简分数并不相同,所以
最后,要计算 Farey 邻项的数目。除了
中解得。两个方程各恰有一组满足
当然,在这个过程中找到的分数
要计算当前分数
递推关系
Farey 序列有一个简洁的递推关系,可以自左向右地顺序求出第
首先,前面的分析指出,在
不过,对于一般的情形,分数
利用这个观察就可以构建如下的递推关系。设
成立。因为差值
随着
可以验证这样得到的分数必然在
递推起点是
习题
以本文材料为背景的题目:
- LOJ 6685. 迷宫
- UVa 10077. The Stern-Brocot Number System
- Luogu P8058. [BalkanOI2003] Farey 序列
- UVa 12995. Farey Sequence
- UVa 10408. Farey Sequences
- UVa 12438. Farey Polygon
- AtCoder ARC123F. Insert Addition
需要在 Stern–Brocot 树上作二分的题目:
- AtCoder ABC333G. Nearest Fraction
- SPOJ DIVCNT1 - Counting Divisors
- SPOJ AFS3 - Amazing Factor Sequence (hard)
参考资料与注释
本页面部分内容译自博文 Дерево Штерна-Броко. Ряд Фарея 与其英文翻译版 The Stern-Brocot Tree and Farey Sequences。其中俄文版版权协议为 Public Domain + Leave a Link;英文版版权协议为 CC-BY-SA 4.0。本页面另有部分内容译自博文 Continued fractions,版权协议为 CC-BY-SA 4.0。内容均有改动。
-
译名来自张明尧、张凡翻译的《具体数学》第 4.5 节。 ↩
创建日期: 2019年7月17日