【算法】二分查找

一、算法理解

二分查找也称折半查找(Binary Search),它是一种效率较高的查找方法,前提是数据结构必须先排好序,可以在数据规模的对数时间复杂度内完成查找。
二分查找要求线性表具有随机访问的特点(例如数组),也要求线性表能够根据中间元素的特点推测它两侧元素的性质,以达到缩减问题规模的效果。

二、适用场景

  1. 有序序列查找。
  2. 元素支持随机访问(指定索引访问)。

三、注意事项

关键点:

  1. 二分查找的关键变量是 left,mid,right三个元素,根据目标值和mid所在索引的数值和target目标值进行比对,根据[mid]中间值的大小重新确定查找区间。
  2. 用途:二分法的基础是分治策略的基础,分治用的最多的是二分拆解计算。
  3. 可以使用 循环查找方式、或递归查找方式。

如:递归查找方式典型模板:(以递增顺序序列为例)

  • right指向是查找范围下一个元素(无效元素)
public int search(int[] nums, int target) {
    return medianSearchByIndex(nums, 0, nums.length, target);   //首次在left[0],right[length]范围查找
}

public int medianSearchByIndex(int[] nums,int left,int right,int target) {
    if(left < right) {    //注意, right是查询终点后一个元素,所以 < 
        int mid = (left + right) / 2;  // 注意
        if (nums[mid] == target) {
            return mid;
        } else if (nums[mid] < target) {
            return searchByIndex(nums, mid+1, right, target);
        } else {
            return searchByIndex(nums, left, mid, target);   //注意, right是查询终点后一个元素,所以是mid
        }
    }
    return 0;
}

  • right是查找范围最后一个有效元素
public int search(int[] nums, int target) {
    return medianSearchByIndex(nums, 0, nums.length, target);   //首次在left[0],right[length]范围查找
}

public int medianSearchByIndex(int[] nums,int left,int right,int target) {
    if(left <= right) {    //注意, right是查询终点最后一个有效元素,最终二分到只有一个元素left==right
        int mid = (left + right) / 2;  // 注意
        if (nums[mid] == target) {
            return mid;
        } else if (nums[mid] < target) {
            return searchByIndex(nums, mid+1, right, target);
        } else {
            return searchByIndex(nums, left, mid-1, target);   //注意, right是查询终点最后一个有效元素,所以是mid-1,应为mid已排除,不用重新查。
        }
    }
    return 0;
}

四、案例

1)【困难】寻找2个正序数组中位数

力扣:https://leetcode-cn.com/problems/median-of-two-sorted-arrays/
给定两个大小为 m 和 n 的正序(从小到大)数组 nums1 和 nums2。
请你找出这两个正序数组的中位数,并且要求算法的时间复杂度为 O(log(m + n))。
你可以假设 nums1 和 nums2 不会同时为空。

示例 1:
nums1 = [1, 3]
nums2 = [2]
则中位数是 2.0

示例 2:
nums1 = [1, 2]
nums2 = [3, 4]
则中位数是 (2 + 3)/2 = 2.5

示例3:
输入:nums1 = [], nums2 = [1]
输出:1.00000

说明:
提示:
nums1.length == m
nums2.length == n
0 <= m <= 1000
0 <= n <= 1000
1 <= m + n <= 2000
-106 <= nums1[i], nums2[i] <= 106

【思路】:

  • 常规的思路是:num1[]和num2[]合并、排序后,变成一个长度为m+n的有序大序列,找其中位数。m+n是基数,则返回索引[(m+n)/2]的值;m+n是偶数,则返回索引[(m+n)/2]、[((m+n)/2)+1]的均值。

  • 如上可知,本质上:对于总长m+n的两个序列,只要找到索引[(m+n)/2]、[((m+n)/2)+1]的值即可。那么考虑用二分查找 从两个序列中找 第k个数 方式来实现呢?
    image

代码参考:

  • 采用循环查找方式
    public double findMedianSortedArrays(int[] nums1, int[] nums2) {
        int totalLength = nums1.length + nums2.length;
        if (totalLength == 0) {
          return 0;
        }
        
        if (totalLength % 2 == 1) {            
            // 第totalLength / 2 + 1个数值
            double median = getKthElement(nums1, nums2, totalLength / 2 + 1); 
            return median;
            
        } else {
            // 第totalLength / 2个数值
            double median1 = getKthElement(nums1, nums2, totalLength / 2);
            // 第totalLength / 2 + 1个数值
            double median2 = getKthElement(nums1, nums2, totalLength / 2 + 1);            
            return (median1 + median2) / 2;
        }
    }

    public int getKthElement(int[] nums1, int[] nums2, int k) {
        int length1 = nums1.length, length2 = nums2.length;
        int index1 = 0, index2 = 0;
        int kthElement = 0;

        while (true) {
            // 边界情况
            if (index1 == length1) {
                return nums2[index2 + k - 1];
            }

            if (index2 == length2) {
                return nums1[index1 + k - 1];
            }

            if (k == 1) {
                return Math.min(nums1[index1], nums2[index2]);
            }

            // 正常情况,第k/2个元素,对应索引k/2-1
            int half = k/2;
            int newIndex1 = Math.min(index1 + half - 1, length1 - 1);
            int newIndex2 = Math.min(index2 + half - 1, length2 - 1);

            if (nums1[newIndex1] <= nums2[newIndex2]) {
                k -= (newIndex1 - index1 + 1);   //排除掉用nums1中0~newIndex1元素,计算个数
                index1 = newIndex1 + 1;    //刷新排除后的索引
            } else {
                k -= (newIndex2 - index2 + 1);
                index2 = newIndex2 + 1;
            }
        }

代码参考:

  • 采用递归查找方式
    public double findMedianSortedArrays(int[] nums1, int[] nums2) {
        int totalLength = nums1.length + nums2.length;
        if (totalLength == 0) {
            return 0;
        }

        if (totalLength % 2 == 1) {
            // 第totalLength / 2 + 1个数值
            double median = getKthElementLoop(nums1, 0,nums2, 0,totalLength / 2 + 1);
            return median;

        } else {
            // 第totalLength / 2个数值
            double median1 = getKthElementLoop(nums1, 0,nums2, 0, totalLength / 2);
            // 第totalLength / 2 + 1个数值
            double median2 = getKthElementLoop(nums1, 0,nums2, 0, totalLength / 2 + 1);
            return (median1 + median2) / 2;
        }

    }
    
    public int getKthElementLoop(int[] nums1, int startIndex1, int[] nums2, int startIndex2, int k) {
        if (startIndex1 < 0 || startIndex1 >= nums1.length) {
            return nums2[startIndex2 + k - 1];
        }

        if(startIndex2 <0 || startIndex2 >= nums2.length) {
            return nums1[startIndex1 + k - 1];
        }

        if (k == 1) {
            return  Math.min(nums1[startIndex1], nums2[startIndex2]);
        }

        int half = k/2;
        int newIndnex1 = Math.min(startIndex1 + half - 1, nums1.length - 1);
        int newIndnex2 = Math.min(startIndex2 + half - 1, nums2.length - 1);

        if (nums1[newIndnex1] <= nums2[newIndnex2]) {
            return getKthElementLoop(nums1, newIndnex1 + 1, nums2, startIndex2, k-(newIndnex1 - startIndex1 + 1));
        }else {
            return getKthElementLoop(nums1, startIndex1, nums2, newIndnex2 + 1, k-(newIndnex2 - startIndex2 + 1));
        }
    }

3)搜索旋转排序数组

升序整数数组nums,数组中的值互不相同。
在传递给函数之前,nums 在预先未知的某个下标 k(0 <= k < nums.length)上进行了旋转,使数组变为 [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]](下标 从 0 开始 计数)。例如, [0,1,2,4,5,6,7] 在下标 3 处经旋转后可能变为 [4,5,6,7,0,1,2] 。

给你 旋转后 的数组 nums 和一个整数 target ,如果 nums 中存在这个目标值 target ,则返回它的索引,否则返回 -1 。

示例 1:
输入:nums = [4,5,6,7,0,1,2], target = 0
输出:4

示例 2:
输入:nums = [4,5,6,7,0,1,2], target = 3
输出:-1

示例 3:
输入:nums = [1], target = 0
输出:-1

提示:
1 <= nums.length <= 5000
-10^4 <= nums[i] <= 10^4
nums 中的每个值都 独一无二
nums 肯定会在某个点上旋转

【思路】:

  1. 遍历,找到旋转点。则:旋转点前的值大,旋转点后的值小。
  2. 比较 旋转点 和 target大小,分别在 旋转点左侧子序列 或 旋转点右侧子序列 二分查找target。

【微码逻辑】:
1)方式一:如果right指向比较边界外位置(right为无效索引)

  • 比较序列后端,right=length,为最后一个元素的下一位置。
  • 此时要注意:递归、或while循环下一轮应该是:[left,mid)【mid已比较过了,下轮作为范围外索引】、[mid+1,right)。
  • 每轮递归是条件是left < right。如:
    • 最后2个位置[3,4),则:mid=3,否则进入下一轮:
      • [3,3),3上轮已比较过了,这轮的3是区间外位置。需要跳出。
      • 或[4,4),4是区间外位置,需要跳出。

2)方式二:right指向边界最后一个位置(right为有效索引)

  • 比较序列后端,right = length-1,为最后一个有效元素。
  • 此时要注意:递归、或while循环下一轮应该是:[left,mid-1]【mid已比较过了,下轮有效索引结尾是mid-1】、[mid+1,right]。
  • 每轮递归是条件是left <= right。如:
    • 循环最后2个位置Index是[x , x+1],则:mid=x,x位置值不相等,则:下一轮进入:
      • [x, x-1],跳出。
      • 或 [x+1 , x+1],则:mid = x+1,x+1位置值不相等,则下一轮进入:
        • [x+1, x]、或[x+2, x+1],跳出
    • 如果最后2个位置是Index[0, 1],则:mid=0,0位置不相等,下轮是[0,-1],[1, 1]

一、代码参考1:递归 + right为无效元素

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

        int index = -1;
        for (int i = 0; i < nums.length - 1; i++) {
            if (nums[i] > nums[i + 1]) {
                index = i + 1;
            }
        }

        int ret = 0;
        if (index == -1) {
            ret = searchFor(nums, 0, nums.length, target);
        } else if (target >= nums[0]) {
            ret = searchFor(nums, 0, index, target);
        } else {
            ret = searchFor(nums, index, nums.length, target);
        }
        return ret;
    }

    public static int searchFor(int[] nums, int left, int right, int target) {
        if(left < right) {
            int mid = (left + right) / 2;

            if (nums[mid] == target) {
                return mid;
            } else if (target < nums[mid]) {
                return searchFor(nums, left, mid, target);
            } else {
                return searchFor(nums, mid + 1, right, target);
            }
        }
        return -1;
    }

二、代码参考2:递归 + right为有效元素

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

        int index = -1;
        for (int i = 0; i < nums.length - 1; i++) {
            if (nums[i] > nums[i + 1]) {
                index = i;
            }
        }

        int ret = 0;
        if (index == -1) {
            ret = searchFor(nums, 0, nums.length -1, target);
        } else if (target >= nums[0]) {
            ret = searchFor(nums, 0, index , target);
        } else {
            ret = searchFor(nums, index + 1, nums.length -1, target);
        }
        return ret;
    }

    public static int searchFor(int[] nums, int left, int right, int target) {
        if(left <= right) {
            int mid = (left + right) / 2;

            if (nums[mid] == target) {
                return mid;
            } else if (target < nums[mid]) {
                return searchFor(nums, left, mid-1, target);
            } else {
                return searchFor(nums, mid + 1, right, target);
            }
        }
        return -1;
    }

三、代码参考3:while循环 + right为无效元素

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

        int index = -1;
        for (int i = 0; i < nums.length - 1; i++) {
            if (nums[i] > nums[i + 1]) {
                index = i + 1;
            }
        }

        int ret = 0;
        if (index == -1) {
            ret = searchFor(nums, 0, nums.length, target);
        } else if (target >= nums[0]) {
            ret = searchFor(nums, 0, index , target);
        } else {
            ret = searchFor(nums, index, nums.length, target);
        }
        return ret;
    }

    public static int searchFor(int[] nums, int left, int right, int target) {
        while(left < right) {
            int mid = (left + right) / 2;

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

四、代码参考4:while循环 + right有效元素

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

        int index = -1;
        for (int i = 0; i < nums.length - 1; i++) {
            if (nums[i] > nums[i + 1]) {
                index = i;
            }
        }

        int ret = 0;
        if (index == -1) {
            ret = searchFor(nums, 0, nums.length -1, target);
        } else if (target >= nums[0]) {
            ret = searchFor(nums, 0, index , target);
        } else {
            ret = searchFor(nums, index + 1, nums.length -1, target);
        }
        return ret;
    }

    public static int searchFor(int[] nums, int left, int right, int target) {
        while(left <= right) {
            int mid = (left + right) / 2;

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

4)小张刷题计划

力扣:https://leetcode-cn.com/problems/xiao-zhang-shua-ti-ji-hua/submissions/

为了提高自己的代码能力,小张制定了 LeetCode 刷题计划,他选中了 LeetCode 题库中的 n 道题,编号从 0 到 n-1,并计划在 m 天内按照题目编号顺序刷完所有的题目(注意,小张不能用多天完成同一题)。

在小张刷题计划中,小张需要用 time[i] 的时间完成编号 i 的题目。此外,小张还可以使用场外求助功能,通过询问他的好朋友小杨题目的解法,可以省去该题的做题时间。为了防止“小张刷题计划”变成“小杨刷题计划”,小张每天最多使用一次求助。

我们定义 m 天中做题时间最多的一天耗时为 T(小杨完成的题目不计入做题总时间)。请你帮小张求出最小的 T是多少。

示例 1:
输入:time = [1,2,3,3], m = 2
输出:3
解释:第一天小张完成前三题,其中第三题找小杨帮忙;第二天完成第四题,并且找小杨帮忙。这样做题时间最多的一天花费了 3 的时间,并且这个值是最小的。

示例 2:
输入:time = [999,999,999], m = 4
输出:0
解释:在前三天中,小张每天求助小杨一次,这样他可以在三天内完成所有的题目并不花任何时间。

限制:
1 <= time.length <= 10^5
1 <= time[i] <= 10000
1 <= m <= 1000

【题解思路】
此题最终结果每天最多耗费x时间刚好能读完所有小册、每天花费x-1时间又读不完所有小册。本质上是寻找x值。x取值范围1~10000。
1)思路一:采用x从小到大遍历的方式,知道第一个能读完所有测试的x,即为结果。
2)思路二:采用思路一的方式效率低。可以考虑采用二分法找x,要求x最终满足如上条件。

    public int readBookTimes(int[] time, int m) {
        int left = 0;
        int right = 10000 * time.length;
        while (left < right) {
            int mid = (left + right)/2;
            if (canReadAll(time, m, mid)) {
                if (mid == 0) {
                    return 0;
                }

                if (!canReadAll(time, m, mid - 1)) {
                    return mid;
                } else {
                    right = mid;
                }
            } else {
                left = mid + 1;
            }
        }

        return -1;
    }

    public boolean canReadAll(int[] times, int day, int maxTimes) {
        int tmpDay = 1;
        int tmpTime = 0;
        int tmpMaxTime = 0;

        for (int i = 0; i < times.length; i++) {
            // 计算当前天读取第i本书的时间
            if (times[i] > tmpMaxTime) {  // 剔除第i本书,前面临时记录的剔除书时间记入总耗时
                tmpTime += tmpMaxTime;
                tmpMaxTime = times[i];
            } else {
                tmpTime += times[i];
            }

            // 如果加上第i本书,超时每天最大时间,则第i本书下一天在读。注这里:i要--回退。
            if (tmpTime > maxTimes) {
                i--;
                // 清空数据,用于下一天计算
                tmpTime = 0;
                tmpMaxTime = 0;

                // 读书天数++
                tmpDay++;
                if (tmpDay > day) {
                    return false;
                }
            }
        }

        return true;
    }
posted @ 2021-06-23 14:32  小拙  阅读(102)  评论(0编辑  收藏  举报