LeetCode 33. 搜索旋转排序数组:O(log n)二分查找

0 阅读6分钟

在LeetCode中等难度题目中,「搜索旋转排序数组」是一道经典的二分查找变形题。它的核心考点的是对“旋转数组”特性的理解,以及如何在非完全升序的数组中,依然保持二分查找O(log n)的时间复杂度。今天就来一步步拆解这道题,从题目分析到代码实现,再到细节注意点,帮你彻底搞懂它。

一、题目解读:什么是旋转排序数组?

题目给出的前提很明确:

  • 原数组是升序排列的,且所有元素互不相同(这一点很关键,避免了重复元素带来的判断干扰);

  • 数组在某个未知下标k处「向左旋转」,旋转后数组变成 [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]];

  • 给定旋转后的数组nums和目标值target,要求找到target的下标,找不到则返回-1,且必须满足O(log n)时间复杂度。

举个例子:原数组 [0,1,2,4,5,6,7],在k=3处向左旋转后,得到 [4,5,6,7,0,1,2]。如果target=0,返回下标4;如果target=3,返回-1。

这里的核心矛盾是:数组不再是完全升序,但又保留了“部分升序”的特性——旋转后数组会被分成两个升序子数组(比如例子中的 [4,5,6,7] 和 [0,1,2])。而二分查找的核心是“通过中间值缩小查找范围”,所以我们的思路就是利用这种“部分升序”,判断target落在哪个升序区间,进而调整双指针。

二、核心思路:二分查找的变形应用

常规二分查找适用于完全升序数组,通过mid值与target的大小对比,直接调整左右指针。但旋转数组有两个升序区间,所以我们需要先判断「mid所在的区间是否是升序区间」,再判断target是否在该区间内,从而确定指针移动方向。

具体步骤拆解:

  1. 初始化双指针l=0(左边界)、r=n-1(右边界),n为数组长度;

  2. 循环条件:l ≤ r(当l超过r时,说明查找范围为空,未找到target);

  3. 计算mid = Math.floor((l + r) / 2)(注意TS/JS中除法会返回浮点数,需手动取整);

  4. 若nums[mid] === target,直接返回mid(找到目标值);

  5. 判断mid所在的区间是否为升序:

    • 情况1:nums[0] ≤ nums[mid] → mid左侧(l到mid)是升序区间;

    • 情况2:nums[0] > nums[mid] → mid右侧(mid到r)是升序区间;

  6. 根据升序区间,判断target是否在该区间内,调整指针:

    • 情况1(左侧升序):若target在[nums[0], nums[mid])之间 → 缩小范围到左侧(r=mid-1);否则缩小到右侧(l=mid+1);

    • 情况2(右侧升序):若target在(nums[mid], nums[n-1]]之间 → 缩小范围到右侧(l=mid+1);否则缩小到左侧(r=mid-1);

  7. 循环结束后仍未找到,返回-1。

三、完整代码实现(TypeScript)

结合上述思路,给出完整的TypeScript代码,关键步骤已添加注释,方便理解:

function search(nums: number[], target: number): number {
    const n = nums.length;
    // 边界处理:数组为空,直接返回-1
    if (n === 0) {
        return -1;
    }
    // 边界处理:数组只有一个元素,直接判断是否等于target
    if (n === 1) {
        return nums[0] === target ? 0 : -1;
    }
    // 初始化双指针:左指针l,右指针r
    let l = 0, r = n - 1;
    // 二分查找循环:当左指针不大于右指针时,继续查找
    while (l <= r) {
        // 计算中间下标mid,手动取整避免浮点数
        const mid = Math.floor((l + r) / 2);
        // 找到目标值,直接返回下标
        if (nums[mid] === target) {
            return mid;
        }
        // 情况1:mid左侧是升序区间(nums[0] <= nums[mid])
        if (nums[0] <= nums[mid]) {
            // 判断target是否在左侧升序区间内(nums[0] <= target < nums[mid])
            if (nums[0] <= target && target < nums[mid]) {
                // 缩小范围到左侧,右指针左移
                r = mid - 1;
            } else {
                // 缩小范围到右侧,左指针右移
                l = mid + 1;
            }
        } else {
            // 情况2:mid右侧是升序区间(nums[0] > nums[mid])
            // 判断target是否在右侧升序区间内(nums[mid] < target <= nums[n-1])
            if (nums[mid] < target && target <= nums[n - 1]) {
                // 缩小范围到右侧,左指针右移
                l = mid + 1;
            } else {
                // 缩小范围到左侧,右指针左移
                r = mid - 1;
            }
        }
    }
    // 循环结束,未找到目标值,返回-1
    return -1;
}

四、关键细节与易错点

这道题的代码不算复杂,但细节处理不到位很容易出错,尤其是以下3个点:

1. 边界条件处理

必须先处理数组为空(n=0)和数组只有一个元素(n=1)的情况。如果忽略这两个边界,当数组长度为0时会出现指针异常,长度为1时会进入循环做无用功,影响效率。

2. mid的计算方式

在TypeScript/JavaScript中,(l + r) / 2 会返回浮点数(比如l=0、r=1时,结果是0.5),所以必须用Math.floor()取整,否则mid会是小数,导致数组下标报错。

补充:也可以用 (l + r) >> 1 进行位运算取整(效果等同于Math.floor((l + r)/2)),但要注意避免溢出(本题中数组长度不会过大,两种方式均可)。

3. 区间判断的等号问题

这是最容易出错的地方,比如:

  • 左侧升序区间判断时,用 nums[0] ≤ nums[mid](包含等于,因为mid可能就是0下标,此时左侧只有一个元素,也是升序);

  • target在左侧区间的判断的是 nums[0] ≤ target && target < nums[mid](target不能等于nums[mid],因为前面已经判断过nums[mid] !== target);

  • 右侧升序区间判断时,target的范围是 nums[mid] < target && target ≤ nums[n-1](同理,target不能等于nums[mid])。

如果等号位置写错,会导致指针调整错误,进而错过目标值或者进入死循环。

五、复杂度分析与题目延伸

1. 时间复杂度

整个算法采用二分查找,每次循环都会将查找范围缩小一半,所以时间复杂度是 O(log n),完全满足题目要求。

2. 空间复杂度

算法只使用了常数个变量(l、r、mid、n),没有使用额外的空间,空间复杂度是 O(1)。

3. 题目延伸

这道题的变形题是「搜索旋转排序数组II」,区别在于数组元素可以重复。此时,nums[0] ≤ nums[mid] 无法直接判断左侧是升序区间(比如 [1,0,1,1,1]),需要先处理重复元素(比如当nums[l] === nums[mid]时,l++),感兴趣的可以后续深入研究。

六、总结

「搜索旋转排序数组」的核心是“利用旋转数组的部分升序特性,改造二分查找”。解题的关键在于:

  1. 判断mid所在的升序区间;

  2. 根据target是否在该升序区间,调整双指针;

  3. 注意边界条件和等号的处理。

这道题虽然是中等难度,但只要掌握了二分查找的核心思想,再结合旋转数组的特性,就能轻松破解。建议大家多动手调试代码,尝试不同的测试用例(比如旋转点在开头、结尾、中间的情况),加深对算法的理解。