引入

类欧几里德算法是洪华敦在 2016 年冬令营营员交流中提出的内容。它常用于解决形如

结构的数列(下标为 )的求和问题。它的主要想法是,利用分数自身的递归结构,将问题转化为更小规模的问题,递归求解。因为分数的递归结构和 欧几里得算法 存在直接的 联系,因此,这一求和方法也称为类欧几里得算法。

因为 连分数Stern–Brocot 树 等方法同样刻画了分数的递归结构,所以利用类欧几里得算法可以解决的问题,通常也可以用这些方法解决。与这些方法相比,类欧几里得算法通常更容易理解,它的实现也更为简明。

类欧几里得算法

最简单的例子,就是求和问题:

其中, 都是正整数。

代数解法

首先,将 取模,可以简化问题,将问题转化为 的情形:

现在,考虑转化后的问题。令

那么,原问题可以写作二次求和式:

交换求和次序,这需要对于每个 计算满足条件的 的范围。为此,将条件变形:

变形过程中多次利用了 取整函数 的性质。代入变形后的条件,原式可以写作:

,这就又回到了前面讨论过的 的情形。

将这两步转化结合在一起,可以发现在过程中, 不断地取模后交换位置,直到 。这就类似于对 进行辗转相除,这也是类欧几里德算法的得名。它的时间复杂度是 的。

在计算过程中,可能会出现 的情形,此时内层递归会出现 。这并不影响最终的结果。但是,如果要求出现 时,直接终止算法,那么算法的时间复杂度可以改良为 的。

模板题的参考实现如下:

几何直观

这个算法还可以从几何的角度理解。类欧几里得算法可以解决的问题主要是直线下整点计数问题。

如下图最左部分所示,该求和式相当于求直线

下方, 轴上方(不包括 轴),且横坐标位于 之间的格点数目。

首先,移除斜率和截距中的整数部分。这一步相当于将上图中间部分的蓝点数量单独计算出来。当斜率和截距都是整数时,蓝点一定构成一个梯形阵列,也就是说,不同纵列的格点形成等差数列,因而这些点的数量是容易计算的。将这些点移除后,剩余的格点和上图最右部分的红点数量一致。问题就转化成了斜率和截距都小于一的情形。因为梯形的高为 且两个底边长度分别为 ,所以,利用梯形面积公式,这一步骤可以归纳为算式

然后,翻转横纵坐标轴。如下图最左部分所示,图中的红点和蓝点构成了一个横向长度为 、纵向长度为 的矩形点阵。要计算红点的数量,只需要计算蓝点的数量,再用矩形点阵的数量减去蓝点的数量即可。翻转后,上图左半部分中的蓝点点阵就变成了某条直线下的红色点阵。而且,翻转后,斜率大于一,就又回到了上文已经处理过的情形。

关键在于如何计算新的红色点阵上方的直线的方程。将上图最左部分的横纵坐标轴翻转,就得到上图中间部分。翻转后的红色点阵上方的直线(中间部分的实线),并非对应翻转前的直线(最左部分的实线),而是翻转前的直线向左上平移一点点的结果(最左部分的虚线)。这是因为,如果直接将直线(最左部分的实线)翻转,将得到中间部分的虚线,但是按照定义,它下方的格点包括恰好落在直线上的格点,这就会导致直线上的格点重复计数。为了避免这一点,需要将翻转直线 后得到的直线 向下平移一点点,得到直线 ,这样它下方的点阵才恰为翻转前的蓝色点阵。

还有另一处细节需要处理。上图中间部分的直线的截距是负数,这意味着还没有回到之前的初始情形。要让截距恢复为非负数,只需要将直线(中间部分的实线)向左平移一个单位。这样做不会漏掉任何格点,因为翻转前的蓝色点阵中没有纵坐标为零的点,翻转后也就不存在横坐标为零的点。最后,直线方程就变为 ;同时,点阵的横坐标的上界也从 变成了 。这一步骤可以归纳为算式

这种递归的算法行得通,主要有两个原因:

  • 第一,直线的斜率不断地先取小数部分再取倒数,这等价于计算直线斜率 连分数展开。因为有理分数的连分数展开的长度是 的,所以这一过程一定在 步后终止;
  • 第二,因为每次翻转坐标轴的时候,直线斜率都是小于一的,因此,直觉上应该有 ,也就是说,经过这样一轮迭代后,横坐标的范围一直是在缩小的。前文的复杂度计算中通过严格的分析说明,每两轮迭代后, 至多为原来的一半,故而这一过程一定在 步后终止。

这也是斜率为有理数时的类欧几里得算法的复杂度是 的原因。

利用类似的几何直观,可以将类欧几里得算法推广到斜率为无理数的情形,具体分析请参考后文的例题。

例题

多组询问。给定正整数 ,求

多组询问。给定正整数 ,求

给定正整数 ,求所有满足 的最简分数 的字典序最小的那个。

万能欧几里得算法

上一节讨论的类欧几里得算法推导通常较为繁琐,而且能够解决的和式主要是可以转化为直线下(带权)整点计数问题的和式。本节讨论一种更为一般的方法,它进一步抽象了上述过程,从而可以解决更多的问题。因此,这一方法也称为万能欧几里得算法。它同样利用了分数的递归结构求解问题,但是与类欧几里得算法约化问题的思路稍有不同。

仍然考虑最经典的求和问题:

其中, 都是正整数。

问题转化

设参数为 的线段为

对于这条线段,可以按照如下方法定义一个由 组成的字符串 ,也称为 操作序列

  • 字符串恰有 组成;
  • 前方的 的数量恰等于 ,其中,

从几何直观上看,这大致相当于从原点开始,每向右穿过一次竖向的网格线,就写下一个 ,每向上穿过一次横向的网格线,就写下一个 。如下图所示:

当然,这样的定义还需要考量一系列特殊情形:

  • 经过整点(即同时上穿和右穿)时,需要先写 再写
  • 字符串开始时,除了在 区间内上穿网格线的次数外,还需要格外补充
  • 字符串结束时,不能有格外的

如果对于几何直观的描述有任何不明晰的地方,可以参考上述代数方法的定义辅助理解。几何直观的描述,有助于理解下文的算法过程。

万能欧几里得算法的基本思路,就是将操作序列中的 都视作某个 幺半群 内的元素,将整个操作序列视为幺半群内元素的乘积,而问题最终的答案与这个乘积有关。

比如,本题中,可以定义状态向量 ,表示自原点开始,经历了若干次上穿和右穿网格线后,当前的状态。其中,第一个分量是常数,第二个分量是纵坐标 ,第三个分量是要求的和式。起始时,有 。每向上穿过一次网格线,纵坐标就累加一,即相当于将状态向量右乘以矩阵

每向右穿过一次网格线,和式就累加一次纵坐标,即相当于将状态向量右乘以矩阵

因此,最终的状态就是乘积 ,其中, 理解为上述矩阵的乘积。所求的答案,就是最终状态的第三个分量。

除了将幺半群中的元素定义为矩阵以外,还可以将它们定义为一段操作序列对于最终结果的贡献,然后将操作的乘积定义为两段操作序列的贡献的合并。

本题中,可以定义每段操作序列的贡献为 。为了严谨地解释这些记号,可以将这些分量都看作是操作序列的函数,也就是说,对于操作序列 ,它的贡献可以写作 。其中, 分别对应着操作序列 的数量,也就是该线段右穿和上穿网格线的次数。最后一项中的求和符号,一般地,有如下定义:对于操作序列上的函数 ,可以定义 ,或记作 ,为下面的表达式:

其中, 中的第 个字符, 中前 个字符组成的前缀。也就是说,这个求和记号,可以看作是对于操作序列 中所有以 结尾的前缀进行的求和。比如说,有

再比如说, 就是操作序列中,每次右穿网格线时,之前上穿网格线的次数的累加。对于整段操作序列来说, 在所有以 结尾的前缀处的值,就是在 处的所有 的值。因此,对于整段操作序列计算的 ,就是本题最终要求的量。

初始时,有 。进一步,可以将两个元素 的乘积定义为

其中,最后一项贡献合并的结果可以通过如下计算得到:

容易验证,这个乘法运算满足结合律,且幺元为 ,所以这些元素在该乘法运算下构成幺半群。所求的答案,就是乘积的第三个分量。

这两种方法都可以得到正确的结果。但是,因为保留了较多的冗余信息,矩阵运算的常数较大,所以第二种方法在处理实际问题时更为实用。

算法过程

与类欧几里得算法整体约化不同,万能欧几里得算法约化问题的手段是将这些操作分批次地合并。记字符串对应的操作的乘积为

约化过程具体如下:

  • 时,操作序列的开始有 ,直接计算它们的乘积,并将这些 从操作序列中移除。此时,第 前方的 的数量等于

    因此,这相当于将线段参数由 变为 。所以,对于这种情形,有

  • 时,操作序列中每个 的前方都至少有 ,可以将它们合并到 上。也就是说,可以用 替代 。合并后的字符串中,第 前方的 的数量等于

    因此,这相当于将线段参数由 变为 。所以,对于这种情形,有

  • 对于剩下的情形,需要翻转横纵坐标,这基本是在交换 ,只是翻转后线段的参数需要仔细计算。结合操作序列的定义可知,需要确定系数 使得变换前的操作序列中,第 前方的 的数量恰为 且总共有 。根据定义可知,

    而第 前方的 的数量,就等于最大的 使得

    因此,。这一推导过程与前文类欧几里得算法的推导类似,同样利用了上下取整函数的性质。

    有两处细节需要处理:

    • 截距项 为负数。注意到,如果将线段向左平移一个单位,就可以让截距项恢复为非负数,因为总有 。因此,可以将交换前的第一段 提取出来,只交换剩余操作序列中的
    • 交换 后,结尾存在多余的 。因此,交换 之前,需要首先将最后一段 提取出来,只交换剩余操作序列中的 。这一段 的数量为

    去掉头尾若干个字符后,第 前方的 的数量变为:

    回忆起,交换前的序列中 的数量为 。而上述左移一个单位的操作,要求保证交换前至少存在一个 ,也就是 。利用这一条件,可以分为两种情形:

    • 对于 的情形,处理了上面的两点后,交换完 的操作序列就是对应着参数为 的线段的合法序列。所以,有

    • 特别地,对于 的情形,交换前的操作序列中只包含 ,无需交换,可以直接返回:

      与类欧几里得算法不同,万能欧几里得算法的这一特殊情形需要单独处理,否则会因涉及负幂次而无法正确计算。

利用这些讨论,就可以将问题递归地解决。

假设幺半群内元素单次相乘的时间复杂度是 的。那么,如果计算过程中这些元素的幂次计算都使用 快速幂 进行,最终的算法复杂度就是 1

万能欧几里得算法的流程可以写成统一的模板,处理具体问题时只需要更改模板类型 T 的实现即可。

参考实现

// Class T implements the monoid.
// Assume that it provides a multiplication operator
//     and a default constructor returning the unity in the monoid.
 
// Binary exponentiation.
template <typename T>
T pow(T a, int b) {
  T res;
  for (; b; b >>= 1) {
    if (b & 1) res = res * a;
    a = a * a;
  }
  return res;
}
 
// Universal Euclidean algorithm.
template <typename T>
T euclid(int a, int b, int c, int n, T U, T R) {
  if (b >= c) return pow(U, b / c) * euclid(a, b % c, c, n, U, R);
  if (a >= c) return euclid(a % c, b, c, n, U, pow(U, a / c) * R);
  auto m = ((long long)a * n + b) / c;
  if (!m) return pow(R, n);
  return pow(R, (c - b - 1) / a) * U *
         euclid(c, (c - b - 1) % a, a, m - 1, R, U) *
         pow(R, n - (c * m - b - 1) / a);
}

利用万能欧几里得算法可以得到模板题的实现如下:

例题

多组询问。给定正整数 ,求

多组询问。给定正整数 ,求

习题

模板题:

应用题:

参考资料与注释

Footnotes

  1. 通常考虑的问题中, 都与 同阶, 这一项可以忽略。而且,如果在调用万能欧几里得算法前,首先进行了一轮类欧几里得算法的取模,消除 的影响,这一项的快速幂的复杂度是可以规避的。这其实是因为通常的问题中, 的初始形式较为特殊,它的幂次有着更简单的形式,不需要通过快速幂计算。比如正文的例子中, 的结果,就是将 中不在对角线上的那个 替换成 ,而无需用快速幂计算。