二分搜索【算法】

二分搜索详解

零、二分查找框架

int binarySearch(vector<int>& nums, int target) {
    
    
	int left = 0, right = ...;
	while (...) {
    
    
		int mid = left + (right - left) / 2;
		if (nums[mid] == target) {
    
    
			...;
		} else if (nums[mid] < target) {
    
    
			ledt = ...;
		} else if (nums[mid] > target) {
    
    
			right = ...;
		}
	}
	return ...;
}
  • 分析二分查找的一个技巧是:不要出现 else,而是把所有情况用 else if 写清楚,这样可以清楚地展现所有细节。
  • 计算 mid 时需要防止溢出,代码中 left + (right - left) / 2 就和 (left + right) / 2 的结果相同,但是有效防止了 leftright 太大,直接相加导致溢出的情况。

一、寻找一个数

即搜索一个数,如果存在,返回其索引,否则返回 -1。

int binarySearch(vector<int>& nums, int target) {
    
    
	int left = 0, right = nums.size();	// 注意 开区间[)
	while(left < right) {
    
    
		int mid = left + (right - left) / 2;
		if (nums[mid] == target) {
    
    
			return mid;
		} else if (nums[mid] < target) {
    
    
			left = mid + 1;		// 注意
		} else if (nums[mid] > target) {
    
    
			right = mid;		// 注意
		}
	}
	return -1;
}

1、为什么 while 循环条件是 <= 或是 < ?
while(left<=right)对应的是闭区间[left,right],且right赋值为nums.size()-1。中止条件为[right+1,right],区间为空。
while(left<right)对应的是开区间[left,right),且right赋值为nums.size()。中止条件为[right,right),区间为空。

2、为什么 left=mid+1,right=mid-1,有的代码是 right=mid,left=mid ?
算法的搜索区间是闭的,即[left,right],当mid不是要找的target时,应该搜索[left,mid-1]或者[mid+1,right],因为mid已经搜索过了应被去除。

二、寻找左侧边界

int left_bound(vector<int>& nums, int target) {
    
    
	int left = 0, right = nums.size();	// 对应开区间[left, right)
	while (left < right) {
    
    				// 中止条件为[right, right), 区间为空
		int mid = left + (right - left) / 2;
		if (nums[mid] == target) {
    
    
			right = mid;
		} else if (nums[mid] < target) {
    
    
			left = mid + 1;
		} else if (nums[mid] > target) {
    
    
			right = mid;			// 因为`right`是开区间, 不用去除
		}
	}	
    if (left == nums.size()) return -1;
    return nums[left] == target ? left : -1;		
}

1、为什么 while 循环条件是 < 或是 <= ?
while(left<=right)对应的是闭区间[left,right],且right赋值为nums.size()-1。中止条件为[right+1,right],区间为空。
while(left<right)对应的是开区间[left,right),且right赋值为nums.size()。中止条件为[right,right),区间为空。
2、为什么left=mid+1, right=mid?
因为搜索区间[left,right)是左闭右开的,当nums[mid]被检测后,下一步应该去mid的左侧或者右侧区间搜索,即[left, mid)[mid+1, right)

三、寻找右侧边界

int right_bound(vector<int>& nums, int target) {
    
    
	int left = 0, right = nums.size();	// 左闭右开[)
	while (left < right) {
    
    
		int mid = left + (right - left) / 2;
		if (nums[mid] == target) {
    
    
			left = mid + 1;
		} else if (nums[mid] < target) {
    
    
			left = mid + 1;
		} else if (nums[mid] > target) {
    
    
			right = mid;
		}
	}
    if(left - 1 < 0) return -1;
    return nums[left - 1] == target ? (left - 1) : -1;
}

1、为什么最后返回 left - 1 而不像左侧边界的函数,返回 left
因为当nums[mid] == target时,left = mid + 1,所以最后的结果一定比右边界大于1。

四、逻辑统一

最基本的二分查找算法

因为我们初始化 right = nums.length - 1
所以决定了我们的「搜索区间」是 [left, right]
所以决定了 while (left <= right)
同时也决定了 left = mid+1 和 right = mid-1

因为我们只需找到一个 target 的索引即可
所以当 nums[mid] == target 时可以立即返回

寻找左侧边界的二分查找

因为我们初始化 right = nums.length
所以决定了我们的「搜索区间」是 [left, right)
所以决定了 while (left < right)
同时也决定了 left = mid + 1 和 right = mid

因为我们需找到 target 的最左侧索引
所以当 nums[mid] == target 时不要立即返回
而要收紧右侧边界以锁定左侧边界

寻找右侧边界的二分查找

因为我们初始化 right = nums.length
所以决定了我们的「搜索区间」是 [left, right)
所以决定了 while (left < right)
同时也决定了 left = mid + 1 和 right = mid

因为我们需找到 target 的最右侧索引
所以当 nums[mid] == target 时不要立即返回
而要收紧左侧边界以锁定右侧边界

又因为收紧左侧边界时必须 left = mid + 1
所以最后无论返回 left 还是 right,必须减一

刷题

0034. 在排序数组中查找元素的第一个和最后一个位置(剑Ⅰ0053)

给定一个按照升序排列的整数数组 nums,和一个目标值 target,找出给定目标值在数组中的开始位置和结束位置。如果数组中不存在目标值 target,返回 [-1, -1]

示例 1:

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

解题思路:

  • 二分搜索的难点就在于如何搜索左侧边界和右侧边界。

代码:

class Solution {
    
    
public:
    vector<int> searchRange(vector<int>& nums, int target) {
    
    
        vector<int> res;
        int left = left_bound(nums, target);
        int right = right_bound(nums, target);
        res.push_back(left);
        res.push_back(right);
        return res;
    }

    int left_bound(vector<int>& nums, int target) {
    
    
        int left = 0, right = nums.size();  // [...)
        while (left < right) {
    
    
            int mid = left + (right - left) / 2;
            if (nums[mid] < target) {
    
           
                left = mid + 1;
            } else {
    
        // ==:(right = mid)
                right = mid;
            }
        }

        if (left == nums.size()) return -1;
        return nums[left] == target ? left : -1;
    }

    int right_bound(vector<int>& nums, int target) {
    
    
        int left = 0, right = nums.size();  // [...)
        while (left < right) {
    
    
            int mid = left + (right - left) / 2;
            if (nums[mid] <= target) {
    
    
                left = mid + 1;
            } else {
    
        // ==:(left = mid + 1)
                right = mid;
            }
        }
        if (left - 1 < 0) return -1;
        return nums[left - 1] == target ? (left - 1) : -1;
    }
};

0035.(剑Ⅱ0068). 搜索插入位置[简单]

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

请必须使用时间复杂度为 O(log n) 的算法。

示例:

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

思路:
当目标元素 target 不存在数组 nums 中时,搜索左侧边界的二分搜索的返回值可以做以下几种解读:

  1. 返回的这个值是 nums 中大于等于 target 的最小元素索引。
  2. 返回的这个值是 target 应该插入在 nums 中的索引位置。
  3. 返回的这个值是 nums 中小于 target 的元素个数。

本题中采用的第二种说法,所以使用基本的搜索左边界就可以了。

代码:

class Solution {
    
    
public:
    int searchInsert(vector<int>& nums, int target) {
    
    
        int left = 0, right = nums.size();
        while (left < right) {
    
    
            int mid = left + (right - left) / 2;
            if (nums[mid] < target) {
    
    
                left = mid + 1;
            } else {
    
        // ==:->(right = mid)
                right = mid;
            }
        }
        return left;
    }
};

0074. 搜索二维矩阵

题目:
编写一个高效的算法来判断 m x n 矩阵中,是否存在一个目标值。该矩阵具有如下特性:

  • 每行中的整数从左到右按升序排列。
  • 每行的第一个整数大于前一行的最后一个整数。

示例:
在这里插入图片描述

输入:matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,60]], target = 3
输出:true

思路:

  • 二维的坐标 (i, j) 可以映射成一维的 index = i * n + j,相反一维 index 也可反解出 i = index / nj = index % n
  • 实现一个 get 函数,把二维坐标抽象为一维,然后二分搜索即可。

代码:

class Solution {
    
    
public:
    bool searchMatrix(vector<vector<int>>& matrix, int target) {
    
    
        int m = matrix.size(), n = matrix[0].size();
        int left = 0, right = m * n;                    // [left, right)
        while (left < right) {
    
    							// 二分搜索
            int mid = left + (right - left) / 2;
            if (get(matrix, mid) < target) {
    
    
                left = mid + 1;
            } else if (get(matrix, mid) > target) {
    
    
                right = mid;
            } else {
    
    
                return true;
            }
        }
        return false;
    }
    /* 得到索引为 index 的二维数组对应元素 */
    int get(vector<vector<int>>& matrix, int index) {
    
    
        int m = matrix.size(), n = matrix[0].size();    // 总行数列数
        int i = index / n, j = index % n;               // index 二维对应坐标
        return matrix[i][j];
    }
};

0354. 俄罗斯套娃信封问题

题目:
给你一个二维整数数组envelopes,其中envelopes[i] = [wi, hi],表示第i个信封的宽度和高度。
当另一个信封的宽度和高度都比这个信封大的时候,这个信封就可以放进另一个信封里。
请计算最多能有多少个信封能组成一组“俄罗斯套娃”信封?

示例:

输入:envelopes = [[5,4],[6,4],[6,7],[2,3]]
输出:3
解释:最多信封的个数为 3, 组合为: [2,3] => [5,4] => [6,7]。

思路
本题相当于在二维求解最长递增子序列。
先对宽度w进行升序排序,如果w相同,按照高度h降序排序。

class Solution {
    
    
public:
    int maxEnvelopes(vector<vector<int>>& envelopes) {
    
    
        int n = envelopes.size();
        sort(envelopes.begin(), envelopes.end(), [](vector<int>& a, vector<int>& b){
    
    
            return a[0] == b[0] ? a[1] > b[1] : a[0] < b[0];
        });
        vector<int> nums(n);
        for (int i = 0; i < n; i++) {
    
    
            nums[i] = envelopes[i][1];
        }
        return lengthOfLIS(nums);
    }

    int lengthOfLIS(vector<int>& nums) {
    
    
        int len = nums.size();
        vector<int> tail;
        tail.push_back(nums[0]);    
        int res = 0;
        for (int i = 1; i < len; i++) {
    
    
            if (nums[i] > tail[res]) {
    
    
                tail.push_back(nums[i]);
                res++;
            } else {
    
    
                int left = 0, right = res;
                while (left < right) {
    
    
                    int mid = left + (right - left) / 2;
                    if (tail[mid] < nums[i]) {
    
    
                        left = mid + 1;
                    } else {
    
    
                        right = mid;
                    }
                }
                tail[left] = nums[i];
            }
        }
        return res + 1;
    }
};

0392. 判断子序列(##滑动指针??##)

给定字符串 s 和 t ,判断 s 是否为 t 的子序列。
示例:

输入:s = "abc", t = "ahbgdc"
输出:true

解题思路:

  • 与其将 left<s.size()写在循环内,将其写在循环外的m=s.size();left<m;会使速度更快。
  • 利用双指针 i, j 分别指向 s, t,一边前进一边匹配子序列。
  • 之后把所有的高度h计算最长上升子序列LIS的长度就是答案。
    在这里插入图片描述

代码:

class Solution {
    
    
public:
    int maxEnvelopes(vector<vector<int>>& envelopes) {
    
    
        int n = envelopes.size();
		// 一维升序二维降序排序
        sort(envelopes.begin(), envelopes.end(), [](vector<int>& a, vector<int>& b){
    
    
            return a[0] == b[0] ? a[1] > b[1] : a[0] < b[0];
        });
		// 将值赋给一维数组
        vector<int> nums(n);
        for (int i = 0; i < n; i++) {
    
    
            nums[i] = envelopes[i][1];
        }
        return lengthOfLIS(nums);
    }

    int lengthOfLIS(vector<int>& nums) {
    
    
        int len = nums.size();				// 当前卡牌总数 
        vector<int> tail;					// 记录宽度为i的LIS尾数
        tail.push_back(nums[0]);    		// 开始时宽度1的tail为nums[0]
        int res = 0;						// tail索引
        for (int i = 1; i < len; i++) {
    
    		// 开始加牌
            if (nums[i] > tail[res]) {
    
    		// 如果牌大于当前最大值
                tail.push_back(nums[i]);	// 将其新添加到tail尾端
                res++;						// 宽度加一
            } else {
    
    						// 如果牌小于当前最大值,寻找合适的插入位置
                int left = 0, right = res;	// 二分法查询位置
                while (left < right) {
    
    
                    int mid = left + (right - left) / 2;
                    if (tail[mid] < nums[i]) {
    
    
                        left = mid + 1;
                    } else {
    
    
                        right = mid;
                    }
                }
                tail[left] = nums[i];		// 替换
            }
        }
        return res + 1;						// 返回数量,为索引加一
    }
};

代码:

class Solution {
    
    
public:
    bool isSubsequence(string s, string t) {
    
    
        // 利用双指针
        int left = 0, right = 0;
        int m = s.size(), n = t.size();
        while (left < m && right < n) {
    
    
            if (s[left] == t[right]) {
    
    
                left++;
            }
            right++;
        }
        return left == m;      
    }
};

0704. 二分查找

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

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

解题思路:

  • 只适用与只有一个目标值的情况。

代码:

class Solution {
    
    
public:
    int search(vector<int>& nums, int target) {
    
    
        return binarySearch(nums, target);
    }

    int binarySearch(vector<int>& nums, int target) {
    
    
        int left = 0, right = nums.size();
        while (left < right) {
    
    
            int mid = left + (right - left) / 2;
            if (nums[mid] < target) {
    
    
                left = mid + 1;
            } else if (nums[mid] > target){
    
      
                right = mid;
            } else {
    
    
                return mid;
            }
        }
        return -1;
    }
};

0793. 阶乘函数后K个零

f(x)x! 末尾是 0 的数量。回想一下 x! = 1 * 2 * 3 * ... * x,且 0! = 1
例如,f(3) = 0 ,因为 3! = 6 的末尾没有 0 ;而 f(11) = 2 ,因为 11!= 39916800 末端有 2 个 0 。
给定 k,找出返回能满足 f(x) = k 的非负整数 x 的数量。

示例 1:

输入:k = 0
输出:5
解释:0!, 1!, 2!, 3!, 和 4! 均符合 k = 0 的条件。

解题思路:

  • 搜索有多少个 n 满足 trailingZeroes(n) == K,其实就是在问,满足条件的 n 最小是多少,最大是多少,最大值和最小值一减,就可以算出来有多少个 n 满足条件了

代码:

class Solution {
    
    
public:
    int preimageSizeFZF(int k) {
    
    
        return (int)(right_bound(k) - left_bound(k) + 1);
    }

    // 判断数字 n! 末尾有几个零 (n 有多少个含5的因式) 
    // 如:100/5=20 + 20/5=4 = 24
    long trailingZeros(long n) {
    
    
        long res = 0;
        for (long d = n; d / 5 > 0; d = d / 5) {
    
    
            res += d / 5;
        }
        return res;     
    }

    /* 探索 trailingZeros(n)==K 的左边界 */
    long left_bound(int target) {
    
    
        long left = 0, right = 10e9;
        while (left < right) {
    
    
            long mid = left + (right - left) / 2;
            if (trailingZeros(mid) < target) {
    
    
                left = mid + 1;
            } else {
    
    
                right = mid;
            }
        }
        return left;
    }

    /* 探索 trailingZeros(n)==K 的右边界 */
    long right_bound(int target) {
    
    
        long left = 0, right = 10e9;
        while (left < right) {
    
    
            long mid = left + (right - left) / 2;
            if (trailingZeros(mid) <= target) {
    
    
                left = mid + 1;
            } else {
    
    
                right = mid;
            }
        }
        return left - 1; 
    }
};

0852. 山脉数组的峰顶索引(剑Ⅱ0069)

符合下列属性的数组 arr 称为 山脉数组 :

  • arr.length >= 3
  • 存在 i(0 < i < arr.length - 1) 使得:arr[0] < arr[1] < ... arr[i-1] < arr[i] arr[i] > arr[i+1] > ... > arr[arr.length - 1]
    给你由整数组成的山脉数组 arr ,返回任何满足 arr[0] < arr[1] < ... arr[i - 1] < arr[i] > arr[i + 1] > ... > arr[arr.length - 1] 的下标 i

示例 1:

输入:arr = [0,1,0]
输出:1

解题思路:

  • 在寻找峰值的过程中,通过 arr[mid]arr[mid+1] 来判断位于峰值左边还是右边。

代码:

class Solution {
    
    
public:
    int peakIndexInMountainArray(vector<int>& arr) {
    
    
        // 确定左右边界
        int left = 1, right = arr.size() - 2;
        while (left < right) {
    
    
            int mid = left + (right - left) / 2;
            // 通过 `arr[mid]` 和 `arr[mid+1]` 来判断位于峰值左边还是右边。
            if (arr[mid] < arr[mid+1]) {
    
    
                left = mid + 1;
            } else {
    
    
                right = mid;
            }
        }
        return left;
    }
};

0875. 爱吃香蕉的珂珂(剑Ⅱ0073)

珂珂喜欢吃香蕉。这里有 n 堆香蕉,第 i 堆中有 piles[i] 根香蕉。警卫已经离开了,将在 h 小时后回来。珂珂可以决定她吃香蕉的速度 k 。每个小时,她将会选择一堆香蕉,从中吃掉 k 根。如果这堆香蕉少于 k 根,她将吃掉这堆的所有香蕉,然后这一小时内不会再吃更多的香蕉。 返回她可以在 h 小时内吃掉所有香蕉的最小速度 k

示例 1:

输入:piles = [3,6,7,11], h = 8
输出:4

解题思路:

  • 二分法的套路为:遇到问题时确定 xf(x)target 分别是什么,并写出单调函数 f 的代码。
  • 本题中吃香蕉的速度为 x,吃完所需要的时间为 f(x),吃香蕉的时间限制为 target

代码:

class Solution {
    
    
public:
    int minEatingSpeed(vector<int>& piles, int h) {
    
    
        // 确定二分法边界
        int left = 1;
        int right = *max_element(piles.begin(), piles.end());
        while (left < right) {
    
    
            int mid = left + (right - left) / 2;
            if (f(piles, mid) <= h) {
    
    
                right = mid;
            } else {
    
    
                left = mid + 1;
            }
        }
        return left;
    }

    // 吃香蕉速度 x 与 吃完所需时间 f(x) 的函数
    int f(vector<int>& piles, int x) {
    
    
        int hours = 0;
        for (int i = 0; i < piles.size(); i++) {
    
    
            // 每堆香蕉需要吃几次
            if (piles[i] % x > 0) {
    
    
                hours++;
            }
            hours += piles[i] / x;
        }
        return hours;
    }
};

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

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

示例 1:

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

解题思路:

  • 如果遇到一个算法问题,尝试确定 xf(x)target 分别是什么,并写出单调函数 f 的代码。
  • 船的运载能力是 x,运输天数 D 与运载能力 x 呈反比,target 为运输天数 D,我们要在 f(x) == D 的约束下,求出 x

tip:

*max_element(weights.begin(), weights.end());	// 求得最大值
accumulate(weights.begin(), weights.end(), 0);	// 初始值为0,求和

代码:

class Solution {
    
    
public:
    int shipWithinDays(vector<int>& weights, int days) {
    
    
        // left求最大的值,right求和
        int left = *max_element(weights.begin(), weights.end());
        int right = accumulate(weights.begin(), weights.end(), 0); 

        while (left < right) {
    
    
            int mid = left + (right - left) / 2;
            if (f(weights, mid) <= days) {
    
    
                right = mid;
            } else {
    
    
                left = mid + 1;
            }
        }
        return left;
    }

    /* 当运载能力为 x 时,需要 f(x) 天运完货物 */
    int f(vector<int>& weights, int x) {
    
    
        int days = 0;
        for (int i = 0; i < weights.size();) {
    
    
            // 尽可能地多装货物
            int cap = x;
            while (i < weights.size()) {
    
    
                if (cap < weights[i]) {
    
    
                    break;
                } else {
    
    
                    cap -= weights[i];
                }
                i++;
            }
            days++;
        }
        return days;
    }
};

1201. 丑数 Ⅲ

给你四个整数:nabc ,请你设计一个算法来找出第 n 个丑数。
丑数是可以被 abc 整除的 正整数

示例 1:

输入:n = 3, a = 2, b = 3, c = 5
输出:4
解释:丑数序列为 2, 3, 4, 5, 6, 8, 9, 10... 其中第 3 个是 4。

解题思路:

  • 可以抽象出一个单调递增的函数 f:f(num,a,b,c) 计算 [1..num] 中,能够整除 abc 的数字的个数,则 f 返回值的个数是随着 num 的增加而增加的。
  • 也就是说需要找到一个 num,使得 f(num,a,b,c)==n
  • [1..num] 中,把能整除 a 的数字归为集合 A,把能整除 b 的数字归为集合 B,把能整除 c 的数字归为集合 C,那么 len(A) = num/alen(B) = num/blen(C) = num/c
  • A∩B=n / lcm(a,b),其中 lcm 是最小公倍数,lcm(a,b) = a * b / gcd(a,b)。其中 gcd 是最大公因数,可用辗转相除法求得。
  • 最后的结果为 res = A + B + C - A∩B - A∩C - B∩C + A∩B∩C

代码:

class Solution {
    
    
public:
    /* 二分搜索求解 */
    int nthUglyNumber(int n, int a, int b, int c) {
    
    
        // 初始化搜索区间
        int left = min(min(a,b),c);     // 下边界显然是a、b、c中最小者
        int right = left * n;           // 上边界是这个最小者的n倍
        // 左边界的二分搜索,f()返回相同的值时找最小的那个边界
        while (left < right) {
    
    
            int mid = left + (right - left) / 2;
            if (f(mid, a, b, c) < n) {
    
    
                // 元素不足n,目标在右侧
                left = mid + 1;
            } else {
    
    
                // 元素大于n,目标在左侧
                right = mid;
            }
        }
        return left;
    }

    /* 计算 [1..num] 之间有多少个能够被 a 或 b 或 c 整除的数字 */
    long f(int num, int a, int b, int c) {
    
    
        long setA = num / a, setB = num / b, setC = num / c;
        long setAB = num / lcm(a, b);
        long setBC = num / lcm(b, c);
        long setAC = num / lcm(a, c);
        long setABC = num / lcm(lcm(a, b), c);
        // 集合论定理:A + B + C - A ∩ B - A ∩ C - B ∩ C + A ∩ B ∩ C
        return setA + setB + setC - setAB - setAC - setBC + setABC;
    }

    /* 辗转相除法求最大公因数 */
    long gcd(long a, long b) {
    
    
        if (a < b) {
    
    
            return gcd(b, a);
        }
        if (b == 0) {
    
    
            return a;
        }
        return gcd(b, a % b);
    }

    /* 求最小公倍数 */
    long lcm(long a, long b) {
    
    
        return a * b / gcd(a, b);
    }
};

猜你喜欢

转载自blog.csdn.net/qq_39547794/article/details/127610254
今日推荐