题目描述
已知一个长度为 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, a1, a2, ..., an-1] 旋转一次的结果为数组 a\[n-1, a0, a1, a2, ..., an-2]。
给你一个元素值互不相同的数组 nums,它原来是一个升序排列的数组,并按上述情形进行了多次旋转。请你找出并返回数组中的最小元素。
你必须设计一个时间复杂度为 O(log n) 的算法解决此问题。
示例 1:
输入:nums = [3,4,5,1,2]
输出:1
解释:原数组为 [1,2,3,4,5] ,旋转 3 次得到输入数组。
示例 2:
输入:nums = [4,5,6,7,0,1,2]
输出:0
解释:原数组为 [0,1,2,4,5,6,7] ,旋转 4 次得到输入数组。
示例 3:
输入:nums = [11,13,15,17]
输出:11
解释:原数组为 [11,13,15,17] ,旋转 4 次得到输入数组。
提示:
- n == nums.length
- 1 <= n <= 5000
- -5000 <= numsi <= 5000
- nums 中的所有整数互不相同
- nums 原来是一个升序排序的数组,并进行了 1 至 n 次旋转
解题思路总览
| 方法 | 核心思想 | 时间复杂度 | 空间复杂度 | 特点 |
|---|---|---|---|---|
| 二分查找(标准版) | 比较 numsmid 与 nums.back(),判断最小值在哪个区间 | O(log n) | O(1) | 标准解法,最常考 |
| 二分查找(变形) | 比较 numsmid 与 nums0,判断最小值在哪个区间 | O(log n) | O(1) | 另一种判断方式 |
| 遍历找最小值(不推荐) | 线性扫描数组 | O(n) | O(1) | 不满足题目要求,仅作对比 |
方法一:二分查找(与 nums.back() 比较)
代码实现
cpp
class Solution {
public:
int findMin(vector<int>& nums) {
int l = -1, r = nums.size() - 1;
while (l + 1 < r) {
int m = l + (r - l) / 2;
if (nums[m] < nums.back()) {
r = m;
} else {
l = m;
}
}
return nums[r];
}
};
核心思想
利用旋转排序数组的特性:
- 数组被分成两段有序数组,第二段的第一个元素就是最小值
- 通过比较 numsm 与 nums.back()(末尾元素)的大小关系,判断 mid 落在哪一段
- 如果 numsm < nums.back(),说明 mid 在第二段(最小值在 l, m 区间)
- 否则 numsm >= nums.back(),说明 mid 在第一段(最小值在 m, r 区间)
算法流程图
以 nums = [4,5,6,7,0,1,2] 为例:
数组结构分析:
第一段:[4,5,6,7] 所有元素 > nums.back()=2
第二段:[0,1,2] 所有元素 <= nums.back()=2
最小值 = 0,在第一段和第二段的交界处
二分查找过程:
初始:l=-1, r=6
第1轮:m=(-1+6)/2=2,nums[2]=6 >= nums.back()=2,l=m=2
第2轮:m=(2+6)/2=4,nums[4]=0 < nums.back()=2,r=m=4
第3轮:m=(2+4)/2=3,nums[3]=7 >= nums.back()=2,l=m=3
第4轮:m=(3+4)/2=3,l+1=4==r,循环结束
结果:nums[r]=nums[4]=0
最小值:0
逐行解析
cpp
int l = -1, r = nums.size() - 1;
初始化二分边界:
- l = -1,表示"哨兵"位置,在数组最左边(实际不存在)
- r = nums.size() - 1,指向数组最后一个元素
注意:使用 l = -1 而不是 l = 0,使得最终返回值是 numsr,而 r 最终指向最小值位置。
cpp
while (l + 1 < r) {
循环条件:l + 1 < r,即 left 和 right 之间至少有一个元素。
当 l + 1 == r 时,l 和 r 相邻,循环结束,此时 r 指向最小值。
cpp
int m = l + (r - l) / 2;
计算中间位置,使用防溢出写法。
cpp
if (nums[m] < nums.back()) {
r = m;
} else {
l = m;
}
关键判断逻辑:
- numsm < nums.back():说明 mid 在第二段(因为第二段所有元素都 <= nums.back()),最小值在 l, m 区间,更新 r = m
- 否则:说明 mid 在第一段,最小值在 m, r 区间,更新 l = m
cpp
return nums[r];
循环结束时,r 指向最小值位置,返回该值。
复杂度分析
| 复杂度 | 分析 |
|---|---|
| 时间 | 每次循环将区间缩小一半,最多 O(log n) 次 |
| 空间 | O(1),只使用了常数个变量 |
方法二:二分查找(与 nums0 比较)
代码实现
cpp
class Solution {
public:
int findMin(vector<int>& nums) {
int n = nums.size();
int left = 0, right = n - 1;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] < nums[0]) {
// mid 在第二段,最小值在 [left, mid]
right = mid;
} else {
// mid 在第一段,最小值在 [mid+1, right]
left = mid + 1;
}
}
// 如果 left == 0,说明数组没有旋转
if (left == 0 && nums[left] <= nums[n - 1]) {
return nums[0];
}
// 否则返回最小值(就是 left 位置)
return nums[left];
}
};
核心思想
与 nums0 比较来判断 mid 落在哪一段:
- numsmid < nums0:mid 在第二段,最小值在 left, mid
- numsmid >= nums0:mid 在第一段,最小值在 mid+1, right
与方法一的对比
| 对比项 | 方法一(与 nums.back() 比较) | 方法二(与 nums0 比较) |
|---|---|---|
| 比较对象 | numsmid vs nums.back() | numsmid vs nums0 |
| 边界初始化 | l = -1, r = n - 1 | left = 0, right = n - 1 |
| 返回值 | numsr | numsleft 或 nums0 |
| 循环条件 | l + 1 < r | left < right |
复杂度分析
| 复杂度 | 分析 |
|---|---|
| 时间 | O(log n) |
| 空间 | O(1) |
方法三:直接遍历(不推荐)
代码实现
cpp
class Solution {
public:
int findMin(vector<int>& nums) {
int minVal = nums[0];
for (int i = 1; i < nums.size(); i++) {
minVal = min(minVal, nums[i]);
}
return minVal;
}
};
复杂度分析
| 复杂度 | 分析 |
|---|---|
| 时间 | O(n),需要遍历整个数组 |
| 空间 | O(1) |
不推荐原因
题目要求时间复杂度为 O(log n),遍历不满足要求。但该方法思路简单直接。
边界情况分析
情况1:数组只有一元素
输入: nums = [1]
分析: l=-1, r=0
循环条件 l+1=-1+1=0 == r=0?不成立,循环不执行
结果: nums[r]=nums[0]=1
情况2:数组没有旋转(旋转了 n 次)
输入: nums = [1,2,3,4,5](旋转了5次,等于没旋转)
分析: 按旋转定义,旋转 n 次后数组不变
结果: 最小值 = 1
情况3:旋转次数为 1(最小值在位置 1)
输入: nums = [5,1,2,3,4](旋转1次)
分析: 最小值 1 在位置 1
结果: 1
情况4:旋转次数为 n-1(最小值在位置 n-1)
输入: nums = [2,3,4,5,1](旋转4次)
分析: 最小值 1 在位置 4
结果: 1
情况5:数组完全倒序(旋转 n-1 次)
输入: nums = [2,1](旋转1次)
分析: 最小值 1 在位置 1
结果: 1
情况6:原数组升序,未旋转
输入: nums = [1,2,3,4,5]
分析: 由于题目说旋转 1 到 n 次,如果旋转 5 次则等于原数组
方法一会正确处理
结果: 1
旋转排序数组特性总结
原数组:[0, 1, 2, 4, 5, 6, 7](n=7)
旋转 k 次后的数组:[nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]]
旋转点(最小值位置)分析:
旋转 0 次:[0,1,2,4,5,6,7] 最小值在位置 0
旋转 1 次:[7,0,1,2,4,5,6] 最小值在位置 1
旋转 2 次:[6,7,0,1,2,4,5] 最小值在位置 2
旋转 3 次:[5,6,7,0,1,2,4] 最小值在位置 3
旋转 4 次:[4,5,6,7,0,1,2] 最小值在位置 4
旋转 5 次:[2,4,5,6,7,0,1] 最小值在位置 5
旋转 6 次:[1,2,4,5,6,7,0] 最小值在位置 6
旋转 7 次:[0,1,2,4,5,6,7] 最小值在位置 0(等于原数组)
关键发现:旋转点就是最小元素的位置。
面试追问 FAQ
| 问题 | 回答 |
|---|---|
| 如何找到旋转点? | 旋转点就是最小元素的位置,通过二分查找可以高效定位 |
| 为什么比较 numsmid 和 nums.back()? | nums.back() 是第二段的上界。如果 numsmid < nums.back(),说明 mid 在第二段 |
| 如果数组没有旋转怎么办? | 旋转 n 次等于原数组,本题算法仍然正确,会返回 nums0 |
| 二分查找的循环条件 l + 1 < r 是什么意思? | 表示 left 和 right 之间至少有一个元素,循环直到它们相邻 |
| 为什么初始化 l = -1 而不是 l = 0? | 这样最终返回 numsr 就是最小值,而 r 最终指向最小值位置 |
| 如何处理数组为空的情况? | 题目保证 n >= 1,不需要处理空数组 |
| 与第 33 题(搜索旋转排序数组)有什么区别? | 本题找最小值,33 题找 target 值。本题更简单,只需要定位最小元素 |
相关题目
| 题目 | 难度 | 核心区别 |
|---|---|---|
| 153. 寻找旋转排序数组中的最小值(本题) | 中等 | 数组中值互不相同 |
| 154. 寻找旋转排序数组中的最小值 II | 困难 | 数组中值可能重复 |
| 33. 搜索旋转排序数组 | 中等 | 在旋转数组中查找 target |
| 81. 搜索旋转排序数组 II | 中等 | 在可能有重复值的旋转数组中查找 target |
| 35. 搜索插入位置 | 简单 | 一维数组找插入位置 |
总结
| 要点 | 说明 |
|---|---|
| 核心思想 | 利用旋转数组两段有序的特性,通过二分查找定位最小值 |
| 关键判断 | numsmid < nums.back() 说明 mid 在第二段(最小值所在段) |
| 边界设置 | l = -1, r = n - 1,使得最终返回 numsr |
| 循环条件 | l + 1 < r,直到 l 和 r 相邻 |
| 时间复杂度 | O(log n) |
| 空间复杂度 | O(1) |
| 防溢出 | 使用 mid = l + (r - l) / 2 |