出处
《算法》上面1.4.18,1.4.19两道题目
这两道题目就是peak finding 问题,找到局部最大/局部最小元素的问题其实是等价的。这里让我们找局部最小的元素
如果你只写了找到局部最大的元素算法,现在想要找局部最小的元素,那么只需要把a中的元素都乘上-1就行了
另外请注意我们讨论的数组是distinct的,如果有重复数字, O ( l o g N ) O(logN) O(logN)的算法是实现不了的
分析
暴力法很容易写出
但是我看到提示
答:检查数组的中间值 a[N/2] 以及和它相邻的元素 a[N/2-1] 和 a[N/2+1]。如果 a[N/2] 是一个局部最小值则算法终止;否则则在较小的相邻元素的半边中继续查找。
感觉很迷惑,这个在较小的相邻元素里面的半边继续查找的依据是什么?不会漏掉正确答案吗?例如
1 2 3 4 5 6 3 5
这种组合,按照上面的依据来说,应该是往左边查找,但是正确答案是右边的3才对
看了一些资料以后发现上述两道题目叙述得其实不完整。。
因为上面题目没有对边界条件进行说明。。导致我以为要找的元素下标必须满足i∈[1,length-1]
😂
完整的题目应该是:对于边界元素,它只需要小于相邻的边界元素,那么这个边界元素就是局部最小的
我们先看一般的情况吧,对于任意一个数列,我们从随机的位置开始寻找局部最小
那么情况可以穷举出来
- 当前元素就是局部最小
- 当前元素不是局部最小,那么此时,总是会有一边以上的元素小于当前元素。可以自己写一下各种情况
看图,图片里面是找局部最大,不过没有关系,思想是一样的,这里是总有一边以上的元素会大于当前元素
那么上面的提示也说得通了,对于1 2 3 4 5 6 3 5
这种组合,N=8,A[N/2]=5 第一次判断的元素是5,4<5<6,4比6小,那么会往左边查找,查找过程中它只有两种情况
- 碰到一个局部最的元素
我们的任务完成了 - 碰到第一个元素
第一个元素一定是局部最小的元素
如果一路上没有碰到局部最优的元素,那么就会走到第一个元素,此时第一个元素必定是局部最小的元素
为什么?递推一下
假设第一个元素不是局部最小的元素,那么a[0]>a[1]
此时a[1]如果不是局部最小的元素,那么a[1]>a[2]
…
此时如果a[N/2-1]不是局部最小元素,那么a[N/2-1]>a[N/2],矛盾的地方出现了
回到这个例子上,我们之所以往左边找,就是因为a[N/2-1]<a[N/2]
这说明我们如果走到了第一个元素,那么第一个元素一定是局部最小的
可能你会怀疑,这只是一边的情况,其实你往右推导也是一样的
进一步可以得出结论,如果a[j]不是局部最小的元素,那么总有一边的相邻元素会小于a[j],由上结论得:
我们一定可以在较小的相邻元素那边找到一个局部最小的元素 ,它或者是边界元素,或者不是
🤘nice啊哈哈
上述结论要求数组是distinct的
1D finding
暴力法很容易写出,O(N)
加了一些边界条件让算法更稳定。伪代码会更简洁
public static int func(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;
}
现在我们有了上述的理论,可以优化算法了,很容易想到二分的思想
public static int func2(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;
}
}
由于一些边界情况的判断,写得比较丑陋,可以把上面改写成递归的形式,下面是python代码
需要注意的是递归传递数组是子数组,要用base变量来记录当前子数组的正确起始下标
class Solution:
def findPeakElement(self, nums: List[int]) -> int:
return self.helper(0, nums)
def helper(self, base, nums):
# print(base,nums)
if (len(nums) == 0):
return -1
if (len(nums) == 1):
return 0 + base
if (len(nums) == 2):
return 0 + base if nums[0] > nums[1] else 1 + base
lo, hi = 0, len(nums) - 1
mi = (hi - lo) // 2
if (nums[mi] > nums[mi - 1] and nums[mi] > nums[mi + 1]):
return mi + base
if (nums[mi - 1] > nums[mi + 1]):
return self.helper(lo + base, nums[lo:mi])
else:
return self.helper(mi + 1 + base, nums[mi + 1:hi + 1])
当然也可以不传递子数组,而是每次递归传递整个数组,然后更改lo,hi两个变量来表示搜索的区间
时间复杂度现在降为
O
(
l
o
g
N
)
O(logN)
O(logN)了,分析请看下图,每次一迭代都问题规模变成一半
当子问题的规模等于
n
/
2
k
n/2^k
n/2k的时候,我们设
n
=
2
k
n=2^k
n=2k,那么就能推出来这是O(lgn)的算法
2D finding
再延伸一下,从数组扩展到二维矩阵,同样是找局部最小元素
同样2D-finding问题的暴力法也很容易写出,遍历每个元素即可
public static int func1(int a[][]) {
// 变化的维度
int dx[] = {0, -1, 0, 1};
int dy[] = {-1, 0, 1, 0};
// 依次遍历矩阵中的元素,找到第一个局部最小的元素
for (int i = 0; i < a.length; i++) {
for (int j = 0; j < a[i].length; j++) {
boolean flag = true;
for (int k = 0; k < 4; k++) {
int tx = i + dx[k];
int ty = j + dy[k];
if (tx < 0 || ty < 0 || tx >= a.length || ty >= a.length) {
continue;
}
if (a[tx][ty] <= a[i][j]) {
flag = false;
break;
}
}
if (flag) return a[i][j];
}
}
// 上面的遍历始终会返回一个数,这里的return是为了通过编译
return -1;
}
但是有了1D-finding问题的推论,我们期望找到一个比暴力法更优的解法,接下来尝试一下能不能找到 O ( N ) O(N) O(N)级别的算法
一个简单的想法就是,对于M=N*N的矩阵,我们一开始找到第M/2个元素,查看它是否满足局部最小,如果满足则返回,如果不满足则在相邻元素之间找到最小的元素,然后以它在矩阵中的位置为新的界限,继续执行二分算法,知道找到第一个满足局部最小的元素
更具体的做法,不考虑边界情况,如果某个元素的左或者上元素更小,那么这个更小元素的位置更新为上界,继续迭代,如果某个元素的右或者下元素更小,那么就以这个更小元素的位置更新为下界,继续迭代
由此我们可以写出以下代码
public static int func2(int a[][]) {
if (a == null || a.length == 0 || (a.length == 1 && a[0].length == 0))
throw new Error("矩阵不能为空");
// 矩阵长度
int N = a.length;
int lo = 0, hi = N * N - 1;
int dx[] = {0, -1, 0, 1};
int dy[] = {-1, 0, 1, 0};
while (true) {
// 二分的mid元素
int mi = lo + (hi - lo) / 2;
// mid元素对应矩阵中的下标
int x = mi / N, y = mi % N;
int min = a[x][y];
// 如果当前a[x][y]不满足条件,则从更小的元素开始二分
int nx = x, ny = y;
boolean flag = true;
for (int k = 0; k < 4; k++) {
int tx = x + dx[k];
int ty = y + dy[k];
if (tx < 0 || ty < 0 || tx >= a.length || ty >= a.length) {
continue;
}
if (a[tx][ty] < a[x][y]) {
flag = false;
// 不需要break,因为要找到周围元素里面最小的
// break;
}
// 标记周围元素中最小的
if (a[tx][ty] < min) {
min = a[ty][ty];
nx = tx;
ny = ty;
}
}
if (flag) return a[x][y];
int min_ind = nx * N + ny;
if (min_ind > mi)
lo = min_ind;
else
hi = min_ind;
}
}
测试用例以及运行结果
int A[][] = {{5, 7, 3}, {8, 4, 1}, {2, 9, 10}};
int B[][] = {{15, 4, 7, 9}, {6, 13, 8, 99}, {14, 21, 3, 17}, {16, 24, 2, 11}};
int C[][] = {{}};
int D[][] = {{1}};
System.out.println(func1(A));
System.out.println(func1(B));
System.out.println(func1(C));
System.out.println(func1(D));
System.out.println("===");
System.out.println(func2(A));
System.out.println(func2(B));
System.out.println(func2(C));
System.out.println(func2(D));
/*
5
4
-1
1
===
2
4
Exception in thread "main" java.lang.Error: 矩阵不能为空
at ch01.part4.ex_1_4_19.func2(ex_1_4_19.java:41)
at ch01.part4.ex_1_4_19.main(ex_1_4_19.java:97)
*/
func2不能传递空数组,会throw Error,而func2(D)的运行结果是1
从结果来看,func2的写法似乎正确,但我不确定这是不是最优的算法,因为上面我写的版本有一些凌乱,下面是MIT的6.006课程讲义上的解法
MIT讲义
注意MIT讲义上面是找局部最大
一种方法是找出矩阵的每一列,然后把每一列的最大元素拼接在一起,再用1D-finding的解法去找局部最大,不过这样做的时间复杂度是
O
(
N
2
)
O(N^2)
O(N2)
下面是第四种写法
slide上面说得不太清楚,我再叙述一下
- 选择中间的列作为起点,找到该列最大的元素,位于i,j
- 比较 ( i , j ) , ( i , j − 1 ) , ( i , j + 1 ) (i,j),(i,j-1),(i,j+1) (i,j),(i,j−1),(i,j+1)
- 如果(i,j-1)比(i,j)大,那么就选择左列然后重复上述步骤
- 如果(i,j+1)比(i,j)大,那么久选择右列然后重复上述步骤
- 如果(i,j)最大,那么return (i,j)
上面递归的边界情况就是递归到只剩下一列,同理我们可以推导这一列里面一定存在一个最大的元素,使得它是局部最大的,所以这个算法能成立
寻找最大元素的复杂度是N,二分的过程是logn,这个算法的时间复杂度是
(
N
l
o
g
N
)
(NlogN)
(NlogN)
那么根据上面的分析可以写出代码
// 寻找局部最大
public static int func3(int a[][]) {
int N = a.length;
int y = N / 2;
while (true) {
int max = 0;
int x = 0;
// 寻找该列最大元素
for (int i = 0; i < a.length; i++) {
if (a[i][y] > max) {
x = i;
max = a[i][y];
}
}
if (y > 0 && a[x][y] < a[x][y - 1])
y = y - 1;
else if (y < a.length - 1 && a[x][y] < a[x][y + 1])
y = y + 1;
else
return a[x][y];
}
然后是最后一个解法,这个算法的时间复杂度是
O
(
N
)
O(N)
O(N)级别的
不过这个解法课程视频上没有说,我另外在youtube上找了其它视频来看,不过好像也没有提到这种解法,这里先空着吧
参考
感谢这位作者维护的repo
https://github.com/reneargento/algorithms-sedgewick-wayne/blob/f31578352b7774e857a664bf8768bca2200e75e0/src/chapter1/section4/Exercise18_LocalMinimum.java
mit的讲义
extension://bfdogplmndidlpjfhoijckpakkdjkkil/pdf/viewer.html?file=http://courses.csail.mit.edu/6.006/spring11/lectures/lec02.pdf
配套课程是
https://www.youtube.com/watch?v=HtSuA80QTyo
SO上的答案
https://stackoverflow.com/questions/12238241/find-local-minima-in-an-array
主定理
https://zh.wikipedia.org/wiki/%E4%B8%BB%E5%AE%9A%E7%90%86
https://blog.csdn.net/woshilsh/article/details/89429130
力扣上面相似的题目
https://leetcode-cn.com/problems/find-peak-element/
https://leetcode-cn.com/problems/search-a-2d-matrix/
https://leetcode-cn.com/problems/peak-index-in-a-mountain-array/
力扣的题解和文章上面的思路略有不同,不过结果都是一样的
延伸
关于上面三道leetcode,额外写了文章汇总
https://blog.csdn.net/hhmy77/article/details/108193937