二分查找的一些总结

二分查找

二分查找真的是个很玄妙的算法,因为它的思路很简单,在一个有序的序列中,想要找到某个数,先判断中间的数与这个目标数的大小,如果中间的数大于目标数,那么则认为,目标数在这个序列的左半边,反之则在右半边。

但是思路容易,实现可是极其麻烦,因为总是分不清边界,l 和 r 什么时候是 < 什么时候是 <= ,还有边界的更新,一会是l = mid,一会是r = mid+1啥的,总是让人头晕。

经过在算法基础课的学习,记下了这个模版,在刷题中使用也很方便。

在二分查找中,最主要的还是要确定好边界的处理,防止产生死循环。

模版

  • 当mid属于左边时
while (l < r) {
  int mid = l + r >> 1;
  if (check(mid)) r = mid;
  else l = mid + 1
}
return l;
  • 当mid属于右边时
while (l < r) {
  int mid = l + r + 1 >> 1;
  if (check(mid)) l = mid;
  else r = mid - 1;
}
return l;

例题及思路

Leetcode 704 二分查找

给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。

示例 1:

输入: nums = [-1,0,3,5,9,12], target = 9
输出: 4
解释: 9 出现在 nums 中并且下标为 4
示例 2:

输入: nums = [-1,0,3,5,9,12], target = 2
输出: -1
解释: 2 不存在 nums 中因此返回 -1

提示:

1.你可以假设 nums 中的所有元素是不重复的。
2.n 将在 [1, 10000]之间。

  1. nums 的每个元素都将在 [-9999, 9999]之间。

这个题目就是一个标准的二分题了,因为数组是有序的了,所以也不用在排序,直接比较mid的值与target的关系,我们可以发现,当nums[mid] > target时,代表着我们的我们的target在左半边,则r需要缩小。

class Solution {
public:
    int search(vector<int>& nums, int target) {
        int l = 0,r = nums.size();
        while (l < r) {
            int mid = l + r >> 1;
            if (nums[mid] == target) return mid;
            else if (nums[mid] > target) r = mid;
            else l = mid + 1;
        }
        return -1;
    }
};

Leetcode 1283. 使结果不超过阈值的最小除数

给你一个整数数组 nums 和一个正整数 threshold ,你需要选择一个正整数作为除数,然后将数组里每个数都除以它,并对除法结果求和。

请你找出能够使上述结果小于等于阈值 threshold 的除数中 最小 的那个。

每个数除以除数后都向上取整,比方说 7/3 = 3 , 10/2 = 5 。

题目保证一定有解。


示例 1:

输入:nums = [1,2,5,9], threshold = 6
输出:5
解释:如果除数为 1 ,我们可以得到和为 17 (1+2+5+9)。
如果除数为 4 ,我们可以得到和为 7 (1+1+2+3) 。如果除数为 5 ,和为 5 (1+1+1+2)。
示例 2:

输入:nums = [2,3,5,7,11], threshold = 11
输出:3
示例 3:

输入:nums = [19], threshold = 5
输出:4

提示:

  • 1 <= nums.length <= 5 * 10^4
  • 1 <= nums[i] <= 10^6
  • nums.length <= threshold <= 10^6

首先要理解题目的意思,我们需要找到最小的除数,使得元素和除以除数小于target,当看到最小值的时候,就得想到贪心做法,也就是说我们除以5都小于target了,那我们一定不会考虑除以3的事了。

第一步确定边界,最小除数定位1,因为不能除以0,最大的除数定位数组中最大的数,否则你怎么加都不会为target。

之后就按照上面的模版来解决,我们可以发现check函数的性质是,当我们的mid < target时,就代表着我们要找的数在左边,则更新右边。

class Solution {
public:
    bool check(vector<int>& nums,int mid,int threshold) {
        int sum = 0;
        for (auto x : nums) {
            sum += x % mid == 0 ? x / mid : x / mid + 1;
            if (sum > threshold) return false;
        }
        return true;
    }
    int smallestDivisor(vector<int>& nums, int threshold) {
        int maxn = nums[0];
        for (auto x : nums) {
            maxn = max(maxn,x);
        }
        int l = 1,r = maxn;
        while (l < r) {
            int mid = l + r >> 1;
            if (check(nums,mid,threshold)) r = mid;
            else l = mid + 1;
        }
        return r;
    }
};

Leetcode 857.爱吃香蕉的珂珂

珂珂喜欢吃香蕉。这里有 N 堆香蕉,第 i 堆中有 piles[i] 根香蕉。警卫已经离开了,将在 H 小时后回来。

珂珂可以决定她吃香蕉的速度 K (单位:根/小时)。每个小时,她将会选择一堆香蕉,从中吃掉 K 根。如果这堆香蕉少于 K 根,她将吃掉这堆的所有香蕉,然后这一小时内不会再吃更多的香蕉。

珂珂喜欢慢慢吃,但仍然想在警卫回来前吃掉所有的香蕉。

返回她可以在 H 小时内吃掉所有香蕉的最小速度 K(K 为整数)。

示例 2:

输入: piles = [30,11,23,4,20], H = 5
输出: 30
示例 3:

输入: piles = [30,11,23,4,20], H = 6
输出: 23

提示:

1 <= piles.length <= 10^4
piles.length <= H <= 10^9
1 <= piles[i] <= 10^9


首先来确定边界,我们左边界定位1,因为猴子珂珂至少吃一个(1个都不吃纯属nt,不过这个题目后面的用例都上万了,也是实属nt),然后右边界定为这个数组中最大的数,因为就算取1个亿,珂珂吃完这一堆香蕉后照样休息不继续了。

然后是我们的check函数,我们可以发现,当我们的mid < target成立时,根据贪心的思想,珂珂还能再吃慢一点,,代表着我们要找的值在左边,所以更新右边

class Solution {
public:
    bool check(vector<int>& piles,int v,int H) {
        int sum = 0;
        for (auto x : piles) {
            sum += (x / v) + (x % v == 0 ? 0 : 1);
        }
        return sum <= H;
    }
    int minEatingSpeed(vector<int>& piles, int H) {
        int maxn = 0;
        for (auto x : piles) {
            maxn = max(x, maxn);
        }
        int l = 1;
        int r = maxn;
        while (l < r) {
            int mid = l + r >> 1;
            if (check(piles,mid,H)) r = mid;
            else l = mid + 1;
        }
        return l;
    }
};

Leetcode 1011 在D天内送达包裹的能力

传送带上的包裹必须在 D 天内从一个港口运送到另一个港口。

传送带上的第 i 个包裹的重量为 weights[i]。每一天,我们都会按给出重量的顺序往传送带上装载包裹。我们装载的重量不会超过船的最大运载重量。

返回能在 D 天内将传送带上的所有包裹送达的船的最低运载能力

示例 1:

输入:weights = [1,2,3,4,5,6,7,8,9,10], D = 5
输出:15
解释:
船舶最低载重 15 就能够在 5 天内送达所有包裹,如下所示:
第 1 天:1, 2, 3, 4, 5
第 2 天:6, 7
第 3 天:8
第 4 天:9
第 5 天:10

请注意,货物必须按照给定的顺序装运,因此使用载重能力为 14 的船舶并将包装分成 (2, 3, 4, 5), (1, 6, 7), (8), (9), (10) 是不允许的。
示例 2:

输入:weights = [3,2,2,4,1,4], D = 3
输出:6
解释:
船舶最低载重 6 就能够在 3 天内送达所有包裹,如下所示:
第 1 天:3, 2
第 2 天:2, 4
第 3 天:1, 4
示例 3:

输入:weights = [1,2,3,1,1], D = 4
输出:3
解释:
第 1 天:1
第 2 天:2
第 3 天:3
第 4 天:1, 1

提示:

  • 1 <= D <= weights.length <= 50000

  • 1 <= weights[i] <= 500


这个题目与上面那两个题目是一个套路,都是标准的套模版。

第一步我们还是来确定边界,我们船的运载能力最小要是这堆货物里的最大的,要不然我一次送一个货物,最大的还送不走,最大的为货物的总和,我一船就送完了。

第二步我们确定check函数,我们发现,当我们可以用小于D的天数就能完成任务时,根据贪心的思想,就代表我如果减少船的容量也是有希望的,代表我们的值在左侧,则更新右边界。

class Solution {
public:
    bool check(vector<int>& weights,int capc,int D) {
        int sum = 0;
        for (auto x : weights) {
            if (sum + x > capc) {
                sum = 0;
                D--;
            }
            sum += x;
        }
        return D > 0;
    }
    int shipWithinDays(vector<int>& weights, int D) {
        int sum = 0,maxn = 0;
        for (auto x : weights) {
            sum += x;
            maxn = max(maxn,x);
        }
        int l = max(sum / D,maxn);
        int r = sum;
        while (l < r) {
            int mid = l + r >> 1;
            if (check(weights,mid,D)) r = mid;
            else l = mid + 1;
        }
        return l;
    }
};

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

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

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

如果数组中不存在目标值,返回 [-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]


题目要求我们时间复杂度为O(log n) 级别,则我们就该想到使用二分查找,这道题目充分考查了我们对check函数的理解,还是老样子

第一步,我们确定边界,我们左边界取0,右边界取最后一个元素的下标。

第二部来确定check函数,第一个元素,则代表着他有如下的性质,它右边所有的数都比他大,所以当我们的mid >= target 时,代表着我们的数在左侧,则我们更新右边界。最后一个元素,则代表着他有着如下的性质,它左边所有的数都比它小,所以当我们的mid <= target 时,代表着我们的数在右侧,则更新左边界。

class Solution {
public:
    vector<int> searchRange(vector<int>& nums, int target) {
        vector<int> res;
        int n = nums.size();
        if(n == 0 ) return vector<int>({-1,-1});
        int l = 0, r = n - 1;
        while(l < r){
            long long mid = l + r >> 1;
            if(nums[mid] >= target) r = mid;
            else l = mid + 1;
        }
        if(nums[r] != target) return vector<int>({-1,-1});
        res.push_back(r);
        l = 0,r = n-1;
        while(l < r){
            long long mid = l + r + 1 >> 1;
            if(nums[mid] <= target) l = mid;
            else r = mid - 1;
        }
        res.push_back(r);
        return res;
    }
};

总结

关于二分查找,总是需要考虑许多细节的地方,尤其是区间的开闭。

举个例子

//1
while (l <= r) {
  .....
}
//2
while (l < r) {
  .....
}

我们看函数1,l <= r 代表着我们的区间是闭合的,[4,4]是存在的。

我们再看函数2,l < r 代表我们是开区间,[4,4]是不存在的,所以在check里容易直接退出去了,从而扫描不到4

还有要注意的就是左右边界的更新,我们可以记住那两个模版

  • 当我们的mid在左边时,我们使r = mid, l = mid + 1,此时mid 就是 (l + r) / 2
  • 当我们的mid在右边时,我们使l = mid, r = mid - 1,此时mid为(l + r + 1) / 2

在计算mid的时候,容易爆int,我们可以写为(l + (r - l)) / 2

最后总结一下二分查找的做题思路吧

  1. 确定二分的边界
  2. 编写框架并完成特判
  3. 设计一个check函数,确定性质(我个人喜欢把mid放在左边)
  4. 判断左右边界如何更新

希望大家可以在二分查找中注意处理各种琐碎的问题。

posted @ 2020-05-12 00:48  阿-栋  阅读(520)  评论(0编辑  收藏  举报