跳转至

连分数

引入

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

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

本文会提供一系列的连分数的算法实现,其中部分算法可能无法保证计算中间过程所涉及的整数都在 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.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:
        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 树的性质和应用,可以参考其主条目页面。

分式线性变换

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

分式线性变换

分式线性变换(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,
    )


def inv(a):
    return (a[3], M - a[1], M - a[2], a[0])


n, q = map(int, input().split())
ps = [(1, 0, 0, 1)]
# Get presum.
for a in map(int, input().split()):
    ps.append(mul(ps[-1], (a, 1, 1, 0)))
for _ in range(q):
    l, r = map(int, input().split())
    res = mul(inv(ps[l - 1]), ps[r])
    u, d = res[0], res[2]
    if l % 2 == 0:
        if u:
            u = M - u
        if d:
            d = M - d
    print(u, d)

连分数的四则运算

利用分式线性变换,可以完成连分数的四则运算。这个算法最早由 Gosper 提出。

算法的基石是计算连分数的分式线性变换。本节以有限连分数为例,但是因为算法每输出一位,只需要读入有限多个连分数的项,所以对于无限连分数也是适用的,而且可以算到任意精度。结合前文的连分数比较算法,可以精确地比较任意精度的实数差异。

连分数的分式线性变换

给定分式线性变换 和连分数 ,求 的连分数表示

解答

算法的基本思路就是逐个确定 的值。记

因为连分数

所以,可以向 通过逐步复合 的方式计算 的大小。但是,如果是希望得到 的连分数表示,那么并不需要完全计算 的值再求出连分数表示。可以在复合 的过程中就能判断 的值。

比如,假设当前计算到

同号。那么, 上单调,且必然取值在 之间。所以,如果

就可以确定它就是 的整数部分 。此时,在左侧复合 就可以得到

此时,继续添加 就可以确定新的整数部分,即 。这样计算下去,直到确定出所有的 的值。

算法要求 同号,是因为要保证函数的不连续点不在 范围内。这总是可能的,因为简单连分数的定义要求(除了 外的)系数都是正整数。由此可以证明,必然在有限步内成立 同号,且将在之后一直保持同号。

具体实现时,只需要维护当前的分式线性变换的系数矩阵 并检查 是否同号以及 是否有相同的整数部分。右复合 时,就可以得到 。如果两者整数部分相同为 ,则在结果的连分数内添加 ,并且左复合 ,这相当于计算

连分数的分式线性变换已经可以用于计算分数和连分数的四则运算问题:

对于一般的连分数之间的四则运算,需要用到双分式线性变换:

连分数的双分式线性变换

给定双分式线性变换 和连分数 ,求 的连分数表示

解答

与单变量的分式线性变换的情形类似,要确定整数部分只需要保证当前的分式线性变换在 内的整数部分保持不变,即 皆同号,且

右复合要替换成计算 ,这同样表示成系数的线性变换。左复合则和单变量的情形完全一致,只需要计算取模就可以了。

相较于单变量的情形,双变量的情形需要决定要先复合 还是 。因为复合的顺序与最后的结果无关,所以可以自由选择复合顺序,比如交替地复合 。或者采用经验法则,优先复合比值差距更大的维度:如果 ,那么就先复合 ;否则,就先复合

循环连分数

类似于循环小数的概念,如果连分数的系数形成了循环,就称为循环连分数。

循环连分数

设连分数 ,且存在自然数 和正整数 使得对于任何 ,都有 ,就称连分数 循环连分数(periodic continued fraction)。满足这个条件的最小的 称为它的最小正周期,而在连分数中重复出现的 序列就称为它的循环节。利用循环节,循环连分数可以写作 。如果 可以取作 ,即 ,就称它为 纯循环连分数(purely periodic continued fraction),否则称它为 混循环连分数(eventually periodic continued fraction)。

二次无理数

与循环连分数密切相关的概念是 (实)二次无理数(quadratic irrational),即整系数二次方程的无理数解。所有的二次无理数都可以表示成

的形式,其中, 是有理数且 是无平方因子的正整数。本文提到的二次无理数都默认是实数。而且, 的共轭是指

Euler 的结果说明,所有循环连分数都是二次无理数。

定理(Euler)

循环连分数表示的都是二次无理数。

证明

对于一般的循环连分数 ,可以设 ,则

其中, 都是分式线性变换。于是,得到 满足的方程

不妨设分式线性变换 ,则得到 满足的方程

因而,循环连分数都是整系数二次方程的解。又因为无限连分数都是无理数,所以循环连分数都表示了二次无理数。

Lagrange 的结果说明反过来也成立,因而二次无理数和循环连分数是等价的。

定理(Lagrange)

二次无理数可以表示成循环连分数。

证明

思路是证明余项会重复出现。设二次无理数 可以写作

的形式,其中, 都是整数且 。这总是可能的,比如二次无理数 总可以写成

再令 即可。

将它写成这种形式的好处是,可以证明它的所有余项都具有类似的形式:

其中, 是整数且 。其中,条件 保证了所有余项的分子中, 前面的系数都是

为得到余项的形式,可以使用数学归纳法。当 时,显然。假设已经得到了 的形式,并设 ,就有

也有类似形式,并和 一起代入上式,有

因为二次无理数表示成 的方式是唯一的,所以比较两侧系数可知

将第二个等式代入第一个等式可以解出

再代入第二个等式,就可以解出

根据归纳假设,,所以确实 都是整数,即 也具有所要求的形式。

最后,证明余项只能取得有限多个值,故而必然重复。前文已经求得余项

而且对于无理数,总有 。同时,它的共轭

对于充分大的 必然小于 ,因为

这就说明

因此, 只能取有限多个值。进而,

所以, 也只能取有限多个值。故而,余项 只有有限多个可能的取值,必然在无限项内重复。

定理的证明也提供了一个计算二次无理数的余项的递推公式:

二次无理数的余项递推公式

二次无理数总可以表示成

的形式,且 。它的余项

中, 都是整数,且满足递推关系

这个递推公式可以直接用于二次无理数的连分数的计算,而且根据定理的证明,。该算法的复杂度取决于循环节的长度,而后者可以证明是 8

二次无理数

给定二次无理数 ,求出其连分数的表示。其中, 不是完全平方。

解答

首先将二次无理数表示成上述形式,再利用递推公式计算即可。连分数的项由 给出。为了求出循环节,需要存储 首次出现的下标。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Return the continued fraction and minimal positive period
//   of a quadratic irrational (x + y * sqrt(n)) / z.
auto quadratic_irrational(int x, int y, int z, int n) {
  int p = x * z;
  int d = n * y * y * z * z;
  int q = z * z;
  int dd = (int)sqrt(n) * y * z;
  int i = 0;
  std::vector<int> a;
  std::unordered_map<size_t, int> used;
  while (!used.count(((1LL << 32) * p) | q)) {
    a.emplace_back((p + dd) / q);
    used[((1LL << 32) * p) | q] = i++;
    p = a.back() * q - p;
    q = (d - p * p) / q;
  }
  return std::make_pair(a, i - used[((1LL << 32) * p) | q]);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Return the continued fraction and minimal positive period
#   of a quadratic irrational (x + y * sqrt(n)) / z.
def quadratic_irrational(x, y, z, n):
    p = x * z
    d = n * y * y * z * z
    q = z * z
    dd = floor(sqrt(n)) * y * z
    i = 0
    a = []
    used = dict()
    while (p, q) not in used:
        a.append((p + dd) // q)
        used[p, q] = i
        p = a[-1] * q - p
        q = (d - p * p) // q
        i += 1
    return a, i - used[p, q]
Tavrida NU Akai Contest - Continued Fraction

给定 ,且 不是完全平方数,。求出 的第 个渐近分数

解答

首先利用上述算法解出 的周期,将循环节表示成分式线性变换,就可以用 快速幂 获得 的值。当然,对于没有进入循环节和不足一个循环节的部分,需要单独处理。

 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
58
59
60
61
62
63
64
65
66
67
68
#include <algorithm>
#include <cmath>
#include <iostream>
#include <tuple>
#include <unordered_map>
#include <vector>

// Return the continued fraction and minimal positive period
//   of a quadratic irrational (x + y * sqrt(n)) / z.
auto quadratic_irrational(int x, int y, int z, int n) {
  int p = x * z;
  int d = n * y * y * z * z;
  int q = z * z;
  int dd = (int)sqrt(n) * y * z;
  int i = 0;
  std::vector<int> a;
  std::unordered_map<size_t, int> used;
  while (!used.count(((1LL << 32) * p) | q)) {
    a.emplace_back((p + dd) / q);
    used[((1LL << 32) * p) | q] = i++;
    p = a.back() * q - p;
    q = (d - p * p) / q;
  }
  return std::make_pair(a, i - used[((1LL << 32) * p) | q]);
}

// Fractional Linear Transformation.
struct FracLinearTrans {
  static constexpr int M = 1e9 + 7;
  int mat[4];

  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);
  }
};

int main() {
  int x, k, L;
  std::cin >> x >> k;
  std::vector<int> a;
  std::tie(a, L) =
      quadratic_irrational(0, 1, 1, x);  // L==a.size()-1 for sqrt(x)
  FracLinearTrans cyc(1, 0, 0, 1);
  for (int i = a.size() - 1; i; --i) {
    cyc = FracLinearTrans(a[i], 1, 1, 0) * cyc;
  }
  // 1/0=Inf.
  FracLinearTrans res(0, 1, 0, 0);
  // Tail terms.
  for (int i = k % L; i; --i) {
    res = FracLinearTrans(a[i], 1, 1, 0) * res;
  }
  // Binary exponentiation.
  for (int b = k / L; b; b >>= 1) {
    if (b & 1) res = cyc * res;
    cyc = cyc * cyc;
  }
  // First term.
  res = FracLinearTrans(a[0], 1, 1, 0) * res;
  printf("%d/%d", res.mat[1], res.mat[3]);
  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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
from math import sqrt, floor


# Return the continued fraction and minimal positive period
#   of a quadratic irrational (x + y * sqrt(n)) / z.
def quadratic_irrational(x, y, z, n):
    p = x * z
    d = n * y * y * z * z
    q = z * z
    dd = floor(sqrt(n)) * y * z
    i = 0
    a = []
    used = dict()
    while (p, q) not in used:
        a.append((p + dd) // q)
        used[p, q] = i
        p = a[-1] * q - p
        q = (d - p * p) // q
        i += 1
    return a, i - used[p, q]


# Compose (A[0]*x + A[1]) / (A[2]*x + A[3]) and (B[0]*x + B[1]) / (B[2]*x + B[3])
def combine(A, B):
    return [
        t % mod
        for t in [
            A[0] * B[0] + A[1] * B[2],
            A[0] * B[1] + A[1] * B[3],
            A[2] * B[0] + A[3] * B[2],
            A[2] * B[1] + A[3] * B[3],
        ]
    ]


# Binary exponentiation.
def bpow(A, n):
    return (
        [1, 0, 0, 1]
        if not n
        else combine(A, bpow(A, n - 1))
        if n % 2
        else bpow(combine(A, A), n // 2)
    )


mod = 10**9 + 7
x, k = map(int, input().split())
a, T = quadratic_irrational(0, 1, 1, x)

A = (1, 0, 0, 1)  # (x + 0) / (0*x + 1) = x

# apply ak + 1/x = (ak*x+1)/(1x+0) to (Ax + B) / (Cx + D)
for i in reversed(range(1, len(a))):
    A = combine([a[i], 1, 1, 0], A)

C = (0, 1, 0, 0)  # = 1 / 0
while k % T:
    i = k % T
    C = combine([a[i], 1, 1, 0], C)
    k -= 1

C = combine(bpow(A, k // T), C)
C = combine((a[0], 1, 1, 0), C)
print(str(C[1]) + "/" + str(C[3]))

纯循环连分数

二次无理数是有循环连分数表示的充分必要条件,本节的讨论则给出了实数具有纯循环连分数表示的充分必要条件。

首先,因为纯循环连分数具有类似有限连分数的形式,所以可以做「反序」操作。类似于反序定理,这样得到的连分数表示和原来的连分数表示之间有确定的关系。

定理(Galois)

对于纯循环连分数

互为「倒数负共轭」,即 的共轭的倒数的相反数是

证明

因为不要求 是最小正周期,所以不妨设 。利用反序定理可知,

因为等式两侧都是既约分数,所以

对于纯循环连分数 ,它的第 个余项就是 ,因此,有

所以,它满足二次方程

同理, 满足二次方程

利用系数的关系可知,这个方程可以写作

,则 满足同一个方程。但是,,因此它们并非同一个根,而是互为共轭的关系,这就证明了原命题。

Galois 利用这个观察,进一步地给出了二次无理数有纯循环连分数表示的充分必要条件。

定理(Galois)

二次无理数 可以表示为纯循环连分数,当且仅当 且它的共轭

证明

如果 是纯循环连分数,那么利用前文的记号,有 ,故而 。又因为它的倒数负共轭也是循环连分数,所以它的共轭 满足 ,亦即 。这就证明了纯循环连分数都满足该条件。

反过来,设二次无理数 。对于 的余项 ,有递推关系

等式两边都是二次无理数,取共轭可知

利用这个递推关系,可以证明 对所有 都成立。

首先,对于 ,有 ,显然。对于 ,由简单连分数定义和 可知,。故而,假设 ,就有

这就归纳地证明了 对所有 都成立。因此,有

因为二次无理数一定是循环连分数,所以存在正整数 和至少某个充分大的 ,有 。但是,此时必然也有

故而,

这意味着最小的能够使得 成立的 必然是 。也就是说, 可以表示成纯循环连分数。

Galois 定理揭示了纯二次不尽根(pure quadratic surd)——即形如 的二次无理数——的连分数表示的规律。

推论

对于有理数 ,如果 是无理数,那么

且对于任意 ,都有

证明

对于二次无理数 ,因为 ,所以 是纯循环连分数:

根据上述定理,它的倒数负共轭具有形式

利用连分数的基本性质可知

但是,又由 的连分数表示可知,

因为无理数的连分数表示是唯一的,所以比较中间的系数就知道, 对所有 都成立。

例子: 的连分数展开

的连分数可以计算如下:(此处仅是为了说明,编程计算应使用前文提到的递归算法)

各个余项分别是:

根据 Galois 的结论,余项 循环部分恰好相反,因此互为倒数负共轭。如果 的循环节长度 为奇数,那么中间的一项就与自身互为倒数负共轭;如果循环节长度 为偶数,就不存在这样的项。Pell 方程一节的讨论会说明,循环节长度的奇偶性将决定了方程 是否有解。

二次无理数 的连分数展开主要应用在 Pell 方程 的求解中。

例题

在掌握了基础概念后,需要研究一些具体的例题来理解如何在算法竞赛中应用连分数的方法。

线下凸包

给定 ,求出满足 的整点 的集合的凸包。

解答

对于无界集合 ,上凸壳就是直线 本身。然而,如下图所示,如果还要求 ,那么上凸壳最终会偏离直线。

开始,可以自左向右地求出上凸壳的所有整点。假设当前已经求出的上凸壳的最后一个整点是 。现在要求出下一个整点 。顶点 右上方,记 为两者的差值。那么,必然有

第二个不等式成立,因为条件 已经在上凸壳上这件事矛盾。观察 需要满足的条件,对于不同的点 ,只有 的上界在变化。所以,只要能解决这个子问题,就可以递归地求出原问题的所有整点。

进而,考虑子问题的解法。对比于原问题,子问题相当于将 的上界修改为 ,并求出上凸壳中与原点相邻的第一个整点。记子问题的解为 。那么, 必然是互素的(否则不是第一个整点),且与原点连线的斜率 是所有位于直线 下方且横坐标不超过 的整点中最大的(否则不在凸包上)。结合前文的 几何解释 可知,这样的点 必然对应于 的一个下中间分数。因为分母越大的下中间分数离 越近,所以子问题的解 对应着所有分母不超过 的下中间分数中分母最大的那个。

当然,实际求解时,没必要对每个子问题都重新求出这样的下中间分数。应该首先求出所有的渐近分数,这相当于提供了遍历所有的下中间分数的方法。然后分母从大到小地遍历下中间分数,每次都尝试将它加到前一个整点 上,直到不能添加为止才继续尝试下一个下中间分数。

此处有一些显然的优化。首先,对于下中间分数 ,必然存在奇数 使得 。只要找到最大的 使得 满足就好了,亦即 。不用担心 越界,因为更大的下渐近分数 已经添加完了。而每次确定添加的次数的时候,直接计算 即可,不必逐个尝试。

优化后的算法的复杂度是 的。虽然下中间分数对应的整点可能有很多,但是真正成为增量的并不多。下面要说明,所有 的下中间分数 中,至多会出现两个增量。假设这些下中间分数中确实出现了增量,则此时必然有 。不妨设 。如果 ,则增量就有 ,故而添加完增量后,就有 ,不会再在这些下中间分数中出现新的增量;如果 ,那么添加完增量后,必然有 ,即使还会在同一段下中间分数中出现新的增量,下次也只能有 。因此,在这样的一段下中间分数中,至多只能出现两个增量。这就说明,总的时间复杂度是 的。

 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
// Find [ah, ph, qh] such that points r[i]=(ph[i], qh[i]) constitute
// upper convex hull of lattice points on 0 <= x <= N and 0 <= y <= r * x,
// where r = [a0, a1, a2, ...] and there are ah[i]-1 integer points on the
// segment between r[i] and r[i+1].
auto hull(std::vector<int> a, int N) {
  std::vector<int> p, q;
  std::tie(p, q) = convergents(a);
  int t = N / q.back();
  std::vector<int> ah = {t};
  std::vector<int> ph = {0, t * p.back()};
  std::vector<int> qh = {0, t * q.back()};

  for (int i = q.size() - 1; i; --i) {
    if (i % 2) {
      while (qh.back() + q[i - 1] <= N) {
        t = (N - qh.back() - q[i - 1]) / q[i];
        int dp = p[i - 1] + t * p[i];
        int dq = q[i - 1] + t * q[i];
        int k = (N - qh.back()) / dq;
        ah.push_back(k);
        ph.push_back(ph.back() + k * dp);
        qh.push_back(qh.back() + k * dq);
      }
    }
  }
  return make_tuple(ah, ph, qh);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# Find [ah, ph, qh] such that points r[i]=(ph[i], qh[i]) constitute
# upper convex hull of lattice points on 0 <= x <= N and 0 <= y <= r * x,
# where r = [a0, a1, a2, ...] and there are ah[i]-1 integer points on the
# segment between r[i] and r[i+1].
def hull(a, N):
    p, q = convergents(a)
    t = N // q[-1]
    ah = [t]
    ph = [0, t * p[-1]]
    qh = [0, t * q[-1]]
    for i in reversed(range(len(q))):
        if i % 2 == 1:
            while qh[-1] + q[i - 1] <= N:
                t = (N - qh[-1] - q[i - 1]) // q[i]
                dp = p[i - 1] + t * p[i]
                dq = q[i - 1] + t * q[i]
                k = (N - qh[-1]) // dq
                ah.append(k)
                ph.append(ph[-1] + k * dp)
                qh.append(qh[-1] + k * dq)
    return ah, ph, qh
Timus - Crime and Punishment

给定正整数 ,求 使得 尽可能大。

解答

这个问题有一个复杂度为 的解法:不妨设 ,因为 ,所以只需要在 中搜索答案即可。这足够通过本题。但是,如果应用连分数方法,那么时间复杂度就可以降低到

为了讨论方便,首先通过代换 来改变 的符号。令 ,则原问题转化为在 的条件下,求最优的 使得 最大。对于每个固定的 ,最优的 的取值为

接下来要说明的是,这个问题和上一个例题具有类似的解法。但是,与上一个例题中使用下中间分数偏离直线不同,本题需要使用上中间分数来接近直线。具体来说, 的值正比于点 与直线 的距离。要最大化 ,就等价于最小化这个距离。算法的目标是要找到直线 下方距离它最近的可行的整点。算法的思路就是从最左侧的点开始,沿着这些整点的上凸壳搜索,逐步缩小与直线的距离,直到得到最优解。

的坐标系内,算法从 出发,递归地寻找并添加最优的增量 ,且保证添加后的点比起之前更靠近直线 ,但是不能到达直线的另一侧,也不能让横坐标大于 。设已经得到的点是 ,那么增量 满足的条件就是

按照沿下凸壳搜索的思路,只需要找到满足这些条件的点中 最小的即可。将第一个不等式改写成

结合前文的 几何解释 可知,只要后面的常数项小于 ,那么满足这个不等式的整点 中横坐标最小的,一定对应着某个上中间分数。这是因为它是所有分母不超过它的分母的分数中,从上方逼近某个实数效果最好的,这只能是上中间分数。而每次添加增量后,都会导致 的上界变得更紧,这意味着必须考察分母更大的上中间分数。

仿照上一个例题的思路。分母从小到大考察所有上中间分数,如果能够找到横坐标和纵坐标都不越界的上中间分数,就添加进去,并更新相应的上界。当所有可行的上中间分数都添加结束后,得到的就是最优解。相较于之前,这个题目需要同时保证横纵坐标都不越界,需要格外注意。基于和上一个例题类似的论述,不过这次是使用 代替之前的 ,可以说明这个算法的复杂度是 的。

 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
// Find (x, y) such that y = (A*x+B)/C,
// such that Cy - Ax is max and 0 <= x <= N.
auto closest(int A, int B, int C, int N) {
  // y <= (A*x + B)/C <=> diff(x, y) <= B
  auto diff = [&](int x, int y) -> int { return C * y - A * x; };
  std::vector<int> p, q;
  std::tie(p, q) = convergents(fraction(A, C));
  int qh = 0, ph = B / C;
  for (int i = 2; i < q.size() - 1; ++i) {
    if (i % 2 == 0) {
      while (diff(qh + q[i + 1], ph + p[i + 1]) <= B) {
        int t = 1 + (diff(qh + q[i - 1], ph + p[i - 1]) - B - 1) /
                        (-diff(q[i], p[i]));
        int dp = p[i - 1] + t * p[i];
        int dq = q[i - 1] + t * q[i];
        int k = (N - qh) / dq;
        if (k == 0) {
          return std::make_pair(qh, ph);
        }
        if (diff(dq, dp) != 0) {
          k = std::min(k, (B - diff(qh, ph)) / diff(dq, dp));
        }
        qh += k * dq;
        ph += k * dp;
      }
    }
  }
  return std::make_pair(qh, ph);
}

auto solve(int A, int B, int N) {
  int x, y;
  std::tie(x, y) = closest(A, N % A, B, N / A);
  return std::make_pair(N / A - x, y);
}
 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
# Find (x, y) such that y = (A*x+B) // C,
# such that Cy - Ax is max and 0 <= x <= N.
def closest(A, B, C, N):
    # y <= (A*x + B)/C <=> diff(x, y) <= B
    def diff(x, y):
        return C * y - A * x

    p, q = convergents(fraction(A, C))
    qh, ph = 0, B // C
    for i in range(2, len(q) - 1):
        if i % 2 == 0:
            while diff(qh + q[i + 1], ph + p[i + 1]) <= B:
                t = 1 + (diff(qh + q[i - 1], ph + p[i - 1]) - B - 1) // (
                    -diff(q[i], p[i])
                )
                dp = p[i - 1] + t * p[i]
                dq = q[i - 1] + t * q[i]
                k = (N - qh) // dq
                if k == 0:
                    return qh, ph
                if diff(dq, dp) != 0:
                    k = min(k, (B - diff(qh, ph)) // diff(dq, dp))
                qh, ph = qh + k * dq, ph + k * dp
    return qh, ph


def solve(A, B, N):
    x, y = closest(A, N % A, B, N // A)
    return N // A - x, y
June Challenge 2017 - Euler Sum

的值,其中, 是自然对数的底。

提示:9

解答

这个和等于集合 中的整点个数。在构建完直线 下的整点的凸包后,可以使用 Pick 定理 计算整点个数。时间复杂度为

原问题要求 。此处 C++ 代码仅作示意,并没有实现高精度计算类。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Find sum of floor(k * x) for k in [1, N] and x = [a0; a1, a2, ...]
int sum_floor(std::vector<int> a, int N) {
  N++;
  std::vector<int> ah, ph, qh;
  std::tie(ah, ph, qh) = hull(a, N);

  // The number of lattice points within a vertical right trapezoid
  // on points (0; 0) - (0; y1) - (dx; y2) - (dx; 0) that has
  // a+1 integer points on the segment (0; y1) - (dx; y2).
  auto picks = [](int y1, int y2, int dx, int a) -> int {
    int b = y1 + y2 + a + dx;
    int A = (y1 + y2) * dx;
    return (A + b) / 2 - y2;  // = (A - b + 2) // 2 + b - (y2 + 1)
  };

  int ans = 0;
  for (size_t i = 1; i < qh.size(); i++) {
    ans += picks(ph[i - 1], ph[i], qh[i] - qh[i - 1], ah[i - 1]);
  }
  return ans - N;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# Find sum of floor(k * x) for k in [1, N] and x = [a0; a1, a2, ...].
def sum_floor(a, N):
    N += 1
    ah, ph, qh = hull(a, N)

    # The number of lattice points within a vertical right trapezoid
    # on points (0; 0) - (0; y1) - (dx; y2) - (dx; 0) that has
    # a+1 integer points on the segment (0; y1) - (dx; y2) but with
    # the number of points on the vertical right line excluded.
    def picks(y1, y2, dx, a):
        b = y1 + y2 + a + dx
        A = (y1 + y2) * dx
        return (A + b) // 2 - y2  # = (A - b + 2) // 2 + b - (y2 + 1)

    ans = 0
    for i in range(1, len(qh)):
        ans += picks(ph[i - 1], ph[i], qh[i] - qh[i - 1], ah[i - 1])
    return ans - N
NAIPC 2019 - It's a Mod, Mod, Mod, Mod World

给定正整数 ,求 的值。

解答

因为和式可以变形为

这个问题可以转化为上一个问题,只要用 替代 即可。单次查询的时间复杂度为

1
2
3
int solve(int p, int q, int n) {
  return p * n * (n + 1) / 2 - q * sum_floor(fraction(p, q), n);
}
1
2
def solve(p, q, N):
    return p * N * (N + 1) // 2 - q * sum_floor(fraction(p, q), N)
Library Checker - Sum of Floor of Linear

给定正整数 ,求 的值。

解答

这是到目前为止最为复杂的题目。它可以通过 类欧几里得算法 计算。此处给出基于连分数的算法,时间复杂度是

可以通过构造直线 以下且 的全部整点的凸包,并用 Pick 定理计算整点的个数。之前已经解决 的情形。对于一般的情形,可以分为两步进行。首先通过添加上中间分数来逐步接近直线(即第二个例题),直到找到最接近直线的点,再通过添加下中间分数来逐步远离直线(即第一个例题)。

 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
58
59
60
61
// Find convex hull of lattice (x, y) such that C*y <= A*x+B.
auto hull(int A, int B, int C, int N) {
  auto diff = [&](int x, int y) -> int { return C * y - A * x; };
  auto a = fraction(A, C);
  std::vector<int> p, q;
  std::tie(p, q) = convergents(a);
  std::vector<int> ah(0), ph(1, B / C), qh(1, 0);
  auto insert = [&](int dq, int dp) -> void {
    int k = (N - qh.back()) / dq;
    if (diff(dq, dp) > 0) {
      k = std::min((int)k, (B - diff(qh.back(), ph.back())) / diff(dq, dp));
    }
    ah.emplace_back(k);
    qh.emplace_back(qh.back() + k * dq);
    ph.emplace_back(ph.back() + k * dp);
  };
  for (int i = 1; i < q.size() - 1; ++i) {
    if (i % 2 == 0) {
      while (diff(qh.back() + q[i + 1], ph.back() + p[i + 1]) <= B) {
        int t = (B - diff(qh.back() + q[i + 1], ph.back() + p[i + 1])) /
                (-diff(q[i], p[i]));
        int dp = p[i + 1] - t * p[i];
        int dq = q[i + 1] - t * q[i];
        if (dq < 0 || qh.back() + dq > N) break;
        insert(dq, dp);
      }
    }
  }
  insert(q.back(), p.back());
  for (int i = q.size() - 1; i; --i) {
    if (i % 2 == 1) {
      while (qh.back() + q[i - 1] <= N) {
        int t = (N - qh.back() - q[i - 1]) / q[i];
        int dp = p[i - 1] + t * p[i];
        int dq = q[i - 1] + t * q[i];
        insert(dq, dp);
      }
    }
  }
  return std::make_tuple(ah, ph, qh);
}

// Sum of floor (Ax+B)/M from 0 to N-1.
auto solve(int N, int M, int A, int B) {
  std::vector<int> ah, ph, qh;
  std::tie(ah, ph, qh) = hull(A, B, M, N);
  // The number of lattice points within a vertical right trapezoid
  // on points (0; 0) - (0; y1) - (dx; y2) - (dx; 0) that has
  // a+1 integer points on the segment (0; y1) - (dx; y2) but with
  // the number of points on the vertical right line excluded.
  auto picks = [&](int y1, int y2, int dx, int a) -> int {
    int b = y1 + y2 + a + dx;
    int A = (y1 + y2) * dx;
    return (A + b) / 2 - y2;  // = (A - b + 2) // 2 + b - (y2 + 1)
  };
  int ans = 0;
  for (int i = 1; i < qh.size(); ++i) {
    ans += picks(ph[i - 1], ph[i], qh[i] - qh[i - 1], ah[i - 1]);
  }
  return ans - N;
}
 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
58
59
# Find convex hull of lattice (x, y) such that C*y <= A*x+B.
def hull(A, B, C, N):
    def diff(x, y):
        return C * y - A * x

    a = fraction(A, C)
    p, q = convergents(a)
    ah = []
    ph = [B // C]
    qh = [0]

    def insert(dq, dp):
        k = (N - qh[-1]) // dq
        if diff(dq, dp) > 0:
            k = min(k, (B - diff(qh[-1], ph[-1])) // diff(dq, dp))
        ah.append(k)
        qh.append(qh[-1] + k * dq)
        ph.append(ph[-1] + k * dp)

    for i in range(1, len(q) - 1):
        if i % 2 == 0:
            while diff(qh[-1] + q[i + 1], ph[-1] + p[i + 1]) <= B:
                t = (B - diff(qh[-1] + q[i + 1], ph[-1] + p[i + 1])) // (
                    -diff(q[i], p[i])
                )
                dp = p[i + 1] - t * p[i]
                dq = q[i + 1] - t * q[i]
                if dq < 0 or qh[-1] + dq > N:
                    break
                insert(dq, dp)

    insert(q[-1], p[-1])

    for i in reversed(range(len(q))):
        if i % 2 == 1:
            while qh[-1] + q[i - 1] <= N:
                t = (N - qh[-1] - q[i - 1]) // q[i]
                dp = p[i - 1] + t * p[i]
                dq = q[i - 1] + t * q[i]
                insert(dq, dp)
    return ah, ph, qh


def solve(N, M, A, B):
    ah, ph, qh = hull(A, B, M, N)

    # The number of lattice points within a vertical right trapezoid
    # on points (0; 0) - (0; y1) - (dx; y2) - (dx; 0) that has
    # a+1 integer points on the segment (0; y1) - (dx; y2) but with
    # the number of points on the vertical right line excluded.
    def picks(y1, y2, dx, a):
        b = y1 + y2 + a + dx
        A = (y1 + y2) * dx
        return (A + b) // 2 - y2  # = (A - b + 2) // 2 + b - (y2 + 1)

    ans = 0
    for i in range(1, len(qh)):
        ans += picks(ph[i - 1], ph[i], qh[i] - qh[i - 1], ah[i - 1])
    return ans - N
OKC 2 - From Modular to Rational

有个未知的有理数 ,可以询问对某个素数 取模后的 的值。请在不超过十次询问内确定 的值。

这个问题等价于找到 中使得 最小的

解答

根据 中国剩余定理,询问对多个素数取模后的结果,相当于询问对这些素数的乘积取模的结果。因此,本题可以看作是询问分数对足够大的模数 取模后的结果,要求确定分数的分子和分母。

对于某个模数 ,使得 成立的数对 可能并不唯一。假设 都可以使得这个等式成立,那么必然有 。根据 的构造可知, 互素,所以 ,亦即 。如果 不为零,那么它的绝对值至少是 。问题中限制了 ,这意味着这个差值不应该超过 ,因此只要取 就可以保证求出的 是唯一的。

现在的问题归结为,给定模数 和余数 ,求不超过 的正整数对 使得 。在已知这样的解是唯一的情况下,其实只要找到 时使得 最小的 即可,因为此时有且仅有一个 使得余数不超过 。这正是前面提到的等价表述。

所在的平面坐标系内,这相当于要找到 时在直线 下方最接近它的整点,因为余数 就正比于整点与直线的距离。结合前文的 几何解释 可知,这样的整点必然对应着有理分数 的某个下中间分数。算法复杂度是

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Find Q that minimizes Q*r mod m for 1 <= k <= n < m.
int mod_min(int r, int n, int m) {
  auto a = fraction(r, m);
  std::vector<int> p, q;
  std::tie(p, q) = convergents(a);
  for (int i = 2; i < q.size(); ++i) {
    if (i % 2 == 1 && (i + 1 == q.size() || q[i + 1] > n)) {
      int t = (n - q[i - 1]) / q[i];
      return q[i - 1] + t * q[i];
    }
  }
  return 0;
}
1
2
3
4
5
6
7
8
9
# Find Q that minimizes Q*r mod m for 1 <= k <= n < m.
def mod_min(r, n, m):
    a = fraction(r, m)
    p, q = convergents(a)
    for i in range(2, len(q)):
        if i % 2 == 1 and (i + 1 == len(q) or q[i + 1] > n):
            t = (n - q[i - 1]) // q[i]
            return q[i - 1] + t * q[i]
    return 0

习题

参考文献与拓展阅读

本页面主要内容译自博文 Continued fractions,版权协议为 CC-BY-SA 4.0,内容有改动。


  1. 自然数 只有非标准表示:。 

  2. 译名来自张明尧、张凡翻译的《具体数学》第 6.7 节。 

  3. 此时不能默认既约分数 一定是渐近分数,虽然 Legendre 定理表明 确实只能是某个渐近分数。对于渐近分数的情形,可以通过渐近分数逼近实数的误差入手加以证明。 

  4. 不同文献可能对此处的 的取值范围是否包括端点有不同的处理。 

  5. 时,应理解为形式连分数,相当于截断到连分数的倒数第二项。 

  6. 此说法并非专业术语。可能转译自俄文文献 ЦЕПНЫЕ ДРОБИ,在 Алгоритм «вытягивания носов» 一节。 

  7. 这些性质表明,全体分式线性变换的群同构于 射影线性群 。 

  8. 证明见 维基百科页面 的参考文献。 

  9. 关于自然对数的底 的连分数展开的证明可以参考 此处。