一句话说明核心方法
最小值就是旋转点(切点) ,而它有一个绝好的性质:只要 nums[mid] > nums[right],mid 就一定在切点左侧 。于是每轮只需比较 nums[mid] 和 nums[right] 一次,决定丢掉左半还是右半,区间收敛到只剩一个元素时,那个元素就是最小值。
思路推导
题意转化:升序数组旋转若干次后,求最小元素。由于原数组升序,「最小值」等价于「切点」,也就是那个"断崖"的位置。
4 5 6 7 | 0 1 2
[ 较大升序段 ] [ 较小升序段 ]
↑ 切点 = 最小值
关键观察 1:为什么这题比 33 题(搜索旋转数组中的 target)简单?
33 题要"找某个值",而 target 的位置没有任何单调性------它可能落在较大段,也可能落在较小段,所以必须先花力气判断"哪半段有序",再做落点判断(两层嵌套 if)。
153 题要"找最小值",而最小值的位置与 nums[right] 的大小关系是单调的:
nums[mid] > nums[right]⟺ mid 落在较大段 ,切点(最小值)必然在 mid 右侧nums[mid] < nums[right]⟺[mid, right]处于同一升序段 ,最小值在 mid 左侧(含 mid)
一次比较就能定性,不需要"有序段判定"这层。这是本题比 33 简单得多的根本原因。
关键观察 2:为什么锚点必须选 nums[right],不能选 nums[left]?
因为 nums[right] 是一个"干净的参照物"------它在当前窗口内,且永远位于切点的右侧或就是切点本身,所以它天然是"较小段"里的元素。拿它作锚点,"mid 在切点左还是右"的判断是单向确定的。
而 nums[left] 的语义会随收缩过程漂移:一旦 left 已经越过切点,nums[left] 自己就落进较小段,再拿它当锚点,比较就失去信息量。后面「易错点」里会给一个具体的反例。
关键观察 3:为什么 right = mid(不加一),而 left = mid + 1(加一)?
这是本题最容易记混、也最应该理解的一处不对称:
nums[mid] > nums[right]时,mid 上的值已经被证明大于 numsright ,而nums[right]本身就是窗口内的合法候选,所以 mid 绝不可能是最小值 → 可以放心用mid + 1排除它。nums[mid] < nums[right]时,mid 自己可能就是最小值 (当 mid 恰好落在切点上时,numsmid 是最小值且小于右侧所有元素)→ 只能用right = mid保留它当候选。
锚点选在右边,就意味着"右边的元素自己就是候选",所以左右收缩的力度必然不同。
二分过程示意(nums = 4,5,6,7,0,1,2)
初始: left = 0, right = 6, mid = 0 + (6 - 0) / 2 = 3
idx: 0 1 2 3 4 5 6
val: 4 5 6 7 0 1 2
L M R
nums[mid] = nums[3] = 7, nums[right] = nums[6] = 2
7 > 2 → mid 在切点左侧 → 最小值在右侧 → left = mid + 1 = 4
第二轮,窗口缩小到右半段 [0,1,2]:
第 2 轮: left = 4, right = 6, mid = 4 + (6 - 4) / 2 = 5
idx: 0 1 2 3 4 5 6
val: 4 5 6 7 0 1 2
L M R
nums[5] = 1, nums[6] = 2
1 > 2 不成立 → [5,6] 同一升序段,最小值在左侧(含 mid) → right = mid = 5
第三轮,窗口只剩两个元素:
第 3 轮: left = 4, right = 5, mid = 4 + (5 - 4) / 2 = 4
idx: 0 1 2 3 4 5 6
val: 4 5 6 7 0 1 2
L R
M
nums[4] = 0, nums[5] = 1
0 > 1 不成立 → right = mid = 4
→ left == right == 4,循环结束 → return nums[4] = 0 ✓
整个过程的感知是:left 像一只手,每当发现"mid 处的值比右边大"就跳到 mid 右边去逼近切点;right 则慢慢向左贴,把"不可能是切点右侧"的部分一点点削掉。两者相遇处就是切点。
Java 完整代码
java
class Solution {
public int findMin(int[] nums) {
int left = 0;
int right = nums.length - 1; // 左闭右闭:right 是有效下标
while (left < right) { // 区间只剩一个元素时即答案
int mid = left + (right - left) / 2;
if (nums[mid] > nums[right]) {
// mid 在切点左侧(较大段),最小值一定在 mid 右边
left = mid + 1;
} else {
// [mid, right] 处于同一升序段,mid 本身可能就是最小值
right = mid;
}
}
return nums[left]; // 此时 left == right,即切点
}
}
关键代码逐行解释
-
while (left < right)而不是left <= right------这是本题的必写形式 。因为右边界收缩用的是right = mid(不排除 mid),当区间只剩一个元素(left == right)时,mid == left == right,nums[mid] > nums[right]必然为 false,于是走right = mid------right 原地不动,死循环 。用left < right让循环在"只剩一个候选"时就停下,那个候选就是答案,不需要再比较一次。 -
nums[mid] > nums[right]中的right必须是当前窗口的右端,不是nums.length - 1------每轮拿到的是"当前区间"的右端点。它天然地"随着收缩不断左移",正因为如此,nums[right]始终落在较小段(或就是切点),锚点才是可信的。写成固定的nums[n-1]会破坏这个不变量。 -
nums[mid] > nums[right]用严格大于 ------本题数组元素互不相同 ,不存在相等歧义。若迁移到 LeetCode 154(含重复),==的情况会出现且无法判断方向,必须额外处理(见「可复用模板」)。 -
left = mid + 1(可以排除 mid) ------nums[mid] > nums[right]意味着 mid 处的值确定大于 窗口右端元素,因此 mid 绝不可能是最小值;而nums[right]本身已经在窗口里作为候选被保留,丢掉[left, mid](含 mid)是安全的。 -
right = mid(不能排除 mid) ------nums[mid] < nums[right]说明[mid, right]是同一条上升线,最小值只可能出现在 mid 或 mid 左边 。当 mid 恰好就是切点时,numsmid 正是答案,所以只能right = mid而不能right = mid - 1。 -
return nums[left]------循环退出时left == right,该位置即最小元素下标。也可以写return nums[right],数值完全相同;统一写left可读性更好(和 33 题的出口风格一致)。 -
left + (right - left) / 2------防溢出写法。本题 n ≤ 5000 不会溢出,但这是二分模板的肌肉记忆,换到大数组场景(如 4 题、300 题的二分优化)真的会炸。
时间、空间复杂度
-
时间复杂度:O(log n)
- 每轮循环区间都严格变小:走
left = mid + 1时,left 至少前进 1;走right = mid时,由于mid = left + ⌊(right - left) / 2⌋ < right(只要left < right),新右端点必然小于旧右端点。更精确地,两个分支都把区间长度约减半,所以轮数上界是 ⌈log₂ n⌉。n = 5000 时约 13 轮。 - 对比 33 题:同样 O(log n),但本题每轮只有一次比较 (33 要比较
nums[mid]与 left/right 两个端点),常数更小。
- 每轮循环区间都严格变小:走
-
空间复杂度:O(1)
- 只有 left / right / mid 三个变量,无递归、无辅助数组。
易错点
-
循环条件写成
while (left <= right)导致死循环 ------这是本题最高频的 bug。left == right时mid == left == right,nums[mid] > nums[right]是 false,走right = mid后右端点纹丝不动,程序卡死。记住三件套绑定:right = mid必须配while (left < right)。 -
右边界写成
right = mid - 1------错在"把 mid 也排除了"。当 mid 恰好是切点时,nums[mid]就是最小值,mid - 1直接把它扔掉,返回偏大的错误答案。判别方法:锚点是右侧,右收缩永不加减。 -
拿
nums[left]当锚点 ------看起来"对称",实际会失败。反例:nums = [3,4,5,1,2],若写成if (nums[mid] < nums[left]) right = mid; else left = mid + 1;:- 第 1 轮
mid=2,nums[2]=5vsnums[0]=3,5 < 3 不成立 →left = 3 - 第 2 轮
left=3, right=4, mid=3,此时nums[left]已经变成nums[3]=1(left 越过切点了),1 < 1不成立 →left = 4 left == right == 4,返回nums[4] = 2✗(正确答案是 1)
根因就是
nums[left]的语义随收缩而漂移。锚点只能用右端。 - 第 1 轮
-
无旋转数组(旋转了 n 次)时以为要特判 ------不需要。比如
[11,13,15,17]:全程nums[mid] < nums[right]成立,一直走right = mid,收敛到left = 0,返回nums[0] = 11,天然正确。切点在 0 这个边界被模板自动覆盖。 -
n == 1 时进不去循环就慌 ------
left = right = 0,while (0 < 0)不成立,直接return nums[0],正确。单元素数组是最小元素它自己的退化情形,模板无需特判。
可复用模板
「右端锚点二分找切点」母版,适用于一切"部分有序、求极值位置"的题:
java
class Solution {
public int findMin(int[] nums) {
int left = 0, right = nums.length - 1;
while (left < right) { // ① 只剩一个候选即答案
int mid = left + (right - left) / 2;
if (nums[mid] > nums[right]) { // ② mid 在切点左侧
left = mid + 1; // 排除 mid(它比 nums[right] 大)
} else { // ③ mid 在切点或其右侧
right = mid; // 保留 mid(它可能就是最小值)
}
}
return nums[left]; // ④ 相遇点 = 切点
}
}
变体提示:
- 含重复元素 (LeetCode 154)→ 出现
nums[mid] == nums[right]时无法判断 mid 在切点哪一侧,只能right--逐格收缩(因为nums[right]有重复副本,丢掉一个不影响结果),最坏退化为 O(n)。 - 求最大值 → 先按本模板求出切点位置
k,最大值就是切点左边那一个:nums[(k - 1 + n) % n]。 - 比较相邻元素的极值题 (LeetCode 162 寻找峰值)→ 结构同源,把锚点换成
nums[mid]与nums[mid + 1]的比较,同样"只收半边",同样"保留可能的极值点"。 - 想复用 33 题的框架找 target → 本模板可作为 33 题"两步法"的第一步:先定位切点 k,再判断 target 落在
[k, n-1]还是[0, k-1],最后做标准二分。
相似题及区别
-
LeetCode 154 寻找旋转排序数组中的最小值 II :本题的"含重复元素"版。
nums[mid] == nums[right]时方向不明,只能把right左移一格(丢弃一个重复副本),最坏 O(n);本题因元素互异,严格 O(log n)。这是检验"是否真理解锚点语义"的最佳变种题。 -
LeetCode 33 搜索旋转排序数组 :同样在旋转数组上二分,但目标是找 target 而非找切点。target 的位置没有单调性,必须先判"哪半段有序"再做落点判断;本题的"最小值的相对位置"天然单调,一次比较即可。两题对照能看清**"可二分的本质是有序性可判定"**。
-
LeetCode 81 搜索旋转排序数组 II:33 题的含重复版,同样退化为最坏 O(n)。和本题 + 154 构成完整的"旋转数组四件套"(无重复/有重复 × 找值/找极值)。
-
LeetCode 162 寻找峰值 :求局部极大值,同样用"比较相邻元素 + 只收半边"的策略,复杂度也是 O(log n)。和本题的区别在于:162 的单调性是局部 的("上升就一定有峰在右边"),本题的单调性是全局的("切点的位置相对右端确定")。放在一起读,能理解"锚点选择"在二分难题里的核心地位。