☰
含重复元素的旋转排序数组:二分查找返回最小索引全解析
2026/10/14 7:17:35 网站建设 项目流程

这几天我帮几位准备算法面试的朋友做复盘,发现“二分法”这个看似基础的话题,被问到最高频的其实是这道变体:搜索旋转排序数组,数组里允许包含重复元素,并且要求返回目标值的最小索引。说它是“压垮”半数候选人的题并不夸张——无重复版本的大体思路很多人都能背下来,但一旦同时加上“含重复元素”和“返回最小索引”两个约束,现场就容易卡住,甚至写错边界条件。

这篇文章我会沿着面试现场的思路,从旋转数组的本质开始,一步步推导出可用的二分代码,再讨论复杂度、边界用例、面试沟通技巧,以及我实际调试中踩过的坑。不管你是刚开始刷题,还是准备系统复习二分法,这篇文章的目标都是让你能把这道题讲明白、写对、扛住追问。

1. 先看清楚题:旋转数组的“两段有序”

1.1 什么是旋转排序数组:形态与来源

旋转排序数组,本质上是一个原本非递减排列的数组,从某个位置被“切开”后左右两半交换位置。举个例子,原数组是:

[0, 1, 2, 4, 5, 6, 7]

从索引 3 处切开,变成:

[4, 5, 6, 7, 0, 1, 2]

这就是一次旋转。数组旋转之后,整体不再是全局有序的,但它有一个非常特殊的结构:断层点左侧是一段有序区间,断层点右侧是另一段有序区间,而且无重复情况下,左段的所有元素都大于右段的所有元素。

你可以这样理解:假设有一本按字母序排列的字典,被人从中间撕开,再把前后两半对调。每一半内部的字母顺序仍然是正确的,但整本书的字母序在接缝处出现了一个“断层”。二分查找之所以还能在这里使用,就是因为我们依然可以利用“每一段内部有序”这个规律。

如果没有发生旋转,数组就是完全有序的,这是最简单的退化情况。如果数组里还允许重复值,比如原数组是[0, 1, 1, 2, 2, 3],旋转后可能变成[2, 3, 0, 1, 1, 2]或[1, 2, 2, 3, 0, 1],那么“左段大于右段”这个特征也会变得模糊。这一点先记住,后面会详细展开。

1.2 二分为什么还适用:从“全局有序”降级为“两段有序”

普通二分查找依赖数组的全局单调性:比较nums[mid]和target之后,可以确定目标值落在哪一半。旋转数组没有全局单调性,但具备“局部有序性”——无论mid落在哪个位置,它都一定落在左段或右段之一,而且至少有一侧是整体有序的。

无重复情况下,判断mid落在哪一段,看的不是nums[mid]和target的比值,而是nums[mid]和nums[left]的大小关系:

  • 如果nums[mid] >= nums[left],说明mid位于左侧有序段内,此时[left, mid]这一段严格递增,可以像普通二分一样判断target是否落在这段区间里。
  • 如果nums[mid] < nums[left],说明旋转点在左半段,mid已经滑到了右侧有序段,此时[mid, right]这一段严格递增。

所以思路的核心转变是:不要试图在整个数组上做二分,而是先通过 mid 和 left 的关系,找到哪一侧是有序的,然后在有序的那一侧判断目标的位置。这个思维转变是理解整道题的地基。

2. 重复元素与最小索引:这道题真正难在哪

2.1 重复元素让“旋转点”失效

在无重复数组里,nums[mid] >= nums[left]是一个很可靠的判断依据。但一旦数组里出现重复值,问题立刻变味了。比如:

[1, 0, 1, 1, 1]

初始时left = 0,right = 4,mid = 2。此时nums[left] = 1,nums[mid] = 1,两者相等。那么问题来了:mid到底位于左段还是右段?

仅凭nums[mid] == nums[left]我们无法判断。它可能是左段末尾的一个重复值,也可能是右段开头的一个重复值。这种情况下,nums[mid] >= nums[left]的条件退化了,我们不能继续用无重复版本的二分逻辑。

再比如[1, 1, 1, 0, 1, 1, 1],这个数组确实是旋转排序数组,旋转点在0和后面那个1之间。但nums[left] == nums[right] == 1,左段和右段的边界值相同,判断起来就更加麻烦。

这是整道题最关键的一个分水岭:重复元素让“哪一段有序”的判断变得不确定。

2.2 “返回最小索引”意味着命中不能直接返回

普通旋转数组版本的写法通常是:

if nums[mid] == target: return mid

找到就返回,非常干脆。但题目要求“返回最小索引”,也就是返回target第一次出现的位置。如果数组里存在多个相同值,第一次命中的位置未必是最小索引。

举个例子:

nums = [2, 2, 2, 0, 2, 2] target = 2

真实最小索引是 0。但标准二分可能先命中索引 2 或者索引 4。如果此时直接返回,答案就是错的。

正确的做法是维护一个ans变量:一旦nums[mid] == target,先记录ans = mid,然后继续在左半段搜索,看是否存在更靠左的目标值。直到搜索区间为空,ans里保存的一定是最小索引。如果始终没有命中,ans保持初始值-1。

这也意味着代码里不能使用“命中即返回”的结构,而要把命中分支改成“记录并继续收缩”。

2.3 相等时的三值关系:一张表理清决策

当nums[left]、nums[mid]、target三者之间出现相等关系时,很多人容易乱。先把最核心的判别关系整理出来:

条件可判断的信息处理策略
nums[mid] == target已经找到一个答案记录ans = mid,并让right = mid - 1,继续向左找更小索引
nums[left] == nums[mid]无法判断 mid 在哪一段左边界left += 1,跳过这个已知不等于 target 的歧义位置
nums[left] < nums[mid]左半段[left, mid]非递减有序按nums[left] <= target < nums[mid]判断 target 是否在左半段
nums[left] > nums[mid]右半段[mid, right]非递减有序按nums[mid] < target <= nums[right]判断 target 是否在右半段

很多面试者忽略了最后一种相等情况:nums[mid] == nums[right]同样会造成判断困难。不过在我们的策略里,统一用“处理左边界相等”的方式规避,代码结构会更简单,后面我会详细说明。

3. 从暴力到最终实现:四步推导

3.1 第一步:暴力解法,别急着写二分

拿到这道题,最简单的解法当然是线性扫描:

def search_brutal(nums, target): for i, v in enumerate(nums): if v == target: return i return -1

时间复杂度O(n),空间复杂度O(1)。暴力解的优点是绝对正确,尤其在调试二分代码时,可以作为对照答案验证结果。

面试时千万不要上来就交暴力解,但可以在开场白里提一句:“如果不管性能,线性扫描是很直接的方案。我来尝试利用旋转数组的有序性,把平均复杂度降到O(logn)。”这样既展示了基础能力,又自然引出二分。

3.2 第二步:无重复版本的旋转二分

先处理无重复元素的经典版本。这个版本是理解后续所有优化的前提:

def search_simple(nums, target): left, right = 0, len(nums) - 1 while left <= right: mid = (left + right) // 2 if nums[mid] == target: return mid if nums[mid] >= nums[left]: # 左半段有序 if nums[left] <= target < nums[mid]: right = mid - 1 else: left = mid + 1 else: # 右半段有序 if nums[mid] < target <= nums[right]: left = mid + 1 else: right = mid - 1 return -1

这个版本的判断逻辑是:

  • nums[mid] >= nums[left]说明 mid 落在左段,[left, mid]严格递增。
  • nums[mid] < nums[left]说明 mid 落在右段,[mid, right]严格递增。

这里有一个细节值得留意:判断 target 是否在左半段时,我写的是nums[left] <= target < nums[mid],右侧是开区间。因为nums[mid] == target的情况已经在前面用一个独立分支处理过了,所以不需要包含mid本身。

同样,右半段用nums[mid] < target <= nums[right],左侧开、右侧闭。

3.3 第三步:命中不等于结束,答案要继续向左收缩

把“返回最小索引”这个要求加进去,只需要修改命中分支:

if nums[mid] == target: ans = mid right = mid - 1 continue

原本的return mid变成了“记录答案 + 收缩右边界”。这里用continue而不是直接结束,是因为right更新后,循环条件left <= right需要重新检查,同时mid也要重新计算。

这个改动的本质是:即使当前命中的位置已经是一个有效答案,它也可能不是最小索引。目标值在数组里可能出现了多次,左侧可能还有更早的出现位置。收缩右边界后,搜索区间被限制在已知答案的左侧,继续找更小索引。

提交答案前别忘了初始化ans = -1,循环结束返回ans。

3.4 第四步:处理nums[left] == nums[mid]的歧义分支

现在处理重复元素带来的“无法判断哪段有序”的问题。当nums[left] == nums[mid]且nums[mid] != target时,最安全的操作是:

if nums[left] == nums[mid]: left += 1 continue

为什么可以放心地left += 1,不会跳过潜在答案?

关键在于顺序:nums[mid] == target的分支已经被优先处理了。也就是说,走到这个分支时,nums[mid]一定不等于target。而nums[left] == nums[mid]意味着nums[left]也一定不等于target。因此,把left向右移动一位,跳过的那个元素绝对不是 target,安全。

这个结论很重要,面试中如果要解释这一行的安全性,就直接说这一点。

为什么不选择right -= 1?两种方式在原理上都可行,但既然题目要求的是“最小索引”,我们要尽可能让搜索重心偏左。统一使用left += 1会让后续逻辑更容易保持一致,也便于自己检查。

3.5 完整代码:Python 最终模板

把四步合并,得到完整的实现:

def search(nums, target): n = len(nums) left, right = 0, n - 1 ans = -1 while left <= right: mid = (left + right) // 2 # 1. 命中目标:记录答案,并继续向左寻找更小索引 if nums[mid] == target: ans = mid right = mid - 1 continue # 2. 左边界值等于中值,无法判断 mid 在哪一段,安全跳过 if nums[left] == nums[mid]: left += 1 continue # 3. 左半段有序 if nums[left] < nums[mid]: if nums[left] <= target < nums[mid]: right = mid - 1 else: left = mid + 1 # 4. 右半段有序 else: if nums[mid] < target <= nums[right]: left = mid + 1 else: right = mid - 1 return ans

如果只想记忆一个模板,这个就是你要背的版本。代码量不大,但每个分支都有明确的语义。我把注意力集中在这几个点上:

  • left <= right作为循环条件,保证区间为空时退出。
  • 每个分支都在缩小搜索区间,不会产生死循环。
  • 命中优先,歧义靠左边界跳过,最后才是正常的有序段判断。

4. 复杂度分析与边界用例

4.1 平均 O(logn),最坏 O(n) 的退化路径

正常旋转数组中的二分,每次循环都会把搜索范围缩小一半,所以大多数情况下时间复杂度是O(logn),空间复杂度是O(1)。

但重复元素会把复杂度拖高。最典型的情况是数组里几乎所有元素都相等,比如:

nums = [1, 1, 1, 1, 1, 1] target = 2

每一次循环都会进入nums[left] == nums[mid]分支,执行left += 1,然后继续循环。一次循环只排除一个元素,整体退化成了线性扫描。所以这道题的完整复杂度结论应该是:平均情况下接近 O(logn),最坏情况下是 O(n)。

面试官问到这一点的概率很高。正确的回答方式不是假装最坏也是 O(logn),而是承认这确实是重复元素带来的代价,并解释退化的触发条件:当数组两端和中点反复出现相同值,且这个值不等于 target 时,就无法通过二分快速排除大量元素。

4.2 测试用例表:这十组用例过了就稳了

二分法边界问题极其隐蔽,光靠肉眼检查代码很难发现问题。我常用的做法是准备一组覆盖各种极端情况的测试用例,每次改完都跑一遍。下面这组用例基本够用:

用例target预期结果覆盖点
[]1-1空数组
[4, 5, 6, 7, 0, 1, 2]04常规无重复,目标在右段
[4, 5, 6, 7, 0, 1, 2]51常规无重复,目标在左段
[1, 1, 1, 1, 0, 1, 1]04重复元素包裹断点
[1, 1, 1, 1, 0, 1, 1]10目标有重复且最小索引在最左
[1, 1, 2, 1, 1]22中间值唯一
[1, 1, 2, 1, 1]10大量重复目标
[2, 2, 2, 2, 2]3-1全相等且不存在目标
[1, 0, 1, 1, 1]01经典歧义用例
[5, 1, 2, 3, 4]11旋转点在开头附近

其中[1, 0, 1, 1, 1]是一个非常具有迷惑性的用例。如果你使用无重复版本的nums[mid] >= nums[left]逻辑,它会误判左半段有序,进而把搜索区间引导到错误的方向。只有显式处理nums[left] == nums[mid]分支,才能得到正确结果。

4.3 一步步演算一个边界例子

我挑一个最能说明问题的例子,把代码的执行过程完整走一遍:

nums = [1, 0, 1, 1, 1] target = 0

初始状态:left = 0,right = 4,ans = -1。

第一次循环:

  • mid = 2,nums[2] = 1,不等于 target。
  • nums[left] == nums[mid]:1 == 1,条件成立,执行left += 1。
  • 此时left = 1,right = 4。

第二次循环:

  • mid = (1 + 4) // 2 = 2,nums[2] = 1,不等于 target。
  • nums[left] = 0,nums[mid] = 1,nums[left] < nums[mid],说明[left, mid]段也就是[0, 1]是递增有序的。
  • nums[left] <= target < nums[mid],即0 <= 0 < 1,成立,所以right = mid - 1 = 1。

第三次循环:

  • left = 1,right = 1,mid = 1。
  • nums[1] = 0,等于 target,命中。
  • ans = 1,right = mid - 1 = 0。

此时left = 1 > right = 0,循环结束。返回ans = 1,正确。

这个例子完美演示了左边界跳过策略如何消除歧义:第一次循环如果没有left += 1,代码就会把左半段当成完全有序,从而错误地排除包含目标值的区间。

5. 面试现场:怎么讲才显得你理解得深

5.1 拿到题先问这三个问题

不要在拿到题目后直接闷头写代码。面试官期待你做的第一件事是澄清需求。针对这道题,我建议至少确认三个信息:

  1. 数组是否可能为空?如果为空,应返回什么?
  2. 重复元素的分布有没有限制?最坏情况是不是所有元素都相同?
  3. “返回最小索引”是指目标值第一次出现的位置,对吗?

这三个问题不只是为了获取信息,更是为了展示你的思维严谨性。尤其第三个问题,直接决定代码里“命中后继续向左搜索”这一步是必要还是可省略。能主动想到这一点的候选人,通常都能给面试官留下好印象。

5.2 思路讲述顺序

面试中讲思路的顺序,我习惯按“暴力解法 → 为什么能用二分 → 重复带来的问题 → 完整解法 → 复杂度分析”来组织:

  • 先说暴力解法的复杂度,表明你知道基线在哪里。
  • 再说旋转数组的两段有序特性,说明为什么可以尝试二分。
  • 然后指出重复元素会让“哪段有序”的判断产生歧义,因此必须特殊处理。
  • 接着给出完整代码,边写边解释每个分支的含义。
  • 最后主动说明复杂度,包括最坏情况退化为 O(n) 的路径。

这样讲的好处是逻辑完整,面试官可以轻松跟上你的思路。即使代码写不出来,清晰的思路也能带来许多加分。

5.3 面试官最常追问的三个点

追问一:为什么最坏复杂度是 O(n),能不能避免?

不能完全避免。因为重复元素让信息量下降,最坏情况下每次只能排除一个元素,例如全等数组。想通过预处理等方式规避,效果有限,反而会增加代码复杂度。面试官考察的是你是否诚实面对这个瓶颈,而不是试图掩盖它。

追问二:为什么不先找旋转点,再对两段分别做二分?

理论上可行,但在有重复元素的数组里,找旋转点本身也需要面对nums[mid] == nums[left]的歧义问题,等于把同样的难题搬到了另一段代码里。综合来看,直接在原数组上二分更简洁,不需要额外空间。

追问三:left += 1会不会跳过一个合法的目标位置?

不会。走到这个分支的前提是nums[mid] != target,而nums[left] == nums[mid],所以nums[left]也一定不等于 target。跳过它不会丢失答案。这一点解释清楚,面试官会非常认可你的代码安全意识。

6. 我踩过的坑:常见错误与排查实录

6.1 死循环:循环条件与 right 收缩的配合

二分法的死循环问题是个经典陷阱。有些人在写这种旋转数组时,会习惯性地用while left < right作为循环条件,然后在分支里用right = mid而不是right = mid - 1。一旦命中条件无法让 left 或 right 严格移动,循环就会卡住。

我的经验是:统一使用while left <= right,并且收缩边界都用mid ± 1。这样区间每次都会严格缩小,从机制上避免死循环。唯一例外是nums[left] == nums[mid]分支里用的left += 1,这也是严格的区间缩小操作。

如果发现自己写的二分陷入了死循环,最快的排查方法是打印每一轮的三个关键变量:

while left <= right: mid = (left + right) // 2 print(left, mid, right, nums[mid])

看到 left 和 mid 一直没变化,基本就是某个分支没有正确移动边界。

6.2 撞上“等于”的右边界:nums[mid] == nums[right]

我在早期版本里只处理了nums[left] == nums[mid],结果在一个例子中仍然出错:

nums = [1, 1, 1, 1, 0] target = 0

实际上这个数组已经足够典型:初始left = 0,right = 4,mid = 2,nums[left] == nums[mid]会触发left += 1。等到left = 2时,nums[left] = 1,nums[mid] = 0,nums[left] > nums[mid],进入右半段有序分支,后续能找到结果。

但有的变体写法判断nums[mid] <= nums[right],认为右半段有序。如果 target 在nums[right]附近,并且nums[mid] == nums[right]恰好成立,就可能走错方向。我的建议是:不要同时处理两边相等的情况,只处理nums[left] == nums[mid],其他情况都留给有序段判断。这样逻辑分支更少,测试更可控。

6.3 一个真实调试现场回放

朋友 A 在写这道题时,卡在了一个很隐蔽的用例上:

nums = [1, 1, 1, 1, 1, 2, 2, 1, 1] target = 2

他的代码没有处理nums[left] == nums[mid]分支,结果mid一直落在值全为 1 的区域,搜索区间无法收敛,最后超时。

我让他加了一行打印,发现前几轮输出长这样:

left=0 mid=4 nums[mid]=1 left=0 mid=4 nums[mid]=1 left=0 mid=4 nums[mid]=1

left 和 mid 完全没变化,这就是典型的“歧义未处理”导致的死循环。加上if nums[left] == nums[mid]: left += 1之后,left 逐步推进,最终到达索引 5,定位到 target 的第一个 2,返回 5。

调试这类问题,我的习惯是:先确认有没有进入歧义分支,再看是否推进了 left 或 right。大多数二分错误都出在这两件事上。

6.4 快速自查口诀

我把这道题的核心要点编成了一个自查口诀,写完代码后对着念一遍,能避免大多数低级错误:

  • 命中先记下,区间向左右:命中后用ans = mid,right = mid - 1继续找更小索引。
  • 等值无法判,左边界后移:遇到nums[left] == nums[mid],执行left += 1。
  • 左段有序看前界,右段有序看后界:根据nums[left] < nums[mid]或nums[left] > nums[mid]选择判断公式。
  • 循环不缩空间必死:每个分支都要保证 left 或 right 严格移动。

这个口诀不是万能模板,但每次写完后用它快速检查,基本能保住边界正确性。

我个人的体会是,这道题表面上考的是二分法的写法,实际考的是你对“循环不变量”的理解。你能否清楚地说明:while 循环开始时,答案有可能存在哪个区间?重复值把信息抹掉之后,你的每一轮还剩下哪些确定信息?想清楚了这些问题,旋转数组、重复元素、最小索引这些变体就都是同一套思路的自然延伸。刷题时与其背代码,不如拿几个含重复元素的小数组,在纸上把 left、mid、right 的变化一步步推演几遍。推演几次之后,你会发现那些看起来复杂的边界,其实只是几个简单规则反复叠加的结果。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询