引言
之前在 双指针技巧之数组 一章中简略提到了二分搜索,但是那里的思维框架仅仅适用于寻找某个特定的元素,很多算法场景对于二分搜索的考察并不那么直接,所以今天将学习一套二分搜索算法运用的框架套路,用于解决关于二分搜索算法更为一般化的题目。
一、二分搜索思维框架
原始的二分搜索代码
无非三种,找特定值、找左边界、找有边界,在前文已有介绍,就不赘述了。
二分搜索问题的泛化
分三步走:
1. 确定函数(自变量与因变量,自变量一般是需要返回的结果,因变量时约束的时间(xx天、xx小时))
2. 确定区间(自变量初始值)
3. 确定更新条件 (满足约束条件则更新)
二分搜索问题模板
class Solution{
public:
int f(vector<int> v, int capability){...} //用于计算因变量的函数,比如完成天数
int solution(vector<int> v, int target){ //target是目标完成条件,如xx小时内完成
int min, max, mid, best;
while( min <= max){
mid = (min+max)/2;
if( f > k ){ //没有满足条件不更新
min = mid + 1;
}
else{ //满足条件更新
best = mid<best?mid:best;
max = mid -1;
}
}
return best;
}
};
二、二分搜索算法经典例题
例题1:爱吃香蕉的珂珂
分析:
1. 函数是H(k),速度为k时吃掉所有香蕉需要的时间
2. min = 1, max = piles(一小时吃完)
3. 当 H < h(只要规定时间完成)都可以作为更新,因为有可能没办法吃完时间刚好等于 h
代码:
class Solution {
public:
long long cnthour(vector<int>& piles, long long speed){
long long ret = 0;
for( int pile : piles){
ret += pile%speed==0 ?pile/speed:pile/speed+1;
}
return ret;
}
int minEatingSpeed(vector<int>& piles, int h) {
long long pilesum = 0;
long long mink = 1, maxk = INT_MAX, best = INT_MAX;
long long left = mink, right = maxk, mid;
while(left <= right){
mid = (left + right)/2;
if( cnthour(piles, mid)> h){
left = mid +1 ;
}
else{
if(mid < best){
best = mid;
}
right = mid -1;
}
}
return best;
}
};
例题2:在 D 天内送达包裹的能力
分析:套公式即可,明确函数和边界
代码:
class Solution {
public:
//1. 确定函数
int countDAYS(vector<int>& weights, int capablility){
int cnt = 0;
int onBoard = 0;
for(int i = 0; i < weights.size(); i++){
onBoard += weights[i];
if(onBoard > capablility){
cnt++;
onBoard = weights[i];
}
}
return cnt + 1;
}
int shipWithinDays(vector<int>& weights, int days) {
//2. 确定区间
long min = *max_element(weights.begin(), weights.end()), max = INT_MAX;
//3. 确定更新条件 :cntDays() <= days,更新best
int best = INT_MAX, mid;
while(min <= max){
mid = (min+max)/2;
if(countDAYS(weights, mid) > days){
min = mid + 1;
}
else{
if(mid < best){
best = mid;
}
max = mid -1;
}
}
return best;
}
};
例题3:分割数组的最大值
分析:这道题几乎和例题二一模一样,但是需要一定的题目转换能力,把分割的数组个数作为变量,把子数组最大值固定,分割出来的数组个数如果大于目标个数(可以理解为第二题的完成天数),说明最大值小了,就需要把左边界增大到mid+1.
计算天数(子数组个数)这个模板需要牢记,遍历nums,两个变量,一个cnt一个onBoard,如果onBoard加上今天的超载了,就cnt++, 并且把以前的(不包括今天的)全部下载。
代码:
class Solution {
public:
int numsSub(vector<int>& nums, int maxSum){
int cnt = 0;
int inArray = 0;
for(int i = 0; i<nums.size(); i++){
inArray += nums[i];
if(inArray > maxSum){
cnt++;
inArray = nums[i];
}
}
return cnt+1;
}
int splitArray(vector<int>& nums, int k) {
int min = *max_element(nums.begin(), nums.end()), max = accumulate(nums.begin(), nums.end(), 0);
int mid, best = INT_MAX;
while(min <= max){
mid = (min + max)/2;
if( numsSub(nums, mid) > k ){
min = mid +1;
}
else{
best = best < mid ? best:mid;
max = mid -1;
}
}
return best;
}
};
例题4:点名
分析:分为两个子数组,左边的nums[i] == i, 右边的 nums[i] != i,需要找到右边数组的首元素索引。
代码:
思路1:单独考虑特殊情况+普通遍历
class Solution {
public:
int takeAttendance(vector<int>& nums) {
if(nums[0] == 1){
return 0;
}
int n = nums.size();
for(int i = 0; i< n ;i++){
if(nums[i] != i){
return i;
}
}
return n;
}
};
思路2: 二分搜素(比较难想)
class Solution {
public:
int takeAttendance(vector<int>& records) {
int n = records.size();
int left = 0, right = n-1;
int mid;
while(left <= right){
mid = (left + right) /2;
if(records[mid] == mid){
left = mid +1;
}
else{
right = mid-1;
}
}
return left;
}
};
例题5:搜索二维矩阵
分析:把二维数组看作一维的,用mid来算出行列就可以了。
代码:
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 -1, mid;
while(left <= right){
mid = (left + right)/2;
if(matrix[mid/n][mid%n] == target){
return true;
}
else if(matrix[mid/n][mid%n] > target){
right = mid -1;
}
else{
left = mid +1;
}
}
return false;
}
};
例题6:搜索二维矩阵 II
分析:从右上角开始,左边都是小于它的,下边都是大于它的,所以使用L形搜素一定能不遗漏的遍历整个数组。
代码:
class Solution {
public:
bool searchMatrix(vector<vector<int>>& matrix, int target) {
int m = matrix.size(), n = matrix[0].size();
int x = 0, y = n-1;
int present;
while(y >= 0 && x < m){
if((present = matrix[x][y]) == target){
return true;
}
else if(present > target){
y--;
}
else{
x++;
}
}
return false;
}
};
例题7:重塑矩阵
分析:一维数组为两个不同形状的二维数组之间的转换提供过渡
代码:
class Solution {
public:
vector<vector<int>> matrixReshape(vector<vector<int>>& mat, int r, int c) {
int m = mat.size();
int n = mat[0].size();
if(m*n != r*c){
return mat;
}
vector<vector<int>> ret(r, vector<int>(c));
for(int i = 0; i< m*n; i++){
ret[i/c][i%c] = mat[i/n][i%n];
}
return ret;
}
};
例题8:找到 K 个最接近的元素
分析:搜索左边界的手搓代码在在排序数组中查找元素的第一个和最后一个位置这道题已经给出,这里使用C++内置的函数lower_bound来搜索左边界,减轻工作量。(返回不小于target的第一个值的迭代器)
因为数组是有序的,所以只需要返回数组的子数组就可以了,从最近的位置开始,向两边探索,while寻找k次,先判断right 和 left的边界情况,如果一端到达了就增长另一端。
代码:
class Solution {
public:
vector<int> findClosestElements(vector<int>& arr, int k, int target) {
//先找到最近的一个
int n = arr.size();
int left = 0, right = n-1;
int mid ;
int nearest, min = INT_MAX;
//搜寻左边界
nearest = lower_bound(arr.begin(), arr.end(), target) - arr.begin();
//此时nearest是左边界
left = nearest -1;
right = nearest;
while(k--){
if(right >= n){
left--;
}
else if(left < 0){
right++;
}
else if(target - arr[left] <= arr[right] - target){
left--;
}
else{
right++;
}
}
return vector<int>(arr.begin()+left+1, arr.begin() + right);
}
};
总结:对于边界的处理情况值得学习。
例题9:寻找峰值
分析:可能是峰值就是 right = mid,一定不是峰值就 left = mid +1,注意不是简单的 mid+1、mid-1。
代码:
class Solution {
public:
int findPeakElement(vector<int>& nums) {
int n = nums.size();
int left= 0, right = n-1;
while(left < right){
int mid = left + (right - left) / 2;
//本身有可能是峰值
if( nums[mid] > nums[mid+1]){
right = mid;
}
else{
left = mid+1;
}
}
//出来的时候right = left,此时指向峰值
return right;
}
};
例题10:山脉数组的峰顶索引
分析:同上
代码:
class Solution {
public:
int peakIndexInMountainArray(vector<int>& arr) {
int left= 0, right = arr.size() -1;
int mid;
while(left < right){
mid = left + (right -left)/2;
if(arr[mid] > arr[mid+1]){
right =mid;
}
else{
left = mid +1;
}
}
return right;
}
};
例题11:搜索旋转排序数组
分析:
while循环找遍整个数组,找不到就出来return -1即可,while循环内部分三种情况。
i)如果target刚好等于nums[mid],直接return mid
ii)如果target在左边,更新边界:right = mid -1(因为mid考虑过了)
左边有可能是升序(nums[left] < nums[mid])、无序(nums[left] < nums[mid])
无序(target大于俩边界或者小于俩边界)、有序(target在俩边界中间)
合并起来判断即可
iii)如果target不在左边,更新边界:left = mid +1
代码:
class Solution {
public:
int search(vector<int>& nums, int target) {
//点在无序子数组的条件是target小于/大于两个边界
//点在有序子数组的条件是target >= nums[left] && target <= nums[right]
int n = nums.size();
int left = 0, right = n-1, mid;
while(left <= right){
mid = left + (right - left)/2;
//如果是mid,返回
if(nums[mid] == target){
return mid;
}
//如果在mid左边
//左边有可能是升序或者无序,写出升序/无序的条件以及target和边界的关系
else if(( nums[left] < nums[mid] &&nums[mid] > target && nums[left] <= target) || (nums[left] > nums[mid] &&nums[mid] <target && nums[left] <= target) || (nums[left] > nums[mid] &&nums[mid] > target && nums[left] >= target)){
right = mid - 1;
}
//如果在mid右边
else{
left = mid + 1;
}
}
return -1;
}
};
例题12:搜索旋转排序数组 II
分析:
化归为例题11即可,学习过程中学到的技能为什么不用呢?删除重复元素+例题11 = 例题12
代码:
class Solution {
public:
vector<int> removeDuplicates(vector<int>& nums) {
int fast = 1, slow = 1;
while(fast < nums.size()){
if(nums[fast] != nums[slow-1]){
nums[slow++] = nums[fast];
}
fast++;
}
nums.resize(slow);
return nums;
}
int search(vector<int>& nums, int target) {
//点在无序子数组的条件是target小于/大于两个边界
//点在有序子数组的条件是target >= nums[left] && target <= nums[right]
nums = removeDuplicates(nums);
int n = nums.size();
int left = 0, right = n-1, mid;
while(left <= right){
mid = left + (right - left)/2;
//如果是mid,返回
if(nums[mid] == target){
return true;
}
//如果在mid左边
//左边有可能是升序或者无序,写出升序/无序的条件以及target和边界的关系
else if(( nums[left] < nums[mid] &&nums[mid] > target && nums[left] <= target)
|| (nums[left] > nums[mid] &&nums[mid] <target && nums[left] <= target)
|| (nums[left] > nums[mid] &&nums[mid] > target && nums[left] >= target)){
right = mid - 1;
}
//如果在mid右边
else{
left = mid + 1;
}
}
return false;
}
};
三、总结
i)C++中找到向量的最大元素的迭代器:max_element( vector.begin(), vector.end()), 取最大值还需要加*
II)C++中的求数组元素和,accumulate(vector.begin(), vector.end(), 0), 0表示初始值。