引言
我还记得在高中的时候,有一道数学题是这么说的:在一片森林中,有一只啄木鸟守护着所有树木的健康,但啄木鸟面对一棵树,如何快速找到害虫呢?
我脑海里第一反应:从上啄到下,不就找到害虫了吗?
数学老师似乎看出了我的想法,他说:啄木鸟比同学们聪明些,它先啄树干的中间,观察害虫的活动范围是在上面还是下面,如果是上面,于是它再去啄上面树干的中间。于是啄木鸟每次只需要关注树干的一半,效率大大提升。
当时稚嫩的我并不知道这其中蕴含的精妙算法,只知道题就是这么做的。多年后,当我接触到编程算法,我才意识到,啄木鸟原来也懂二分,万能的造物者啊。
1. 二分
二分是编程算法中最简单的一个算法,它思想十分朴素,就是每次只关注目标的一半,一半又划分为一半,每次都缩小1/2,那么刚好符合对数的形式,因此二分的时间复杂度为O(lgn) < O(n)。二分的使用有一个重要限定,那就是目标数组是有序的。
2. 二分基本型
比如我们需要在数组[2,4,6,8,9,12]中查找6,用二分代码如下:
func search(nums []int, target int) int {
l, r := 0, len(nums)
for l < r {
mid := l + ((r - l) >> 1) // 这里请大家注意,请用这种方式来二分
if nums[mid] == target {
return mid
}
if nums[mid] > target {
r = mid
} else {
l = mid + 1
}
}
return -1
}
这就是二分的基本型,也称为标准二分。我们来分析一下。l < r ,说明这是左闭右开,左闭右闭可以吗?当然可以,对应代码如下:
func search(nums []int, target int) int {
l, r := 0, len(nums)-1
for l <= r {
mid := l + ((r - l) >> 1) // 这里请大家注意,请用这种方式来二分
if nums[mid] == target {
return mid
}
if nums[mid] > target {
r = mid - 1
} else {
l = mid + 1
}
}
return -1
}
这同样是标准二分。我们来观察一下对应关系:
左闭右开
变量 | 初始值 | 过程值 | 判断条件 |
---|---|---|---|
l | 0 | mid + 1 | l < r |
r | len(nums) | mid |
左闭右闭
变量 | 初始值 | 过程值 | 判断条件 |
---|---|---|---|
l | 0 | mid + 1 | l <= r |
r | len(nums)-1 | mid - 1 |
聪明的你应该能体悟到这之间的对应关系。那么这就是二分的基本型。请大家记住这两个固定的关系。
二分细节很复杂,哪里-1,哪里+1?细节是魔鬼,但是我们不用去玩弄那些细节,请记住基本型,我们所有的解题都基于基本型。
3. 二分基本型的应用
3.1. 二分找边界
二分更多的应用是用来找边界,题目LeetCode 34. 在排序数组中查找元素的第一个和最后一个位置
找左边界:
func findLeftEdge(nums []int, target int) int {
l, r := 0, len(nums)-1
for l <= r {
mid := l + ((r - l) >> 1)
if nums[mid] == target {
r = mid - 1 // 收紧右边界,目标更靠近左边界
} else if nums[mid] < target {
l = mid + 1
} else {
r = mid - 1
}
}
// 返回左边界
return l
}
查右边界
func findRightEdge(nums []int, target int) int {
l, r := 0, len(nums)-1
for l <= r {
mid := l + ((r - l) >> 1)
if nums[mid] == target {
l = mid + 1 // 收紧左边界,目标更靠近右边界
}else if nums[mid] < target {
l = mid + 1
}else {
r = mid - 1
}
}
// 返回右边界
return r
}
可以很清楚的看到,我们查询边界没有做很复杂的改动,都是基于我们的基本型来的。所以基本型要牢记于心。该题题解如下:
func searchRange(nums []int, target int) []int {
l := findLeftEdge(nums, target)
// 越界 或 目标值不存在,直接返回
if l == len(nums) || nums[l] != target {
return []int{-1,-1}
}
r := findRightEdge(nums, target)
return []int{l, r}
}
// 寻找左边界
func findLeftEdge(nums []int, target int) int {
l, r := 0, len(nums)-1
for l <= r {
mid := l + ((r - l) >> 1)
if nums[mid] == target {
r = mid - 1 // 收紧右边界,目标更靠近左边界
}else if nums[mid] < target {
l = mid + 1
}else {
r = mid - 1
}
}
// 返回左边界
return l
}
// 寻找右边界
func findRightEdge(nums []int, target int) int {
l, r := 0, len(nums)-1
for l <= r {
mid := l + ((r - l) >> 1)
if nums[mid] == target {
l = mid + 1 // 收紧左边界,目标更靠近右边界
}else if nums[mid] < target {
l = mid + 1
}else {
r = mid - 1
}
}
// 返回右边界
return r
}
3.2. 二分找峰值
请看LeetCode 162. 寻找峰值
我这边直接给出题解:
func findPeakElement(nums []int) int {
l, r := 0, len(nums)-1
for l <= r {
mid := l + ((r - l) >> 1)
// 收紧右边界,目标更靠近左边界
if mid + 1 < len(nums) && nums[mid] == nums[mid+1] {
r = mid - 1
// 向右爬坡
} else if mid + 1 < len(nums) && nums[mid] < nums[mid+1] {
l = mid + 1
// 向左爬坡
} else {
r = mid - 1
}
}
return l
}
可以看到,我们依然是基于二分基本型来解题的。唯一不同的是,我们比较的目标是nums[mid+1],nums[mid] < nums[mid+1]向右爬坡,nums[mid] > nums[mid+1]向左爬坡,这个过程是不是就像爬坡一样。
3.3. 旋转数组
请看LeetCode 153. 寻找旋转排序数组中的最小值
我这边直接给出题解:
func findMin(nums []int) int {
l, r := 0, len(nums)-1
for l <= r {
mid := l + ((r - l) >> 1)
// 收紧右边界,目标更靠近左边界
if nums[mid] == nums[len(nums)-1] {
r = mid - 1
// 向左下坡
} else if nums[mid] < nums[len(nums)-1] {
r = mid - 1
// 向右爬坡
} else {
l = mid + 1
}
}
return nums[l]
}
可以看到,我们还是基于二分基本型来解题的。不同的是,我们比较的目标是nums[len(nums)-1],nums[mid] < nums[len(nums)-1]向左下坡,nums[mid] > nums[len(nums)-1]向右爬坡,这里就是与找峰值的区别所在,就决定了我们的目标是比较nums[len(nums)-1]而不是nums[mid+1]。这个过程大家仔细去分析下程序就明白了。
4. 二分基本型扩展应用
那掌握基本型是不是就完了?不不不。
上面的题目都是直接给我们数组,我们对着数组就是二分。有难度的题目会那么明显让我们看出二分的意图吗?要有那么简单的话,世界就充满爱了。
我们直接上题:LeetCode 剑指 Offer II 073. 狒狒吃香蕉
请大家好好体味一下这道题,然后再来看题解。
func minEatingSpeed(piles []int, h int) int {
sort.Ints(piles)
l, r := 1, piles[len(piles)-1]
for l <= r {
mid := l + ((r - l) >> 1)
if judge(piles, mid) == h {
r = mid - 1
}
if judge(piles, mid) > h {
l = mid + 1
} else {
r = mid - 1
}
}
return l
}
func judge(piles []int, mid int) int {
res := 0
for i := 0; i < len(piles); i++ {
res += piles[i] / mid
if piles[i] % mid != 0 {
res++
}
}
return res
}
现在大家能明白了吧,我们要二分的数组是[1, piles[len(piles)-1]],它是隐藏的,需要我们自己挖掘。题目往往难点就在这里,需要通过分析题目后找出隐藏目标。
5. 结语
好了,二分基本型就到这里了。