跳转至

连分数

引入

连分数可以将实数表示为一个收敛的有理数数列的极限。这个数列中的有理数易于计算,而且提供了这个实数的最佳逼近,因而在算法竞赛中常常会用到连分数。除此之外,连分数还和欧几里得算法密切相关,因而可以应用到一系列数论问题中。

关于连分数相关的算法实现

本文会提供一系列的连分数的算法实现,其中部分算法可能无法保证计算中间过程所涉及的整数都在 32 位或 64 位整型变量的取值范围内。对于这种情形,请参考相应的 Python 的实现,或将 C++ 实现中的整型变量替换为 高精度整数类。为突出重点,本文行文过程中的部分代码可能会调用前文实现过的函数而不再重复给出实现。

连分数

连分数(continued fraction)本身只是一种形式记号。

有限连分数

对于数列 ,连分数 表示展开式

连分数有意义,当且仅当对应的展开式有意义。这些 称为连分数的 (term)或 系数(coefficient)。

记号

更一般的连分数允许展开式中的分子不恒为 ,相应的连分数记号也需要修改,这超出了本文的范畴。另外,有些文献中会将第一个逗号「」写作分号「」,这与本文的记号在含义上没有差异。

当然,连分数还可以推广到无穷数列的情形。

无限连分数

对于无穷数列 ,连分数 表示极限

连分数有意义,当且仅当对应的极限有意义。其中, 称为 的第 渐近分数(convergent)或 收敛子,而 称为 的第 余项完全商(complete quotient)。相应地,项 有时也称为第 部分商(partial quotient)。

简单连分数

数论中,主要考虑连分数的项都是整数的情形。

简单连分数

对于连分数 ,如果 是整数, 都是正整数,则称它为 简单连分数(simple continued fraction),也简称 连分数。如果数列 有限,则称为 有限(简单)连分数;否则称为 无限(简单)连分数。而且, 称为它的 整数部分(integer part)。

除非特别说明,本文提到的连分数都指的是简单连分数。可以证明,无限的简单连分数必然是收敛的,而且简单连分数的余项也一定是正的。

连分数有如下基本性质:

性质

设实数 。那么,成立如下性质:

  1. 对于任意 ,有
  2. 对实数 ,有 ,且它的倒数

有限连分数对应的是有理数。每个有理数都有且仅有两种方式可以表示成连分数,长度必然一奇一偶。这两种方式唯一的区别在于最后一项是否为 ,即

这两个连分数称为有理数 连分数表示(continued fraction representation)。其中,末项不为一的称为标准表示,末项为一的称为非标准表示。1

例子

有理数 的连分数表示为

无限连分数对应的是无理数。而且,每个无理数仅有唯一的方式表示为连分数,称为无理数的连分数表示。

连分数表示的求法

要求某个实数 的连分数表示,只需要注意到它的余项 如果不是整数,就一定满足

而且,。因此,可以从 开始递归地计算

这个过程产生的数列 总是唯一确定的,除非某个余项 成为整数。如果出现了 是整数,则说明过程应当终止,可以选择输出相应的标准表示或者非标准表示。

在算法竞赛中,往往处理的都是有理数 的情形。此时,每个余项 都是有理数 ,而且对于 ,因为 ,就总有 。具体计算上述递推关系,可以发现

此时的计算过程实际上是对 辗转相除法。这也说明,对于有理数 ,连分数表示的长度是 的。计算有理数 的复杂度也是 的。

参考实现

给定分数的分子 和分母 ,输出连分数的系数序列

1
2
3
4
5
6
7
8
9
// Find the continued fraction representation of P/Q.
auto fraction(int p, int q) {
  std::vector<int> a;
  while (q) {
    a.push_back(p / q);
    std::tie(p, q) = std::make_pair(q, p % q);
  }
  return a;
}
1
2
3
4
5
6
7
# Find the continued fraction representation of P/Q.
def fraction(p, q):
    a = []
    while q:
        a.append(p // q)
        p, q = q, p % q
    return a

渐近分数

在连分数的定义中介绍了渐近分数的概念。实数的渐近分数就是它的连分数表示的渐近分数:在实数 的连分数表示中,只保留前 个项,得到的连分数 就称为实数 的第 个渐近分数。实数 的渐近分数 都是有理数,而且序列 收敛于实数

例子:黄金分割比的渐近分数

连分数 的前几个渐近分数分别是

可以归纳地证明

其中,斐波那契数列。根据它的通项公式可知,

其中的 是黄金分割比。当 趋于无穷时,有

因而,连分数 表示的是黄金分割比

这些渐近分数趋近于相应的实数,所以可以用于逼近该实数。为此,有必要了解渐近分数的性质。

递推关系

首先,要解决这些渐近分数的计算问题。虽然渐近分数总是在连分数的后面添加一项,但是并不需要每次都重新计算它的值。其实,渐近分数有如下递推关系:

递推公式

对于连分数 ,设它的第 个渐近分数 可以写成分数 。那么,有

递推的起点是(形式)分数

证明

渐近分数 的分子和分母可以看作 的多元多项式:

根据渐近分数的定义,有

和上式比较,不妨设 ,就可以将渐近分数写作

且多项式 有递推关系

因为

所以,递推的起点是

如果设

可以验证对于 也成立上述递推关系。这相当于规定了形式分数

满足上述递推关系的多项式列 称为 连项式2(continuant)。它可以写作行列式的形式:

这是一个 三对角矩阵 的行列式,从左上角开始展开,可以验证它具有上面的递推关系和初值条件。反过来,从右下角开始展开,则又能得到递推关系

这就是所要求证的。

记号

本文将渐近分数 记作 时,总是默认分子 由上面的递推关系给出。下文还要说明,这样总能得到渐近分数的既约表示。

这个递推式说明

介于 之间。

作为渐近分数的递推关系的推论,成立如下的反序定理和倒数定理:

反序定理

设实数 的第 个渐近分数是 ,则相邻两个渐近分数的分子和分母的比值分别为

如果 ,则第一个连分数应当理解为在倒数第二项处截断,即

证明

的递推关系中,左右两侧分别同除以 ,就得到

迭代这两个式子,就可以得到两个连分数。再代入初始值 即可。至于 的情形,将得到的连分数理解为形式表达式,则它的余项

因而可以直接略去最后两项。如果需要严格的证明,只要注意到这个式子可以看作 的极限即可。

倒数定理

实数 的渐近分数的倒数是 的渐近分数。

证明

不妨设 且有连分数表示 ,则 的连分数表示是 。它们的渐近分数可以从递推关系中求得。而且,对于 ,有初值条件 ;对于 ,有初值条件 。因此,有 。根据递推关系,可以得到 。这就说明, 的渐近分数的倒数是 的渐近分数。对于 的情形,也可以做类似讨论。

利用本节得到的递推关系,可以得到计算渐近分数的算法如下:

参考实现

给定连分数的系数 ,求渐近分数的分子和分母序列

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Find the convergents of a continued fraction A.
// Numerators and denominators stored separately in P and Q.
auto convergents(std::vector<int> a) {
  std::vector<int> p = {0, 1};
  std::vector<int> q = {1, 0};
  for (auto it : a) {
    p.push_back(p.back() * it + p.end()[-2]);
    q.push_back(q.back() * it + q.end()[-2]);
  }
  return std::make_pair(p, q);
}
1
2
3
4
5
6
7
8
9
# Find the convergents of a continued fraction A.
# Numerators and denominators stored separately in P and Q.
def convergents(a):
    p = [0, 1]
    q = [1, 0]
    for it in a:
        p.append(p[-1] * it + p[-2])
        q.append(q[-1] * it + q[-2])
    return p, q

误差估计

利用渐近分数的递推公式,可以估计用渐近分数逼近实数产生的误差。

首先,可以计算相邻的渐近分数的差值:

渐近分数的差分

是实数 的第 个渐近分数。那么,有

因此,相邻两项的渐近分数的差分是

证明

根据递推关系,有

这就是 。对两边同除以 ,就得到关于 的结论。

因而,奇数项渐近分数总是大于相邻两项,偶数项渐近分数总是小于相邻两项:渐近分数是交错变化的。

如果只看偶数项(奇数项)渐近分数,序列也是单调递增(递减)的。这是因为

为偶数(奇数)时为正(负)。同时,因为成立递推关系 ,分母 的增长速度不会慢于斐波那契数列的速度。所以,相邻两项的差一定趋近于零。这就说明,偶数项和奇数项渐近分数分别自下而上和自上而下地逼近同一极限。这就证明了无限简单连分数一定收敛。渐近分数趋近于相应实数的动态可以见下图:

上(下)渐近分数

对于实数 和它的渐近分数 ,如果 ),就称 上(下)渐近分数(upper (lower) convergent)。

前面已经说明,上渐近分数就是奇数项渐近分数,下渐近分数就是偶数项渐近分数。

利用差分公式,可以将实数 写成交错级数的形式:

连分数定义中的渐近分数和余项就是该级数的部分和和余项。

利用差分公式,还可以直接对渐近分数逼近实数产生的误差做出估计:

误差

是实数 的第 个渐近分数。那么,有

其中, 是实数 的第 个余项。进而,有

证明

因为 ,而且对形式连分数也成立渐近分数的差分公式,所以有

其中, 就是按照递推公式得到的这个形式连分数的第 个渐近分数的分母。

要完成随后的不等式估计,只需要注意到当 时,总成立

所以有

因此,有不等式

要得到外侧的放缩,再注意到 就可以了。

本节的差分公式还有一个简单推论:渐近分数 都是既约的。

推论

对于任何实数 ,且渐近分数 的分子和分母由递推公式给出,则 是既约分数,即

证明

对差分公式应用 裴蜀定理 即可。

其实,线性同余方程的解可以通过连分数的方法求解。

线性同余方程的求解

给定 。查找 ,使得 成立。

解答

虽然这个问题通常是用 扩展欧几里得算法 解决的,但是同样可以通过连分数求解。

。上面证明了 。将 替换为 ,得到

其中,。如果 整除 ,则一组解为 ;否则无解。

1
2
3
4
5
6
7
8
9
// Return (x,y) such that Ax+By=C.
// Assume that such (x,y) exists.
auto dio(int A, int B, int C) {
  std::vector<int> p, q;
  std::tie(p, q) = convergents(fraction(A, B));
  C /= A / p.back();
  int t = p.size() % 2 ? -1 : 1;
  return std::make_pair(t * C * q.end()[-2], -t * C * p.end()[-2]);
}
1
2
3
4
5
6
7
# Return (x, y) such that Ax+By=C.
# Assume that such (x, y) exists.
def dio(A, B, C):
    p, q = convergents(fraction(A, B))
    C //= A // p[-1]  # divide by gcd(A, B)
    t = (-1) if len(p) % 2 else 1
    return t * C * q[-2], -t * C * p[-2]

丢番图逼近

连分数理论的一个重要应用就是丢番图逼近理论。丢番图逼近(Diophantine approximation)是指用有理数逼近实数。当然,由于有理数的稠密性,如果不加以限制,可以得到误差任意小的逼近。因此,需要对可以使用的有理数做出限制,比如只能选择分母小于某个值的有理数。本节就讨论了这种限制下的最佳逼近和连分数的关系。

用渐近分数逼近实数

首先,利用渐近分数的误差估计,立刻得到如下结果:

定理(Dirichlet)

对于无理数 ,存在无穷多个既约分数 使得

成立。

证明

根据渐近分数的误差估计,对于无理数 的第 个渐近分数 ,有

检查误差公式的证明即可知,对于任一无理数 ,取等条件并不成立。因此,它的所有渐近分数的分子和分母都满足要求。

这个定理也可以看作是 Dirichlet 逼近定理 的推论。这几乎已经是最好的结果了。不等式右侧分母中的指数 已经不能再改进,但是常数上可以做得更好。Hurwitz 定理说明,不等式右侧可以缩小到 ,且这是最好的界。

Hurwitz 定理

对于无理数 ,存在无穷多个既约分数 使得

成立,而且不等式右侧的 不能换成更大的实数。

证明(Borel)

Borel 实际上证明了,无理数 的连续三个渐近分数中,必然有至少一个满足上述条件。因为渐近分数无穷多,且都是既约分数,那么 Hurwitz 定理的第一部分就必然成立。

反证法。不妨设存在无理数 和它的渐近分数 ,使得

成立。因为相邻的渐近分数必然位于 两侧,所以由差分公式知

它可以写成关于商 的不等式

因为左侧是有理数,右侧是无理数,等号必然无法取得。又因为 ,所以可以解得

同理,可以证明

但是根据递推公式,并结合两式可知,

这与简单连分数的定义矛盾。所以,Borel 的结论成立。

要说明这样得到的界是最好的,只需要找到 使得对于任何 ,都只存在有限多个既约分数 使得不等式

成立。下面证明 就是这样的 3

的共轭根。它们都是方程 的根。因而,对任意实数 ,都有

代入既约分数 ,就得到

对于 ,可以直接解出 ,因而不可能存在无穷多组解满足上述不等式。

这些定理的证明说明,渐近分数提供了相当好的丢番图逼近。但是,这未必是最佳逼近。要讨论最佳逼近,需要说明逼近程度的度量。这常常有两种选择。

可能存在最佳逼近相关结论不成立的情形

接下来的两节,会叙述一些关于最佳逼近的结果。这些结果可能对个别无趣的情形并不成立。比如,最佳逼近的两类定义都要求严格不等号,但是对于半奇数 ,它的连分数的形式可以是 。此时,它的前两个渐近分数 的分母都是 ,而且到 的距离是一样的。这说明,它们都不是最佳逼近。对于本节的结论的叙述,读者应当默认这样的情形已经排除在外。如果读者不关心最末尾的几个渐近分数,抑或是只关心无理数的逼近,那么不必理会这些额外的复杂情形。

第一类最佳逼近:中间分数

第一类最佳逼近使用

衡量逼近的程度。

第一类最佳逼近

对于实数 和有理数 ,如果对于任意的 都有

就称有理数 是实数 第一类最佳逼近(best approximation of the first kind)。

第一类最佳逼近未必是渐近分数,而是一类更宽泛的分数。

中间分数

设实数 有渐近分数 ,且整数 满足 4,则分数 称为 中间分数(intermediate fraction)、半收敛子(semiconvergent)或 次渐近分数(secondary convergent)。5

类似于渐近分数的情形,大于(小于) 的中间分数称为 上(下)中间分数(upper (lower) semiconvergent)。

根据递推公式,中间分数可以写成

它必然是既约分数,而且位于渐近分数 之间。随着 增大,它也逐渐向 靠拢:(以 为偶数的情形为例)

因为渐近分数的分子和分母都是递增的,中间分数 )的分子和分母落在了 之间。如果将这些分数按照分母大小排列,中间分数就是位于相邻的渐近分数中间的一些分数。

所有的第一类最佳逼近都是中间分数,但是并不是所有的中间分数都是第一类最佳逼近。

定理

所有的第一类最佳逼近都是中间分数。

证明

因为 ,所以第一类最佳逼近必然位于 之间。所有中间分数从小到大可以排列成

同阶的中间分数是连续出现的,而不同阶的中间分数之间又没有间隔。这意味着,任何位于 之间的有理数 必然落在两个同阶的中间分数 之间。不妨设它不是中间分数且小于 ,因而有

此时,一方面有

另一方面有

因此,必然有

也就是说,有理数 的分母一定大于 的分母,但是它并不是更好的逼近:

因此,它不可能是第一类最佳逼近。这就说明,不是中间分数,就不是第一类最佳逼近;亦即所有第一类最佳逼近都是中间分数。

反过来,并不能断言所有的中间分数都是第一类最佳逼近。但是,的确可以给出中间分数成为第一类最佳逼近的条件。

定理

所有渐近分数都是第一类最佳逼近。除此之外,设 ,则中间分数 是第一类最佳逼近,当且仅当 或者

证明

下面会证明,渐近分数都是第二类最佳逼近,因而必然是第一类最佳逼近。关键在于那些不是渐近分数的中间分数。

前文已经说过,中间分数 的分母位于 之间,且随着 的增加逐渐增大,但是 却逐渐接近 ,因而更接近 。以 为例,它与相邻的中间分数的相对位置关系满足:

因为 的分母小于 ,所以 成为第一类最佳逼近的必要条件就是,它比 更接近 。这也是充分条件,因为作为渐近分数,没有比 分母更小但是距离更近的了,而那些比 分母还要大的中间分数,必然与 同阶,但是分母更小,就必然距离 更远。对于渐近分数和中间分数的误差,经计算可知

其中用到了 。因此, 更接近 ,成为第一类最佳逼近,当且仅当

此时,有三种可能的情况:

  1. 如果 ,那么 。因为两侧都是整数,所以 ,故而 。此时, 不是第一类最佳逼近;
  2. 如果 ,那么 。因为两侧都是整数,所以 。此时, 是第一类最佳逼近;
  3. 如果 是偶数,还有第三种情况,即 。上述条件等价于 ,亦即

所以,如果将实数 的所有第一类最佳逼近按照分母自小到大的顺序排列,那么它会根据与 的大小关系分成若干段。每一段总是由若干个(可以是零个)连续的同阶的中间分数组成,且总以渐近分数结尾。段内总能保持在实数 的一侧,段与段之间则交错排列在 两侧。

例子:圆周率 的第一类最佳逼近

圆周率 ,因而它分母最小的前 15 个第一类最佳逼近是:

第二类最佳逼近

第二类最佳逼近使用 来衡量逼近的程度。

第二类最佳逼近

对于实数 和有理数 ,如果对于任意的 都有

就称有理数 是实数 第二类最佳逼近(best approximation of the second kind)。

第二类最佳逼近的条件等价于

因为 ,所以第二类最佳逼近的条件比第一类最佳逼近更为严苛。

第二类最佳逼近能且仅能是渐近分数。

定理

所有的第二类最佳逼近一定是渐近分数,所有的渐近分数也一定是第二类最佳逼近。

证明

要证明第一部分,因为第二类最佳逼近也一定是第一类最佳逼近,所以只需要证明不是渐近分数的中间分数不能成为第二类最佳逼近就可以了。为此,设 是中间分数但是不是渐近分数,那么,设 ,有

因为 之间的误差

并利用渐近分数的误差估计,所以总是有

的逼近程度不优于分母更小的 逼近程度,所以不可能是第二类最佳逼近。

反过来,要证明第二部分,即每个渐近分数 都是第二类最佳逼近。这就是要说明,对于所有分数 ,都有 。不考虑半奇数的情形,则可以假定 。首先,根据渐近分数逼近实数的误差估计,有

不等式全部成立等号,当且仅当 且是连分数的末项。不考虑这样的情形,那么 严格劣于

任取一分数 ,因为有差分公式 ,所以由 Cramer 法则可知,线性方程组

必然存在唯一的整数解 。如果 ,那么 ,矛盾。否则,,即 异号,那么因为 也异号,就有 同号,故而

最后的不等号是严格的,因为 严格劣于 ,且 。这就说明, 是第二类最佳逼近。

这个性质表明,渐近分数确实是相当好的丢番图逼近。

渐近分数的判定

第二类最佳逼近提供了判断某个分数是否是渐近分数的充分必要条件。这说明,可以通过检查某个分数逼近的相对程度来判断它是否是渐近分数。Legendre 判别法则提供了根据逼近的绝对程度来判断渐近分数的方法。Legendre 判别法的原始表述提供了充分必要条件,但是它的形式并不实用。本节提供了 Legendre 判别法的简化版本,并说明它并没有漏掉太多的渐近分数。

定理(Legendre)

对于实数 与分数 ,如果有

那么 一定是 的渐近分数。

证明

是使得

成立的常数。将有理数 展开成连分数 。此处,有理数有两种连分数表示,其中的 恰好相差一,所以可以取连分数表示使得 ,并记这个连分数表示的渐近分数为 。设实数 满足

那么,必然有

故而,有

这说明

也展成连分数 ,则

这是合法的简单连分数,所以 就是 的渐近分数。

这个证明实际说明 成为渐近分数的充分必要条件是上述证明中的 ,这正是 Legendre 判别法的原始形式。

这个判别法说明,只要逼近的程度足够好,就一定是渐近分数。下一个定理说明,这样好的渐近分数足够多:至少有一半的渐近分数都符合这个条件。

定理(Valhen)

实数 的相邻两个渐近分数中至少有一个满足

证明

假设不然。存在实数 有两个相邻的渐近分数 满足

因为 位于 之间,所以

这说明 。故而必然有 。此时前两个渐近分数是 。故而命题的唯一反例是半奇数,按照前文的说明,本文不考虑这种情形。

几何解释

连分数理论有着优美的几何解释。

如图所示,对于实数 ,直线 将第一象限(包括 坐标轴上的点但不包括原点,下同)上的整点(lattice point)分成上下两部分。对于有理数 的情形,直线 上的点既算作直线上方的点,又算作直线下方的点。考虑这两部分的点的凸包。那么,奇数项渐近分数是上半部分的凸包的顶点,偶数项渐进分数是下半部分的凸包的顶点。凸包上两个相邻顶点之间的连线上的整点就是中间分数。图中展示了 的渐近分数和中间分数(灰点)。

前文关于连分数的大部分结论都有相应的几何解释:

几何解释
  • 每个分数 都对应着第一象限内的一个整点 ,分数的大小对应着与原点连线的斜率。

  • 直线 的方向向量是 。利用 叉积 的概念,可以通过 的正负判断点在直线上方还是下方。因而,在直线上方的点就对应着大于等于 的分数,在直线下方的点就对应着小于等于 的分数。叉积的绝对值 正比于点 与直线 的距离

    对应着分数 对实数 的逼近程度。

  • 将渐近分数 对应的点记作 ,则递推公式就可以写作

    递归的起点是

  • 对于整数 ,如果 ,那么点

    就落在连结点 和点 的线段上。它们对应着中间分数

  • 利用几何的方法可以构造出所有的渐近分数和中间分数。从点 和点 开始,两个点位于直线 两侧,这意味着 符号相反。将 按照向量的加法添加到 上,直到无法继续添加而不穿过直线 为止,将结果记作 ,此时仍与 不同侧。再将 添加到 上,直到无法继续添加而不穿过直线 为止,将结果记作 ,此时仍与 不同侧。这个过程可以一直持续到无穷,除非在有限步内某个 就恰好落在直线 上。后者意味着向量 共线,即 为有理点。这个过程就可以得到前面示意图中的图形。Boris Delaunay 将这个过程形象地称为鼻子拉伸算法(nose-streching algorithm)6

  • 如果需要快速计算每一步将 添加到 需要的次数,可以借助叉积。因为 符号相反,所以如果记 为向 添加 得到的结果,则 不改变符号,就意味着没有穿过直线。在不变号之前, 的绝对值会逐渐下降。记

    那么,最大可以下降的次数就是

    这就是连分数展开的第 项。而且, 就是连分数展开的余项,它满足关系式:

    这就是连分数关系式

  • 因为每次添加向量造成的 的变化的步长都是 ,所以最后剩余的距离 必然严格小于 。这说明,渐近分数的逼近程度(由 衡量)是随着 的增加严格更优的。

  • 利用叉积的运算法则,有

    归纳可知

    这就是渐近分数的差分公式

  • 上下两个凸包之间的面积可以剖分成若干个(可能是无穷多个)三角形,其中每个三角形的顶点分别是 。这样的三角形的面积是

    根据 Pick 定理,这意味着如果设 分别为三角形内部和边界上的整点个数,则

    又已知三角形边界上已经有了 这共计 个整点。这说明,就一定有 。因而,三角形的边上没有更多的整点,三角形内部也没有整点。也就是说, 是既约的,中间分数是连结 的边上的全部整点,且第一象限的所有整点都在上下两个凸包内。

这样得到的上下两个凸包称为 Klein 多边形。在高维空间内也可以做类似定义,得到 Klein 多面体(Klein polyhedron),它可以将连分数的概念推广到高维空间内。

连分数的树

主条目:Stern–Brocot 树与 Farey 序列

Stern–Brocot 树是存储了所有位于 之间的分数的 二叉搜索树。有限连分数实际上编码了 Stern–Brocot 树上从根到某个分数所在位置的路径。也就是说,有理数 的连分数表示 意味着从树根 开始,需要先向右子节点移动 次,再向左子节点移动 次,交替方向移动,直到向某个方向移动了 次为止。应当注意,此处只能使用末尾为 的连分数表示。

将连分数表示理解为 Stern–Brocot 树上的路径,可以得到比较连分数大小的算法。

连分数大小比较

给定连分数 ,比较两者大小。

解答

首先将两个连分数表示都转化成末尾是 的形式。不妨设题目所给的已经是这样形式的连分数,即 。因为偶数位置(下标从 开始)是向右移动的步数,奇数位置是向左移动的步数,所以, 当且仅当按照 字典序 比较时,有

相较于连分数表示,交替地添加正负号,删去末尾的 ,并且长度不足的位置用 补齐。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Expand [..., n] to [..., n-1, 1] if needed.
void expand(std::vector<int>& a) {
  if (a.size() == 1 || a.back() > 1) {
    --a.back();
    a.push_back(1);
  }
}

// Check if a is smaller than b.
bool less_than(std::vector<int> a, std::vector<int> b) {
  expand(a);
  expand(b);
  for (int i = 0; i < a.size() - 1 || i < b.size() - 1; ++i) {
    int d = (i < a.size() - 1 ? a[i] : 0) - (i < b.size() - 1 ? b[i] : 0);
    if (i & 1) d = -d;
    if (d < 0) {
      return true;
    } else if (d > 0) {
      return false;
    }
  }
  return false;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Expand [..., n] to [..., n-1, 1] if needed.
def expand(a):
    if a[-1] != 1 or len(a) == 1:
        a[-1] -= 1
        a.append(1)
    return a


# Check if a is smaller than b.
def less_than(a, b):
    a = expand(a)
    b = expand(b)
    a = [(-1) ** i * a[i] for i in range(len(a))]
    b = [(-1) ** i * b[i] for i in range(len(b))]
    return a < b
最佳内点

对于 ,求使得 成立且 最小的有理数

解答

因为 Stern–Brocot 树既是 中的分数的二叉搜索树,又是二元组 笛卡尔树,所以题意几乎可以转化为求 Stern–Brocot 树上两个点的 LCA(最近公共祖先)。但是,LCA 只能处理闭区间内的情形,LCA 可能是端点本身。为了避免额外的讨论,可以首先构造出 ,再计算 LCA。在已经通过连分数计算出根到节点的路径的情况下,LCA 只要取最长的公共路径即可。

要构造出 ,只需要在节点 处首先向右(左)移动一次,再向左(右)移动 次即可。转化成连分数的语言,对于分数 ,可以知道 必然是 ,因而只需要比较这两个连分数,将较大(小)的定义为

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Get X +- EPSILON.
auto pm_eps(std::vector<int> a) {
  constexpr int inf = 0x3f3f3f3f;
  // Deal with empty continued fraction for 1/0.
  if (a.empty()) {
    a.emplace_back(inf);
  }
  auto b = a;
  expand(b);
  a.emplace_back(inf);
  b.emplace_back(inf);
  return less_than(a, b) ? std::make_pair(a, b) : std::make_pair(b, a);
}

// Find the lexicographically smallest (q, p)
//   such that p0/q0 < p/q < p1/q1.
auto middle(int p0, int q0, int p1, int q1) {
  auto a0 = pm_eps(fraction(p0, q0)).second;
  auto a1 = pm_eps(fraction(p1, q1)).first;
  std::vector<int> a;
  for (int i = 0; i < a0.size() || i < a1.size(); ++i) {
    if (a0[i] == a1[i]) {
      a.emplace_back(a0[i]);
    } else {
      a.emplace_back(std::min(a0[i], a1[i]) + 1);
      break;
    }
  }
  auto pq = convergents(a);
  return std::make_pair(pq.first.back(), pq.second.back());
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Get X +- EPSILON.
def pm_eps(a):
    # Deal with empty continued fraction for 1/0.
    if not a:
        a.append(float("inf"))
    b = expand(a.copy())
    a.append(float("inf"))
    b.append(float("inf"))
    return (a, b) if less_than(a, b) else (b, a)


# Find the lexicographically smallest (q, p)
#   such that p0/q0 < p/q < p1/q1.
def middle(p0, q0, p1, q1):
    a0 = pm_eps(fraction(p0, q0))[1]
    a1 = pm_eps(fraction(p1, q1))[0]
    a = []
    for i in range(min(len(a0), len(a1))):
        if a0[i] == a1[i]:
            a.append(a0[i])
        else:
            a.append(int(min(a0[i], a1[i])) + 1)
            break
    p, q = convergents(a)
    return p[-1], q[-1]
GCJ 2019, Round 2 - New Elements: Part 2

给定 个正整数对 ,求正整数对 使得 严格递增。在所有符合要求的数对中,输出字典序最小的一对。

解答

不妨设 。问题转化为求 使得所有 都是整数。这些数对可以分为四种情形:

  1. 的情形可以忽略,因为已经假设
  2. 的情形直接输出「IMPOSSIBLE」;
  3. 的情形相当于约束
  4. 的情形相当于约束

因此,取 是第四种情形中最大的 ,再取 是第三种情形中最小的 。原问题就变成了找到字典序最小的 使得 成立。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
void solve() {
  int n;
  std::cin >> n;
  std::vector<int> C(n), J(n);
  // p0/q0 < y/x < p1/q1
  int p0 = 0, q0 = 1, p1 = 1, q1 = 0;
  bool fail = false;
  for (int i = 0; i < n; ++i) {
    std::cin >> C[i] >> J[i];
    if (i) {
      int A = C[i] - C[i - 1];
      int B = J[i] - J[i - 1];
      if (A <= 0 && B <= 0) {
        fail = true;
        break;
      } else if (B > 0 && A < 0) {  // y/x > (-A)/B if B > 0
        if ((-A) * q0 > p0 * B) {
          p0 = -A;
          q0 = B;
        }
      } else if (B < 0 && A > 0) {  // y/x < A/(-B) if B < 0
        if (A * q1 < p1 * (-B)) {
          p1 = A;
          q1 = -B;
        }
      }
    }
  }
  if (fail || p0 * q1 >= p1 * q0) {
    printf("IMPOSSIBLE\n");
  } else {
    auto pq = middle(p0, q0, p1, q1);
    printf("%d %d\n", pq.first, pq.second);
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def solve():
    n = int(input())
    C = [0] * n
    J = [0] * n
    # p0/q0 < y/x < p1/q1
    p0, q0 = 0, 1
    p1, q1 = 1, 0
    fail = False
    for i in range(n):
        C[i], J[i] = map(int, input().split())
        if i > 0:
            A = C[i] - C[i - 1]
            B = J[i] - J[i - 1]
            if A <= 0 and B <= 0:
                fail = True
                break
            elif B > 0 and A < 0:  # y/x > (-A)/B if B > 0
                if (-A) * q0 > p0 * B:
                    p0, q0 = -A, B
            elif B < 0 and A > 0:  # y/x < A/(-B) if B < 0
                if A * q1 < p1 * (-B):
                    p1, q1 = A, -B
    if fail or p0 * q1 >= p1 * q0:
        return "IMPOSSIBLE"
    p, q = middle(p0, q0, p1, q1)
    return str(q) + " " + str(p)

想要了解更多 Stern–Brocot 树的性质和应用,可以参考其主条目页面。

分式线性变换

和连分数相关的另一个重要概念是所谓的分式线性变换。

分式线性变换

分式线性变换(fractional linear transformation)是指函数 ,使得

其中,

关于条件

容易验证,当 时,函数可能没有定义或者是常函数。

分式线性变换有如下性质:

分式线性变换的性质

是分式线性变换,并记 的系数形成的矩阵为

则它们有如下性质:7

  1. 分式线性变换的复合 和逆变换 仍然是分式线性变换,即全体分式线性变换构成
  2. 分式线性变换在系数同乘以非零常数后保持不变,即对于任意 ,如果 ,则
  3. 分式线性变换的复合的系数矩阵,对应着系数矩阵的乘积,即如果 ,则
  4. 分式线性变换的逆变换的系数矩阵,对应着系数矩阵的逆矩阵,即如果 ,则
证明

此处仅提供分式线性变换的复合和逆变换的形式。得到这个形式后,所有性质都是容易验证的。

分式线性变换 的复合:

分式线性变换 的逆变换:

有限连分数 可以看做是一系列分式线性变换复合的结果。设

那么,有限连分数

其中,分式线性变换 变换在 处的取值是 ,这是函数在 时的极限值。

对于一般的连分数,设实数 的余项为 ,即 ,则有

这同时也给出了分式线性变换 的形式。

当然也可以直接验证这个表达式。最开始的时候是

随后,如果 具有

的形式,那么根据分式线性变换的复合公式,有

这就可以归纳地得到了上述形式。分式线性变换也提供了递推公式和初值条件的另一个角度的理解。

DMOPC '19 Contest 7 P4 - Bob and Continued Fractions

给定正整数数组 组查询,每次查询给定 ,并要求计算 的值。

解答

将连分数理解为一列分式线性变换的复合在 处取值的结果,只需要能够多次查询一段分式线性变换的复合即可。因为每个分式线性变换都可以取逆,所以可以预处理前缀和再用差分的方法查询,复杂度为 的;如果需要修改,也可以用线段树等结构存储。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include <algorithm>
#include <iostream>
#include <tuple>
#include <vector>

constexpr int M = 1e9 + 7;

// FLTs. Essentially 2x2 matrix.
struct FracLinearTrans {
  int mat[4];

  FracLinearTrans() : mat{} {}

  FracLinearTrans(int x) : mat{x, 1, 1, 0} {}

  FracLinearTrans(int a, int b, int c, int d) : mat{a, b, c, d} {}

  FracLinearTrans operator*(const FracLinearTrans& rhs) const {
    return FracLinearTrans(
        ((long long)mat[0] * rhs.mat[0] + (long long)mat[1] * rhs.mat[2]) % M,
        ((long long)mat[0] * rhs.mat[1] + (long long)mat[1] * rhs.mat[3]) % M,
        ((long long)mat[2] * rhs.mat[0] + (long long)mat[3] * rhs.mat[2]) % M,
        ((long long)mat[2] * rhs.mat[1] + (long long)mat[3] * rhs.mat[3]) % M);
  }

  FracLinearTrans inv() const {
    return FracLinearTrans(mat[3], M - mat[1], M - mat[2], mat[0]);
  }
};

int main() {
  int n, q;
  std::cin >> n >> q;
  // Get prefix sum of FLTs.
  std::vector<FracLinearTrans> ps(1, {1, 0, 0, 1});
  ps.reserve(n + 1);
  for (int i = 1; i <= n; ++i) {
    int a;
    std::cin >> a;
    ps[i] = ps[i - 1] * FracLinearTrans(a);
  }
  // Query.
  for (; q; --q) {
    int l, r;
    std::cin >> l >> r;
    // Difference.
    auto res = ps[l - 1].inv() * ps[r];
    int u = res.mat[0], d = res.mat[2];
    // Correct signs.
    if (!(l & 1)) {
      if (u) u = M - u;
      if (d) d = M - d;
    }
    printf("%d %d\n", u, d);
  }
  return 0;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# PYTHON IS TOO SLOW TO PASS THIS PROBLEM.
# JUST FOR REFERENCE.
M = 10**9 + 7


def mul(a, b):
    return (
        (a[0] * b[0] + a[1] * b[2]) % M,
        (a[0] * b[1] + a[1] * b[3]) % M,
        (a[2] * b[0] + a[3] * b[2]) % M,
        (a[2] * b[1] + a[3] * b[3]) % M,
    )