题目
给定一个包含 n + 1 个整数的数组 nums ,其数字都在 [1, n] 范围内(包括 1 和 n),可知至少存在一个重复的整数。
假设 nums 只有 一个重复的整数 ,返回 这个重复的数 。
你设计的解决方案必须 不修改 数组 nums 且只用常量级 O(1) 的额外空间。
示例 1:
输入:nums = [1,3,4,2,2]
输出:2
示例 2:
输入:nums = [3,1,3,4,2]
输出:3
示例 3 :
输入:nums = [3,3,3,3,3]
输出:3
先搬上我的解法——人类的记忆机制。
人类的记忆机制
最自然的想法就是一个一个地去看,从左往右,第一个开始,然后看看以前出现过的元素我见过没。圈起来的应该不算额外的空间复杂度开支吧?(我只是把我见过的元素给圈起来了而已,反而是Python的索引因为其机制会导致额外的时空复杂度开支)
class Solution:
def findDuplicate(self, nums: List[int]) -> int:
mem = set()
for i,j in enumerate(nums):
if j in mem:
return j
mem.add(j)
我看到一个题解很有意思,顺便学了一下Algorithms书里面不会提到的一些算法。首先我用非常数学的办法分析了一下这个题目的特征。
等价类的划分
这题有一些特征:
- nums长n+1,这意味着其索引的范围是[0,n]
- nums中元素的范围则是[1,n],被[0,n]完全包含
这就意味着可以把nums中的元素拿出来抽“索引”。现在定义一个函数fff:f(x)=nums[x],x∈numsf(x) = \text{nums}[x], x\in \text{nums}f(x)=nums[x],x∈nums这就定义了一个nums上的自我变换。
明显这个从nums到nums的变换不是一个双射,至少第一个元素没有任何原像可以映射到它,因此f(x)f(x)f(x)的值域一定是其定义域的真子集。nums中只有一个数字是重复的,很明显就是因为这个重复的数字导致了fff不是双射。我们回顾一下没有重复数字的情况。这种情况下,可以定义nums上的全排列,根据抽象代数的基本知识可知,全排列中的每一个数字都属于某个循环,这些循环是全排列对应集合的一个分割。现在我们缩小目光,只考虑fff在其值域上的限制。fff在其值域上的限制如果是个满射,一定是个单射,因此是双射,形成值域内的全排列。所以,fff在其值域上的限制内一定会有循环(但是未必分割这个值域),如果路径到了循环,路径可以建模成一个只有一个环的链表。
我们知道从nums[0]出发,肯定是在上述的循环外面出发。为了更深入地理解这道题,我们给出以下论断:
论断一:从任一位置出发,一定会进入某个循环。
证:设nums = [a0,a1,…,ana_0, a_1,\dots, a_na0,a1,…,an],其中有且仅有k (2≤k≤n+12\le k\le n+12≤k≤n+1) 个位置的数字相等,记其索引为i1,…,iki_1,\dots,i_ki1,…,ik。
对kkk使用数学归纳法。
- 当k=n+1k=n+1k=n+1时对任意nnn明显成立
- 假设k=n+2−bk=n+2-bk=n+2−b时对某个b≥1b\ge1b≥1和任意的nnn成立,下面证明k=n+1−bk=n+1-bk=n+1−b的情况论断也成立:从左往右数,我们找到第一个出现的非相等数字(设该数字的索引为jjj),如果我们把这个非相等的数字变成相等的数字,那么就转化成了k=n+2−bk=n+2-bk=n+2−b的情形,所以这两种情况的区别就是这种变化带来的;我们仔细考察一下这种变化——在相等数字的情形中,从任一位置出发一定能够经过这个数字,而且一定会到这个数字指向的某个循环,且这个数字恰好是循环的入口;现在恢复成原样——凡是不经过num[j][j][j]的路径其实都不会受到影响,在这部分论断显然成立,所以我们考虑经过num[j][j][j]的路径——经过num[j][j][j],这些路径下一步都是到[1,n]范围内的某个位置num[j][j][j],这个数字在nums中只出现一次,它左边都是相等的那个数字,如果接下来的路径不经过num[j][j][j],显然成立;如果又经过num[j][j][j],就形成了一个循环,这个循环包含了num[j][j][j]。
- 综上,论断一总是成立
论断二:nums中一定有一个循环包含这个重复的数字。
证:使用数学归纳法,任取并固定n,现在从k=2出发。
- 考虑0到n的全排列,其中0不在第一个位置上,根据抽象代数的基本知识,此时nums根据fff的规则被不同的循环分割;接下来把0所在位置的值改成[1,n]中的某个数字,使之成为k=2的情况,此时原先不过0且0不去的不变,设nums变换前是这类结构(0,x1,x2,…)(y1,…)(z1,…)…
①若变换后是(y1,x1,x2,…)(y1,…)(z1,…)…的结构,可以看出变换后,重复的数字一定在某个循环中
②如果num变换后是(xi,x1,x2,…)(y1,…)(z1,…)…,那么重复的那个数字xi在循环(xi,xi+1_{i+1}i+1,…)中
于是重复的那个数字一定位于某个循环,论断二此时成立 - 假设论断二在k(k≥2)的情况成立,下面证明论断二在k+1的情况成立;先回到论断二在k的情况,此时重复的数字一定在某个循环里面,现在把任一不重复的数字变成这个重复的数字——如果这个不重复的数字在循环外,那个循环并没有被破坏,论断二成立;如果这个不重复的数字在循环内,循环只是变小了,这个重复的数字仍然在循环内,论断二在k+1的情况成立。
- 综上,论断二总是成立
论断三:从nums[0]出发,一定会经过这个重复的数字,而且这个重复数字恰好就是循环的入口。
证:继续使用数学归纳法。类似论断一的证明过程。num[0]是唯一一个不在值域内的数字,由论断一,只要证明入口就是重复的数字即可。如果路径不经过num[j][j][j]显然成立;如果经过nums[j][j][j],j=0j=0j=0显然成立,不妨j>0j>0j>0,那么就是这个重复的数字出发,只要证明路径还会回到这个重复的数字论断三便成立;已知路径中一定包含nums[0]、jjj(可能nums[0]=jjj)、nums[j][j][j],如果num[0]<j[0]<j[0]<j,那么从num[0][0][0]出发即达不动点,此时论断三成立;nums[0]≥j[0]\ge j[0]≥j时——反设存在n,b,使得从nums[j][j][j]出发之后一直到不了nums[0](记之为D),也就是进入了一个没有D的循环,此时必有num[j]≥j>0[j]\ge j>0[j]≥j>0,num[0]≠[0]\ne[0]=num[j][j][j]——如果等式成立,那么进入了只有jjj的循环,而且j≠j\nej= D,而jjj在nums中存在且唯一,从D出发的路径不可能会包含jjj,这与已知路径包含jjj矛盾,所以等式不成立,即num[j]>j>0[j]>j>0[j]>j>0,所以从num[j][j][j]出发走任意步数之后如果到达xxx(不论是数值还是位置)始终有x≥jx\ge jx≥j,也就是在jjj的右边存在一个循环,已知一共有n+1−bn+1-bn+1−b个重复的数字,其中左边连续出现了jjj个,那位置大于jjj的地方还有n+1−b−j=n+1−j−b=(n−j)−(b−1)n+1-b-j=n+1-j-b=(n-j)-(b-1)n+1−b−j=n+1−j−b=(n−j)−(b−1)个重复数字,b−1b-1b−1个不重复的数字,这个循环的大小一定小于bbb,b=1b=1b=1时循环不存在,与推断一矛盾;现在考虑n−1≥b>1n-1\ge b>1n−1≥b>1的情况;此时,nums上所有的数字可以划分为两大阵营:
①存在S≥0,走S步可抵达值D(包括所有值<j的以及所有位置<j的以及可以到它们的,包括所有值为D的数字)
②不可到D的数字(必为唯一数)(含num[D]、j、nums[j]≠D)(j≤位置)(j≤取值范围≤n),存在S’≥0,走S’步可以抵达无D循环
那么从任一位置出发最终进入无D循环(根据反设一定存在),也就是D不构成循环,所有的循环不包含D,这与论断二矛盾,证毕。
那么,问题就是,先从nums[0]出发,然后通过这个重复的数字D再进入这个循环。那么这时场景就可以建模成为带有一个环的链表。
龟兔赛跑算法及其在本题上的证明
这是一个在链表上的算法,又称Floyd判圈算法。通过等价类的划分,我们已经知道关于圈一些基本事实,以及为什么可以建模成为带一个环的链表(设这个环的长度为n),那么接下来,就介绍一下这个龟兔赛跑算法。
该算法用了一快一慢两个指针,快指针的速度是2,慢指针的速度则是1。第一步,将两个指针都置于链表的起点。然后,Run起来!
我们标注一下慢指针踏入环的瞬间,不妨标记此时慢指针已经走了l步(但是我们并不知道这个l是多少)。现在按照顺序给环标号,比如慢指针进来的地方是0。显然,这个时刻快指针一定是在环内的。设这个时刻,此时快指针比慢指针在环中快m步(m<n),那么这个时候快指针在环中的位置就是m。我们稍稍看看这个m是多少。如果不考虑环的效应,快指针原来应该领先慢指针l步,现在考虑环的效应,那么有l%n=m。
因为快指针的速度是2,慢指针的速度是1,二者同向,那么速度差为1。慢指针每走一步,快指针与慢指针的距离就会增加1。我们是在环中计算的,这个增加有上限,当增加到n的时候,就是两个指针碰面的时刻。所以,不论原来的m是多少,两个指针一定会碰面。所以,两个指针在环中标号为n-m的地方碰面(快指针在m+2(n-m)%n=n-m的位置)。
下面这个算法要找入口。现在这俩指针的位置一样,把快指针的速度降为1,然后放到链表的起点。原来的慢指针不动。现在继续让它们跑起来。
显然,接下来它们都需要走l步。Floyd判圈算法神奇的地方在于,这时候这两个指针会相遇。让我们验证一下这个结论。圈里的指针位于n-m+l处,而圈外指针走了l步后到达0处。神奇的是(n-m+l)%n=(n+l-m)%n=(n+kn+m-m)%n=0。这就意味着两个指针是相遇的!指针的位置,就是环入口(在环里)的位置。这个值,恰好就是重复的那个数字。
class Solution:
def findDuplicate(self, nums: List[int]) -> int:
slow = nums[0]
fast = nums[0]
fast = nums[nums[fast]]
slow = nums[slow]
while fast!=slow:
fast = nums[nums[fast]]
slow = nums[slow]
fast = nums[0]
while fast!=slow:
fast = nums[fast]
slow = nums[slow]
return fast
1940

被折叠的 条评论
为什么被折叠?



