资讯动态

魔术索引题解:非严格递增数组的二分查找与剪枝优化

发布时间:2026/10/18 6:15:49 来源:尧图企业网站定制
《程序员面试金典》里的这道“魔术索引”是我刷题阶段反复琢磨过的一道题。它要求在给定的有序整数数组里找出满足 nums[i] i 的索引找不到返回 -1存在多个时返回最小的那个。题干短到不能再短但题目在细节里埋了一个大坑数组是“非严格递增”的允许重复元素。就这一个条件直接把常见的标准二分查找打回原形。这篇文章我想把这题从暴力扫描、标准二分、到修改版二分剪枝的完整思路拆开讲把每一步推导和面试现场该注意的点都写清楚适合正在准备算法面试、或者想把二分思想真正吃透的读者。1. 先把题目读懂两个版本之间藏着一个大坑1.1 原书版本与题库版本的细微差别《程序员面试金典》原书第 8.3 节“魔术索引”的原始条件其实比我们现在在网上看到的要宽松很多它说的是“给定一个有序的、元素互不相同的整数数组”在这个前提下满足 nums[i] i 的索引至多只有一个。这个条件极其关键因为数组严格递增且没有重复数值之间的最小间隔就是 1这直接让“比较 nums[mid] 和 mid 的大小关系”变成了一个可靠的决策依据。但在在线测评平台的面试题系列里这题被收录时悄悄改了两个地方一是删掉了“元素互不相同”数组变成了非严格递增允许大量重复值二是明确要求“若有多个魔术索引返回索引值最小的一个”。这两个改动叠加在一起原书的标准二分解法就彻底失效了。我见过不少人拿着网上流传的无重复版本题解去提交结果在[0, 0, 3]这种用例上直接翻车问题就出在没意识到版本差异。为了后面叙述方便我把两个版本的核心差异整理成一张表对比项原书版本题库版本数组条件严格递增无重复非严格递增允许重复魔术索引个数至多一个可能多个返回值要求找到唯一一个即可必须返回最小的那个标准二分是否可行可行不可行期望解法标准二分修改版二分 剪枝1.2 这道题真正想考的是什么我个人觉得这题考察的不是“你会不会写二分模板”而是“你能不能理解二分搜索的本质”。二分的本质不是“每次砍掉一半”这个动作本身而是“利用有序性先证明某个区间里绝对没有答案然后把搜索空间安全地切小”。在无重复版本里比较 nums[mid] 和 mid就能直接断定答案只可能在左半边或者右半边一旦允许重复这种“非此即彼”的判断就靠不住了必须引入新的信息才能继续剪枝。这里有一个很直观的类比查字典的时候正常二分是每次翻到中间根据拼音或字母顺序决定往前翻还是往后翻。但如果字典里允许大量重复的条目你翻到中间看到一个词依然没法确定目标词在左边还是右边只能根据“中间这个词本身的内容”去猜测哪一段绝对不可能是答案剩下的两边都得翻一翻。魔术索引就是这种“根据值来跳过区域”的思路它比标准二分多了一层对数组值域的利用。2. 从暴力解开始简单、正确但只是起点2.1 线性扫描的写法与正确性先不给任何花哨技巧最简单的解法就是从左到右扫一遍遇到第一个 nums[i] i 直接返回。这段代码几乎不需要解释def find_magic_index(nums): for i in range(len(nums)): if nums[i] i: return i return -1这个写法天然返回的是最小的魔术索引因为从左往右遍历第一个命中的就是最小下标。时间复杂度 O(n)空间复杂度 O(1)。在 n 最大到一百万的数据规模下实际也就跑一百万次比较很多评测平台上是能直接通过的甚至性能还不算差。那为什么我们还要花大力气研究更复杂的解法因为这道题出现在面试题库里考察的定位就不是“能不能跑过”的问题而是“在有序数组这个前提下你能不能做得比线性扫描更好”。如果面试时只能给出这个版本相当于把题目送你的有序条件白白丢掉了。2.2 为什么面试里不能停在暴力解我见过一些面试复盘提到这道题时有人觉得自己答得不错因为暴力解很快写出来了样例也过了。但面试官几乎必然追问一句“数组有序这个条件你利用了没有”这时候如果答不上来前面写得多快都白搭。我个人的习惯是先给暴力解作为基线同时在代码里把边界情况返回 -1、返回最小索引写清楚然后立刻接一句“数组有序所以我们可以尝试二分思路但因为有重复元素标准二分需要改造”。这样面试官看到的不是一个只会背模板的人而是一个先确认需求、再逐步优化、有完整思考链路的人。还有一个沟通层面的细节动手之前先确认输入到底是不是严格递增、允不允许重复、要求返回任意一个还是最小的一个。这几个问题直接决定了后面写哪种解法。很多候选人一上来就闷头写结果把无重复版本的标准二分写上去等到被反例打脸才反应过来面试观感会差很多。3. 标准二分为什么会被重复元素一击致命3.1 无重复版本下的标准二分与推导先看无重复版本为什么能用标准二分。假设数组严格递增且元素互不相同取区间中点 mid如果 nums[mid] mid因为数组严格递增对任意 k mid有 nums[k] ≥ nums[mid] (k - mid) mid (k - mid) k所以 nums[k] k右半边不可能有魔术索引。如果 nums[mid] mid对称地对任意 k mid有 nums[k] ≤ nums[mid] - (mid - k) mid - (mid - k) k所以 nums[k] k左半边不可能有魔术索引。如果 nums[mid] mid直接命中。这段推导依赖一个关键事实严格递增的整数数组相邻元素差值至少为 1。正是这个“至少为 1”的间隔保证了从 nums[mid] 的大小可以推算出其他位置数值的下界或上界从而安全地排除半边。标准二分代码也很短def find_magic_index_distinct(nums): l, r 0, len(nums) - 1 while l r: mid (l r) // 2 if nums[mid] mid: return mid elif nums[mid] mid: r mid - 1 else: l mid 1 return -13.2 允许重复后一个反例就够一旦允许重复上面推导里的“差值至少为 1”就不成立了。重复值会让数组中段出现大片数值不涨但下标在涨的区域魔术索引可能藏在任何一段看似“应该排除”的地方。最直观的反例是nums [0, 0, 3]。标准二分从 l0, r2 开始mid1nums[1]0 1按照“答案在右边”的规则l 跳到 2。接着检查 mid2nums[2]3 2于是 r 跳到 1此时 l2 r1循环结束返回 -1。但实际最小的魔术索引是 0因为 nums[0] 0它就在标准二分一开始就放弃的左半边。另一个更隐蔽的反例是nums [0, 0, 2]。标准二分最后会返回索引 2这确实是一个魔术索引但不是最小的那个——最小值 0 被漏掉了。这类“结果碰巧对但过程是错的”用例最坑人因为单看输出好像没毛病只有对照题目“返回最小的魔术索引”的要求才会发现错误。3.3 标准二分的判断条件到底哪里失效了失效的根源在于“nums[mid] mid 则答案在右边”这个推理在重复数组里不成立。无重复版本中nums[mid] mid 能推出左半边所有位置的 nums[k] 都严格小于 k但有重复时nums[k] 可能停留在某个很小的值上而 k 却在增长两者完全有机会相遇。举个具体例子数组[0, 0, 0]中 nums[0] 0魔术索引在左端点但 mid1 时 nums[1] 0 1标准二分会毫不犹豫地放弃左半边。问题不在于比较本身而在于“小于”这个信息在重复环境下承载不了“整个半边都无解”这么强的结论。要修复它必须找到一个新的、在重复情况下依然成立的剪枝条件——这就引出了下一节的修改版二分。4. 正确解法用 nums[mid] 的值来剪枝4.1 核心观察跳过必无解区间的数学依据这是全文最重要的一节也是这道题唯一的干货所在。对当前搜索区间 [l, r]取中点 mid设 d nums[mid]我们有两条在“非严格递增、允许重复”条件下依然严格成立的剪枝规则规则一若 d mid考虑任意下标 k满足 mid k d。因为数组有序nums[k] ≥ nums[mid] d又因为 k d所以 nums[k] ≥ d k即 nums[k] k这样的 k 绝对不可能是魔术索引。因此区间 [mid1, d-1] 可以整体跳过右边的搜索直接从下标 d 开始。规则二若 d mid考虑任意下标 k满足 d k mid。因为数组有序nums[k] ≤ nums[mid] d又因为 k d所以 nums[k] ≤ d k即 nums[k] k这样的 k 也绝对不可能是魔术索引。因此区间 [d1, mid-1] 可以整体跳过左边的搜索最多搜到下标 d 就停。这两条规则的关键在于我们不再武断地说“答案在左边还是右边”而是让 nums[mid] 的具体数值充当一个指针告诉我们“往这个方向走多远才会有希望”。注意规则一里下标 d 本身没有被跳过因为 nums[d] 完全可能等于 d所以右边界是 d 而不是 d1规则二里同理下标 d 本身仍然要搜所以左边界是 d 而不是 d-1。这个细节很多人第一次写都会错。4.2 搜索顺序先左、再中、后右题目要求返回最小的魔术索引这个要求直接决定了递归的访问顺序必须先搜左子区间再检查当前中点最后才搜右子区间。左边一旦返回了非 -1 的结果它就是整个数组里最小的魔术索引因为左子区间所有下标都小于当前中点 mid更小于右子区间里的任何下标。不少人代码写对了但结果不对就是因为顺序写反了或者左边搜完之后没有“先判断结果再决定要不要检查中点”而是直接检查了中点、把更小的左边候选覆盖掉了。这个错误在无重复版本里不会暴露因为魔术索引唯一但在允许重复的版本里一定会翻车。递归关系的标准写法如下def dfs(l, r): if l r: return -1 mid (l r) // 2 # 第一步先搜左半注意用 nums[mid] 修剪左边界 left_res dfs(l, min(mid - 1, nums[mid])) if left_res ! -1: return left_res # 第二步检查中点 if nums[mid] mid: return mid # 第三步搜右半用 nums[mid] 跳过必无解区域 return dfs(max(mid 1, nums[mid]), r)4.3 完整代码与两个手推案例把上面思路整理成可以直接提交的版本from typing import List class Solution: def findMagicIndex(self, nums: List[int]) - int: def dfs(left: int, right: int) - int: if left right: return -1 mid (left right) // 2 # 先搜左边保证返回最小索引 res dfs(left, min(mid - 1, nums[mid])) if res ! -1: return res # 再检查当前中点 if nums[mid] mid: return mid # 最后搜右边nums[mid] 帮我们跳过一片必无解区域 return dfs(max(mid 1, nums[mid]), right) return dfs(0, len(nums) - 1)推演案例一nums [0, 0, 3]。初始 dfs(0, 2)mid1nums[1]0 1所以左子区间是 dfs(0, min(0, 0)0)。进入 dfs(0, 0)mid0nums[0]0左子区间 dfs(0, min(-1, 0)-1) 直接返回 -1nums[0] 0 命中返回 0。外层收到 0 后立刻返回 0。标准二分在这里漏掉的答案修改版准确找回。推演案例二展示剪枝收益。nums [-5, -4, 3, 100, 100, 100, 100]。初始 dfs(0, 6)mid3nums[3]100 3左子区间 dfs(0, min(2, 100)2)。进入左侧后mid1nums[1]-4左子区间 dfs(0, min(0, -4)-4) 返回 -1nums[1]-4 不等于 1右子区间 dfs(max(2, -4)2, 2)。mid2nums[2]3 ≠ 2左子区间 dfs(2, min(1, 3)1) 返回 -1右子区间 dfs(max(3, 3)3, 2) 返回 -1所以这一层返回 -1。回到根节点nums[3]100 不等于 3右子区间 dfs(max(4, 100)100, 6) 因为 100 6 直接返回 -1。整个过程一次都没检查下标 4、5、6——这就是 nums[3]100 这个值带来的剪枝威力。4.4 易错细节边界条件与递归终止这部分是实战里最容易写错的地方我一个个列一下min 和 max 不要用反。左边界是 min(mid - 1, nums[mid])右边界是 max(mid 1, nums[mid])。用反了会导致漏解甚至无限递归。递归终止条件是 left right不是 left right。因为单元素区间也必须检查写成 会跳过唯一可能命中的位置。nums[mid] 可能为负数。比如 mid3 时 nums[3] -2那么 min(mid-1, -2) -2左子区间 dfs(0, -2) 直接因 l r 返回 -1。这一跳是安全的左侧所有下标的 nums 值都不超过 -2不可能等于非负下标。mid 的计算建议写成 left (right - left) // 2。Python 里整数不会溢出但保持这个习惯对写其他语言有好处。左边递归返回 -1 之后才检查 nums[mid] mid这个顺序绝对不能颠倒。5. 复杂度分析这道题的复杂度没你想的那么简单5.1 时间复杂度理想 O(log n)最坏 O(n)网上很多题解直接写“时间复杂度 O(log n)”严格来说是不严谨的。这个修改版二分在无重复或者重复很少的数组上剪枝非常充分一次递归路径能跳过大量区间表现确实接近 O(log n)。但在最坏情况下剪枝可能完全失效递归树会铺开很多节点。最坏情况是什么样比如一个由大量重复值组成的数组且这些重复值都不等于对应下标此时左边界或右边界几乎不会被 nums[mid] 剪掉多少递归需要在多个分支里反复探索。可以证明每个数组下标在整个递归树中最多被选中一次作为中点因为一旦成为 mid它就被排除在左右子区间之外而不同分支的区间互不重叠所以递归树的节点总数不超过 O(n)每个节点做 O(1) 工作最坏时间复杂度是 O(n)。这和暴力扫描同阶但实际访问的节点往往少得多而且理想情况下的表现远优于线性扫描。5.2 空间复杂度递归深度的上界空间消耗主要来自递归栈。观察每次递归左子区间长度至多约为父区间长度的一半右子区间同理因为 mid 本身被排除在外而且剪枝只会让区间更短。所以从根到叶子的一条递归路径区间长度每次至少减半递归深度是 O(log n)。在 n 等于一百万时深度最多也就二十层左右完全不用担心栈溢出。有些同学会问能不能改成迭代版本。可以但比较麻烦因为必须要用显式栈模拟“先左、再中、后右”的遍历顺序同时还要保存每一层待处理的右区间信息。面试时给出递归版本完全够用把空间复杂度 O(log n) 说清楚即可。5.3 三种思路的横向对比把三种解法放在一起看各自的定位就很清楚了解法时间复杂度空间复杂度是否返回最小索引适用前提暴力扫描O(n)O(1)是任意数组标准二分O(log n)O(1)仅唯一解时成立严格递增无重复修改版二分 剪枝最坏 O(n)理想 O(log n)O(log n)是有序数组允许重复面试时如果能把这张表讲明白说明你对这题的理解已经超过了绝大多数只背模板的候选人。6. 面试实战怎么答才能拿高分6.1 动手前先和面试官对齐需求这道题特别适合展示“需求确认”能力因为不同版本解法完全不同。我建议拿到题后按这个顺序问先确认数组是否严格递增、是否允许重复。这是决定解法走向的最关键问题。再确认返回值要求是任意一个魔术索引还是最小的那个这决定了搜索顺序。如果面试官说“任意一个”递归顺序可以稍微放松但为了稳妥我建议仍然按左→中→右来写逻辑最简单也最不容易出错。最后可以问一下数据规模如果数组特别大、无法整体载入内存我会补充说明可以分块加载再结合剪枝这属于加分项。6.2 面试官喜欢追问的四种变体根据我刷题和模拟面试的经验面试官围绕这道题常做的变形有这些变体一去掉“返回最小”的限制只要求任意一个魔术索引。此时不需要强制的先左后中右但保留这个顺序也没问题返回第一个找到的即可。变体二数组无序。有序前提一消失所有剪枝规则全部失效线性扫描就是最优解。别硬凹直接说 O(n)。变体三把魔术索引条件改成 nums[i] i k也就是带偏移量。只要把比较基准从 i 换成 i k剪枝公式同步平移即可。本质思想完全一样迁移很顺畅。变体四只能顺序读取一个超大文件内存放不下。可以分块扫描每次把一块读进内存后用二分剪枝块的大小根据内存上限决定。这个变体考察的是工程化能力能说出思路就很有竞争力。6.3 常见错误与自查清单这部分是我自己踩过坑之后的总结非常实用只依赖题目给的示例没有自己构造重复用例。示例里可能刚好都是好处理的情况一旦用[0, 0, 3]这种用例去测标准二分立刻露馅。递归顺序写反。先搜了右边导致返回的不是最小索引。边界写错。min(mid - 1, nums[mid])写成min(mid, nums[mid])会造成重复检查甚至死循环。终止条件写错。if left right写成了if left right单元素区间被跳过。左边搜完直接检查中点没有判断左结果是否已命中导致更小的答案被覆盖。我自己第一次手撕这道题时就犯了“先搜右边”和“没检查左结果直接检查中点”两个错误白白多花了一轮调试时间。后来我把这几个误区整理成自查清单每写一道二分变体题都先过一遍正确率高了很多。7. 从一道题到一类题二分思想的迁移7.1 通用套路让值域信息参与剪枝这道题最大的启发是当“比较中点与目标”不足以决定排除哪一侧时可以看看“中点的值本身”能帮你跳过哪一段。在有序数组里nums[mid] 的数值限定了后续所有元素的下界这个下界可以直接和下标做比较从而砍掉一批绝对无解的区间。这种“用数据约束数据”的思路在很多看似不相关的题目里都会反复出现。比如在“搜索旋转排序数组”里核心是先判断 mid 落在哪一段有序区间再决定往哪边走在“寻找峰值”里比较 nums[mid] 和 nums[mid1] 的趋势来决定方向在“杨氏矩阵查找”里从右上角出发当前值比目标大就往左、比目标小就往下每一步都能砍掉一行或一列。它们本质上都是“利用已有的信息证明某些区域无解”和魔术索引的剪枝思想同源。7.2 相关题目的串联复习路径如果你正在准备面试我建议不要孤立地刷这一道题而是把二分相关的题目按类型分组第一类是模板二分比如求平方根、猜数字大小用来练手感和边界。第二类是二分边界定位比如在排序数组中找某个数第一次和最后一次出现的位置核心是重复元素下的左右边界二分。第三类是二分答案比如在“最小值最大化”类型的题目里对答案值域二分。第四类就是魔术索引这种“比较中点不够、需要值域参与剪枝”的题目。四类各找两三道题练透比按题号顺序盲目刷效率高得多。7.3 这道题在工程里的影子可能有人觉得这种题面试完就没用了其实“在有序数据里快速定位满足条件的元素”在真实系统里到处都是。举个我工作里遇到的场景在按时间排序的日志里要找到第一条出现某个错误码的记录。日志量大、错误码可能在多天反复出现直接线性扫会很难受但有了“时间有序”这个前提就可以用类似思想先根据当前记录的日志时间戳判断“更早的日志大概在哪个区间”再结合错误码是否可能出现在该区间做剪枝。再比如在数据库索引的 B 树节点查找、版本列表里找满足兼容性约束的版本号底层都是同一套逻辑。理解“如何用已有的排序信息跳过绝不可能的区域”比背会这一道题本身的价值大得多。最后分享一点我自己的体会。这种“题干极短、陷阱藏在细节里”的题最适合用来检验自己是不是真的理解某个算法而不是只会套模板。我在刷这道题时踩过三次坑每次回头去看都是同一个原因——没有把“为什么会成立”想透就开始写代码。如果你也正在准备面试建议把这道题当成一个“二分思想的试金石”合上书自己从反例出发推导一遍剪枝公式再手写一遍递归最后再用随机用例和全重复用例自测一遍。这里有一个我用过的小技巧本地自测时可以用nums [i - 1 if i % 5 0 else i for i in range(n)]这类构造在“基本有序但有重复和偏移”的输入上验证返回的确实是最小魔术索引。能把这个构造解释清楚比背十道题都能证明你真的理解了题意。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑