二分法的框架好写,但正确的二分法难写。
私以为:二分法的精髓在于,每次迭代时,保留答案存在的区间或者丢弃答案不存在的区间,从而缩小查找范围。
需要注意的是:保留答案存在的区间时,需要避免无限循环;二分区间状态转移的一致性。
题单:https://leetcode.cn/studyplan/binary-search/
有序不重复数组 简单二分 单调递增数组找目标元素下标,元素不重复,目标元素不在其中则返回-1。
https://leetcode.cn/problems/binary-search/description/
1 2 3 4 5 6 7 8 9 10 11 12 13 14 class Solution { public int search (int [] nums, int target) { int l=0 , r=nums.length -1 ; while (l <= r){ int mid = (l+r)/2 ; if (nums[mid] == target) return mid; if (nums[mid] < target) l = mid + 1 ; else r = mid - 1 ; } return -1 ; } }
看起来很简单,对吧。但是我们思考以下几个问题:
l,r的初始值
while循环中 l<r 还是 l<=r
mid的向下取整问题,当前写法(l+r)/2存在向下取整问题, $left \le mid <right $
前两个问题我们可以得出结论:
l、r的初始值代表着区间表示法
while循环中,代表着当前仍在合法区间内
举例子:
1 2 3 4 5 int right = nums.size () - 1 ; while (left <= right) { int right = nums.size (); while (left < right) {
换成左闭右开的写法后,right就不再是mid-1了,因为左闭右开区间自然不包括右边的值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 class Solution { public int search (int [] nums, int target) { int left = 0 , right = nums.length; while (left < right) { int mid = left + ((right - left) >> 1 ); if (nums[mid] == target) return mid; if (nums[mid] < target) { left = mid + 1 ; } else { right = mid; } } return -1 ; } }
搜索插入位置 给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。
比如数组【1】,元素0返回下标0,元素1返回下标0,元素2返回下标1。
https://leetcode.cn/problems/search-insert-position/
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 class Solution { public int searchInsert (int [] nums, int target) { int l=0 , r=nums.length-1 ; while (l<=r){ int mid = (r+l) / 2 ; if (nums[mid]>=target) r=mid-1 ; else l=mid+1 ; } return l; } } class Solution { public int searchInsert (int [] nums, int target) { int l = 0 , r = nums.length; while (l < r){ int mid = (l+r) / 2 ; if (nums[mid]>=target) r = mid; else l = mid+1 ; } return l; } }
将每一次缩小区间的过程看成一次状态转移:从一个大区间到一个小区间,其中状态转移的一致性就是指永远保留答案在我的窗口区间内(或者每次舍弃答案不在的区间)。
这里两种写法的区别并不大,需要注意的是返回值,第一种写法返回l或者r+1,第二种写法返回l或者r都行。
旋转排序数组中的最小值 https://leetcode.cn/problems/find-minimum-in-rotated-sorted-array/description/
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 class Solution { public int findMin (int [] nums) { int l=0 , r=nums.length-1 ; if (nums[l]<=nums[r]) return nums[l]; while (l<r && nums[l]>nums[r]){ int mid = (l+r)>>1 ; if (nums[mid]>=nums[l]){ l = mid+1 ; }else { r = mid; } } return nums[l]; } }
旋转排序数组,其实是分成了两个有序段,左半段和右半段,左半段的值肯定是大于右半段的值。
这题的难点在于:
左右两段的情况同时存在
应用二分法的过程中,可能从两个段跨越到一个段,导致语义错误
那么我就应用状态机的思想,维持我left和right的区间始终为两个段的情况(nums[l]>nums[r]),如果跨越到右半段,直接返回右半段的最左边的值。
贴上mid和right比较的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 class Solution { public int findMin (int [] nums) { int l=0 , r=nums.length-1 ; while (l<r){ int mid = (l+r)>>1 ; if (nums[mid] < nums[r]){ r = mid; }else { l = mid+1 ; } } return nums[l]; } }
搜索旋转排序数组 给你一个target,在旋转排序数组中找它的下标,不存在返回-1。
https://leetcode.cn/problems/search-in-rotated-sorted-array/
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 class Solution { public int search (int [] nums, int target) { if (nums==null ||nums.length==0 ) return -1 ; int l=0 , r=nums.length-1 ; while (l<=r){ int mid = (l+r)>>1 ; if (nums[mid] == target) return mid; if (nums[l] <= nums[mid]){ if (nums[l] <= target && target < nums[mid]){ r = mid-1 ; }else { l = mid+1 ; } }else { if (nums[mid] < target && target <= nums[r]){ l = mid+1 ; }else { r = mid-1 ; } } } return -1 ; } }
这道题的难点还是在于:存在两段单调递增的区间,中点值与target值的比较无法引导区间的缩小。
观察到:mid一定落在两个单调递增的区间中的一个,如果mid落在左区间,那么left与mid构成单调区间;如果mid落在右区间,mid与right构成单调区间。如果target落在这段单调区间,那么就能二分;如果target没有落在这段单调区间,正好,这段单调区间可以舍弃。
怎么判断mid落在哪段单调区间呢?
观察到:
如果nums[l] <.nums[r] 则只有一个单调区间,直接二分
如果nums[l]>nums[r]
mid落在左区间,则$nums[mid]>=nums[l], nums[mid]>nums[r]$
mid落在右区间,则$nums[mid]<nums[l], nums[mid]<=nums[r]$
上述取到等于号时,是因为区间和 mid的计算方式,可能使得mid==l或者r
有序重复数组 排序数组寻找元素第一个和最后一个位置 https://leetcode.cn/problems/find-first-and-last-position-of-element-in-sorted-array
给你一个按照非递减顺序排列的整数数组 nums,和一个目标值 target。请你找出给定目标值在数组中的开始位置和结束位置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 class Solution {public : int lowerBound (vector<int >& nums, int target) { int l=0 ,r=nums.size(); int mid; while (l < r){ mid = (l+r)/2 ; if (nums[mid] >= target){ r = mid; }else { l = mid + 1 ; } } return l; } int upperBound (vector<int >& nums, int target) { int l=0 ,r=nums.size(); int mid; while (l < r){ mid = (l+r)/2 ; if (nums[mid] > target){ r = mid; }else { l = mid + 1 ; } } return l; } vector<int > searchRange (vector<int >& nums, int target) { int first = lowerBound(nums, target); int second = upperBound(nums, target); if (first==nums.size() || nums[first]!=target){ return {-1 , -1 }; } return {first, second-1 }; } };
Lower_bound 的语义:有序重复数组寻找目标元素第一次出现的位置,如果不存在,返回应该插入的位置。
Upper_bound 的语义:有序重复数组寻找大于目标元素的数字第一次出现的位置,如果不存在,返回应该插入的位置。
换位思考:先求序列中第一个大于x的元素的位置,然后减1就是x最后出现的位置。
泛化 通过思考可以发现,lower和upper函数都在解决这样一个问题:在一个有序序列中第一个满足某条件的元素的位置。
lower-bound就是,从左到右,找第一个满足值“大于等于”target的元素。
upper-bound就是,从左到右,找第一个满足值”大于“target的元素。
于是就有了这样一个模版:
1 2 3 4 5 6 7 8 9 10 11 int solve (int left, int right) { while (left < right){ int mid = (left+right)/2 ; if (条件成立) right = mid; else left = mid+1 ; } return left; }
这个函数解决在某序列中,从左到右,第一个满足条件的元素下标。
比如求最后一个x<4的元素下标,数组为[1,2,2,2,5],答案为3。
先求第一个满足x>=4的下标,为4,然后减去1,得到答案3。
搜索旋转排序数组2 给你一个target,在旋转排序数组(这个数组可能含有重复元素)中找它的下标,不存在返回-1。
https://leetcode.cn/problems/search-in-rotated-sorted-array-ii/
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 class Solution { public boolean search (int [] nums, int target) { if (nums==null ||nums.length==0 ) return false ; int l=0 , r=nums.length-1 ; while (l<=r){ int mid = (l+r)>>1 ; if (nums[mid] == target) return true ; if (nums[l] == nums[mid]) {l++;continue ;} if (nums[l] < nums[mid]){ if (nums[l]<= target && target < nums[mid]){ r = mid-1 ; }else { l = mid+1 ; } }else { if (nums[mid]<target && target<=nums[r]){ l = mid+1 ; }else { r = mid-1 ; } } } return false ; } }
这里比上题就多出了一行代码:
1 if (nums[l] == nums[mid]) {l++;continue ;}
题目 keke吃香蕉 https://leetcode.cn/problems/koko-eating-bananas/description/
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 class Solution { public int solve (int [] piles, int speed) { int time = 0 ; for (int banana: piles){ time += (banana+speed-1 )/speed; } return time; } public int minEatingSpeed (int [] piles, int h) { int l = 1 , r = -1 ; for (int pile:piles){ r = Math.max(r, pile); } while (l < r){ int mid = (l+r)/2 ; int time = solve(piles, mid); if (time<=h){ r = mid; }else { l = mid+1 ; } } return r; } }
最小速度可以为1,但1不保证能在h小时吃完所有香蕉。最大速度怎么求?无限大?只要找到一个能在h小时吃完所有香蕉的速度speed就行。这个speed是个上界,但不是上确界。
小技巧:上取整的实现
1 int time = p/s + (p%s>0 ) ? 1 :0 ;
货物运输 https://leetcode.cn/problems/capacity-to-ship-packages-within-d-days/
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 class Solution { public int transit (int [] weights, int load) { int day = 0 ; int cur = 0 ; for (int weight:weights){ if (cur+weight <load){ cur += weight; }else if (cur+weight ==load){ ++day; cur = 0 ; } else { ++day; cur = weight; } } if (cur>0 ) ++day; return day; } public int shipWithinDays (int [] weights, int days) { int l = 0 , r=0 ; for (int weight:weights){ r += weight; l = Math.max(l , weight); } System.out.println(transit(weights, 5 )); while ( l < r){ int mid = l + (r-l)/2 ; int day = transit(weights, mid); if (day <= days){ r = mid; }else { l = mid+1 ; } } return r; } }
这题与上面一题几乎一样!注意点:
1、左右边界的确定
左边界是weights的最大,因为船至少能装一个货物。最大就是求和,这样能在一天运完。
2、计算当前负载需要天数的函数
用模拟方式进行,负载装满就出发。
总结 在应用二分法,注意左右边界的获取。
根据区间的表示法,选择合适while循环判断条件。
在应用二分法时,每次缩小区间,其实是排除不符合条件的区间过程。
状态转移的一致性就是指永远保留答案在我的窗口区间内(每次舍弃答案不在的区间)。