文章目录
题目
一共三道,每道题之间互相有联系
https://leetcode-cn.com/problems/peak-index-in-a-mountain-array/
https://leetcode-cn.com/problems/find-peak-element/
https://leetcode-cn.com/problems/search-a-2d-matrix/
852. 山脉数组的峰顶索引
方法1 暴力
读题,发现直接遍历即可
class Solution {
public int peakIndexInMountainArray(int[] A) {
for(int i = 1; i<A.length-1; i++){
if(A[i]>A[i+1])
return i;
}
return -1;
}
}
方法2 二分
二分查找的思想,山脉数组总是一个先上升后下降的趋势,那么按照二分的思想,每次取得mid进行判断
- mid就是山顶,返回mid
- mid处于局部上升的区间,返回右边区间的二分
- mid处于局部下降的区间,返回左边区间的二分
由此可以写出代码
class Solution {
public int peakIndexInMountainArray(int[] A) {
int left = 0, right = A.length - 1;
while (left<=right) {
int mid = (right + left) / 2;
if (A[mid] > A[mid + 1] && A[mid] > A[mid - 1]) {
return mid;
} else if (A[mid] < A[mid + 1]) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
}
但是上面的二分并没有很好的利用山脉数组的性质,写起来很复杂,下面有改进的二分
方法3 改进的二分
观察暴力法,我们发现整个山脉数组一定是先上升后下降的趋势,那么这个数组里面的点一共有三种情况
- 当前点是峰值
- 当前点处于一个局部上升区域,即A[i]<A[i+1]
- 当前点处于一个局部下降区域,即A[i]>A[i+1]且A[i-1]>A[i]。需要注意的是,如果只有A[i]>A[i+1],这个时候有可能是峰顶
继续分析,如果我们想要使用二分,可以这样写
class Solution {
public int peakIndexInMountainArray(int[] A) {
int left = 0, right = A.length - 1;
// 二分到只有一个元素的时候退出,不再比较
while (left<right) {
int mid=(right+left)/2;
// 可能是局部下降区域,也可能是peak,判断左边区域,包括当前元素,以免错过峰顶
if(A[mid]>A[mid+1])
right=mid;
else if(A[mid]<A[mid+1]) // 是局部上升区域,无需考虑mid点,判断右边区域
left=mid+1;
}
return left;
}
}
可以手动推算二分的过程,发现上面的二分总是在往峰值方向逼近的,最后得出的结果也是正确的
162. 寻找峰值
就是之前提到的1D-finding问题,不过在leetcode上面可以做得更巧妙
方法1 暴力
自然而然就想到的做法
class Solution {
public int findPeakElement(int[] a) {
// 特殊判断
if (a.length == 0)
return -1;
if (a.length == 1)
return 0;
// 判断是否在边界
if (a[0] > a[1])
return 0;
else if (a[a.length - 1] > a[a.length - 1 - 1])
return a.length - 1;
// 不在边界就一定在中间
for (int i = 1; i < a.length - 1; i++) {
if (a[i] > a[i - 1] && a[i] > a[i + 1])
return i;
}
return -1;
}
}
方法2 二分
需要推导一下,如果一个数组是distinct的,那么这个数组至少会有一个峰值,且如果峰值的下标i不属于
{
i
∣
0
<
i
<
A
.
l
e
n
g
h
t
}
\{i| 0<i<A.lenght\}
{i∣0<i<A.lenght},那么峰值一定会出现在数组A的起始或者末尾
基于上述的结论,二分法在distinct属性的数组里面一定能找到峰值
不过我们写代码的时候要额外判断一下
class Solution {
public int findPeakElement(int[] nums) {
if (nums == null || nums.length == 0)
return -1;
if (nums.length == 1) {
return 0;
}
if (nums.length == 2) {
return nums[0] > nums[1] ? 0 : 1;
}
int lo = 0, hi = nums.length - 1;
int mi = lo + (hi - lo) / 2;
// 没有碰到边界情况
while (mi != 0 && mi != nums.length - 1) {
if (nums[mi] > nums[mi + 1] && nums[mi] > nums[mi - 1]) {
return mi;
} else {
if (nums[mi + 1] > nums[mi - 1])
lo = mi + 1;
else
hi = mi - 1;
}
mi = lo + (hi - lo) / 2;
}
// 边界情况特殊判断
if (mi == 0) {
return nums[mi] > nums[mi + 1] ? mi : mi + 1;
} else {
return nums[mi] > nums[mi - 1] ? mi : mi - 1;
}
}
}
方法3 改进的暴力
由山脉数组这道题推导得来的结论
其实这种严格不重复的数组不外乎就四种形状
- 一直上升
- 一直下降
- 先上升后下降
- 先下降再上升
任何更复杂的数组,它的局部一定能找到上述形状,既然局部存在着上述形状,那么一定会存在峰值,所以我们可以分析这四种形状来解决问题
基于这种结论,我们使i=0,然后从第一个元素开始比较,只需要比较第i个元素和第i+1个元素的大小即可,对于i属于0到A.length-1,当A[i]>A[i+1]的时候,A[i]一定是峰值,这是因为
如果A[i]不是峰值,那么必定有A[i-1]>A[i],此时如果A[i-1]是峰值就不会遍历到A[i],所以A[i-1]不是峰值,继续往上推导,得出结论A[0]一定大于A[1],否则A[1]是峰值,但是A[1]不是,所以A[0]一定大于A[1],于是A[0]是峰值,但是A[0]必然不是峰值,因为已经遍历到A[i],所以可以得出A[0],A[1]…A[i-1]一定是小于A[i]的,反证法得出A[i]一定是峰值
由上我们可以得出更加简洁的暴力法
class Solution {
public int findPeakElement(int[] nums) {
int i=0;
for(;i<nums.length-1;i++){
if(nums[i]>nums[i+1]){
return i;
}
}
return i;
}
}
方法4 改进的二分
我们再继续讨论一下二分法能否用在这个问题上,对于nums,我们随机取得一个下标mid,
m
i
d
⊂
{
i
∣
0
<
i
<
n
u
m
s
.
l
e
n
g
t
h
−
1
}
mid\subset\{i| 0<i<nums.length-1\}
mid⊂{i∣0<i<nums.length−1},当nums[mid]同时满足nums[mid-1]<nums[mid]<nums[mid+1]
的时候,mid对应了一个峰值。
但是经过上述的讨论我们发现,改进的暴力法只需要比较nums[mid]和nums[mid+1]的大小即可,由此我们先尝试写出二分的三个步骤,再验证是否正确。
三个步骤就是:满足条件(二分边界),往左二分,往右二分
取mid
- 如果nums[mid]>nums[mid+1],此时需要判断nums[mid-1]与nums[mid]的关系,需要往左边二分,由于nums[mid]有可能是峰顶,所以我们往左边二分的区间是[left,mid]
- 如果nums[mid]<nums[mid+1],那么nums[mid]必然不是峰顶,而numd[mid+1]是峰顶的可能性存在,所以我们需要往右边二分,二分的区间是[mid+1,right]
- 上面分别是往左和往右二分的情况,现在我们需要终止二分的条件,思考极端情况,当数组元素只有一个的时候,那么这个元素一定是峰值,所以我们可以得出,当二分的left和right相等的时候,此时直接返回left即可,这个元素一定是峰值
观察1,2两个步骤,可能会有疑问,如果往左或者往右二分的时候,峰值不存在怎么办?回到这篇文章的1D finding推论,当多次二分达到边界之后,此时边界一定会是峰值,所以可以这样二分
由此可以写出代码
class Solution {
public int findPeakElement(int[] nums) {
int lo=0,hi=nums.length-1;
while(lo<hi){
int mid = (hi+lo)/2;
if(nums[mid]>nums[mid+1]){
hi=mid;
}else{
lo=mid+1;
}
}
return lo;
}
}
其实这个解法和第一题的二分解法是很像的
这个二分第一次接触真的感觉很玄学,可以自己试着推导一下为什么二分递归达到边界的时候,边界一定是峰值
如果对二分不熟悉的同学,比如说什么时候用开区间,什么时候用闭区间,边界值的取值,我一般做法就是推导一下过程,没有特意去记开区间还是闭区间
74. 搜索二维矩阵
这道题不是我们说的2D-finding问题(找到矩阵中的某个元素,它大于四周的元素),但是思想挺相近的,所以也写一下
贴一下题目吧
方法1 暴力
首先暴力法很容易想到,时间复杂度 O ( M N ) O(MN) O(MN)
class Solution {
public boolean searchMatrix(int[][] matrix, int target) {
for(int i=0;i<matrix.length;i++){
for(int j=0;j<matrix[i].length;j++){
if(matrix[i][j]==target)
return true;
}
}
return false;
}
}
方法2 二分
一个二维矩阵,总是可以展开成一维数组来存储,满足题目中条件的矩阵展开成一维数组后就是一个升序序列,求一个升序序列中是否存在目标值,当然可以用二分法来做,明确可以用二分之后,也有很多种做法,这里先写用整个矩阵来二分的做法
取得矩阵的元素个数m*n个,按照lo=0,hi=m*n-1的区间来二分,取得mid之后,要做的是把一维数组中的mid转化成矩阵里面的i,j,细节在代码上。时间复杂度是 O ( l o g ( m n ) ) O(log(mn)) O(log(mn))
class Solution {
public boolean searchMatrix(int[][] matrix, int target) {
if(matrix==null || matrix.length==0||(matrix.length==1&&matrix[0].length==0))
return false;
// m是行,n是列
int m=matrix.length,n=matrix[0].length;
int lo=0,hi=m*n-1;
int i=0,j=0;
while(lo<=hi){
int mid=(hi+lo)/2;
i=mid/n;
j=mid%n;
if(matrix[i][j]==target)
return true;
else if(matrix[i][j]>target){
hi=mid-1;
}else{
lo=mid+1;
}
}
return false;
}
}
上面是用整个矩阵做二分,注意到这个矩阵里面,每一行都是一个升序序列,那么我们可以确定target在矩阵中大概位于哪一行,然后再在那个区间判断,这样不需要对整个矩阵做二分了
改进后的二分解法,时间复杂度是 O ( m + l o g ( n ) ) O(m+log(n)) O(m+log(n))
class Solution {
public boolean searchMatrix(int[][] matrix, int target) {
if(matrix==null || matrix.length==0||(matrix.length==1&&matrix[0].length==0))
return false;
int flag=0;
// 确定target可能位于哪一行
for(int i=0;i<matrix.length;i++){
if(matrix[i][0]>target){
//如果起点大于target,那么target不会出现在这一行及以后
flag=i-1;
break;
}else if(matrix[i][0]<target){
flag=i; //如果起点小于target,那么有可能存在这一行中
}else{
return true;
}
}
if(flag<0) return false;
return helper(matrix[flag],target);
}
public boolean helper(int A[],int target){
int lo=0,hi=A.length-1;
while(lo<=hi){
int mid=(hi+lo)/2;
if(A[mid]==target)
return true;
else if(A[mid]>target){
hi=mid-1;
}else{
lo=mid+1;
}
}
return false;
}
}
注意到 矩阵的第一列一定也是一个升序数组,所以我们对第一个遍历过程改成二分
class Solution {
public boolean searchMatrix(int[][] matrix, int target) {
if(matrix==null || matrix.length==0||(matrix.length==1&&matrix[0].length==0))
return false;
// 确定target可能位于哪一行
int flag=find_index(matrix,target)-1;
if(flag<0) return false;
return helper(matrix[flag],target);
}
public int find_index(int[][] matrix, int target){
// upper_bound 返回第一个大于target的数 注意传入的区间是[0,matrix.length]
int lo=0,hi=matrix.length;
while(lo<hi){
int mid=(hi+lo)/2;
// 如果起始元素大于target,hi不动,让lo逼近hi
if(matrix[mid][0]>target){
hi=mid;
}else{
// 如果起始元素小于或等于target,那么一定不是要找的数,让lo=mid+1往后找
lo=mid+1;
}
}
return lo;
}
public boolean helper(int A[],int target){
int lo=0,hi=A.length-1;
while(lo<=hi){
int mid=(hi+lo)/2;
if(A[mid]==target)
return true;
else if(A[mid]>target){
hi=mid-1;
}else{
lo=mid+1;
}
}
return false;
}
}
写得很复杂,没有原来的方法简洁,但是时间复杂度变为 O ( l o g ( m + n ) ) O(log(m+n)) O(log(m+n))了
方法3 利用数组特性
这个数组的特点很明显,要想办法利用一下
每行中的整数从左到右按升序排列。
每行的第一个整数大于前一行的最后一个整数。
这个矩阵虽然只说了行的特性,但是按照这个性质,每一列的元素也是递增的,而最小值分布在矩阵左上角,最大值分布在矩阵的右下角,很直观就想到从左上角出发按照下面的步骤遍历
- 判断当前[x,y]与target的值
- 如果[x,y]大于target,那么x–
- 如果[x,y]小于target,那么y++
- 如果[x,y]==target,那么返回true
- 如果遍历过程中x,y出界,那么就返回false
但是实际操作过程中,这样会漏掉数字
matrix = [
[1, 3, 5, 7],
[10, 11, 16, 20],
[23, 30, 34, 50]
]
target = 16
如上,会一直y++导致出界,所以从左上角出发不合适
然后试一下从右下角50出发,发现也不合适
试一下从左下角23出发,发现可以找到,那么上述步骤变为
x=matrix.length-1,y=0
- 判断当前[x,y]与target的值
- 如果[x,y]大于target,那么x–
- 如果[x,y]小于target,那么y++
- 如果[x,y]==target,那么返回true
- 如果遍历过程中x,y出界,那么就返回false
时间复杂度是 O ( m + n ) O(m+n) O(m+n) 其实不如上面的二分
class Solution {
public boolean searchMatrix(int[][] matrix, int target) {
if(matrix==null || matrix.length==0||(matrix.length==1&&matrix[0].length==0))
return false;
int x=matrix.length-1,y=0;
while(x>=0&&y<matrix[0].length){
if(matrix[x][y]>target)
x--;
else if(matrix[x][y]<target)
y++;
else
return true;
}
return false;
}
}
同时上面的解法在《剑指offer》里面也有介绍,不过我觉得没有上面的二分的做法更巧妙
总结
上面这三道题就是finding问题,由于数组的特殊性,巧妙的解法都是从A[M]
和A[M-1],A[M+1]
的关系来入手的