bigywater.tech
算法与数据结构 · LeetCode 33

LeetCode 33. 搜索旋转排序数组

一个升序、元素互不相同的数组,在某个位置被旋转了一下(例如 [0,1,2,4,5,6,7] 变成 [4,5,6,7,0,1,2])。给定一个目标值,在 O(log n) 时间内找到它的下标,找不到返回 -1。关键观察:把旋转数组从中间劈开,左右两半里至少有一半是有序的。每次二分先判断哪一半有序,再看目标落不落在有序的那一半里,据此丢掉一半。本文用真实数据展示"忽略旋转、当普通二分"会错多少,实测二分和线性扫描的耗时,给出五种语言的实现,并把每一步二分做成分步演示。

20,000
组随机对拍,与暴力解的结果全部一致
4 步
分步演示,每一帧都来自解法的真实执行,另附 5 种语言实现

题意与示例

完整题目见官方页面(链接在文末)。基准解是线性查找(list.index),与二分完全不同。一共 14 组用例,覆盖:官方示例、目标不存在、单个元素、两个元素、未旋转的数组、旋转点在头或尾:

真实实测 · 本机执行输出
输入 [[4,5,6,7,0,1,2],0]
输出 4
输入 [[4,5,6,7,0,1,2],3]
输出 -1
输入 [[1],0]
输出 -1
输入 [[1],1]
输出 0
输入 [[1,3],3]
输出 1
输入 [[3,1],1]
输出 1

陷阱:忽略旋转,当普通有序数组二分

普通二分假设整个数组升序,看 nums[mid] 与 target 的大小就能决定往哪边找。旋转数组不满足这个假设:nums[mid] < target 时目标可能在左边,也可能在右边。我把所有可能的情形都枚举了一遍:长度为 n 的数组共有 n 种旋转,每种旋转下取数组里的每个值作为目标,共 n × n 种,逐一对比普通二分和线性查找的答案:

真实实测 · 本机执行输出
# 忽略旋转、当普通有序数组二分的写法
长度 4:所有 4 种旋转 × 4 个目标共 16 种,普通二分答错 4 种(25.0%),旋转二分答错 0 种
长度 6:所有 6 种旋转 × 6 个目标共 36 种,普通二分答错 11 种(30.6%),旋转二分答错 0 种
长度 8:所有 8 种旋转 × 8 个目标共 64 种,普通二分答错 18 种(28.1%),旋转二分答错 0 种
长度 10:所有 10 种旋转 × 10 个目标共 100 种,普通二分答错 31 种(31.0%),旋转二分答错 0 种
例:[4, 5, 6, 7, 0, 1, 2] 找 0:普通二分 -> -1,solve -> 4

对长度 4 到 10 的所有情形,普通二分都有 25% 到 31% 的情形答错,而本文的旋转二分一个都没错。例如 [4, 5, 6, 7, 0, 1, 2] 找 0:普通二分返回 -1,正确答案是 4。这个枚举只针对元素互不相同、长度 4 到 10 的数组,覆盖了这个长度上的全部旋转和全部存在的目标,不含目标不存在的情形。

判断哪一半有序:五种语言的实现

每次取中点 mid。如果 nums[lo] <= nums[mid],说明左半 [lo, mid] 有序(旋转点不在里面):若 target 落在 [nums[lo], nums[mid]) 内,就在左半找,否则去右半;反过来右半 [mid, hi] 有序:若 target 落在 (nums[mid], nums[hi]] 内就在右半找,否则去左半。Java、Go、C++ 里中点写成 lo + (hi - lo) / 2,避免 lo + hi 溢出(下一题会用真实运行展示这个溢出)。Python、Java、Go、JavaScript、C++ 都是真实编译运行的。

Python · 解法代码def solve(nums, target): lo, hi = 0, len(nums) - 1 while lo <= hi: mid = (lo + hi) // 2 if nums[mid] == target: return mid if nums[lo] <= nums[mid]: # 左半 [lo, mid] 是有序的 if nums[lo] <= target < nums[mid]: # target 落在有序的左半里 hi = mid - 1 else: lo = mid + 1 else: # 否则右半 [mid, hi] 是有序的 if nums[mid] < target <= nums[hi]: # target 落在有序的右半里 lo = mid + 1 else: hi = mid - 1 return -1
真实实测 · Python · 前几组用例的输出
$ python3 solution.py   # 统一入口:逐行读取 cases.jsonl,调用上面的 solve,打印 JSON
[[4,5,6,7,0,1,2],0]  →  4
[[4,5,6,7,0,1,2],3]  →  -1
[[1],0]  →  -1
[[1],1]  →  0
[[1,3],3]  →  1
[[3,1],1]  →  1
Java · 解法代码class Solution { public int search(int[] nums, int target) { int lo = 0, hi = nums.length - 1; while (lo <= hi) { int mid = lo + (hi - lo) / 2; // 写成 lo + (hi - lo) / 2,避免 lo + hi 溢出 if (nums[mid] == target) return mid; if (nums[lo] <= nums[mid]) { // 左半 [lo, mid] 是有序的 if (nums[lo] <= target && target < nums[mid]) hi = mid - 1; else lo = mid + 1; } else { // 否则右半 [mid, hi] 是有序的 if (nums[mid] < target && target <= nums[hi]) lo = mid + 1; else hi = mid - 1; } } return -1; } }
真实实测 · Java · 前几组用例的输出
$ javac -d build Main.java Solution.java && java -cp build Main < cases.jsonl
[[4,5,6,7,0,1,2],0]  →  4
[[4,5,6,7,0,1,2],3]  →  -1
[[1],0]  →  -1
[[1],1]  →  0
[[1,3],3]  →  1
[[3,1],1]  →  1
Go · 解法代码func search(nums []int, target int) int { lo, hi := 0, len(nums)-1 for lo <= hi { mid := lo + (hi-lo)/2 if nums[mid] == target { return mid } if nums[lo] <= nums[mid] { // 左半 [lo, mid] 是有序的 if nums[lo] <= target && target < nums[mid] { hi = mid - 1 } else { lo = mid + 1 } } else { // 否则右半 [mid, hi] 是有序的 if nums[mid] < target && target <= nums[hi] { lo = mid + 1 } else { hi = mid - 1 } } } return -1 }
真实实测 · Go · 前几组用例的输出
$ go build -o bin . && ./bin < cases.jsonl
[[4,5,6,7,0,1,2],0]  →  4
[[4,5,6,7,0,1,2],3]  →  -1
[[1],0]  →  -1
[[1],1]  →  0
[[1,3],3]  →  1
[[3,1],1]  →  1
JavaScript · 解法代码function search(nums, target) { let lo = 0, hi = nums.length - 1; while (lo <= hi) { const mid = lo + Math.floor((hi - lo) / 2); if (nums[mid] === target) return mid; if (nums[lo] <= nums[mid]) { // 左半 [lo, mid] 是有序的 if (nums[lo] <= target && target < nums[mid]) hi = mid - 1; else lo = mid + 1; } else { // 否则右半 [mid, hi] 是有序的 if (nums[mid] < target && target <= nums[hi]) lo = mid + 1; else hi = mid - 1; } } return -1; }
真实实测 · JavaScript · 前几组用例的输出
$ node run.js < cases.jsonl
[[4,5,6,7,0,1,2],0]  →  4
[[4,5,6,7,0,1,2],3]  →  -1
[[1],0]  →  -1
[[1],1]  →  0
[[1,3],3]  →  1
[[3,1],1]  →  1
C++ · 解法代码#include <vector> using namespace std; class Solution { public: int search(vector<int>& nums, int target) { int lo = 0, hi = (int)nums.size() - 1; while (lo <= hi) { int mid = lo + (hi - lo) / 2; // 写成 lo + (hi - lo) / 2,避免 lo + hi 溢出 if (nums[mid] == target) return mid; if (nums[lo] <= nums[mid]) { // 左半 [lo, mid] 是有序的 if (nums[lo] <= target && target < nums[mid]) hi = mid - 1; else lo = mid + 1; } else { // 否则右半 [mid, hi] 是有序的 if (nums[mid] < target && target <= nums[hi]) lo = mid + 1; else hi = mid - 1; } } return -1; } };
真实实测 · C++ · 前几组用例的输出
$ clang++ -std=c++17 -O2 -o bin sol.cpp && ./bin < cases.jsonl
[[4,5,6,7,0,1,2],0]  →  4
[[4,5,6,7,0,1,2],3]  →  -1
[[1],0]  →  -1
[[1],1]  →  0
[[1,3],3]  →  1
[[3,1],1]  →  1
真实实测 · 本机执行输出
共 14 组用例(含边界),以暴力解的结果为基准逐组比对:
  Python      通过 14/14
  Java        通过 14/14
  Go          通过 14/14
  JavaScript  通过 14/14
  C++         通过 14/14

复杂度实测:对数对线性

数组长度分别为 1 万、10 万、100 万、300 万,旋转点取在三分之一处,每次查找的目标是数组的最后一个元素(线性扫描要走完整个数组),每个规模取三次中最快的一次:

真实实测 · 本机执行输出
数组长度 n(找最后一个元素) | 暴力解 | 本文解法
     10000 |     0.160 ms | 0.002 ms
    100000 |     1.573 ms | 0.003 ms
   1000000 |    15.992 ms | 0.014 ms
   3000000 |    47.504 ms | 0.017 ms

线性查找(这里是 C 实现的 list.index)随长度线性增长,300 万时约 47.5ms;二分只需要约 log₂(300万) ≈ 22 次比较,耗时在微秒量级,300 万时约 0.017ms,约 2800 倍。二分的耗时在这个范围内几乎不变,小规模上的读数受计时精度影响,没有细究。

随机对拍

Python 版本另外和线性查找做了随机对拍,数组是随机的互不相同的升序数组经随机旋转得到,目标 60% 取自数组内、40% 随机取(可能不存在):

真实实测 · 本机执行输出
随机对拍 20000 组(长度 1 到 12、互不相同的升序数组,随机旋转,target 60% 取自数组内、40% 随机取(可能不存在)),solve 与 brute 的结果全部一致。

另外四种语言只在上面 14 组固定用例上验证过,没有做大规模随机对拍。

分步演示:每次二分先判断哪一半有序

下面的演示用官方示例 [4, 5, 6, 7, 0, 1, 2] 找 0。每一帧都是把上面的解法在这组输入上真实执行记录下来的,运行器还检查了演示的最终结果与 solve 的返回值一致。第一次 mid = 3:左半 [4, 5, 6, 7] 有序,0 不在里面,去右半;第二次 mid = 5,左半 [0, 1] 有序,0 在里面,去左半,下一次直接命中。

搜索旋转排序数组:[4, 5, 6, 7, 0, 1, 2],target = 0
mid当前搜索区间 [lo, hi]已排除找到的位置

    参考与说明

    • 官方题目页:LeetCode 33. 搜索旋转排序数组。本文只用自己的话概括了题意,完整约束以官方页面为准。
    • 数据来自本机真实运行,五种语言的版本写在页脚;耗时对比只用 Python 测量。
    • 没有覆盖:含重复元素的旋转数组(见第 81 题,最坏会退化成线性);先找旋转点再做两次普通二分的做法。
    京ICP备2026067502号-1京公网安备11010502063487号