浅析二分查找

164 阅读1分钟

主题列表:juejin, github, smartblue, cyanosis, channing-cyan, fancy, hydrogen, condensed-night-purple, greenwillow, v-green, vue-pro, healer-readable, mk-cute, jzman, geek-black

贡献主题:github.com/xitu/juejin…

theme: juejin highlight:

常见的二分查找实现

减治思想实现二分查找

  1. 循环可以继续的条件写成 while (left < right)

  2. 写if else 时思考当 nums[mid] 满足什么性质时,nums[mid] 不是目标元素。接着判断 mid 左边有没有可能存在目标元素,mid 右边有没有可能存在目标元素。

  3. 根据边界收缩行为修改取中间数的方式

    1. int mid = left + (right - left) / 2; 避免 (left + right) / 2 造成整型溢出的风险。

    2. "/" 整数除法默认向下取整会带来一个问题:

      int mid = left + (right - left) / 2; 永远取不到右边界 right,,在面对 left = mid 和 right = mid - 1 这种边界收缩行为时,有可能发生死循环。

  4. 针对目标元素在查询数组中不存在的情况,需要在退出循环后需要对 nums[left] 是否是目标元素做下判断。


LC 35 搜索插入位置

给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。

你可以假设数组中无重复元素。

示例 1:

输入: [1,3,5,6], 5
输出: 2

示例 2:

输入: [1,3,5,6], 2
输出: 1

示例 3:

输入: [1,3,5,6], 7
输出: 4

示例 4:

输入: [1,3,5,6], 0
输出: 0

当 mid 满足 nums[mid] < target 时,mid 必定不是目标元素。以此作 if 语句,当 nums[mid] < target 时下次数组搜索范围是 [mid + 1, right] 相反的 else 的下次数组搜索范围是 [left, mid]。

代码如下:

public int searchInsert(int[] nums, int target) {
        int len = nums.length;
        if (len == 0) {
            return 0;
        }
  			// 此处还可以简化逻辑
        if (nums[len-1] < target) {
            return len;
        }
        int left = 0, right = len - 1;
        while (left < right) {
            // 当nums[mid]严格小于target,nums[mid]不是目标元素
            int mid = left + (right - left) / 2;
            if (nums[mid] < target) {
                // 下一次搜索区间 [mid+1, right]
                left = mid + 1;
            } else {
                // 相反地,下一次搜索区间 [left, mid]
                right = mid;
            }
        }
        return left;
    }
  • 时间复杂度:O(logn) 其中 n 是数组的长度,二分查找的时间复杂度是 O(logn)
  • 空间复杂度:O(1) 我们只用到了变量 left right mid。

以上代码还可以继续优化,将 nums[len-1] < target 即 target 作为新元素插入数组末尾的特殊情况,纳入通用流程。

right 扩展到 len,因为这种情况下会一直执行 left = mid + 1,直到 left = right = len,和原逻辑返回值 len 相同的效果。

public int searchInsert(int[] nums, int target) {
        int len = nums.length;
        if (len == 0) {
            return 0;
        }
        int left = 0, right = len;
        while (left < right) {
            // 当nums[mid]严格小于target,nums[mid]不是目标元素
            int mid = left + (right - left) / 2;
            if (nums[mid] < target) {
                // 下一次搜索区间 [mid+1, right]
                left = mid + 1;
            } else {
                // 相反地,下一次搜索区间 [left, mid]
                right = mid;
            }
        }
        return left;
    }

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

给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。

你的算法时间复杂度必须是 O(log n) 级别。

如果数组中不存在目标值,返回 [-1, -1]。

示例 1:

输入: nums = [5,7,7,8,8,10], target = 8 输出: [3,4] 示例 2:

输入: nums = [5,7,7,8,8,10], target = 6 输出: [-1,-1]

来源:力扣(LeetCode) 链接:leetcode-cn.com/problems/fi…

分治思想进行二分查找的核心是找出 mid 必定不是 target 元素的可能,以此来写 if 语句。为了简化问题,你可以先查找 target 的第一个位置,如果找不到 target 出现的第一个位置,说明数组中不存在该元素,剪枝查找最后位置逻辑,直接返回 {-1, -1}。

  • 查找元素出现的第一个位置 findFirstIndex。假设 nums[mid] < target 由于是升序数组所以 target 第一个位置只能在[mid + 1, right]范围内。

    相反地 nums[mid] < target 的对立面是 nums[mid] >= target 这说明 mid 有可能是 target 的第一个位置但也有可能是最后一个位置。所以下次搜索范围是[left, mid]不能跳过mid。

  • 查找元素的最后一个位置 findLastIndex。加和 nums[mid] > target 同样由于是升序数组,所以 target 最后一个位置必定不在[mid, right] 内,下次搜索范围是 [left, mid-1]。

    相反地 nums[mid] > target 的对立面是 nums[mid] <= target 此时 mid 有可能是 target 的最后一个位置,但同样也可能是第一个位置。下次搜索范围是[mid, right] 不能跳过 mid。

  • 向下和向上取整中位数 当遇到 right = mid -1 & left = mid 的情况时,默认的向下取整中位数有可能会出现死循环,你需要向上取整。可以形象的记忆口诀:左动取左,右动取右。即 if(...) left = mid + 1 时向下(左)取整; if (...) right = mid - 1 时向上(右)取整。

class Solution {
    public int[] searchRange(int[] nums, int target) {
        int len = nums.length;
        if (len == 0) {
            return new int[]{-1,-1};
        }
        if (len == 1 && nums[0] != target) {
            return new int[]{-1, -1};
        }
        int firstIndex = this.findFirstIndex(nums, target, len);
        if (firstIndex == -1) {
            return new int[]{-1, -1};
        }
        int lastIndex = this.findLastIndex(nums, target, len);
        return new int[]{firstIndex, lastIndex};
    }

    public int findFirstIndex(int[] nums, int target, int len) {
        int left = 0, right = len - 1;
        while (left < right) {
            int mid = left + (right - left) / 2;
            // nums[mid] < target 寻找目标元素的的开始位置,收缩边界
            if (nums[mid] < target) {
                // nums[mid] 不是要找的开始位置,下次查询范围 [mid+1, right]
                left = mid + 1;
            } else {
                // 反面 nums[mid] >= target,nums[mid]有可能是开始位置也可能是结束位置;
                // 下次查询范围 [left, mid]
                right = mid;
            }
        }
        if (nums[left] != target) {
            return -1;
        }
        return left;
    }

    public int findLastIndex(int[] nums, int target, int len) {
        int left = 0, right = len - 1;
        while (left < right) {
            // 注意向上取整,否则出现死循环!
            int mid = left + (right - left + 1) / 2;
            // nums[mid] > target 寻找目标元素的结束位置,收缩边界
            if (nums[mid] > target) {
                // [mid, right] 一定不存在结束位置,下次搜索范围 [left, mid-1]
                right = mid - 1;
            } else {
                // 反面 nums[mid] <= target,nums[mid]有可能是结束位置,但也有可能是开始位置
                // 下次搜索范围 [mid, right]
                left = mid;
            }
        }
        return left;
    }
}

补充出现死循环的测试用例如下:

nums:[5,7,7,8,8,10] target:8
left -> 0 right -> 5
left -> 2 right -> 5
left -> 3 right -> 5
left -> 4 right -> 5 
left -> 4 right -> 5//如果默认向下取整取中位数,此时 mid= 3+(5-3)/2=4&&nums[4]==target走left=mid=4逻辑。left恒等于4。
left -> 4 right -> 5 
......