LeetCode hot100——153.寻找旋转排序数组中的最小值:Java 二分模板与 O(log n) 分析

一句话说明核心方法

最小值就是旋转点(切点) ,而它有一个绝好的性质:只要 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 == rightnums[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 == rightmid == left == rightnums[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=2nums[2]=5 vs nums[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] 的语义随收缩而漂移。锚点只能用右端

  • 无旋转数组(旋转了 n 次)时以为要特判 ------不需要。比如 [11,13,15,17]:全程 nums[mid] < nums[right] 成立,一直走 right = mid,收敛到 left = 0,返回 nums[0] = 11,天然正确。切点在 0 这个边界被模板自动覆盖。

  • n == 1 时进不去循环就慌 ------left = right = 0while (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 的单调性是局部 的("上升就一定有峰在右边"),本题的单调性是全局的("切点的位置相对右端确定")。放在一起读,能理解"锚点选择"在二分难题里的核心地位。

相关推荐
光电的一只菜鸡1 小时前
isp中关于锐化对图像清晰度的影响
算法
磐链科技1 小时前
交易所开发中的合规与安全:KYC、AML、冷热钱包设计要点
java·区块链
码农飞哥1 小时前
企业级RAG系统架构详解
java·人工智能·ai编程·rag·ai应用
Wang's Blog1 小时前
Java框架快速入门: Spring Security+OAuth2之搭建授权服务器(依赖与表结构)
java·服务器·spring
土司大王1 小时前
LeetCode hot100——4.寻找两个正序数组的中位数:Java 二分第K小 递归裁剪
java·算法·leetcode
dllxhcjla1 小时前
SpringAI+DeepSeek大模型应用开发
java
薛定e的猫咪1 小时前
(ICML2024)QSM:基于 Q 函数梯度对齐分数场的扩散模型离策略强化学习
人工智能·深度学习·算法
小鱼干..1 小时前
青少年ctf url
java