题目描述:
Given an array nums containing n + 1 integers where each integer is between 1 and n (inclusive), prove that at least one duplicate element must exist. Assume that there is only one duplicate number, find the duplicate one.
Note:
- You must not modify the array (assume the array is read only).
- You must use only constant extra space.
- Your runtime complexity should be less than O(n2).
- There is only one duplicate number in the array, but it could be repeated more than once.
题目大意:
给定一个包含n + 1个整数的数组,其中每一个整数均介于[1, n]之间,证明其中至少有一个重复元素存在。假设只有一个数字出现重复,找出这个重复的数字。
注意:
- 不可以修改数组(假设数组是只读的)
- 只能使用常数空间
- 运行时间复杂度应该小于O(n2)
- 数组中只存在一个重复数,但是可能重复多次
解题思路:
解法I:时间复杂度O(n)
参考:http://keithschwarz.com/interesting/code/find-duplicate/FindDuplicate.python.html
以及博文:http://bookshadow.com/weblog/2015/07/10/leetcode-linked-list-cycle-ii/
这道题(据说)花费了计算机科学界的传奇人物Don Knuth 24小时才解出来。并且我只见过一个人(Keith Amling)用更短时间解出此题。 问题的第一部分 - 证明至少存在一个重复元素 - 是鸽笼原理的直接应用。如果元素的范围是[1, n],那么只存在n种不同的值。如果有n+1个元素,其中一个必然重复。 问题的第二部分 - 在给定约束条件下寻找重复元素 - 可就难多了。 要解决这个问题,我们需要敏锐的洞察力,使问题通过一列的转化,变为一个完全不同的问题。 解决本题需要的主要技巧就是要注意到:由于数组的n + 1个元素范围从1到n,我们可以将数组考虑成一个从集合{1, 2, ..., n}到其本身的函数f。这个函数的定义为f(i) = A[i]。基于这个设定,重复元素对应于一对下标i != j满足 f(i) = f(j)。我们的任务就变成了寻找一对(i, j)。一旦我们找到这个值对,只需通过f(i) = A[i]即可获得重复元素。 但是我们怎样寻找这个重复值呢?这变成了计算机科学界一个广为人知的“环检测”问题。问题的一般形式如下:给定一个函数f,序列x_i的定义为 x_0 = k (for some k) x_1 = f(x_0) x_2 = f(f(x_0)) ... x_{n+1} = f(x_n) 假设函数f从定义域映射到它本身,此时会有3种情况。首先,如果定义域是无穷的,则序列是无限长并且没有循环的。例如,函数 f(n) = n + 1,在整数范围内满足这个性质 - 没有数字是重复的。 第二, 序列可能是一个闭合循环,这意味着存在一个i使得x_0 = x_i。在这个例子中,序列在一组值内无限循环。第三,序列有可能是的“ρ型的”,此时序列看起来像下面这样: x_0 -> x_1 -> ... x_k -> x_{k+1} ... -> x_{k+j} ^ | | | +-----------------------+ 也就是说,序列从一列链条型的元素开始进入一个环,然后无限循环。我们将环的起点称为环的“入口”。 对于从数组中寻找重复元素这个问题,考虑序列从位置n开始重复调用函数f。亦即从数组的最后一个元素开始,然后移动到其元素值对应的下标处,并且重复此过程。可以得到:此序列是ρ型的。要明白这一点,需要注意到其中一定有环,因为数组是有限的并且当访问n个元素时,一定会对某个元素访问两次。无论从数组的哪一个位置开始,这都是成立的。 另外,注意由于数组元素范围1到n,因此不存在值为0的元素。进而,从数组的第一个元素开始调用一次函数f之后,再也不会回到这里。这意味着第一个元素不会是环的一部分,但如果我们继续重复调用函数f,最终总会访问某个节点两次。从0节点开始的链条与环形相接,使得其形状一定是ρ型。 此外,考虑一下环的入口。由于节点位于环的入口,一定存在两个输入,其对应的函数f的输出值都等于入口元素下标。要使其成立,一定存在两个下标i != j,满足f(i) = f(j),亦即A[i] = A[j]。因而环的入口一定是重复值。 这是由Robert Floyd提出的一个著名算法,给定一个ρ型序列,在线性时间,只使用常数空间寻找环的起点。这个算法经常被称为“龟兔”算法,至于原因下面就明白了。 算法背后的思想是定义两个变量。首先,令c为进入环的链的长度,然后令l为环的长度。接下来,令l'为大于c的l的倍数的最小值。可以得出结论:对于上文定义的任意ρ型序列的l',都有 x_{l'} = x_{2l'} 证明实际上非常直观并且具有自明性 - 这是计算机科学中我最喜欢的证明之一。思路就是由于l'至少为c,它一定包含在环内。同时,由于l'是环长度的倍数,我们可以将其写作ml,其中m为常数。如果我们从位置x_{l'}开始(其在环内),然后再走l'步到达x_{2l'},则我们恰好绕环m次,正好回到起点。 Floyd算法的一个关键点就是即使我们不明确知道c的值,依然可以在O(l')时间内找到值l'。思路如下。我们追踪两个值"slow"和"fast",均从x_0开始。然后迭代计算 slow = f(slow) fast = f(f(fast)) 我们重复此步骤直到slow与fast彼此相等。此时,我们可知存在j满足slow = x_j,并且fast = x_{2j}。 由于x_j = x_{2j},可知j一定至少为c,因为此时已经在环中。另外,可知j一定是l的倍数,因为x_j = x_{2j}意味着在环内再走j步会得到同样的结果。最后,j一定是大于c的l的最小倍数,因为如果存在一个更小的大于c的l的倍数,我们一定会在到达j之前到达那里。所以,我们一定有j = l',意味着我们可以在不知道环的长度或者形状的情况下找到l'。 要完成整个过程,我们需要明白如何使用l'来找到环的入口(记为x_c)。要做到这一步,我们再用最后一个变量,记为"finder",从x_0出发。然后迭代重复执行过程: finder = f(finder) slow = f(slow) 直到finder = slow为止。我们可知:(1) 两者一定会相遇 (2) 它们会在环的入口相遇。 要理解这两点,我们注意由于slow位于x_{l'},如果我们向前走c步,那么slow会到达位置x_{l' + c}。由于l'是环长度的倍数,相当于向前走了c步,然后绕环几圈回到原位。换言之,x_{l' + c} = x_c。另外,考虑finder变量在行进c步之后的位置。 它由x_0出发,因此c步之后会到达x_c。这证明了(1)和(2),由此我们已经证明两者最终会相遇,并且相遇点就是环的入口。 算法的美妙之处在于它只用O(1)的额外存储空间来记录两个不同的指针 - slow指针和fast指针(第一部分),以及finder指针(第二部分)。但是在此之上,运行时间是O(n)的。要明白这一点,注意slow指针追上fast指针的时间是O(l')。由于l'是大于c的l的最小倍数,有两种情况需要考虑。首先,如果l > c,那么就是l。 否则,如果l < c,那么我们可知一定存在l的倍数介于c与2c之间。要证明这一点,注意在范围c到2c内,有c个不同的值,由于l < c,其中一定有值对l取模运算等于0。最后,寻找环起点的时间为O(c)。这给出了总的运行时间至多为O(c + max{l, 2c})。所有这些值至多为n,因此算法的运行时间复杂度为O(n)。
Python代码:
class Solution(object):
def findDuplicate(self, nums):
# The "tortoise and hare" step. We start at the end of the array and try
# to find an intersection point in the cycle.
slow = 0
fast = 0
# Keep advancing 'slow' by one step and 'fast' by two steps until they
# meet inside the loop.
while True:
slow = nums[slow]
fast = nums[nums[fast]]
if slow == fast:
break
# Start up another pointer from the end of the array and march it forward
# until it hits the pointer inside the array.
finder = 0
while True:
slow = nums[slow]
finder = nums[finder]
# If the two hit, the intersection index is the duplicate element.
if slow == finder:
return slow
解法II:时间复杂度O(n * log n)
二分查找(Binary Search)+ 鸽笼原理(Pigeonhole Principle)
参考维基百科关于鸽笼原理的词条链接:https://en.wikipedia.org/wiki/Pigeonhole_principle
“不允许修改数组” 与 “常数空间复杂度”这两个限制条件意味着:禁止排序,并且不能使用Map等数据结构
小于O(n2)的运行时间复杂度可以联想到使用二分将其中的一个n化简为log n
参考LeetCode Discuss:https://leetcode.com/discuss/60830/python-solution-explanation-without-changing-input-array
二分枚举答案范围,使用鸽笼原理进行检验
根据鸽笼原理,给定n + 1
个范围[1, n]
的整数,其中一定存在数字出现至少两次。
假设枚举的数字为 n / 2
:
遍历数组,若数组中不大于n / 2
的数字个数超过n / 2
,则可以确定[1, n /2]
范围内一定有解,
否则可以确定解落在(n / 2, n]
范围内。
Python代码:
class Solution(object):
def findDuplicate(self, nums):
"""
:type nums: List[int]
:rtype: int
"""
low, high = 1, len(nums) - 1
while low <= high:
mid = (low + high) >> 1
cnt = sum(x <= mid for x in nums)
if cnt > mid:
high = mid - 1
else:
low = mid + 1
return low
本文链接:http://bookshadow.com/weblog/2015/09/28/leetcode-find-duplicate-number/
请尊重作者的劳动成果,转载请注明出处!书影博客保留对文章的所有权利。
wei 发布于 2015年10月4日 07:03 #
这题我花了半天左右的时间想出解法,这么说我比Knuth解这道题的速度还快。。
在线疯狂 发布于 2015年10月4日 11:07 #
忍不住赞一下!:)
Dean 发布于 2015年10月8日 13:59 #
I like how you can map every point to another point in the same array and form a cycle!
志佳 发布于 2016年2月15日 12:44 #
二分搜索的方法,对于数组 [1,2,2,2,5,6,7], 找出来的结果是不对的?
j_ 发布于 2016年2月16日 10:24 #
有个问题,例如如果: f(xi) == f(f(xi)) 这个怎么避免?题目没有直接说明已定是P型数列,这个条件是怎么得出的?
在线疯狂 发布于 2016年2月16日 11:21 #
我测试的结果是2
在线疯狂 发布于 2016年2月16日 21:11 #
参考下这一段文字“由于数组元素范围1到n,因此不存在值为0的元素。从数组的第一个元素开始调用一次函数f之后,再也不会回到这里。这意味着第一个元素不会是环的一部分,但如果我们继续重复调用函数f,最终总会访问某个节点两次。从0节点开始的链条与环形相接,使得其形状一定是ρ型。”
j_ 发布于 2016年2月18日 00:56 #
thx, 这个是中文的分析,我可以理解,但是英文题目里面没有说从0开始。
j_ 发布于 2016年2月18日 02:44 #
题目没有说从值0开始吧?
在线疯狂 发布于 2016年2月18日 20:43 #
嗯,题目描述的数据范围是从1到n
洪北七 发布于 2016年4月21日 17:23 #
如果存放的元素不是1-n这样可以作为下标的值,而是比如任意数值,如何用类似的方法串联成一个P型链表呢?
在线疯狂 发布于 2016年4月21日 17:40 #
题设这样修改的话,暂时没有想到常数空间复杂度,线性时间复杂度的解法。。。
Zenn 发布于 2016年5月6日 12:17 #
你这个数组有七个元素,按照题意,里面的元素取值范围应该是1~6,你这个输入本来就不对了啊。。
在线疯狂 发布于 2016年5月10日 22:37 #
是的,我都没有注意到!:D
zuozhubinge 发布于 2016年8月26日 15:31 #
n+1个数每个数的范围是1-n,你这个例子最大的数应该是6不是7,违反题意了
亦生 发布于 2016年9月2日 07:13 #
为什么我感觉第二种做法,时间复杂度也是 O(n) ?
第一次要扫描整个数组, n
第二次扫描一半的数组,n/2
第三次扫描四分之一的数组, n/4
n + n/2 + n/4 + ... ---> O(n)
是这样吗?
在线疯狂 发布于 2016年9月2日 09:48 #
二分查找的复杂度是O(log n),while循环内每一次线性查找的开销是O(n),两者相乘,所以是O(n * log n)
汤州林 发布于 2016年9月29日 21:30 #
楼主可以测试一下[1,2,2,3,5,6,7,8]或者是[1,2,3,4,6,7,7,8]必然有一个是错的。。。。
在线疯狂 发布于 2016年10月1日 22:08 #
题目描述中:nums数组包含n+1个介于1-n之间的整数,所以你提供的测试用例不符合题目要求。
我是游客 发布于 2016年10月30日 10:09 #
感谢!