不一样的二分查找:无序数组也能使用二分查找算法

284 阅读7分钟

二分查找算法是面试的基础算法之一,它能在有序数组中使用O(logN)的时间复杂度查找目标元素是否存在。在这种经典的二分查找中,数组有序是使用二分算法非常重要的前提条件之一,那么是不是无序数组就一定不能使用二分查找算法呢?

最经典的二分查找

leetcode704 二分查找

我们先来看一下最经典的二分查找问题,也就是leetcode704的原题,给定长度为n一个升序数组和一个目标值,如果数组中目标值存在就返回它的下标,如果不存在就返回-1。最朴素的想法肯定是for循环遍历整个数组,这样做的时间复杂度是O(N),但题目中给到的“升序数组”这个条件显然没有用到。

二分查找算法的流程是这样的:设置两个指针leftright,初始状态left指向nums[0]right指向nums[n-1],计算出leftright这个区域的中间位置,假设下标是mid,也就有mid = (left + right) / 2。如果nums[mid] == target,那说明已经找到了目标元素,返回mid,整个算法流程就可以结束了。如果nums[mid] > target,由于数组是升序的,nums[mid+1] ... nums[right]的所有元素都是大于nums[mid],也就更大于target了,所以nums数组中mid ... right的这部分就肯定不等于target,此时可以将right指针指向mid-1位置,去数组的左侧区域继续查找;同理,如果nums[mid] < targetnums[left] ... nums[mid-1]的所有元素都是小于nums[mid]的,也就更小于target了,所以nums数组中left ... mid的这部分也就肯定不等于target了,所以可以让left指向mid + 1,去数组的右侧区域继续查找。循环的条件是left <= right,如果直到循环结束还没找到目标值,说明数组中不存在这个target,返回-1

以题目中给的两个示例为例,示例1中nums = [-1, 0, 3, 5, 9, 12],初始状态left指针指向0位置的-1right指针指向5位置的12mid指向left ... right位置的中间区域,也就是2位置的3,由于3 < 9,说明nums[0]...nums[2]肯定都不等于target,所以将left指向mid + 1,也就是3位置的5,此时right依然指向5位置的12,本轮的mid指针指向4位置的9,和目标值相等,所以直接返回。

示例2中,nums = [-1, 0, 3, 5, 9, 12], target = 2。初始状态依然是left指向0位置的-1right指向5位置的12,首轮mid指向的是2位置的3,大于目标值,所以nums[mid] ... nums[right]中的元素肯定都不等于目标值,不予考虑。将right指向mid-1,也就是1位置的0,此时计算mid = (0+1) / 2 = 0nums[mid] < target,所以应该取的是数组的右半部分,即left = mid + 1 = 1。此时left = 1, right = 1,依然符合循环的条件,所以计算mid = (1 + 1) / 2 = 1nums[1] = 0,小于目标值,所以执行left = mid + 1,此时left = 2,不再满足left <= right的循环条件,所以循环结束,返回-1表示没有找到目标值。

// 二分查找
public int binarySearch(int[] nums, int target) {
    int left = 0;
    int right = nums.length - 1;

    while (left <= right) {
        int mid = (left + right) / 2;

        if (nums[mid] == target) {
            return mid;
        }

        // 去左侧找
        if (nums[mid] > target) {
            right = mid - 1;
        } else {
            // nums[mid] < target, 去右侧找
            left = mid + 1;
        }
    }

    return -1;
}

根据二分查找的算法流程可以知道,由于每次循环都会将待查询区域缩小一半,所以算法的时间复杂度是O(logN)。

leetcode 34. 在排序数组中查找元素的第一个和最后一个位置

leetcode34题也是一道经典的二分查找变种题,给定一个非递减顺序排列的数组numstarget,让我们求出target在数组中的开始位置和结束位置。

我们思考一下,在二分的过程中,如果nums[mid] > target,那么依然需要去二分的左侧位置去查找;如果nums[mid] < target,需要去二分的右侧位置查找,这两种case和经典二分是一样的。如果nums[mid] == target,那我们可以将当前位置作为一个候选结果存下来,但数组非递减,也就意味着当前位置的左侧和右侧都可能还存在target值,如果我们要找的是开始位置,那么需要去左侧位置继续尝试,所以让right = mid - 1;如果要找的是结束位置,那么需要去右侧继续尝试,让left = mid + 1。所以我们就得到了下面的代码。我们可以发现,在这个代码中,findFirstfindLast两个方法比较相似,所以也可以在方法中加一个参数作为区分,然后在代码里做参数判断执行不同的分支逻辑,大家可以自行尝试。

public int[] searchRange(int[] nums, int target) {
    return new int[]{findFirst(nums, target), findLast(nums, target)};
}

public int findFirst(int[] nums, int target) {
    int left = 0;
    int right = nums.length - 1;

    int res = -1;
    while (left <= right) {
        int mid = (left + right) / 2;

        if (nums[mid] == target) {
            res = mid;
        }
        if (nums[mid] >= target) {
            right = mid - 1;
        } else {
            left = mid + 1;
        }
    }

    return res;
}

public int findLast(int[] nums, int target) {
    int left = 0;
    int right = nums.length - 1;

    int res = -1;
    while (left <= right) {
        int mid = (left + right) / 2;

        if (nums[mid] == target) {
            res = mid;
        }
        if (nums[mid] > target) {
            right = mid - 1;
        } else {
            left = mid + 1;
        }
    }

    return res;
}

leetcode 162. 寻找峰值

思考一下在文章的开篇中我们提到的问题,是不是无序数组就一定不能使用二分查找呢?

我们看一下leetcode162题:寻找峰值。给定一个整数数组,找到峰值元素并返回索引,数组中可能包含多个峰值,我们只需要返回任意一个峰值的索引即可。这里的峰值元素是指严格大于左右相邻值的元素。重点是这里:假设nums[-1] = nums[n] = -∞,这意味着如果把nums数组中所有元素在二维坐标系中表示出来并连成曲线的话,这个曲线趋势一定是先上升后下降的,这意味着曲线在中间一定至少有一个拐点存在,也就是题目中要求的峰值。

首先考虑以下几种特殊情况:如果数组中仅有一个元素,由于这个元素左右两侧都认为是-∞,所以这个元素就是数组的峰值;如果nums[0] > nums[1],而nums[0]的左侧又是-∞,所以0位置也可以认为是数组的峰值;同理可得,如果nums[n-1] > nums[n-2],而nums[n-1]的右侧是-∞,所以n-1位置也可以认为是数组的峰值。

如果没有命中上面的特殊情况,我们让left指针指向nums[0]right指针指向nums[n-1],再找到left...right区域的中点mid,如果nums[mid]比它左右两侧的值都大,说明mid位置就是峰值元素,将索引mid返回。如果mid位置的元素比它右侧的元素小,说明此时的mid还在爬坡阶段,mid左侧可能也会有峰值元素,也可能没有,但mid右侧一定有峰值元素,所以我们去二分的右侧继续查找;

如果mid位置的元素比它右侧的元素大,说明此时的mid已经开始下降,左侧一定会有峰值元素,右侧可能有也可能没有,所以我们去二分的左侧继续查找。循环执行上面的步骤,直到找出峰值元素。

代码贴在下面,供大家参考。

public int findPeakElement(int[] nums) {
    int n = nums.length;

    if (n == 1 || nums[0] > nums[1]) {
        return 0;
    }

    if (nums[n-2] < nums[n-1]) {
        return n-1;
    }

    int left = 0;
    int right = n - 1;
    while (left <= right) {
        int mid =  (left + right) / 2;
        boolean gtLeft = mid == 0 || nums[mid] > nums[mid - 1];
        boolean gtRight = mid == n - 1 ||  nums[mid] > nums[mid + 1];
        if (gtLeft && gtRight) {
            return mid;
        }

        if (nums[mid] < nums[mid + 1]) {
            left = mid + 1;
        } else if (nums[mid] > nums[mid + 1]) {
            right = mid - 1;
        }
    }

    return -1;
}

总结

通过上面的几道题目的练习,相信大家对二分查找算法有了一些基础的认识。虽然在最经典的二分查找中我们要求数组必须有序,但有序并不是使用二分的必要条件。只要你的数组在二分之后具有二段性,即其中的一侧一定满足某种性质,另一侧不一定满足,就可以向二分查找的方向去思考。

如果这篇文章对你有帮助,请你帮我点一个免费的赞,这对我非常重要,谢谢!

欢迎关注公众号《程序员冻豆腐》,所有文章都会在公众号首发!