从一道 leetcode 题到离散对数求解算法
我们先来看题面,点进去之前可以先思考一下:
Given an array of integers
nums
containing integers where each integer is in the range inclusive, .There is only one repeated number in
nums
, return this repeated number.You must solve the problem without modifying the array and using only constant extra space.
算法题解答
如果不看第三行,那么任意的排序都可以做这道题。即使不限制 的空间的话,因为元素在 之间,桶排序也是可以 AC 的。但如果既不能修改数组 nums
,也不能新开数组,那就只能考虑双指针解法了。
一个比较容易想到的做法是 binary search,既然值域在 ,我们可以令 ,然后取 num
中
这里使用反证法,记为当“condition”为真时值为,否则为。
假设 之间存在重复元素且 。因为重复元素只有一个,因此这些元素只会存在个或者个。因此 ,两式相加可得 ,矛盾!
因此假设不成立,原结论正确, 之间没有重复元素。
有了上述的结论,我们可以断定当时,应当继续在 中寻找。而当时,根据抽屉原理显然 之间有重复元素,应当在 中继续搜索。
以上过程递归进行,总时间复杂度为 。
1 | def findDuplicate(self, nums: List[int]) -> int: |
Floyd’s cycle-finding algorithm
但题面中还有一个 follow up:
Can you solve the problem in linear runtime complexity?
这便是 binary search 不能触及到的领域了。这里我选择直接放弃,然后知道了一个名为 Floyd’s cycle-finding 的算法,它也有一个别名叫 tortoise and the hare algorithm:算法过程如下:
首先根据 nums
数组建图,数组的 index 是一个端点(这里 index 从 1 开始),而这个 index 对应的值则是这个端点指向的端点。例如以下数组:
1 | [5,3,1,6,2,5] |
对应了以下这个有向图:

首先 hare 和 tortoise 随机选一个点同时出发(为了演示方便,这里选择 4 号点),此时 hare 跑得快些,一次跑两步,tortoise 速度慢些,一次爬一步。于是便会有这样的情景(左 hare,右 tortoise):
1 | 4 4 |
这个时候 hare 与 tortoise 正式相遇了,这说明算法成功找到了一个环(不然 hare 只会永远在 tortoise 前面)。
然后算法的下一步为了找到环的“起始点”(也就是点 5。如果他们一开始就在环里那自然就是起点了)。将 tortoise 放回了起点 4,hare 保持原先位置 3 不动,随后它们每次走一步:
1 | 3 4 |
可以发现它们在环的“起始点” 5 处相遇了。可以证明,无论图的形状如何,它们始终都会在环的“起始点”处相遇:
设环的周长为,尾巴(也就是图中 5->6->4 的长度为 )当他们第一次相遇时,hare 走了 步,tortoise 走了 步,显然我们有 。而当 tortoise 被移回起点时,由于此时它们速度一样,hare 和 tortoise 的距离就恒为 。当 tortoise 走了 步到了“起始点”时,hare 走了 步,由于 ,因此 hare 和 tortoise 必相遇,并且都在“起始点”。
算法的最后一步就是 hare 不动,tortoise 绕一圈测出周长,这个算法就结束了。
回到本题,由于重复的数字只有一个,所以肯定是“起始点”本身,因此我们只需要做算法的前两步即可 AC。时间复杂度 。
1 | def findDuplicate(self, nums: List[int]) -> int: |
Brent’s algorithm
Brent 在 Floyd 的探圈算法基础上进行了改进,使用了 2 的幂次步长power
来检测循环。并引入了lambda
(每次迭代自增,可以理解为周长)和 mu
(自增,最后得出尾巴 的值)。该算法的优势有两点:
- 直接在第一步找到了环的周长。
- 每一次迭代只需要计算一次,而不是 floyd 的三次。
举一个例子,如果图长这样(假设它们都从 1 开始):
1 | 1 -> 2 -> 3 -> 4 -> 5 -> 3 |
然后我们有:
- Frame 1: Tortoise at 1, Hare at 2,
power = 1
,lambda = 1
. - Frame 2: Tortoise at 2, Hare at 3,
power = 2
,lambda = 1
. - Frame 3: Tortoise at 2, Hare at 4,
power = 2
,lambda = 2
. - Frame 4: Tortoise at 4, Hare at 5,
power = 4
,lambda = 1
. - Frame 5: Tortoise at 4, Hare at 3,
power = 4
,lambda = 2
. - Frame 6: Tortoise at 4, Hare at 4,
power = 4
,lambda = 3
(cycle length detected). - Frame 7: Tortoise at 1, Hare at 1 (reset for finding position of length
lambda
). - Frame 8: Tortoise at 1, Hare at 2.
- Frame 9: Tortoise at 1, Hare at 3.
- Frame 10: Tortoise at 1, Hare at 4. (repeated
lambda
times) - Frame 11: Tortoise at 2, Hare at 5,
mu = 1
. - Frame 12: Tortoise at 3, Hare at 3,
mu = 2
. (cycle start found)
python 代码如下:
1 | def brent(f, x0) -> (int, int): |
pollard_rho
由于这个建图很容易让人想起这个希腊字母,刚好 Pollard 本人也是这么想的。于是他找到了一个代数结构:有限环,然后加了一个运算 用来生成一个伪随机序列 。如果 ,那么我们假设这个随机序列在中均匀分布,根据生日悖论,我们期望在 的时间内找到一对相同的值。
但我们事先不知道的值是多少,因此需要通过 是否不为1来判断。因为如果 ,显然 就是 的倍数,从而 就不为1。
那么我们如何来遍历这里的 (hare and tortoise)呢,虽然序列是 的,但考虑 与 , 因为是一个域,对一个元素反复做运算,一定会形成一个周期(或者叫“轨道”)。这样的话,Floyd’s cycle-finding 就派上用场了,在经过有限次的迭代后,可能在 中 先相遇返回结果。同理,或者在 中先相遇返回结果。或者也有可能会在 中相遇返回结果,然而这个结果是平凡的,需要重新执行该算法。
这个算法便称为 pollard_rho 素性检测,因为 在最坏的情况是 的,因此这个时候 pollard_rho 的平均时间复杂度为 。
1 | from math import * |
pollard_rho using Brent
为了水一篇论文,Brent 也使用了自己的算法替换了 pollard_rho 中的 Floyd 的探圈方法,并用实验证明了他的探圈方法效率比 Floyd 提高了 36%,从而导致总共的运行效率提高了 24%。
这里就不贴代码了,一种可能的实现参见:
https://comeoncodeon.wordpress.com/2010/09/18/pollard-rho-brent-integer-factorization/
pollard_rho for DLP
就像将质因数分解问题联想到 DLP(离散对数问题)那样自然一样,除了在有限环中做 pollard_rho 以分解质因数之外,Pollard 本人还将该方法论尝试在 DLP 中得到应用。
我们假设需要求解离散对数问题 ,这里 为素数。我们可以寻找 来满足 ,从而我们代入上式,有:
根据欧拉定理,我们有:
就可以通过 exgcd 求出对应的 。
而对于如何找到这样的 ,关键在于如何选取 的映射(类比于之前的),使得 在 上足够均匀。Pollard 本人给出了一种映射方式,但他并没有说明这种映射方式充分均匀随机:
这里我们使用这个公式的变种:
然后设初始值 tortoise 为 ,hare 为 ,建立伪随机序列 。这个时候请出我们的 Floyd’s cycle-finding 算法,分别计算 。然后对应地去调整相应的(聪明的你应该知道怎么去调整),最后在有限次迭代中可以找到 的一个碰撞。
以下是 的一个例子,以下每一行是 迭代一次, 迭代两次的结果:
1 | i x a b X A B |
可以看到当 时发生了碰撞,此时代入 可得 ,可得 或 (舍去)。
于是我们就在约 步后()找到了一个碰撞(不会证),因此算法的复杂度为 ,与确定性算法 BSGS (大步小步算法相同)。
DLP 与 pohlig-hellman 算法结合
pohlig-hellman 算法也可以用于高效求解 DLP 问题,主要思路将 进行质因数分解,然后使用某种算法(链接里使用了 BSGS)来进行中离散对数的求解,最后用 CRT(中国剩余定理)合并各个质数的结果。具体的介绍阮行止写得很好,可以去看一看:
若知道 DLP 中 的质因数分解方法,则可以使用该算法与先前的 pollard_rho 算法结合,将 DLP 求解时间复杂度从 pollard_rho 的 DLP 版本的时间复杂度从降低至,其中 是 的最大质因数。
因此综上所述 DLP 这个问题取决于 本身是否光滑,对于通用 (例如大素数),时间复杂度是Pollard’s Rho 或 BSGS 的 ,如果 已分解为,则时间复杂度为 。
参考资料
An Introduction to Mathematical Cryptography
https://www.youtube.com/watch?v=pKO9UjSeLew
https://www.ruanx.net/pohlig-hellman/
https://en.wikipedia.org/wiki/Pollard's_rho_algorithm_for_logarithms
https://en.wikipedia.org/wiki/Pollard's_rho_algorithm
https://en.wikipedia.org/wiki/Pohlig–Hellman_algorithm
https://en.wikipedia.org/wiki/Cycle_detection#Brent.27s_algorithm
https://comeoncodeon.wordpress.com/2010/09/18/pollard-rho-brent-integer-factorization/