团灭5题leetcode旋转排序数组

139 阅读3分钟

旋转排序数组题目的解法一般都是二分查找。大家都知道二分查找是细节狂魔,下面我们来总结一下leetcode上5题关于旋转排序数组题目,来得到一些解题经验。题目如下:

  1. 33. 搜索旋转排序数组
  2. 81. 搜索旋转排序数组 II
  3. 153. 寻找旋转排序数组中的最小值
  4. 154.寻找旋转排序数组中的最小值-ii
  5. 面试题 10.03. 搜索旋转数组

文章首发于👉团灭5题leetcode旋转排序数组

第一题【33. 搜索旋转排序数组】

整数数组 nums **按升序排列**,数组中的值 **互不相同** 。
在传递给函数之前,nums 在预先未知的某个下标 k(0 <= k < nums.length)上进行了旋转,
使数组变为 [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]](下标 从 0 开始 计数)。
例如, [0,1,2,4,5,6,7] 在下标 3 处经旋转后可能变为 [4,5,6,7,0,1,2] 。
给你旋转后的数组 nums 和一个整数 target ,如果 nums 中存在这个目标值 target ,则返回它的下标,否则返回 -1 。

对于有序数组,我们可以使用二分查找来找到元素。本题中,数组本身不是有序的,进行旋转后只保证了数组的局部是有序的。其实这道题的关键就是局部有序,将数组从中间分开成左右两部分的时候,一定有一部分的数组是有序的,我们就可以从有序的那一部分入手。下面举两个情况,以题目中的例子来说。

  1. [0,1,2,4,5,6,7] 在下标 3 处经旋转后可能变为 [4,5,6,7,0,1,2],那么此时左边边界 left数值4 到 mid数值7 是保持升序的,我们可以先判断target是不是在此区间,以此来收缩right边界或者left边界。
  2. [0,1,2,4,5,6,7] 在下标 4 处经旋转后可能变为 [5,6,7,0,1,2,4],那么此时左边边界 mid数值0 到 right数值4 是保持升序的,我们可以先判断target是不是在此区间,以此来收缩left边界或者right边界

最终的代码如下,细节点已经注释:

var search = function (nums, target) {
  let left = 0;
  let right = nums.length - 1;
  // 细节点1
  // 如果是left<right, 那么当left===right时结束循环
  // 此时有可能nums[left]===nums[right] === target, 错过答案
  while (left <= right) {
    const mid = Math.floor((left + right) / 2);

    if (nums[mid] === target) return mid;
    // 细节点2
    // 当中间值与左边界left对应的元素相等时,将左边界left右移
    // 因为此时arr[left]===arr[mid]但是arr[mid] !== target,即arr[left] !== target
    // 所以target一定落在[l+1,r]区间内
    if (nums[mid] === nums[left]) {
      left++;
    } else if (nums[mid] > nums[left]) {
      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;
};

第二题【81. 搜索旋转排序数组 II】

已知存在一个按非降序排列的整数数组 nums ,数组中的值**不必互不相同**。
在传递给函数之前,nums 在预先未知的某个下标 k(0 <= k < nums.length)上进行了旋转,使数组变为 [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]](下标 从 0 开始 计数)。
例如, [0,1,2,4,4,4,5,6,6,7] 在下标 5 处经旋转后可能变为 [4,5,6,6,7,0,1,2,4,4] 。
给你 旋转后 的数组 nums 和一个整数 target 
请你编写一个函数来判断给定的目标值是否存在于数组中。
如果 nums 中存在这个目标值 target ,则返回 true ,否则返回 false 。
你必须尽可能减少整个操作步骤。

对于数组中有重复数值的情况,二分查找时可能会有 nums[l]===nums[mid]===nums[r]的情况,此时无法判断区间 [l,mid] 和区间 [mid+1,r] 哪个是有序的。直接上题解:

var search = function (nums, target) {
  let left = 0;
  let right = nums.length - 1;

  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    if (nums[mid] === target) return true;
    // 当中间值与左边界left对应的元素相等时,将左边界left右移
    // 因为此时arr[left]==arr[mid]但是arr[mid] != target,即arr[left] != target
    // 所以target一定落在[l+1:r]区间内
    if (nums[left] === nums[mid]) {
      left++;
      // 同理
    } else if (nums[right] === nums[mid]) {
      right--;
    } 
    // 后面的处理跟第一题一样
    else 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 false;
};

第三题【153. 寻找旋转排序数组中的最小值】

已知一个长度为 n 的数组,预先按照升序排列,经由 1 到 n 次 旋转 后,得到输入数组。
例如,原数组 nums = [0,1,2,4,5,6,7] 在变化后可能得到:
若旋转 4 次,则可以得到 [4,5,6,7,0,1,2]
若旋转 7 次,则可以得到 [0,1,2,4,5,6,7]
注意,数组 [a[0], a[1], a[2], ..., a[n-1]] 旋转一次的结果为数组 [a[n-1], a[0], a[1], a[2], ..., a[n-2]] 。

给你一个元素值 互不相同 的数组 nums ,它原来是一个升序排列的数组,并按上述情形进行了多次旋转。
请你找出并返回数组中的 最小元素 。

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

根据第一题分析过的旋转数组的局部有序的特点,我们分开讨论:

  1. nums[mid] = nums[left] 此时mid和left重合,更新min最小值,left++
  2. nums[mid] > nums[left] 此时left到mid保持升序,此段区间内最小值为left,更新min最小值,需要看mid到right段,left = mid+1
  3. nums[mid] < nums[left] 此时mid到right保持升序,此段区间内最小值为mid,更新min最小值,需要看left到mid段,right = mid-1

其中由于元素的值互不相同,1和2的情况可以合并。代码如下:

var findMin = function (nums) {
  let left = 0;
  let right = nums.length - 1;
  let min = Number.MAX_SAFE_INTEGER;
  while (left <= right) {
    const mid = Math.floor((left + right) / 2);

    if (nums[mid] >= nums[left]) {
      min = Math.min(min, nums[left]);
      left = mid + 1;
    } else {
      min = Math.min(min, nums[mid]);
      right = mid - 1;
    }
  }

  return min;
};

第四题【154.寻找旋转排序数组中的最小值-ii】

已知一个长度为 n 的数组,预先按照升序排列,经由 1 到 n 次 旋转 后,得到输入数组。
例如,原数组 nums = [0,1,4,4,5,6,7] 在变化后可能得到:
若旋转 4 次,则可以得到 [4,5,6,7,0,1,4]
若旋转 7 次,则可以得到 [0,1,4,4,5,6,7]
注意,数组 [a[0], a[1], a[2], ..., a[n-1]] 旋转一次的结果为数组 [a[n-1], a[0], a[1], a[2], ..., a[n-2]] 。

给你一个可能存在 重复 元素值的数组 nums ,它原来是一个升序排列的数组,并按上述情形进行了多次旋转。
请你找出并返回数组中的 最小元素 。

你必须尽可能减少整个过程的操作步骤。

参考第三题的题解,不合并1和2的情况,因为本题中元素的值可以重复,nums[left] === nums[mid] 时left!==mid, left++而不是 left = mid + 1。代码如下:

var findMin = function (nums) {
    let left = 0
    let right = nums.length - 1
    let min = Number.MAX_SAFE_INTEGER
    while (left <= right) {
        const mid = Math.floor((left + right) / 2)

        if (nums[left] === nums[mid]) {
            // [1,1,1,1,1,1,1,1]
            min = Math.min(min, nums[left])
            left++
        } else if (nums[left] < nums[mid]) {
            min = Math.min(min, nums[left])
            left = mid + 1
        } else if (nums[left] > nums[mid]) {
            min = Math.min(min, nums[mid])
            right = mid - 1
        }
    }

    return min
};

第五题【面试题 10.03. 搜索旋转数组】

搜索旋转数组。给定一个排序后的数组,包含n个整数,但这个数组已被旋转过很多次了,次数不详。
请编写代码找出数组中的某个元素,假设数组元素原先是按升序排列的。若有多个相同元素,返回索引值最小的一个。

示例1:
 输入: arr = [15, 16, 19, 20, 25, 1, 3, 4, 5, 7, 10, 14], target = 5
 输出: 8(元素5在该数组中的索引)
 
示例2:
 输入:arr = [15, 16, 19, 20, 25, 1, 3, 4, 5, 7, 10, 14], target = 11
 输出:-1 (没有找到)

这道题目是上面几题的加强版本,注意关键字眼:【多个相同元素】,【返回索引值最小的一个】

大体上的思路是有多个相同元素,我们可以逐步向左逼近,直到arr[left] === target,所以当target === arr[mid]时,我们需要更新right边界,即right = mid。其余思路跟上面几题相同。

代码如下:

var search = function (arr, target) {
  let left = 0;
  let right = arr.length - 1;

  while (left <= right) {
    // 当左边界l对应元素为target时直接返回l,因为题目要求返回最小索引
    if (arr[left] === target) return left;

    const mid = Math.floor((left + right) / 2);
    // 当中间值等于target时,将右边界r左移到mid,因为mid左边可能还有等于target的元素
    if (target === arr[mid]) right = mid;
    
    else if (arr[left] > arr[mid]) {
      if (target > arr[mid] && target <= arr[right]) {
        left = mid + 1;
      } else {
        right = mid - 1;
      }
    } else if (arr[left] < arr[mid]) {
      if (target >= arr[left] && target < arr[mid]) {
        right = mid - 1;
      } else {
        left = mid + 1;
      }
    } else {
      // 当中间值与左边界l对应的元素相等时,将左边界l右移
      // 因为此时arr[l]==arr[mid]但是arr[mid] != target,即arr[l] != target
      // 所以target一定落在[l+1:r]区间内
      left = left + 1;
    }
  }
  return -1;
};

总结

最后我们可以总结一下旋转排序数组带重复元素的代码思路:

var search = function (nums, target) {
  let left = 0;
  let right = nums.length - 1;

  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    if (nums[mid] === target) return true;
    // 当中间值与左边界left对应的元素相等时,将左边界left右移
    // 因为此时arr[left]==arr[mid]但是arr[mid] != target,即arr[left] != target
    // 所以target一定落在[l+1:r]区间内
    if (nums[left] === nums[mid]) {
      left++;
      // 同理
    } else if (nums[right] === nums[mid]) {
      right--;
    }
    // 左边边界 left 到 mid 是保持升序的
    else if (nums[left] < nums[mid]) {
      // 我们可以先判断target是不是在此区间, 此来收缩right边界
      if (target >= nums[left] && target < nums[mid]) {
        right = mid - 1;
      } else {
        // left边界
        left = mid + 1;
      }
    } else {
      // 左边边界 mid 到 right 是保持升序
      if (target > nums[mid] && target <= nums[right]) {
        // 收缩left边界
        left = mid + 1;
      } else {
        // right边界
        right = mid - 1;
      }
    }
  }

  return false;
};