☰
搜索旋转排序数组含重复元素返回最小索引:二分法完整推导与面试解析
2026/10/14 7:17:15 网站建设 项目流程

面试题里有一个很经典的组合套路:二分法。但它从来不会老老实实给你一个有序数组,而是会把数组“旋转”一下,再加点重复元素,最后再要求你返回最小索引。于是题目就变成了“搜索旋转排序数组(含重复元素,返回最小索引)”。很多候选人能写出不含重复元素的版本,一遇到重复值,left、right、mid三个位置的值相等时就直接懵了;好不容易处理了重复值,又发现题目要求返回的是最小索引,找到目标后不能立刻返回,还得继续往左探。这道题三个难点各自为政,叠在一起才是真正的区分度所在。

这篇文章我从一个准备面试的候选人和面试官两个视角出发,把完整推导、代码实现、边界测试和常见追问全部过一遍。你不需要有任何竞赛基础,只要会写最普通的二分查找,就能跟完全程。读完之后,你不仅能把这道题的代码写对,还能在面试官追问“最坏时间复杂度是多少”的时候,给出让他满意的回答。

1. 先从基础题说起:旋转数组里的二分是怎么成立的

1.1 旋转后的数组藏着两个“有序段”

要理解这道题,先得搞清楚什么是旋转排序数组。假设你有一个严格递增的数组,比如[0, 1, 2, 4, 5, 6, 7],然后从某个位置“切一刀”,把后面那段搬到前面来,比如切成[0, 1, 2]和[4, 5, 6, 7],再把[4, 5, 6, 7]放到[0, 1, 2]前面,就得到了[4, 5, 6, 7, 0, 1, 2]。这个结果就是旋转排序数组。

注意观察它的结构:整个数组不是单调递增的,但它由两个递增段拼接而成,左段[4, 5, 6, 7]递增,右段[0, 1, 2]也递增。两段之间有一个“断点”,也就是从 7 掉到 0 的那个位置,而那个位置恰好是整个数组的最小值所在处。这个观察极其重要,因为后续所有二分决策都建立在这个结构上:一个旋转数组中最多只有一个下降沿。

在面试里,旋转方向的定义有时会不一样。有的题目说是“把数组整体右移若干位”,有的说是“左移”,但这些都不影响核心思路。你只需要知道:数组本质上被分成两段,每一段内部都是递增的,而两段之间的边界就是旋转点。能在白板上用两三句话说清楚这个结构,面试官就会知道你是真的理解旋转数组,而不是背了模板。

1.2 二分的本质:每次排除一半的底气

很多人对二分有误解,以为它要求数组全局有序。实际上,二分查找能够成立的唯一条件是:在每一步,你都能确定目标值不可能出现在某一边,于是可以放心地把那一半丢掉。旋转数组虽然整体不是有序的,但每次取中点 mid 之后,左右两半中至少有一半是严格递增的。这一点可以严格证明:因为整个数组只有一个下降沿,mid 把数组切成两段时,最多只有一段会跨过这个下降沿,另一段一定是完整落在某一个递增段内部。

拿[4, 5, 6, 7, 0, 1, 2]举例,mid 落在索引 3,值是 7。左半段[4, 5, 6, 7]是完全递增的;右半段[0, 1, 2]也是递增的,但右半段跨过了数组开头吗?不,mid 位置恰好是左段末,所以左右两半全是严格递增的。再换一个数组[6, 7, 0, 1, 2, 3, 4],mid 落在索引 3,值是 1。左半段[6, 7, 0, 1]跨过了下降沿,不是全局递增;右半段[1, 2, 3, 4]是递增的。所以不管什么情况,总有一半能让你做“有序区间里的普通二分”。

具体做法是:比较nums[left]和nums[mid]。如果nums[left] <= nums[mid],说明左半段没有跨过下降沿,左半段有序;否则说明左半段跨过了下降沿,那右半段一定有序。判断出哪边有序之后,再看目标值在不在这个有序区间里:如果在,就缩小到这个区间;如果不在,就走到另一半。这就是无重复元素版本的完整思路。

def search_basic(nums, target): left, right = 0, len(nums) - 1 while left <= right: mid = left + (right - left) // 2 if nums[mid] == target: return mid if nums[left] <= nums[mid]: # 左半有序 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

这个版本是后面所有变形的基础。如果你第一次接触旋转数组,我建议先把这段代码默写到自己能闭眼写出的程度,再去碰重复元素和最小索引的变体。因为后面的麻烦,本质上都是在“判断哪边有序”这一步被重复值扰乱了。

2. 加入重复元素后的核心变化:端点相等怎么办

2.1 相等端点如何破坏二分判断

一旦数组里允许重复元素,上面那套“比较nums[left]和nums[mid]判断左半是否有序”的逻辑就可能失效。最典型的反例是[1, 0, 1, 1, 1],目标值是 0。第一轮 left=0,right=4,mid=2,nums[left]和nums[mid]都等于 1。按照无重复版本的逻辑,nums[left] <= nums[mid]成立,所以你会认为左半段有序,然后检查 0 在不在[1, 1)区间里。这个区间是空的,于是你跑去右边找,直接漏掉了索引 1 上的 0。

问题出在哪?当nums[left] == nums[mid]时,你根本没法判断左半段到底是一个有序的相等段,还是旋转点正好藏在这段中间。比如[1, 1, 1, 2, 1]旋转后,左端点、中点和右端点可能都是同一个值,整个数组看起来像是“平的”,但中间又可能夹着比它小或者比它大的元素。你没法用大小关系做出任何可靠判断。

这就是“含重复元素”版本和“无重复元素”版本最本质的分水岭。面试官加这个条件,不是想考你会不会写边界条件,而是想看你能不能意识到:重复值让原来的“必有一段有序”的结论不再恒成立。你必须设计一种机制,在无法判断的时候安全地缩小搜索范围,而不是强行套用二分规则。

2.2 处理手段:收缩左边界而不是二分

当nums[left] == nums[mid]且目标值不等于这个相等值的时候,我采用的处理办法很简单:left += 1。

很多候选人第一次看到这行代码会问:不是二分吗?怎么变成一个个挪了?这里的关键在于,这一步不是一个“猜测”动作,而是一个“排除”动作。因为我们在进入这个分支之前,已经先检查过nums[mid] == target。既然能走到nums[left] == nums[mid]这个分支,说明nums[mid]不等于 target,那么与它相等的nums[left]也一定不等于 target。所以把最左边的元素丢掉,是绝对安全的。

一句话概括:相等端点让你无法二分,但至少能让你排除一个确定不是目标的值。丢一个、缩一步,循环条件仍然保证 left 严格前进,所以不会死循环。这个技巧的代价是速度,但收益是正确的答案。很多人在这一步试图做更精明的判断,比如根据nums[mid] == nums[right]做对称收缩,但实际写起来非常容易出错。

我在实际面试陪练里见过不少人死磕“能不能左右同时缩”。左右同时缩在某些实现里确实可以,但它要求你额外论证“右边丢掉的那个元素也一定不是目标”,并且在你已经记录过候选答案、又要向右边界收缩时,边界条件会变得很绕。与其在高压面试现场为了常数优化增加心智负担,不如选择单一收缩策略:逻辑线性,一句话能解释清楚。

2.3 为什么推荐只用 left++ 而不是左右同时收缩

我不止一次看到网上模板处理重复元素时使用这种写法:当nums[left] == nums[mid] == nums[right]时,left += 1同时right -= 1。这个写法在 LeetCode 第 81 题“搜索旋转排序数组 II”里是成立且常用的,因为那道题只要求判断目标值是否存在,不要求返回索引。左右各丢一个,只要两个端点都不是目标值,就不影响存在性判断。

但这道题不一样,它要求返回的是最小索引。一旦你在代码中加入“记录 ans 后 right = mid - 1 继续向左探”的逻辑,右边界已经承担了“收缩到候选区间右端”的职责。如果这时还混入“三端点相等时 right -= 1”的规则,你就必须反复确认:这次的 right 收缩到底是因为“找到了目标,想去左边找更小的”,还是因为“端点相等,排除一个确定不是目标的值”。两个语义混在一起,代码读起来非常累,一旦写错,排查起来也很花时间。

所以我的建议是:本题主动放弃“同时收缩”,只用left += 1。这不会改变算法的正确性,只会让最坏情况下的常数稍差一点。但在面试场景里,正确性、可解释性、代码可读性优先于那一点点常数优化。而且当你主动告诉面试官“为了逻辑清晰,我用单一收缩策略,最坏复杂度是 O(n)”时,对方通常反而会点头,因为这说明你权衡过实现方案的取舍。

3. 要求“返回最小索引”:找到之后不能立刻返回

3.1 找到就返回是这道题最大的陷阱

如果只是问“目标值是否存在”,那命中 mid 直接返回 True 就结束了。如果问“返回任意一个下标”,那命中 mid 直接 return mid 也可以。但这道题要求返回最小索引,也就是目标值第一次出现的位置。数组里有重复值时,mid命中的那个位置不一定是第一次出现的位置。

看一个最直接的例子:nums = [1, 1, 1, 2, 1],目标值是 1。第一轮 mid 落在索引 2,nums[2] == 1。如果直接 return 2,看起来好像没问题,数组里确实有 1。但题目要求最小索引,索引 0 才是正确答案。一个看似“做对了”的代码,只要漏掉这个要求,在面试官的自测用例上会当场翻车。

这个陷阱的杀伤力在于:很多人写这道题时,大脑还停留在“搜索旋转数组”的原始模板里,模板里找到就 return,于是顺手就写了 return mid。面试官追问一句“你确定这是最小索引吗?”才反应过来。其实从题意翻译到代码,只需要一步:命中目标时不要立刻返回,而是记录当前下标,然后继续往左找。

3.2 引入 ans 变量,命中后收缩右边界向左探

具体做法是维护一个ans,默认值为 -1。每当nums[mid] == target,就把ans更新为mid,然后把right设为mid - 1,继续在左侧区间搜索。循环结束时,ans就是从左到右最后一次被更新的值,也就是最小的那个索引。

理解这个逻辑的关键点在于:右侧的索引无论再命中多少次目标,都比当前的ans大,不可能成为最小索引。所以一旦在某个 mid 命中目标,整个右半边就不需要再看了。你要做的唯一一件事,是压缩到mid左边,看看是否还有更早的相同值。这本质上是从“二分查找”变成“二分查找最左边界”的过程,和你在一段有序数组里找target第一次出现位置的做法一致,只不过这里的“有序段”判断逻辑要服务于旋转数组的特性。

这个设计引入的额外操作只有三行:记录ans、修改right、保留 -1 默认值。但它把题目的第三层难度直接消解掉了。很多人在处理“找到后向左搜”时,会担心左侧区间不是严格有序,其实这个担心没有必要,因为我们不是在左侧区间做普通二分,而是继续沿用完整的旋转数组二分逻辑。目标值一旦被记录过一次,后续所有搜索都是“找更小的索引”,这和我们整个题目的搜索流程完全兼容。

3.3 完整代码实现与逐段注释

把前面的思路全部拼起来,就得到了本题的完整解法。我给出一个可以直接运行的 Python 版本:

class Solution: def search(self, nums, target): left, right = 0, len(nums) - 1 ans = -1 while left <= right: mid = left + (right - left) // 2 # 情况 1:命中目标,记录当前索引,继续向左找更小的索引 if nums[mid] == target: ans = mid right = mid - 1 continue # 情况 2:左端点与中点值相等,无法判断哪一侧有序,只能收缩左边界 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 else: # 情况 4:右半段严格递增(此时 nums[left] > nums[mid]) if nums[mid] < target <= nums[right]: left = mid + 1 else: right = mid - 1 return ans

建议你亲手敲一遍这段代码,不要复制粘贴。敲的过程中你会自然注意到几个关键点:nums[left] < nums[mid]用的是严格小于,而检查目标区间时用的是<=,这些细节全是边界条件的“命门”。写完之后,再用下面几组用例过一遍,能加深印象。

我手动推演一个最有迷惑性的例子:nums = [1, 0, 1, 1, 1],target = 0。

  • 第一轮:left=0,right=4,mid=2,nums[2]=1,不等于 0;nums[0]==nums[2]==1,进入情况 2,left 变成 1。
  • 第二轮:left=1,right=4,mid=2,nums[2]=1,不等于 0;nums[1]=0,nums[2]=1,两者不等,且左半段严格递增,0 落在[0, 1)区间内,于是 right 变成 1。
  • 第三轮:left=1,right=1,mid=1,nums[1]=0,命中目标,ans=1,right 变成 0。循环结束,返回 1。

这个用例非常关键,因为它同时考验了两件事:左端点和中点相等时没有盲目二分;命中目标后没有立刻返回。这两个坑只要踩了其中一个,最终答案就会错。面试时如果你能把这个用例的推演过程讲给面试官听,基本可以证明你是真的理解整道题,而不是背了一个答案。

4. 复杂度分析与面试追问

4.1 平均 O(logn),最坏 O(n) 的退化条件

这道题让人又爱又恨的地方在于复杂度。不含重复元素时,旋转数组二分可以严格做到 O(logn);一含重复元素,事情就起了变化。因为nums[left] == nums[mid]时只能left += 1,这一步每次只缩小一个单位,而不是像普通二分那样缩小一半。当这种“无法判断”的情况频繁发生时,算法就会退化为线性扫描。

最极端的例子是nums = [1, 1, 1, 1, 1],目标值是 0。整个数组里没有目标值,且所有端点值都相等。每一轮 left 都只能挪动一位,直到 left 越过 right。长度为 n 的数组需要执行 n 轮,所以最坏时间复杂度是 O(n)。这种退化并不罕见,只要目标值不存在,而数组前端大量重复,就会出现。

面试官特别喜欢在这个位置追问复杂度。如果你答“O(logn)”,会被认为是背了模板、没有理解重复值的影响;如果你主动说“平均 O(logn)、最坏 O(n)”,并能给出触发最坏情况的构造,反而是一个加分项。这就像开车时知道自己的车在冰雪路面会打滑,比一个永远宣称“我的车不会打滑”的司机更靠谱。

我把不同场景下的复杂度整理成一个直观的对照表,面试前扫一眼能帮你在脑子里建立“达标线”。

场景时间复杂度原因
不含重复元素O(logn)每轮都能可靠排除一半区间
含少量重复,但目标值存在接近 O(logn)命中目标后提前记录,搜索范围快速收敛
大量重复且目标值不存在O(n)端点相等时只能逐个排除
大量重复但目标值恰好能在 mid 命中O(logn)+O(k)找到后向左探的区间长度可能较大

4.2 面试官常问的几个变体

这道题的变体非常多,面试官一般不会只问一题,而是会从同一个模板演化出好几问。第一个最常见的变体是“不要求最小索引,只判断是否存在”。这时候你把ans相关逻辑删掉,命中nums[mid] == target直接返回 True,循环结束返回 False 即可。这个版本对应的是搜索旋转排序数组 II,代码比本题短一截,逻辑也更好讲。

第二个变体是“数组不含重复元素”。这是最基础版本,前面 1.2 节的代码就是答案。面试官有时候会先问基础版,再逐渐往题目里加约束,观察你能不能从简单版平滑地升级到复杂版。我建议你在准备时把两个版本的 diff 背下来:基础版多了哪一行,重复版多了一次命中判断和一个 left++,以及为什么必须多这两处。

第三个变体稍微隐蔽一点:如果题目告诉你旋转了多少位,比如旋转 k 位。那么你可以直接建立原数组下标和旋转后下标的映射关系,把问题转化为普通有序数组二分,再用一次映射还原最小索引。结论本身不是重点,重点是你能从一个搜索模板引申出映射法,说明你对“为什么二分能在这类题目里成立”有本质理解。

第四个变体是“不找目标值,而是找旋转数组里的最小值”。这道题在很多公司的面试里和当前题目交替出现。它的核心判断逻辑和搜目标值相似,但边界条件略有不同。如果你已经把本篇文章的代码吃透,再花半小时刷一道“寻找旋转排序数组中的最小值 II”,两者对比着看,比孤立地刷任何一道效果都好。

4.3 边界条件三件套

写这道题时,边界条件的失误率极高。我把最容易出问题的三个位置单独拎出来,每个都配一句“为什么”。

首先是循环条件用left <= right还是left < right。我习惯用left <= right,因为旋转数组的搜索区间是左闭右闭,left == right时区间里还有一个元素需要检查,如果放过了它,遇到单元素数组可能会直接返回 -1。其次是判断有序段时用严格比较:nums[left] < nums[mid]表示左半段严格递增;一旦出现相等,就走收缩分支。第三,mid 的计算写成left + (right - left) // 2,避免 left 和 right 很大时相加溢出。Python 里不太会溢出,但这个写法是面试通用规范,写上去显得专业。

还有一个经常被忽略的小细节:检查nums[left] <= target < nums[mid]时,等号放在前一个位置。因为 target 可能正好等于nums[left],此时它仍然属于左半段;如果把它漏掉,你会错误地跳到右半段去搜索。这类等于号归属问题,在普通有序二分里不重要,因为两边同时检查会兜住,但在旋转数组中,一次错误的区间选择很可能把目标值永远排除在搜索空间之外。

5. 调试实录与避坑清单

5.1 高频 Bug 速查表

写这道题的过程中,有几个错误是我在模拟面试和刷题群里反复看到的。它们症状不同,但根因高度集中。把问题、原因和解决办法整理成一张速查表,比你看十遍别人正确的代码都有用,因为你最后要防的正是这些错法。

现象可能原因解决办法
程序死循环,left 不前进nums[left] == nums[mid]时没有处理或没有left += 1确认收缩分支一定让 left 发生变化
目标值明明存在,却返回 -1相等时盲目二分,错误排除了含目标值的区间先处理nums[mid] == target,再处理相等端点
返回的不是最小索引命中 mid 后直接 return使用 ans 记录,并 right = mid - 1
返回的索引比预期大 1 或小 1比较大小符号错误,多一个等号或少一个等号用闭区间推导,检查每个比较的边界值
单元素数组出错循环条件写错,或 mid 计算时 left/right 已经越界手推长度为 1 的用例
最坏场景超时大量重复导致每次只能挪一位主动说明最坏 O(n),若题目要求严格 O(logn) 则说明不可行

前三个问题是最常见的。死循环通常出现在你忘了在跳过分支里让 left 前进;漏答案通常出现在你没有意识到相等端点破坏的是“判断哪边有序”的前提;返回非最小索引则是没有把题目要求落到ans的逻辑上。这三个错法每一个我都见过不止一次,每次候选人自己 debug 半天,最后发现就是一两行的事。

5.2 上线前的自测用例清单

如果你把代码写完,想自己验证一下,不要只挑一个用例就宣布成功。我建议准备一组覆盖所有边界的用例,在本地一次跑完。下面这组用例是我根据高频错误人为构造的,每种都代表一个独立的边界类型。

测试数组目标值期望结果覆盖的边界场景
[1]10单元素数组命中
[1]2-1单元素数组未命中
[3, 1]11长度为 2 的旋转数组
[3, 1]30长度为 2 的目标在左端
[1, 1, 1, 2, 1]10重复值加旋转,最左索引被掩盖
[2, 2, 2, 0, 2, 2]03目标出现在重复旋转段的唯一位置
[1, 0, 1, 1, 1]01端点值相等且目标位于左半段
[1, 1, 1, 1, 1]10全相同数组,存在目标
[1, 1, 1, 1, 1]0-1全相同数组,目标不存在,触发最坏复杂度
[4, 5, 6, 7, 0, 1, 2]3-1标准无重复旋转数组,目标不存在

这组用例跑完之后,你的代码基本就是稳的。注意第 5 个用例和第 7 个用例,它们是专门用来打脸“找到就返回”和“相等端点乱二分”这两个错误的。如果只挑一个用例验收,我强烈建议挑第 7 个[1, 0, 1, 1, 1],它在一条用例里串起了这道题的两大难点。

5.3 我自己的实现与复盘习惯

写这类题多年,我有一个习惯:永远不把“模板代码”和“题目要求”拆开背,而是先把题目要求拆成最小约束,再让代码逐条满足。对这道题来说,约束有三条:数组是旋转过的;数组里可能有重复元素;要返回最小索引。所以代码必须有三个对应组件:旋转数组的有序段判断、重复端点时的收缩、命中后的 ans 左探。少一个组件,题目就做不对。我复盘时也是按这三个组件来记,而不是死记硬背整段代码。

另一个实际心得是:手推测试用例时,不要只在脑子里推,拿纸笔画一下 left、mid、right 的位置变化。尤其是遇到端点相等的路线,你很容易对“left 到底前进到了哪里”产生错觉。我见过很多候选人写的时候信誓旦旦,一画图就发现某一轮 mid 根本不在自己想象的区间里。画一遍图,比 debug 半小时管用得多。

这道题后续还可以这样扩展:把 return 从“最小索引”改成“最大索引”,只需要命中后left = mid + 1继续向右探,其余逻辑不变;把目标值改成“找旋转点下标”,则要换一套判断逻辑,但同样要处理重复元素。如果你下一次遇到变体,先把三条约束拆出来,再往这套骨架上套,基本不会跑偏。

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

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

立即咨询