点击蓝色“
五分钟学算法
”关注我哟
加个“
星标
”,天天中午 12:15,一起学算法
二分查找
算是最为基本的一个算法,也比较容易掌握。但是有些时候,我们可能因为一些细节的点没有考虑全而程序出错。
虽然这是一个简单的算法,但是其也有比较高级的应用,比如
按值二分
,这篇文章将会从解题模版开始,来介绍一些二分查找的常见应用和题型。
比二分查找更简单的算法,我能想到的只有
遍历枚举
,说的直白些,就是写 for 循环。
我们通常需要在一个数组当中找一个数,这个时候我们可以写一个 for 循环去挨个查找,这么下来,时间复杂度就会是
O(n)
。
如果我告诉你,这个数组是排序好的,这时我们就可以使用二分查找去找这个数。
我们可以选择数组中间的元素,这个中间元素会把数组分成前后两个相等的区间,如果我们要找的元素比中间元素要大,证明这个元素只可能在后半部分区间,我们就只需要去到后半部分区间用类似的方法再次查找。
如果比中间元素要小,则需要去到前半部分区间用类似的方法再次查找,直到最后我们找到了,或者说整个数组给分完了(没找到)
这样的话时间复杂度是
O(logn)
。
O(n) 和 O(logn) 可能直接看表达式不够形象,我举个例子你就懂了,假设数组长度是 4294967296,如果是 O(n) 时间的话,最差情况下你需要去找 4294967296 次,如果是 O(logn),最差情况下你只需要去找 32 次。
相关阅读:
冰与火之歌:「时间」与「空间」复杂度
你可以形象地理解每次操作我们都把数组对折,形成一个新的数组,因此
二分查找又叫做折半查找
。相信有一定编程基础的人都能够理解这个算法,我们的重点将会放在具体的实现和题型上面。
我之前也讲过,解题模版能够省去你很多的额外思维负担,当然,重要的还是理解,这里我首先放上一个我觉得好的模版,可能会跟其他的一些常见模版有所不同,但我后面会慢慢解释其中的原因。
int start = 0 , end = nums.length - 1 ;while (start + 1 int mid = start + (end - start) / 2 ; if (...) { start = mid; } else { end = mid; } }
首先解释一下,上面模版当中的
start + (end - start) / 2
,这里不直接写成
(end + start) / 2
的目的是防止计算越界,举个例子,假如 end = 2^31 - 1, start = 100,如果是后一种写法的话就会在计算
end + start
的时候越界,导致结果不正确。
第一个正确的二分查找法实现还有一个趣闻,感兴趣的可以戳这:
面试官,我会写二分查找法!对,没有 bug 的那种!
二分查找算法如果没有实现好,会有两种后果:
先来说说为什么程序会死循环,上面的代码中,每次我都会依据情况把 mid 赋给 start 或者是 end,考虑一个例子,在 [2,2] 数组中,我们要找到元素 3 并返回 index,上面模版中的 while 循环条件改为
start < end
,相信不难看出,mid 计算后都会等于 start 的初始值,然后将 mid 赋值给 start,导致 start 和 end 一直紧挨在一起,这时就会导致死循环,当然很多人可能会说,那好办,我每次赋值的时候把 mid 往后移一格就好了,于是模版写成了下面的形式:
int start = 0 , end = nums.length - 1 ;while (start int mid = start + (end - start) / 2 ; if (...) { start = mid + 1
; } else { end = mid - 1 ; } }
如果写成上面的形式,之前的例子的确是可以过,但是还是会存在新的问题。现在来思考这么一个问题
“在一个允许重复元素的按升序排好序的数组中,返回元素的 index,如果有重复,就返回最大的那个 index”
,比如说 [1,2,2,3] 这个例子,假如我们要找的元素的值是 2,那么正确的返回值应该是 2,也就是第二个 2 的 index,如果我们使用上面的模版,最后的程序就会是:
int start = 0 , end = nums.length - 1 ;while (start int mid = start + (end - start) / 2 ; if (nums[mid] <= target) { start = mid + 1 ; } else { end = mid - 1 ; } } 根据条件返回 end 或者 start
这里解释下,针对这个问题,
要找的元素可能会有很多个,我们需要找最后出现的那个
,即使你找到这个元素,你也不知道是不是最后一个,
但是我们知道答案肯定不是在之前,可能是在这个位置,也可能是在之后
,因此我们还得在后半区间中继续查找,直到循环退出,你会发现最后返回的值是 3,来看看具体的过程,下面我们用 s 来表示 start 指针,e 来表示 end 指针,m 来表示 mid:
图1
这就是我们之前提到的
跳过本该查找的位置
的情况。网上还有一种很常见的模版,就是把 while 循环中的条件设为
start <= end
,如果用来实现上面这个问题,就会是:
int start = 0 , end = nums.length - 1 ;while (start <= end ) { int mid = start + (end - start) / 2 ; if (nums[mid ] <= target) { start = mid + 1 ; } else { end = mid - 1 ; } } 根据条件返回 end 或者 start
代码区别就是 while 循环中的条件,这个模版是可以得到正确解的,依照之前的 case ,来看看具体过程:
图2
你会发现最后的 end 指针就是我们所要找的位置,但是你要知道的是,这个模版中,while 循环结束后,end + 1 == start,而且只要是数组的长度不为 0,这个等式一直都是成立的。
但是这个模版也有不好的地方,如果输入数组是 [1],那么 while 循环结束后,要么是 start 超出数组范围,要么是 end 变成 -1,也就是说最后你不仅需要判断 start 和 end 对应的元素是不是要找的元素,还需要判断 start 和 end 是否是在合法的范围内,如果你这样做了,程序不会出错,你习惯了上面的模版,你可以继续使用,但是要知道会存在这么一个情况。
回到我在一开始给出的模版:
int start = 0 , end = nums.length - 1 ;while (start + 1 end) { int mid = start + (end - start) / 2 ; if (...) { start = mid ; } else { end = mid ; } }
现在你应该不难发现这个模版和别的模版相比,其优势所在,我们不需去关注
是否需要 mid +/- 1
,也不需要去判断 while 循环后的 start、end 是否合法,具体问题我们只需要套模版即可。
当然,有些人可能会说,根据具体问题具体调整,但是我想说的是,随机应变是好的,但这也失去了模版的意义,
一个好的模版就相当于一个好的框架一样,我们不再需要去关注代码的实现细节,大大降低了我们解决问题的思维复杂度。
我会用上面的模版去解决 LeetCode 上面的一些题目,如果你的模版和我给的不一样也没关系,看看对于不同的问题,你的模版是不是都适用,而且并不需要改变特别多的实现细节。
题目来源于 LeetCode 上第 278 号问题:第一个错误的版本。题目难度为 Easy,目前通过率为 32.9% 。
题目描述
你是产品经理,目前正在带领一个团队开发新的产品。不幸的是,你的产品的最新版本没有通过质量检测。由于每个版本都是基于之前的版本开发的,所以错误的版本之后的所有版本都是错的。
假设你有
n
个版本
[1, 2, ..., n]
,你想找出导致之后所有版本出错的第一个错误的版本。
你可以通过调用
bool isBadVersion(version)
接口来判断版本号
version
是否在单元测试中出错。实现一个函数来查找第一个错误的版本。你应该尽量减少对调用 API 的次数。
示例:
给定 n = 5,并且 version = 4 是第一个错误的版本。 调用 isBadVersion(3) -> false 调用 isBadVersion(5) -> true 调用 isBadVersion(4) -> true 所以,4 是第一个错误的版本。
题目分析
其实题目就是要找最先出现的元素,在这种情况下,如果我们找到了元素,依旧不知道它是不是最先(小)的,但是我们知道答案肯定不在后面,肯定在这或者是之前,因此这种情况需要将尾指针往前移。
代码实现
public int firstBadVersion (int n) { int start = 0 , end = n; while (start + 1 int mid = start + (end - start) / 2 ; if (isBadVersion(mid)) { end = mid; } else { start = mid; } } if (isBadVersion(start)) { return start; } return end; }
题目来源于 LeetCode 上第 34 号问题:在排序数组中查找元素的第一个和最后一个位置。题目难度为 Easy,目前通过率为 37.4% 。
题目描述
给定一个按照升序排列的整数数组
nums
,和一个目标值
target
。找出给定目标值在数组中的开始位置和结束位置。
你的算法时间复杂度必须是 O(log n) 级别。
如果数组中不存在目标值,返回
[-1, -1]
。
示例 1:
输入: nums = [5,7,7,8,8,10], target = 8 输出: [3,4]
示例 2:
输入: nums = [5,7,7,8,8,10], target = 6 输出: [-1,-1]
题目分析
给定一个元素,要找其最先出现的 index,还要找其最后出现的 index。这道题把之前的两个问题合在了一起。我们只需要用两次二分查找,一次找前,一次找后。
仔细看的话,你会发现,这道题其实
就是在找一个元素在数组中出现的范围
,因为数组有序,所以这个范围是连续的。
代码实现
public int [] searchRange(int [] nums, int target) { int [] result = new int []{-1 , -1
}; if (nums == null || nums.length == 0 ) { return result; } int start = 0 , end = nums.length - 1 ; while (start + 1 int mid = start + (end - start) / 2 ; if (nums[mid] >= target) { end = mid; } else { start = mid; } } if (nums[start] == target) { result[0 ] = start; } else if (nums[end] == target) { result[0 ] = end; } start = 0 ; end = nums.length - 1 ; while (start + 1 int mid = start + (end - start) / 2 ; if (nums[mid] <= target) { start = mid; } else { end = mid; } } if (nums[end] == target) { result[1 ] = end; } else if (nums[start] == target) { result[1 ] = start; } return result; }
题目来源于 LeetCode 上第 74 号问题:搜索二维矩阵。题目难度为 Medium,目前通过率为 35.7% 。
题目描述
编写一个高效的算法来判断 m x n 矩阵中,是否存在一个目标值。该矩阵具有如下特性:
每行中的整数从左到右按升序排列。
每行的第一个整数大于前一行的最后一个整数。
示例 1:
输入: matrix = [ [1, 3, 5, 7], [10, 11, 16, 20], [23, 30, 34, 50] ] target = 3输出: true
示例 2:
输入: matrix = [ [1, 3, 5, 7], [10, 11, 16, 20], [23, 30, 34, 50] ] target = 13输出: false
题目分析
我们需要去判断一个元素在不在矩阵中,这个矩阵是排序好的,前一行的元素都比后一行的元素小,在同一行中,元素也是从小到大排列的。这个问题也很简单,我们先找行,再找列,但是需要注意的是,在找行的时候,你必须确保这一行开头的元素是
小于等于
你要找的元素的,不然接下来的操作将会没有意义。
代码实现
public boolean searchMatrix (int [][] matrix, int target) { if (matrix == null || matrix.length == 0 || matrix[0 ].length == 0 ) { return false ; } int startRow = 0 , endRow = matrix.length - 1 ; while (startRow + 1 int mid = startRow + (endRow - startRow) / 2 ; if (matrix[mid][0 ] == target) { return
true ; } else if (matrix[mid][0 ] startRow = mid; } else { endRow = mid; } } int selectRow = 0 ; if (matrix[endRow][0 ] <= target) { selectRow = endRow; } else { selectRow = startRow; } int startCol = 0 , endCol = matrix[0 ].length - 1 ; while (startCol + 1 int mid = startCol + (endCol - startCol) / 2 ; if (matrix[selectRow][mid] == target) { return true ; } else if (matrix[selectRow][mid] startCol = mid; } else { endCol = mid; } } if (matrix[selectRow][startCol] == target || matrix[selectRow][endCol] == target) { return true ; } return false ; }
题目来源于 LeetCode 上第 240 号问题:搜索二维矩阵 II。题目难度为 Medium,目前通过率为 37.4% 。
题目描述
编写一个高效的算法来判断 m x n 矩阵中,是否存在一个目标值。该矩阵具有如下特性:
每行的元素从左到右升序排列。
每列的元素从上到下升序排列。
示例:
现有矩阵 matrix 如下:
[ [1 , 4 , 7 , 11 , 15 ], [2 , 5 , 8 , 12 , 19 ], [3 , 6 , 9 , 16 , 22 ], [10 , 13 , 14 , 17 , 24 ], [18 , 21 , 23 , 26 , 30 ] ]
给定 target = 5,返回 true。
给定 target = 20,返回 false。
题目分析
这道题是上面那道题的变种,问题没变,主要是变在输入矩阵上,题目只保证矩阵中元素行和列是排好序的,也就是说前一行的元素不一定都比后一行的元素小,这个时候,我们之前的先找行,再找列的策略就失效了。
于是可以考虑从
对角线
入手,对角线上的点往右和往下分别做二分,这样可以把时间复杂度控制在
O(2 * (1 + log2 + … + log(n)) = O(log(n!)) = O(nlog(n))
。
除了这种做法,还有一种 O(n) 的解法比较 tricky,我在一个专栏里有详细介绍与动画描述,感兴趣的话可以复制下面的链接进行查看。
https://xiaozhuanlan.com/topic/2504391687
代码实现
public boolean searchMatrix (int [][] matrix, int target) { if
(matrix == null || matrix.length == 0 || matrix[0 ].length == 0 ) { return false ; } int numOfFind = Math.min(matrix.length, matrix[0 ].length); for (int i = 0 ; i boolean upDown = find(matrix, i, target, true ); boolean leftRight = find(matrix, i, target, false ); if (upDown || leftRight) { return true ; } } return false ; }private boolean find (int [][] matrix, int i, int target, boolean isFindRow) { int start = i; int end = isFindRow ? matrix.length - 1 : matrix[0 ].length - 1 ; while (start + 1 int mid = start + (end - start) / 2 ; if (isFindRow) { if (matrix[mid][i] start = mid; } else if (matrix[mid][i] > target) { end = mid; } else { return true ; } } else { if (matrix[i][mid] start = mid; } else if (matrix[i][mid] > target) { end = mid; } else { return true ; } } } if (isFindRow) { if (matrix[start][i] == target || matrix[end][i] == target) { return true ; } } else { if (matrix[i][start] == target || matrix[i][end] == target) { return true ; } } return false ; }
有一类题型是把一个数组向右或者向左移动了一下,比如数组 [1,2,3,4,5,6] 向右移动两格就会变成 [5,6,1,2,3,4],这样这个数组就变成了一个
非排序状态
。但是仔细看的话你会发现,
移动后的数组变成了两截,这两截内的元素是按序排列的
,比如在上面的例子中,移动后的数组就会有 [5,6] 和 [1,2,3,4] 这两个区间,这两个区间内的元素都是按序排列的。
仔细观察这两个区间,你会发现,
其中一个区间内的所有元素都比另一个区间的任意元素小,
这个点就给我们二分查找创造了条件,我们可以根据
尾元素
作为区分值,但要清楚一点的是,没有移动过的数组也需要被你的程序考虑在内。
我们结合实际的题目来看看。
题目来源于 LeetCode 上第 153 号问题:旋转数组的最小数字。题目难度为 Medium,目前通过率为 49.2% 。
题目描述
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组
[0,1,2,4,5,6,7]
可能变为
[4,5,6,7,0,1,2]
)。
请找出其中最小的元素。
你可以假设数组中不存在重复元素。
示例 1
:
输入: [3,4,5,1,2] 输出: 1
示例 2:
输入: [4,5,6,7,0,1,2] 输出: 0
题目分析
找出转动数组中最小的值,之前讲过的转动数组的性质,这个数组其实可以看成两个排序好的数组,
一前一后,一大一小,
我们做二分的时候,二分取到的中点有可能在前区间,也有可能在后区间,怎么判断?
可以使用尾元素作为区分值,
二分中点对应的值比尾元素小的话那就说明二分中点是在后面的区间,大的话就会是在前面的区间
。
如果中点在后面的区间,那我们就要移动尾指针,如果是在前面的区间的话,我们就要移动首指针,
其实就是逐步逼近后区间首元素的一个过程
。
可能很多人会有一个疑问就是,这里能不能使用首元素作为区分值,其实是不行的,考虑一个例子 [1,2,3,4,5],如果还是使用尾元素,那么整个过程就是一直移动尾指针,直到逼近答案,但是使用首元素的话,如果你还是考虑这个数组有两个区间,比首元素大证明在第一个区间,要移动首指针,这里就会出问题,我们跳过了答案。
多试几个例子,相信不难理解。
动画描述
代码实现
public int findMin(int [] nums) { if (nums == null || nums.length == 0 ) { return 0 ; } int start = 0 , end = nums.length - 1 ; while (start + 1 end) { int mid = start + (end - start) / 2 ; if (nums[mid ] <= nums[end ]) { end = mid ; } else { start = mid ; } } if (nums[start] > nums[end ]) { return nums[end ]; } return nums[start]; }
题目来源于 LeetCode 上第 33 号问题:搜索旋转排序数组。题目难度为 Medium,目前通过率为 36.1% 。
题目描述
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组
[0,1,2,4,5,6,7]
可能变为
[4,5,6,7,0,1,2]
)。
搜索一个给定的目标值,如果数组中存在这个目标值,则返回它的索引,否则返回
-1
。
你可以假设数组中不存在重复的元素。
你的算法时间复杂度必须是 O(log n) 级别。
示例 1:
输入: nums = [4,5,6,7,0,1,2], target = 0 输出: 4
示例 2:
输入: nums = [4,5,6,7,0,1,2], target = 3 输出: -1
题目分析
这道题和前面那道题相比更复杂了些,我们不是要找值最小的那个元素,而是要找给定元素在数组中出现的 index。
前一道题,我们只需要判断二分中点在哪个位置即可,
这里会比之前多了一个判断,就是我们要找的元素是在前后两个区间中的哪一个?
我们知道了二分中点所在的区间,以及要找的元素所在的区间后,就可以按情况移动前后指针,其实一共有下面几种情况,我用 t 表示 target,也就是我们要找的元素,用 m 表示二分中点:
[... ][... ] -> 二分中点在前区间,要找的元素在后区间 m t [... ][... ] -> 二分钟点在后区间,要找的元素在前区间 t m [... ][... ] -> 二分中点和要找的元素都在前区间,要找的元素在二分中点之后 m t [... ][... ] -> 二分中点和要找的元素都在前区间,要找的元素在二分中点之前 t m [... ][... ] -> 二分中点和要找的元素都在后区间,要找的元素在二分中点之后 m t [... ][... ] -> 二分中点和要找的元素都在后区间,要找的元素在二分中点之前 t m
分析归分析,我们最后还是要转化成可执行的代码,写代码的时候你会发现有些情况是可以合并的:
[... ][... ] -> 要找的元素在前区间,二分中点在后区间,或者是在前区间但比要找的元素大,这时我们需要移动尾指针 t m m m [... ][... ] -> 要找的元素和二分中点都在前区间,但是要找的元素比二分中点要大,这时移动首指针 m t [... ][... ] -> 要找的元素在后区间,二分中点在前区间,或者是在后区间但比要找的元素小,这时我们需要移动首指针 m m m t [... ][... ] -> 要找的元素和二分中点都在后区间,但是要找的元素比二分中点要小,这时移动尾指针 t m
注意思考分析的时候也需要想想,如果数组没有被转动,那么我们的思路是否正确。
代码实现
public int search (int [] nums, int target) { if (nums == null || nums.length == 0 ) { return -1 ; } int start = 0 , end = nums.length - 1 ; while (start + 1 int mid = start + (end - start) / 2 ; if (target > nums[end]) { if (nums[mid] > target || nums[mid] <= nums[end]) { end = mid; } else if (nums[mid] == target) { return mid; } else { start = mid; } } else { if (nums[mid] > nums[end] || nums[mid] start = mid; } else if (nums[mid] == target) { return mid; } else { end = mid; } } } if (nums[start] == target) { return start; } if (nums[end] == target) { return end; } return -1 ; }
题目来源于 LeetCode 上第 33 号问题:搜索旋转排序数组 II。题目难度为 Medium,目前通过率为 33.9% 。
题目描述
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [0,0,1,2,2,5,6] 可能变为 [2,5,6,0,0,1,2] )。
编写一个函数来判断给定的目标值是否存在于数组中。若存在返回 true,否则返回 false。
示例 1:
输入: nums = [2,5,6,0,0,1,2], target = 0 输出: true
示例 2:
输入: nums = [2,5,6,0,0,1,2], target = 3 输出: false
题目分析
这道题和前面那道题相比,只是增加了一个条件,就是数组中允许出现重复的元素。
可能很多人纠结的地方会是在首尾两个指针上,允许重复说明,这两个指针上的元素也会是重复的,就比如我们当前的二分中点的元素值是 x,然后你对比发现首尾两个元素的值也都是 x,那么你怎么确定这个数是在前区间还是后区间?
我的做法是用循环去做判断,如果二分中点元素的值和尾指针元素的值相同,那么我就会向后移动这个二分中点,如果发现移到某一点,这一点并不是尾指针,那么说明这个二分中点在前区间,如果移到了尾指针处,说明这个点在后区间,其他照旧
。
这里说明一点,这道题的最坏情况的时间复杂度会退化到 O(n) 的,也就是说数组里面的元素全部相同,但是我们要找的元素不在数组内,比如 [2,2,2,2,2,2,2],我们要找的数是 1。
代码实现
public