likes
comments
collection
share

数据结构与算法 -- 二分法

作者站长头像
站长
· 阅读数 3

二分法是面试当中出现频率比较高,考察的点也会比较多,像LeetCode的热题中也会考察很多,因此我们先从简单的二分法入手。

1 二分法入门

一般来说,使用二分法的前提数组是有序的,然后从排序数组中找到target值的位置,常用的方法就是递归实现,当然在面试中可能会要求使用非递归的方式也实现一遍。

1.1 递归实现

public int binarySearch(int[] nums, int start, int end, int target) {
    //中间查找
    int middle = (start + end) / 2;
    if (nums[middle] == target) {
        return middle;
    }
    if (nums[middle] < target) {
        return binarySearch(nums, middle + 1, end, target);
    } else {
        return binarySearch(nums, start, middle - 1, target);
    }
}

递归实现是比较简单的,下面我们着重介绍一下非递归的实现方案。

1.2 非递归实现

当然从排序数组中找到target的位置比较简单,但是如果数组中有重复的数字,我们需要通过二分法拿到数字出现的第一个位置,或者数字出现的最后一个位置,其实我们是有一个通用的模板的。

public static int binarySearch2(int[] nums, int target) {
    if (nums == null || nums.length == 0) {
        return -1;
    }

    int start = 0;
    int end = nums.length - 1;
    while (start + 1 < end) {
        int middle = start + (end - start) / 2;
        if (nums[middle] == target) {
            start = middle;
        } else if (nums[middle] < target) {
            start = middle;
        } else {
            end = middle;
        }
    }

    Log.d("TAG", "binarySearch2: start=" + start + " end=" + end);
    if (nums[start] == target) {
        return start;
    }

    if (nums[end] == target) {
        return end;
    }
    return -1;
}

技术要点:

  • 双指针,start和end分别指向数组的头部和尾部;
  • 循环结束的条件,使用start + 1 < end目的是为了防止死循环;
  • 循环结束后,如果题目要求找到数组中某个元素的位置,或者在数组中第一次出现的位置,那么就使用start判断;否则就使用end判断,命中就返回,没有命中就返回-1.

1.3 查找数字第一次或者最后一次在数组中出现的位置

前面我们基本的二分法模板,通过双指针start和end,最终拿到了对应的target,此时start和end是连续的,例如 1,2 和 3,4,所以我们要判断target出现的位置,可以有下面的3种情况:

  • start位置上的数字等于target但是end位置上的数字不等于target,此时start位置就是最后一次出现的位置;
  • start位置上的数字不等于target但是end位置上的数字等于target,此时end位置就是第一次出现的位置;
  • 如果start和end位置上的数组都等于target,那么就需要前后寻找对应第一次出现和最后一次出现的位置。
public int[] searchRange(int[] nums, int target) {
    if(nums == null || nums.length == 0){
        return new int[]{-1,-1};
    }

    int start = 0;
    int end = nums.length-1;

    while(start + 1 < end){

        int mid = start + (end - start) / 2;
        if(nums[mid] == target){
            //此时end拿到的是第一次出现的位置
            start = mid;
        }else if(nums[mid] < target){
            start = mid;
        }else{
            end = mid;
        }
    }
    int[] res = new int[]{-1,-1};
    //这里start和end是相邻的
    if(nums[start] == target && nums[end] != target){
        //start是最后一个
        res[1] = start;
        //找第一次出现的位置
        while(start >=0 && nums[start] == target){
            start--;
        }
        res[0] = start+1;
        return res;
    }
    if(nums[end] == target && nums[start] != target){
        //end是第一个
        res[0] = end;
        //找最后一次出现的位置
        while(end < nums.length && nums[end] == target){
            end++;
        }
        res[1] = end-1;
        return res;
    }
    //分别寻找,例如[1,2,2,2,2] target = 2
    if(nums[end] == target && nums[start] == target){
        while(start >=0 && nums[start] == target){
            start--;
        }
        res[0] = start+1;
        while(end < nums.length && nums[end] == target){
            end++;
        }
        res[1] = end-1;
        return res;
    }

    //没进到循环里的,例如[1],[2,2],[1,2] target = 2
    if(start == end){
        if(nums[0] == target){
            return new int[]{0,0};
        }
    }else{
        //两位数
        if(nums[0] == target && nums[1] == target){
            res[0] = 0;
            res[1] = 1;
        }else if(nums[0] != target && nums[1] == target){
            res[0] = 1;
            res[1] = 1;
        }else{
            // res[0] = 0
        }
    }


    return res;
}

1.4 拓展:寻找离target最近的k个数字

题目:给定一个升序的数组,非负数k,和目标数值target,要求返回离target最近的k个数字,如果两者距离一致,那么相对小的放在前面。

如果数组中有target数字,那么就很简单,只需要两个背向双指针移动即可;而如果没有target这个值,那么按照1.2中的算法,此时start指向的就是target应在位置的上一个元素的位置,例如:

arr: [2,4,6,9,12] target = 5

此时start = 1,就是指向了元素4的位置,那么中心线就是46的中心位置

看算法:

public static int[] binarySearch3(int[] nums, int target, int k) {
    if (nums == null || nums.length == 0) {
        return new int[]{};
    }

    int start = 0;
    int end = nums.length - 1;
    while (start + 1 < end) {
        int middle = start + (end - start) / 2;
        if (nums[middle] == target) {
            start = middle;
        } else if (nums[middle] < target) {
            start = middle;
        } else {
            end = middle;
        }
    }

    Log.d("TAG", "binarySearch2: start=" + start + " end=" + end);
    if (nums[start] == target) {
        start = start - 1;
        end = start + 1;
    } else {
        //不存在target
        end = start + 1;
    }

    int[] res = new int[k];
    int pos = 0;
    //两个指针转为背向双指针
    while (start >= 0 && end < nums.length) {
        if (pos == k - 1) {
            return res;
        }
        //先左后右
        res[pos] = nums[start];
        pos++;
        res[pos] = nums[end];
        pos++;
        start--;
        end++;
    }
    //因为存在两边长度不一致的况
    while (pos < k && start >= 0) {
        //此时右侧已经结束了
        res[pos] = nums[start];
        pos++;
        start--;
    }

    while (pos < k && end < nums.length) {
        //此时左侧已经结束了
        res[pos] = nums[end];
        pos++;
        end++;
    }

    return res;
}

前半部分,依然使用我们的二分查找模板,此时拿到的start就是target第一次出现的位置,或者应该出现的位置的上一个现有元素位置(nums中不存在target),那么接下来的分析就是获取中心线:

  • 如果target存在,那么start和end分别为target两边的元素index;
  • 如果target不存在,那么start不变,end为start的下一个元素,相当于target属于一个虚拟的中心线。
  • 双指针依次背向而驰,因为如果指针的两边都有值,那么距离相近,需要按照相对小在前的原则,先处理start指针入组,然后end指针入组。
  • 如果有一侧指针超限,那么剩下的补充即可,只需按照距离即可,不需要关心大小。

2 无序数组xxoo二分法

前面我们介绍的都是有序数组的二分,只通过寻找中间值,与target做比较,决定舍弃哪块区域。而如果数组是无序的,如何通过二分法解决这类问题。

1.1 峰值问题

峰值元素是指其值严格大于左右相邻值的元素。

给你一个整数数组 nums,找到峰值元素并返回其索引。数组可能包含多个峰值,在这种情况下,返回 任何一个峰值 所在位置即可。

数据结构与算法 -- 二分法

如图,数组中可能会存在多个峰值,题目要求我们只返回一个即可,所以我们可以通过二分查找,当mid的下一个元素大于mid时,说明此时正在上升,因此去往mid后面找峰值即可。


public int findPeakElement(int[] nums) {
    if(nums == null || nums.length == 0){
        return -1;
    }
    //双指针
    int start = 0;
    int end = nums.length-1;

    while(start + 1 < end){
        //求中点
        int middle = start + (end - start) / 2;
        if(nums[middle] > nums[middle + 1]){
            //下山
            end = middle;
        }else{
            //上山
            start = middle;
        }
    }
    if(nums[start] > nums[end]){
        return start;
    }else{
        return end;
    }
}

1.2 旋转数组问题

已知一个长度为 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], a[1], a[2], ..., a[n-1]] 旋转一次 的结果为数组 [a[n-1], a[0], a[1], a[2], ..., a[n-2]] 。

给你一个元素值 互不相同 的数组 nums ,它原来是一个升序排列的数组,并按上述情形进行了多次旋转。请你找出并返回数组中的 最小元素 。

你必须设计一个时间复杂度为 O(log n) 的算法解决此问题。

数据结构与算法 -- 二分法

这里需要注意的一点是没有重复元素,很重要!因为如果存在重复元素,无法判定需要舍弃哪部分。

例如我们在做辅助线的时候,如下图所示,我们希望的是两边往中间挤,因为第一次求中点,如果是数组长度是奇数,那么mid一定落在中间。

数据结构与算法 -- 二分法

那么这个时候,如果在两侧开刀,如果mid大于end,说明起点在右边,需要抛弃左边,start来到mid的位置,从图中也可以看出;反之亦然。


public int findMin(int[] nums) {
    if(nums == null || nums.length == 0){
        return -1;
    }

    int start = 0;
    int end = nums.length-1;

    while(start + 1 < end){

        //拿mid值
        int mid = start + (end - start) / 2;
        if(nums[mid] > nums[end]){
            //起点在右边
            start = mid;
        }else{
            //起点在左边
            end = mid;
        }

    }
    return Math.min(nums[start],nums[end]);
}

注:这种算法可以获取旋转数组中的最大值和最小值,因为start和end一定有一个对标着最大值和最小值的索引。

那么既然可以找到最小值或者最大值的索引,我们便可以从数组中通过二分法找到target值,具体流程为:

  • 先通过二分查找到最小值的位置minIndex;
  • 判断target和数组最后一位nums[end]的大小,如果比target大,那么只需要在[0,mindex]上二分即可;如果比target小,那么只需要在[mindex,end]上二分即可。

二次二分,时间复杂度依然为logn。


public int search(int[] nums, int target) {
    if(nums == null || nums.length == 0){
        return -1;
    }

    int start = 0;
    int end = nums.length-1;

    while(start + 1 < end){

        //找mid
        int mid = start + (end - start) / 2;
        if(nums[mid] > nums[end]){
            start = mid;
        }else{
            end = mid;
        }
    }

    //先找到最小值的位置
    int minIndex = -1;
    if(nums[start] < nums[end]){
        minIndex = start;
    }else{
        minIndex = end;
    }

    if(nums[nums.length-1] < target){
        //那么就在0-minIndex上二分
        start = 0;
        end = minIndex;
    }else if(nums[nums.length-1] > target){
        start = minIndex;
        end = nums.length-1;
    }else{
        return nums.length-1;
    }

    while(start + 1 < end){

        int mid = start + (end - start) / 2;
        if(nums[mid] < target){
            start = mid;
        }else{
            end = mid;
        }
    }

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

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

    return -1;

}