力扣热题100实战 | 第33期:搜索旋转排序数组——二分查找的变体艺术

力扣热题100实战 | 第33期:搜索旋转排序数组------二分查找的变体艺术

当有序数组被旋转,二分查找还能用吗?答案是肯定的,核心在于判断哪一半是单调的。这道题教会我们:在看似混乱的数据中,只要找到一个有序的片段,就能继续用二分缩小范围。

前言

你好,我是@礼拜天没时间。

上一期我们攻克了"最长有效括号"(第32题),掌握了动态规划和栈在括号匹配中的应用。这一期,我们来解一道二分查找的经典变体------搜索旋转排序数组(LeetCode 第33题)。

这道题在力扣上被标记为"中等",但它却是二分查找思想应用的最佳范例。很多人学完经典二分后,遇到数组被"旋转"一下就不知道该怎么下手了。其实,只要我们能够识别出数组的"部分有序"性质,二分查找依然能够大显身手。

今天,我希望能带你从最朴素的线性搜索出发,一步步推导出二分查找的核心逻辑,并掌握处理边界条件和重复元素的技巧。


一、题目:在旋转数组中找目标值

先看题目描述(LeetCode 第33题):

整数数组 nums 按升序排列,数组中的值 互不相同

在传递给函数之前,nums 在预先未知的某个下标 k0 <= k < nums.length)上进行了旋转,使数组变为 [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]]。例如, [0,1,2,4,5,6,7] 在下标 3 处旋转后变成 [4,5,6,7,0,1,2]

给你旋转后的数组 nums 和一个整数 target ,如果 target 在数组中,则返回它的下标,否则返回 -1

你必须设计一个时间复杂度为 O(log n) 的算法解决此问题。

示例 1:

复制代码
输入:nums = [4,5,6,7,0,1,2], target = 0
输出:4

示例 2:

复制代码
输入:nums = [4,5,6,7,0,1,2], target = 3
输出:-1

示例 3:

复制代码
输入:nums = [1], target = 0
输出:-1

关键点解读

  1. 唯一性 :数组中的值互不相同,这简化了问题。进阶版(第81题)允许重复元素,会更复杂。

  2. 旋转定义:本质就是把原有序数组的前面一部分(长度为k)移到末尾,形成"两段有序"的结构。

  3. 时间复杂度要求 :O(log n),这直接暗示我们要用二分查找

  4. 寻找旋转点:不一定需要显式找到旋转点,可以在二分过程中动态判断哪一半是有序的。


二、第一反应:线性扫描(违反要求)

当我第一次看到这道题,我的第一反应是:直接遍历一遍,找到目标值返回下标

java 复制代码
class Solution {
    public int search(int[] nums, int target) {
        for (int i = 0; i < nums.length; i++) {
            if (nums[i] == target) return i;
        }
        return -1;
    }
}
  • 时间复杂度:O(n) ------ 不符合题目要求
  • 空间复杂度:O(1)

虽然能过测试,但题目明确要求 O(log n),必须用二分。


三、核心解法:二分查找(判断有序半区)

3.1 核心思想

因为数组原本有序,只是被旋转了一次,所以它有一个重要的性质:对于任意中间点 mid,数组的左半部分 [left, mid] 和右半部分 [mid+1, right] 至少有一个是严格递增的

我们可以通过比较 nums[left]nums[mid] 来判断哪一边是有序的:

  • 如果 nums[left] <= nums[mid],说明左半部分 [left, mid] 是递增的(没有旋转点落在这一区间)。
  • 否则,右半部分 [mid+1, right] 是递增的。

确定了有序半区之后,我们就可以判断目标值是否在这个有序半区内:

  • 如果在,则继续在这个半区内二分。
  • 如果不在,则目标在另一半(无序半区),继续二分。

3.2 算法步骤

  1. 初始化 left = 0, right = nums.length - 1
  2. left <= right 时循环:
    • 计算 mid = left + (right - left) / 2
    • 如果 nums[mid] == target,直接返回 mid
    • 判断左半部分是否有序:if (nums[left] <= nums[mid])
      • 若左半有序,检查 target 是否在 [nums[left], nums[mid]) 范围内:
        • 如果是,则 right = mid - 1
        • 否则,left = mid + 1
    • 否则,右半部分有序:
      • 检查 target 是否在 (nums[mid], nums[right]] 范围内:
        • 如果是,则 left = mid + 1
        • 否则,right = mid - 1
  3. 循环结束返回 -1

3.3 图解流程

nums = [4,5,6,7,0,1,2], target = 0 为例:

第1步left=0, right=6, mid=3

复制代码
nums = [4,5,6,7,0,1,2]
        ↑     ↑     ↑
       left  mid   right
  • nums[left]=4 <= nums[mid]=7 → 左半 [4,5,6,7] 有序。
  • target=0 不在 [4,7] 内 → 去右半 left=mid+1=4

第2步left=4, right=6, mid=5

复制代码
nums = [4,5,6,7,0,1,2]
                    ↑
                 left,mid?
准确下标:left=4(0), right=6(2), mid=5(1)
  • nums[left]=0 <= nums[mid]=1 → 左半 [0,1] 有序。
  • target=0 在 [0,1] 内 → right=mid-1=4

第3步left=4, right=4, mid=4

  • nums[mid]=0 == target → 返回4 ✅

3.4 代码实现

java 复制代码
class Solution {
    public int search(int[] nums, int target) {
        int left = 0, right = nums.length - 1;
        
        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] == target) {
                return mid;
            }
            
            // 判断左半部分是否有序
            if (nums[left] <= nums[mid]) {
                // 左半部分有序
                if (target >= nums[left] && target < nums[mid]) {
                    right = mid - 1;
                } else {
                    left = mid + 1;
                }
            } else {
                // 右半部分有序
                if (target > nums[mid] && target <= nums[right]) {
                    left = mid + 1;
                } else {
                    right = mid - 1;
                }
            }
        }
        return -1;
    }
}

3.5 复杂度分析

  • 时间复杂度:O(log n) ------ 每次循环将区间缩小一半
  • 空间复杂度:O(1) ------ 只用了常数变量

四、细节剖析:面试官真正关心的问题

Q1:为什么条件是 nums[left] <= nums[mid] 而不是 <

答案 :当 left == mid 时(即区间内只有1或2个元素),nums[left] <= nums[mid] 成立,此时左半部分也是有序的(单个元素自然有序)。如果用 <,可能会在 left == mid 时误判,导致逻辑错误。

Q2:如何判断目标值是否在有序半区?为什么有的用 < 有的用 <=

答案 :因为我们已经检查过 nums[mid] == target,所以:

  • 对于左半部分有序,范围是 [nums[left], nums[mid]),左闭右开,因为 mid 已经排除。
  • 对于右半部分有序,范围是 (nums[mid], nums[right]],左开右闭。
    这样能避免重复判断 mid 位置。

Q3:如果数组没有旋转(k=0),算法还能正确工作吗?

答案 :能。此时 nums[left] <= nums[mid] 始终成立,算法会一直走左半有序分支,等同于在完全有序数组上进行普通二分查找。

Q4:为什么不需要显式找到旋转点?

答案 :因为旋转信息隐含在比较 nums[left]nums[mid] 的结果中。我们不需要提前知道旋转点位置,只需要在每次二分时动态判断哪一半有序,然后根据 target 决定下一步搜索区间。

Q5:如果数组中有重复元素(LeetCode 81),这个算法还适用吗?

答案 :不能直接适用,因为当 nums[left] == nums[mid] 时,无法判断哪一半有序,需要退化到线性扫描或特殊处理。LeetCode 81 提供了进阶版本。


五、面试官追问进阶版

追问1:如何实现一个方法找到旋转数组中的最小值(LeetCode 153)?

思路 :同样用二分,但比较的是 nums[mid]nums[right]。如果 nums[mid] > nums[right],说明最小值在右半;否则在左半(包含 mid)。类似地,也可以比较 nums[left]nums[mid]

java 复制代码
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]) {
            left = mid + 1;
        } else {
            right = mid;
        }
    }
    return nums[left];
}

追问2:如果数组旋转了多次(其实等价于一次旋转),是否会影响正确性?

答案:不会。任意次旋转等价于一次旋转(因为旋转是循环移动),我们的算法依然有效。

追问3:如何用递归实现二分?

思路:递归分解子问题,本质上与迭代相同,但递归深度 O(log n) 会有额外空间开销。

追问4:如果数组长度非常大(10⁷),这个二分法仍然高效吗?

答案:是的,二分查找的对数复杂度在数据量极大时优势非常明显。10⁷ 的数据量只需约 24 次比较即可完成。


六、实际开发:这道题到底有什么用?

很多读者会问:"旋转数组中查找,实际工作中哪用得到?"

其实它的思想无处不在:

场景1:日志索引查询

日志系统常常按时间戳排序存储,但可能因为系统故障导致部分日志被移动到其他位置(相当于旋转)。二分查找的思想能快速定位特定时间点的日志。

场景2:循环队列中的查找

循环队列在物理上是线性的,但逻辑上是环形的。在环形缓冲中查找元素,可以转化为旋转数组的查找。

场景3:数据库索引中的断裂页

某些数据库索引中,数据页可能因为整理导致顺序被打乱但依然是部分有序,二分查找变体可以用于在这些段中快速定位。

场景4:游戏开发中的物品栏排序

游戏中的背包物品可能按某种顺序排列,但用户可以对物品进行旋转排序(如把前面的物品移到后面),查找特定物品时可以用类似思想。

场景5:算法面试题

这道题是二分查找变体中最经典的考题,考察对二分思想的深刻理解。


七、总结:从一道题到一类题

回顾一下,我们从搜索旋转排序数组学到了什么:

维度 收获
算法思维 线性扫描 → 二分查找,理解"部分有序"性质如何被利用
代码技巧 动态判断有序半区、区间端点比较的细节
复杂度分析 O(log n) 时间、O(1) 空间,最优解
面试要点 为什么要判断有序半区?条件为什么是 <=?如何处理边界?
工程关联 日志索引、环形缓冲、数据库索引、背包管理

力扣热题100的第三十三题,不是为了难住你,而是为了告诉你:二分查找的舞台远不止完全有序的数组。学会识别问题中的"部分有序",你就能让二分查找的光芒在不完美中继续闪耀。

下一期预告:《在排序数组中查找元素的第一个和最后一个位置》------二分查找的两次运用


附录:思考题

看完这篇文章,你可以试着回答:

  1. 如果数组中允许重复元素(LeetCode 81),上述算法需要怎么调整?
  2. 如何用这个思想解决"旋转数组中的峰值查找"问题?
  3. 你能用这道题的思路,去解 LeetCode 153(寻找旋转排序数组中的最小值)和 LeetCode 154(寻找旋转排序数组中的最小值 II)吗?

欢迎在评论区留下你的思考!

相关推荐
Jenlybein2 小时前
用 uv 替代 conda,速度飙升(从 0 到 1 开始使用 uv)
后端·python·算法
400分2 小时前
LangChain 与大模型技术全链路详解
算法·架构
电科一班林耿超2 小时前
第 14 课:动态规划(DP)—— 算法思想的巅峰,面试的终极分水岭
数据结构·算法·动态规划
lihao lihao2 小时前
Linux文件与fd
java·linux·算法
Navigator_Z2 小时前
LeetCode //C - 1026. Maximum Difference Between Node and Ancestor
c语言·算法·leetcode
We་ct2 小时前
LeetCode 63. 不同路径 II:动态规划解题详解
前端·算法·leetcode·typescript·动态规划
如君愿2 小时前
考研复习 Day 20 | 数据结构与算法--查找
数据结构·考研·算法·记录考研
xin_nai2 小时前
LeetCode热题100(Java)(3)滑动窗口
算法·leetcode·滑动窗口
黎阳之光3 小时前
视频孪生赋能智慧能源园区:黎阳之光打造全域数智化新标杆
大数据·人工智能·算法·安全·数字孪生