平时我们写程序的时候,总是莫名其妙的出现Bug,一鼓作气的写完一个接口后,经常被队友喷「你怎么一回事?结果与预期不一致啊」
老脸一红,然后一个人藏在角落调试半天,才发现原来是某一个边界条件没处理好,导致Bug重重,今天我们从二分查找算法,来聊聊如何写出正确的程序
像我们熟知的二分查找算法,在1946年就被提出来了,但是到了1962年才出现了完全没有Bug的二分查找法
二分查找法的思想并不复杂,我们很多人张口就能来,但是为什么间隔了辣么多年,才诞生完全没有Bug的二分查找算法呢?
二分查找算法,难就难在边界条件的处理上,我们经常写出Bug,往往也是出现在边界条件没处理好而导致
因为学习一个算法的思想是很简单的,但是让思想落地,写出完全没有Bug的二分查找,却并不是一件容易的事情
所以我们在写程序时,首先就需要明确的边界的意义,在程序内部的实现时,就是要不断的维护这个边界的意义
二分查找法的思想在这简单复述一下
1.在一个有序的数组中,查询一个目标值target,
2.如果目标值target 比数组的中间值要大,那么我们就往数组中间值后面的范围内去查询
3.如果目标值target 比数组的中间值要小,我们就往数组中间值前面的范围内查询
4.如此往复的执行2,3步,直到查询到目标值target 等于其数组范围内的某一个值,然后返回其值对应的索引位置,又或者数组中压根就没有我们想要找的目标值target
好了,说完了算法的思想,我们就来动手实践一下把。在这里我就用Go语言来实现了,给你们宣传了一个多月让你们学Go语言,还给你们找了那么多学习资料,有从小白到中高级的Go语言视频,也有从小白到大牛的书籍资料。你们要现在一点都没看,那可就枉费我一片苦心了
想学Golang,但还没有头绪的童鞋,看完文章后直接在公众号后台回复「Go」,即可领取全部学习资源,别忘了回来点个在看啊
把算法思想落地
我们先定义一个二分查找的函数MyBinarySearch
,之所以叫这名字-,-是因为Go有个binary
包,里面有叫BinarySearch
的函数,咱们需要跟它区分开来,否则编译器会提示我们在瞎搞(仿佛在跟我说:你是不是傻,有现成的不用,偏要自己搞)
func MyBinarySearch(arr []int, target int) (middle int) {
}
我们传入一个数组,以及一个要查询的目标值target,如果target 存在于数组中,我们则返回它对应的索引位置
二分查找,容易出问题的地方就是边界问题,那咱们先定义先设定一个边界,我们需要在边界范围内去查询
// 明确边界的意义,在 [left,right] 中寻找target
left, right := 0, len(arr)-1
我们要铭记,我们在程序中,每一个变量都是有意义的,我们需要明确每一个变量的意义,我们的查询范围,就是在[left,right]
中去查询目标值target ,看清楚了,我在这定义的是一个闭区间,也就包含了left 和right 本身所在的位置
明确边界变量的意义后,我们在后面的查找过程中,还需要不断的维护这个意义
我们来循环判断,边界是否有意义,如果这个边界的范围区间包含了有效的整数,则代表这个边界是有意义的,所以当边界存在意义时,我们循环判断此时的中间值是否等于目标值target
for left <= right {
// 在 [left,right] 中寻找target
middle = (left + right) / 2 // 中间值的索引位置
}
在这可能有人不解,为什么要用<=
呢?,比如闭区间[8,8] ,那么这个区间依然是有意义的,因为有一个整数8 ,如果去掉=
,那就变成了[8,8),而此时这个区间范围内,是没任何整数的
现在我们就需要来判断,目标值target 于现在查询范围内中间值的关系,看它是等于,还是小于又或者是大于中间值
如果相等,那就好办了,这不就是我们要找的嘛~直接返回中间值的索引位置就好了
if arr[middle] == target {
return
}
那如果不满足,我们就只好再来判断,目标值是小于中间值还是大于中间值了
如果目标值target 小于中间值,那么我们就需要缩小查询的区间范围了
这个时候,我们查询范围就发生了改变,右侧要范围要缩短到中间值的位置,用Code来表达,就是right = middle - 1
if target < arr[middle] {
// 如果target < 中间值 则代表我们要在左边区间查找
right = middle - 1
}
你可能会问,为什么要-1
呢?因为我们已经明确知道target < arr[middle]
,也就是middle
所在的位置,不可能是我们要找的位置,所以我们就需要再往左侧移动一位
说到现在,我想你现在已经彻底弄懂了left
与right
所代表的含义,那么当target > arr[middle]
时,我们的right
自然也要在middle
的位置上往右移动一位
if target > arr[middle] {
// 如果target > 中间值,则代表我们要在右边区间查找
left = middle + 1
}
上面的Code全部拼凑起来,就是如下所示,如果当目标值不在数组中时,我们就返回 -1
// MyBinarySearch 二分查找法,在有序数组中查询目标元素target,并返回元素对应的索引值
func MyBinarySearch(arr []int, target int) (middle int) {
left, right := 0, len(arr)-1 // 明确边界的意义,在[left,right]中寻找target
for left <= right {
middle = (left + right) / 2
// 当left > right 时,意味着边界不存在,则代表数组中没有目标值target
// 所以当left <= right 时,我们就遍历数组
if arr[middle] == target {
return
}
if target < arr[middle] {
// 如果target < 中间值 则代表我们要在左边区间查找
right = middle - 1
}
if target > arr[middle] {
// 如果target > 中间值,则代表我们要在右边区间查找
left = middle + 1
}
}
return -1
}
我还是简单说下Go语言的函数语法吧,因为我在定义函数的时候,就写明了返回值的变量名middle
,给返回值声明了变量名后,执行函数时会给返回值的变量初始化为0值,return
返回值也会自动指定对应的变量名
所以我在函数中没有定义middle
以及把return middle
直接写成了return
循环不变量
我猜很多人都是第一次听说这个专业术语,其实我上面已经多次强调这个术语所代表的含义
现在再刻意解释一下,仔细看完上面的内容的你,现在我一说,你肯定就能懂
我们在上面的二分查找中,一直在循环left <= right
,这就是循环,当left <= right
时,我们的循环不会终止
而不变量是什么呢?left
以及right
不都是变量么?你怎么说它的不变量呢?
left
以及right
的值虽然一直都是在改变的,但是它们所代表的含义却是一直都没有改变过,因为我们寻找的永远都是在[left,right]
这个闭区间中寻找我们的目标值
程序中left
以及right
的变化,也只是在不断缩小这个闭区间的范围,并没有改变其声明时所代表的含义,注意我说的,是没有改变声明时的含义
所以要想写出正确的程序,在声明每一个变量时,我们都需要明确其含义,变量在改变时,我们只能改变其数值,而不能改变其变量所代表的意义
一旦声明其变量的意义,后面的程序都是在维护其意义。就像我们每一个人一生下来就有意义,而我们人生的经历,都是在为了完成人生意义而必须所拥有的铺垫
之所以我们的Bug越写越多,多半是因为对变量的含义理解的不透彻,并且经常声明一些无意义的变量所导致(可能你认为有些变量并不是无意义的变量,但事实多半如此)
你以为到这就完了?
二分查找从提出到最后无Bug实现,期间经历了16年,要这么简单的就完结了。。。怕是没办法瞒天过海16年了
我们在上面实现的二分查找还是有个Bug隐藏着,那就是middle = (left +right) / 2
如果当left
和right
数值足够大的时候,我们再这样求和时,int
类型就越界啦~
那越界了咋搞呢?还记得我们之前有说过如何进行大整数求和吧?嗯,记得的话,那你还挺不错的,如果你用那种方式去解决它们求中间值时造成的越界…我只能说…你是不是傻啊…
其实我们稍微改一改middle 的求值方式就好了,从加法改成了减法,那么自然也就不会越界啦~
就是改成这样子middle = left + (right - left) / 2
就好了~
我这几十年之后的马后炮,真酸爽
写在最后
之前说的每周算法题,说实话,停更有好长一段时间了
今天这篇继上一篇,间隔时间估摸着有好几周了…emmmm
不是我不想在这个系列上保持持续更新,而是这货你们连个在看都不点,让我没有欲望继续写下去
你仔细看看我之前的每周算法题系列,就会发现,没有一篇是只说算法的,都是夹杂着其它的思想,或者是开发实践,又或者是一些别的经验
如果只是拿着LeetCode上的题目实现一遍,然后跟你们讲解一遍,我觉得这样做是没用任何意义,因为你们完全可以去Github上看人家的solution,一次性看几十个题都没问题,也可以直接去LeetCode一天刷个几十题,何苦还需要在我这看我BB叨呢…
最后为了证明你彻底掌握循环不变量的关键思想,给你们留个问题吧
我们在之前声明时left = 0
,right = len(arr) - 1
,现在将right
改成right = len(arr)
,我们后面在处理区间时,当target < arr[middle]
时,left
和right
又该怎么赋值呢?
给你们一个提示,我们在写right = len(arr)-1
时的查询范围是[left,right]
,而right = len(arr)
时,查询的范围是[left,right)
,注意查询区间的改变~
之所以给你们留个问题,是因为只看不做还不思考,是很难彻底弄明白的~
在底下留言给出你的思考吧!
微信扫码关注公众号「闹闹吃鱼」,每周都有好分享,还可领取学习资源哦~不仅仅只是技术!
有疑问加站长微信联系(非本文作者)