peak finding 问题

出处

《算法》上面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. 当前元素不是局部最小,那么此时,总是会有一边以上的元素小于当前元素。可以自己写一下各种情况

看图,图片里面是找局部最大,不过没有关系,思想是一样的,这里是总有一边以上的元素会大于当前元素
在这里插入图片描述

那么上面的提示也说得通了,对于1 2 3 4 5 6 3 5这种组合,N=8,A[N/2]=5 第一次判断的元素是5,4<5<6,4比6小,那么会往左边查找,查找过程中它只有两种情况

  1. 碰到一个局部最的元素
    我们的任务完成了
  2. 碰到第一个元素
    第一个元素一定是局部最小的元素

如果一路上没有碰到局部最优的元素,那么就会走到第一个元素,此时第一个元素必定是局部最小的元素

为什么?递推一下

假设第一个元素不是局部最小的元素,那么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上面说得不太清楚,我再叙述一下

  1. 选择中间的列作为起点,找到该列最大的元素,位于i,j
  2. 比较 ( i , j ) , ( i , j − 1 ) , ( i , j + 1 ) (i,j),(i,j-1),(i,j+1) (i,j),(i,j1),(i,j+1)
  3. 如果(i,j-1)比(i,j)大,那么就选择左列然后重复上述步骤
  4. 如果(i,j+1)比(i,j)大,那么久选择右列然后重复上述步骤
  5. 如果(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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值