本文将对二分查找算法进行详细的归纳,并对LeetCode上出现的一些相关的题目进行归纳。
什么是二分查找 二分查找针对的是一个有序 的数据集合,查找思想有点类似分治思想。每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为0。举个例子,当我们猜一件衣服的价格,如果被告知价格区间在500到1000之间,我们第一次可能会猜价格为750,如果750低了,我们会往比750高的价格猜,比如875,如果875高了,我们下一次猜的价格就可以是(750 + 875)/ 2 = 812,依次猜下去,直到猜到正确价格。
二分查找的时间复杂度是O(logn), 因为每次查找后数据区间都会缩小为原来的一半,所以二分查找是一种十分高效的查找算法。有时候比O(1)的算法还要高效。因为有些O(1)的算法可能表示的是一个非常大的常数值。
一个最简单的二分查找 最简单的二分查找算法就是针对不存在重复元素的有序数组。以下给出其递归实现和非递归实现的Java代码。
非递归实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public int bsearch (int [] a, int n, int value) { int low = 0 ; int high = n - 1 ; while (low < = high) { int mid = (low + high) / 2 ; if (a[mid] == value) { return mid; }else if (a[mid] < value) { low = mid + 1 ; }else { high = mid - 1 ; } } return -1 ; }
上述代码要注意的地方
循环退出条件
这里的循环退出条件是low <= high, 而不是low < high。如果写 low < high, 举个例子,当 a = [2, 4, 5, 6, 7, 8, 9], value = 2时,刚开始low = 0, high = 6, mid = 3, 由于 a[3] = 6 > value, high 更新为 3 - 1 = 2, mid 更新为 (2 + 0)/ 2 = 1, 由于a[1] = 4 > value, 所以 high 更新为 1 - 1 = 0, 此时如果循环退出条件是 low < high, 那么 a[0] = 2 就没有被检查到。
mid的取值
mid = (low + high) / 2 这种写法其实并不是很好,如果low和high都比较大的时候,两者之和可能会溢出,这里可以改进写法为 mid = low + ((high - low) >> 1); 因为计算机处理位运算要更比除法运算更快一点。
low = mid + 1 & high = mid - 1;
如果代码中直接写 low = mid 或者 high = mid, 可能会发生死循环,比如,high = low = 3, 而且 a[3] != value,low和high就会一直等于3,这样就会导致循环无法退出。
递归实现
和非递归实现差不多,递归实现的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public int bsearch (int [] a, int n, int val) { return bsearchInternally(a, 0 , n - 1 , val); } private int bsearchInternally (int [] a, int low, int high, int value) { if (low > high) return -1 ; int mid = low + ((high - low) >> 1 ); if (a[mid] == value) { return mid; } else if (a[mid] < value) { return bsearchInternally(a, mid+1 , high, value); } else { return bsearchInternally(a, low, mid-1 , value); } }
二分查找的适用情况 虽然二分查找算法的时间复杂度是O(logn), 但是二分查找只适合在一定的场景下使用。
针对java,就是数组,或者基于数组的容器ArrayList,因为二分查找需要按照下标随机访问元素,如果二分查找依赖的是其他数据结构,比如链表,链表访问一个元素需要traverse the whole list,其时间复杂度是O(n),数据如果用链表存储,其对应的二分查找的时间复杂度会非常高。
数据必须是有序的,如果数据是无须的,要使用二分查找之前我们还需要先排序,而排序的时间度最低是O(nlogn),所以对于我们要使用的数据,最好没有频繁的插入和删除操作,如果有,每次插入和删除操作之后,由于还需要排序,其时间复杂度会比较高。
数据量太小,直接顺序遍历就好,比如 n = 10 时。数据量比较大,二分查找的优势才会比较明显。
不过,这里有一个例外。如果数据之间的比较操作非常耗时,不管数据量大小,我都推荐使用二分查找。比如,数组中存储的都是长度超过 300 的字符串,如此长的两个字符串之间比对大小,就会非常耗时。我们需要尽可能地减少比较次数,而比较次数的减少会大大提高性能,这个时候二分查找就比顺序遍历更有优势。
二分查找的底层需要依赖数组这种数据结构,而数组为了支持随机访问的特性,要求内存空间连续,对内存的要求比较苛刻。如果我们没有那么大的连续内存空间,那么就不适合用二分查找。
二分查找的变形题目 二分查找有许多变形题目,本文将归纳一些常见的二分查找变形题目。
变形1: 查找第一个值等于给定值的元素 直接上代码,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public int bsearch (int [] a, int n, int value) { int low = 0 ; int high = n - 1 ; while (low <= high) { int mid = low + ((high - low) >> 1 ); if (a[mid] > value) { high = mid - 1 ; } else if (a[mid] < value) { low = mid + 1 ; } else { if ((mid == 0 ) || (a[mid - 1 ] != value)) return mid; else high = mid - 1 ; } } return -1 ; }
对上述代码的解释如下:
a[mid] 跟要查找的 value 的大小关系有三种情况:大于、小于、等于。对于 a[mid]>value 的情况,我们需要更新 high= mid-1;对于 a[mid]< value 的情况,我们需要更新low=mid+1。 这两点都很好理解。那当 a[mid]=value 的时候应该如何处理呢?如果我们查找的是任意一个值等于给定值的元素,当 a[mid] 等于要查找的值时,a[mid] 就是我们要找的元素。但是,如果我们求解的是第一个值等于给定值的元素,当a[mid] 等于要查找的值时,我们就需要确认一下这个 a[mid] 是不是第一个值等于给定值的元素。我们重点看第 11 行代码。如果 mid 等于 0,那这个元素已经是数组的第一个元素,那它肯定是我们要找的;如果 mid 不等于 0,但 a[mid] 的前一个元素 a[mid-1] 不等于 value,那也说明 a[mid] 就是我们要找的第一个值等于给定值的元素。如果经过检查之后发现 a[mid] 前面的一个元素 a[mid-1] 也等于 value,那说明此时的 a[mid] 肯定不是我们要查找的第一个值等于给定值的元素。那我们就更新high = mid-1,因为要找的元素肯定出现在 [low, mid-1] 之间。
变形2: 查找最后一个值等于给定值的元素 和变形1原理类似,这里直接贴代码了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public int bsearch (int [] a, int n, int value) { int low = 0 ; int high = n - 1 ; while (low <= high) { int mid = low + ((high - low) >> 1 ); if (a[mid] > value) { high = mid - 1 ; } else if (a[mid] < value) { low = mid + 1 ; } else { if ((mid == n - 1 ) || (a[mid + 1 ] != value)) return mid; else low = mid + 1 ; } } return -1 ; }
变形3: 查找第一个大于等于给定值的元素 变形3和变形1、2略微有点点不同,但整体还是差不多的,这里也直接贴代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public int bsearch (int [] a, int n, int value) { int low = 0 ; int high = n - 1 ; while (low <= high>) { int mid = low + ((high - low) >> 1 ); if (a[mid] < value) { low = mid + 1 ; }else { if (mid == 0 || a[mid-1 ] < value) return mid; else high = mid - 1 ; } } return -1 ; }
变形4: 查找最后一个小于等于给定值的元素 代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public int bsearch (int [] a, int n, int value) { int low = 0 ; int high = n - 1 ; while (low <= high) { int mid = low + ((high - low) >> 1 ); if (a[mid] > value) { high = mid - 1 ; }else { if (mid == n-1 || a[mid + 1 ] > value) return mid; else low = mid + 1 ; } } return -1 ; }
LeetCode上相关的题目
下面四道题目全针对rotated sorted array
最基本的二分查找题目
Binary Search
704的衍生题
Search Insert Position
这题表明二分查找一个特点:如果target value没有出现在要查找的数组之中,然后要把这个值插入到数组中,插入的位置是最后跳出循环的low的值,可以用如下的逻辑来大致推理一下:
如果要找的数字是4, 数组中只有3和5,而没有4,最后low和high两个指针要退出循环之前要么同时指向3, 要么指向5, 如果是指向3, 那么此时mid = low = high, a[mid]会等于3 < 4, 下一次循环low指针会加1,也就是4要插入的位置。如果退出循环之前low和high同时指向5, 那么此时mid = low = high, a[mid]会等于5 > 4, 下一次循环high指针会减1, low指针位置不变,low指针还是4要插入的位置。
变形4加class设计拓展(结合HashMap)
Time Based Key-Value Store
代码如下:
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 43 class Data { String value; int timestamp; public Data (String value, int timestamp) { this .value = value; this .timestamp = timestamp; } } class TimeMap { Map<String, List<Data>> map; public TimeMap () { map = new HashMap<String, List<Data>>(); } public void set (String key, String value, int timestamp) { if (!map.containsKey(key)) map.put(key, new ArrayList<Data>()); map.get(key).add(new Data(value, timestamp)); } public String get (String key, int timestamp) { return binarySearch(map.get(key), timestamp); } public String binarySearch (List<Data> list, int timestamp) { int low = 0 ; int high = list.size()-1 ; while (low <= high) { int mid = low + ((high - low) >> 1 ); if (list.get(mid).timestamp <= timestamp){ if (mid == list.size()-1 || list.get(mid+1 ).timestamp > timestamp){ return list.get(mid).value; }else { low = mid + 1 ; } }else { high = low - 1 ; } } return "" ; } }
针对rotated sorted array的二分查找(比较灵活,也容易出错)
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 class Solution { public int search (int [] nums, int target) { int low = 0 ; int high = nums.length-1 ; while (low <= high) { int mid = low + ((high - low) >> 1 ); if (nums[mid] == target) return mid; if (nums[low] <= nums[mid]) { if (nums[low] <= target && target < nums[mid]) { high = mid - 1 ; }else { low = mid + 1 ; } } else { if (nums[mid] < target && target <= nums[high]) { low = mid + 1 ; }else { high = mid - 1 ; } } } return -1 ; } }
33的变形题,考虑到重复元素的出现
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 28 29 30 31 32 class Solution { public boolean search (int [] nums, int target) { int low = 0 ; int high = nums.length - 1 ; while (low <= high) { int mid = low +((high - low) >> 1 ); if (nums[mid] == target) return true ; if (nums[low] < nums[mid]) { if (target >= nums[low] && target < nums[mid]) { high = mid - 1 ; }else { low = mid + 1 ; } }else if (nums[low] > nums[mid]) { if (nums[mid] < target && target <= nums[high]) { low = mid + 1 ; } else { high = mid - 1 ; } } else { low ++; } } return false ; } }
主要注意如何处理duplicate elements那一块。而且要注意的是在最坏的情况下,即如果数组nums中的所有元素都相同的时候,算法的时间复杂度退化到O(n)。
33的变形题,要找rotated sorted array中最小的数,数组中没有重复元素的出现
Find Minimum in Rotated Sorted Array
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class Solution { public int findMin (int [] nums) { int low = 0 ; int high = nums.length - 1 ; while (low < high) { if (nums[low] < nums[high]) return nums[low]; int mid = low + ((high - low) >> 1 ); if (nums[mid] >= nums[low]) { low = mid + 1 ; }else { high = mid; } } return nums[low]; } }
注意这题,之前所有题目循环退出条件都是while(low <= high), 这一题是while(low < high)。如果写的是while(low <= high), 那么最后return的是nums[low-1]。有点绕,还不如上面代码的写法。
这道题再变形一下,可以找到maximum in rotated sorted array. 代码应该如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 class Solution { public int findMin (int [] nums) { int low = 0 ; int high = nums.length - 1 ; while (low < high) { if (nums[low] < nums[high]) return nums[high]; int mid = low + ((high - low) >> 1 ); if (nums[mid] >= nums[low]) { low = mid; }else { high = mid-1 ; } } return nums[low]; } }
153的变形题,要找rotated sorted array中最小的数,数组中有重复元素
Find Minimum in Rotated Sorted Array II
我第一次的做法,状态AC
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 class Solution { public int findMin (int [] nums) { int low = 0 ; int high = nums.length - 1 ; while (low < high) { int mid = low +((high - low) >> 1 ); if (nums[mid] < nums[high]) { high = mid; }else if (nums[mid] > nums[high]) { low = mid + 1 ; }else { high--; } } return nums[low]; } }
这个做法是在看153的题目的解答看到答案区别人给出的一个答案来的灵感。
这里把153题某网友的解答贴出来(原代码是C++,这里翻译成了java):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 class Solution { public int findMin (int [] nums) { int low = 0 ; int high = nums.length - 1 ; while (low < high) { int mid = low + (high - low) / 2 ; if (nums[mid] < nums[high]) high = mid; else low = mid + 1 ; } return nums[low]; } }
这个写法和上面那个153的写法的区别在于到底是判断nums[mid]和nums[low]还是nums[high]的关系,前一种写法中的 if(nums[low] < nums[high]) return nums[high]; 这一行是必不可少的,但这个写法中没有这一个判断也可以的。(加上也可以,其实有这个判断我个人觉得是更好理解和记忆的。)
下面给出判断154的另一种解法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class Solution { public int findMin (int [] nums) { int low = 0 ; int high = nums.length - 1 ; while (low < high) { if (nums[low] < nums[high]) return nums[low]; int mid = low + ((high - low) >> 1 ); if (nums[mid] > nums[low]) { low = mid + 1 ; }else if (nums[mid] < nums[low]){ high = mid; }else { low++; } } return nums[low]; } }