一 二分查找介绍
二分查找法作为一种常见的查找方法,将原本是线性时间提升到了对数时间范围,大大缩短了搜索时间,但它有一个前提,就是必须在有序数据中进行查找。
出错原因主要集中在判定条件和边界值的选择上,很容易就会导致越界或者死循环的情况。
譬如数组{1, 2, 3, 4, 5, 6, 7, 8, 9},查找元素6,用二分查找的算法执行的话,其顺序为:
1. 第一步查找中间元素,即5,由于5<6,则6必然在5之后的数组元素中,那么就在{6, 7, 8, 9}中查找,
2. 寻找{6, 7, 8, 9}的中位数,为7,7>6,则6应该在7左边的数组元素中,那么只剩下6,即找到了。
二分查找算法就是不断将数组进行对半分割,每次拿中间元素和goal进行比较。
二分查找需要注意的问题:
(1)是否检查参数的有效性
low/high是否相同,数组中是否存在记录?low/high构成的区间是否有效?
(2)二分查找中值的计算
这是一个经典的话题,如何计算二分查找中的中值?试卷中,大家一般给出了两种计算方法:
算法一: mid = (low + high) / 2
算法二: mid = low + (high – low)/2
乍看起来,算法一简洁,算法二提取之后,跟算法一没有什么区别。但是实际上,区别是存在的。
算法一的做法,当数组比较长的时候,low + high是会溢出的,(low + high)存在着溢出的风险,进而得到错误的mid结果,导致程序错误。
而算法二能够保证计算出来的mid,一定大于low,小于high,不存在溢出的问题。
注意:在获取中间值时,要使用:left + (right - left) / 2,而不是使用(left + right) / 2。这是因为如果left和right都是很大的int时,可能会导致left + right超过Integer.maxValue,导致溢出。
推荐写法:int mid = (left + right) >>> 1 ;
left 和 high 都是整型最大值的时候,注意,此时 3232 位整型最大值它的二进制表示的最高位是 00,它们相加以后,最高位是 11 ,变成负数,但是再经过无符号右移 >>>(重点是忽略了符号位,空位都以 00 补齐),就能保证使用 + 在整型溢出了以后结果还是正确的。
递归调用存在着压栈/出栈的开销,其效率是比较低下的
二 基本用法
二分查找最基本的用法是在一个给定的有序数组或者列表中,判断某个元素是否存在:
//简单二分查找 public boolean binarySearch(int num,int [] nums){ int left = 0, right = nums.length - 1, mid; while(left <= right){ mid = left + (right - left)/2; if(num == nums[mid]){ return true; }else if(num < nums[mid]){ right = mid - 1; }else{ left = mid + 1; } } return false; }
其中,有几个要注意的点:
循环的判定条件是:low <= high
为了防止数值溢出,mid = low + (high - low)/2
当 A[mid]
不等于target
时,high = mid - 1
或low = mid + 1
也可以用递归来实现:
//简单二分查找 - 递归实现 public boolean binarySearch(int num,int [] nums,int left,int right){ if(left <= right){ int mid = left + (right - left)/2; if(num == nums[mid]){ return true; }else if(num > nums[mid]){ return binarySearch(int num,mid + 1,right); }else { return binarySearch(int num, left,mid - 1); } } return false; }
其查找时间为O(logn)
LeetCode原题:
35. 搜索插入位置:给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。你可以假设数组中无重复元素。
https://leetcode-cn.com/problems/search-insert-position/
根据题意,可得判断出这个问题等价于如下分析:
如果目标值(严格)大于排序数组的最后一个数,返回这个排序数组的长度,否则进入第 2 点。
返回排序数组从左到右,大于或者等于目标值的第 1 个数的索引。
public int searchInsert(int[] nums, int target) { int len = nums.length; if (nums[len - 1] < target) { return len; } int left = 0; int right = len - 1; while (left <= right) { int mid = (left + right) / 2; // 等于的情况最简单,我们应该放在第 1 个分支进行判断 if (nums[mid] == target) { return mid; } else if (nums[mid] < target) { // 题目要我们返回大于或者等于目标值的第 1 个数的索引 // 此时 mid 一定不是所求的左边界, // 此时左边界更新为 mid + 1 left = mid + 1; } else { // 既然不会等于,此时 nums[mid] > target // mid 也一定不是所求的右边界 // 此时右边界更新为 mid - 1 right = mid - 1; } } // 注意:一定得返回左边界 left, // 如果返回右边界 right 提交代码不会通过 // 【注意】下面我尝试说明一下理由,如果你不太理解下面我说的,那是我表达的问题 // 但我建议你不要纠结这个问题,因为我将要介绍的二分查找法模板,可以避免对返回 left 和 right 的讨论 // 理由是对于 [1,3,5,6],target = 2,返回大于等于 target 的第 1 个数的索引,此时应该返回 1 // 在上面的 while (left <= right) 退出循环以后,right < left,right = 0 ,left = 1 // 根据题意应该返回 left, // 如果题目要求你返回小于等于 target 的所有数里最大的那个索引值,应该返回 right return left; }
三 二分查找变形
1 查找目标值区域的左边界/查找与目标值相等的第一个位置/查找第一个不小于目标值数的位置
例如:
A = [1,3,3,5, 7 ,7,7,7,8,14,14]
target = 7
return 4
//查找目标值区域的左边界/查找与目标值相等的第一个位置/查找第一个不小于目标值数的位置 public int binarySearch6(int num,int [] nums,int left,int right){ while(left <= right){ int mid = (right + left) >>> 1; if(num <= nums[mid]){//这里用 <= ,因此right最终会指向最近的小于num的元素,而left指向最左的num right = mid - 1; }else if(num > nums[mid]){ left = mid + 1; } } if(nums[left] == num){ return left; }else{ return -1; } } @Test public void test6() { int[] nums = {1, 2, 2,3,3,3, 5, 6, 7}; System.out.println(binarySearch6(3, nums,0,nums.length - 1)); }
在查找时,由于判断条件是num <= nums[mid],因此right最终会指向等于num的数的前一位,之后left会一直加1,直到left = right+1,即最左边等于num是位。
2 查找目标值区域的右边界/查找与目标值相等的最后一个位置/查找最后一个不大于目标值数的位置
A = [1,3,3,5,7,7,7, 7 ,8,14,14]
target = 7
return 7
//查找目标值区域的右边界/查找与目标值相等的最后一个位置/查找最后一个不大于目标值数的位置 public int binarySearch7(int num,int [] nums,int left,int right){ while(left <= right){ int mid = (right + left) >>> 1; if(num < nums[mid]){ right = mid - 1; }else if(num >= nums[mid]){ left = mid + 1; } } if(nums[right] == num){ return right; }else{ return -1; } } @Test public void test7() { int[] nums = {1, 2, 2,3,3,3, 5, 6, 7}; System.out.println(binarySearch7(3, nums,0,nums.length - 1)); }
此题以可变形为查找第一个大于目标值的数/查找比目标值大但是最接近目标值的数
,我们已经找到了最后一个不大于目标值的数,那么再往后进一位,返回high + 1
,就是第一个大于目标值的数。
剑指offer原题:
题目描述
统计一个数字在排序数组中出现的次数。
解题思路
正常的思路就是二分查找了,我们用递归的方法实现了查找k第一次出现的下标,用循环的方法实现了查找k最后一次出现的下标。
除此之外,还有另一种奇妙的思路,因为data中都是整数,所以我们不用搜索k的两个位置,而是直接搜索k-0.5和k+0.5这两个数应该插入的位置,然后相减即可。
方法1:按照上面的二分查找法,找到第一个和最后一个
//统计一个数字在排序数组中出现的次数。 public int binarySearch8(int num,int [] nums,int left,int right){ int min = findLeft(num,nums,left,right);//找到最左的 if (min == -1){ return 0; } int max = findRight(num,nums,left,right);//找到最右的 return max - min + 1; } public int findLeft(int num,int [] nums,int left,int right){ int mid; while (left <= right){ mid = (left + right) >>> 1; if (num <= nums[mid]){ right = mid - 1; }else if (num > nums[mid]){ left = mid + 1; } } if (nums[left] == num){ return left; }else { return -1; } } public int findRight(int num,int [] nums,int left,int right){ int mid; while (left <= right){ mid = (left + right) >>> 1; if (num < nums[mid]){ right = mid - 1; }else if (num >= nums[mid]){ left = mid + 1; } } if (nums[right] == num){ return right; }else { return -1; } } @Test public void test8() { int[] nums = {1, 2, 2,3,3,3,3,3, 5, 6, 7}; System.out.println(binarySearch8(3, nums,0,nums.length - 1)); }
方法二:由于都是整数,例如:1,3,4,6,8,9,9,9,11,查找9出现的次数,那么可以查找8.5和9.5两个数应该插入的位置,因此相减可以得到:
//统计一个数字在排序数组中出现的次数。 public int binarySearch9(int num, int[] nums, int left, int right) { int min = findInsert(num - 0.5, nums, left, right);//找到最左的 int max = findInsert(num + 0.5, nums, left, right);//找到最右的 return max - min; } public int findInsert(double num, int[] nums, int left, int right) { int mid; while (left <= right) { mid = (left + right) >>> 1; if (num < nums[mid]) { right = mid - 1; } else{ left = mid + 1; } } return left; } @Test public void test9() { int[] nums = {1, 3, 4, 6, 8, 9, 9, 9, 9,11}; System.out.println(binarySearch9(9, nums, 0, nums.length - 1)); }
3 查找最后一个小于目标值的数/查找比目标值小但是最接近目标值的数
A = [1,3,3, 5 ,7,7,7,7,8,14,14]
target = 7
return 5
此题以可由第1 题变形而来,我们已经找到了目标值区域的下(左)边界,那么再往左退一位,即low - 1
,就是最后一个小于目标值的数。其实low - 1
也是退出循环后high
的值,因为此时 high
刚好等于low - 1
,它小于low
,所以 while 循环结束。我们只要判断high
是否超出边界即可。
int low = 0, high = n, mid; while(low <= high){ mid = low + (high - low) / 2; if(target <= A[mid]){ high = mid - 1; }else{ low = mid + 1; } } return high < 0 ? -1 : high;
查找第一个大于目标值的数/查找比目标值大但是最接近目标值的数同理是题2的变形。
三 旋转数组返回最小元素
1 查找旋转数组的最小元素(假设不存在重复数字)
LeetCode:153. 寻找旋转排序数组中的最小值 https://leetcode-cn.com/problems/find-minimum-in-rotated-sorted-array/
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [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:暴力法
搜索整个数组,找到其中的最小元素,这样的时间复杂度是 O(N)其中 N 是给定数组的大小。
//LeetCode:153. 寻找旋转排序数组中的最小值 public int findMin(int [] nums){ if (nums == null || nums.length == 0){ return 0; } int min = nums[0]; for(int i = 1;i<nums.length;i++){ if(nums[i] < min){ return nums[i]; } } return min; } @Test public void testFindMin() { int[] nums = {4,5,6,1,2,3}; System.out.println(findMin(nums)); }
解法二:二分查找
一个非常棒的解决该问题的办法是使用二分搜索。在二分搜索中,我们找到区间的中间点并根据某些条件决定去区间左半部分还是右半部分搜索。
由于给定的数组是有序的,我们就可以使用二分搜索。然而,数组被旋转了,所以简单的使用二分搜索并不可行。
在这个问题中,我们使用一种改进的二分搜索,判断条件与标准的二分搜索有些不同。
我们希望找到旋转排序数组的最小值,如果数组没有被旋转呢?如何检验这一点呢?
如果数组没有被旋转,是升序排列,就满足 last element > first element。
因此,如果nums[0] < nums[nems.length - 1],即数组没有旋转,因此返回nums[0]即可。
上面的例子中 3 < 4,因此数组旋转过了。这是因为原先的数组为 [2, 3, 4, 5, 6, 7],通过旋转较小的元素 [2, 3] 移到了后面,也就是 [4, 5, 6, 7, 2, 3]。因此旋转数组中第一个元素 [4] 变得比最后一个元素大。
这意味着在数组中你会发现一个变化的点,这个点会帮助我们解决这个问题,我们称其为变化点。
在这个改进版本的二分搜索算法中,我们需要找到这个点。下面是关于变化点的特点:
所有变化点左侧元素 > 数组第一个元素
所有变化点右侧元素 < 数组第一个元素
算法
找到数组的中间元素 mid。
如果中间元素 > 数组第一个元素,我们需要在 mid 右边搜索变化点。
如果中间元素 < 数组第一个元素,我们需要在 mid 左边搜索变化点。
上面的例子中,中间元素 6 比第一个元素 4 大,因此在中间点右侧继续搜索。
当我们找到变化点时停止搜索,当以下条件满足任意一个即可:
nums[mid] > nums[mid + 1],因此 mid+1 是最小值。
nums[mid - 1] > nums[mid],因此 mid 是最小值。
在上面的例子中,标记左右区间端点。中间元素为 2,之后的元素是 7 满足 7 > 2 也就是 nums[mid - 1] > nums[mid]。因此找到变化点也就是最小元素为 2。
public int findMin1(int [] nums){ if(nums == null || nums.length == 0){ return -1; } if(nums[0] < nums[nums.length - 1]){ return nums[0]; } //以下操作说明数组已经被翻转 int left = 0,right = nums.length - 1; int mid ; while(left <= right){ mid = (left + right) >>> 1; if(nums[mid] > nums[mid + 1]){ return nums[mid + 1]; } if(nums[mid - 1] > nums[mid]){ return nums[mid]; } if(nums[mid] > nums[0]){//中间值大于最左边值,则从右边查找 left = mid + 1; }else{ right = mid - 1; } } return -1; }
复杂度分析
时间复杂度:和二分搜索一样 O(\log N)
空间复杂度:O(1)
2 查找旋转数组的最小元素(假设存在重复数字)
LeetCode:154. 寻找旋转排序数组中的最小值 II https://leetcode-cn.com/problems/find-minimum-in-rotated-sorted-array-ii/
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。
请找出其中最小的元素。
注意数组中可能存在重复的元素。
示例 1:
输入: [1,3,5]
输出: 1
示例 2:
输入: [2,2,2,0,1]
输出: 0
说明:
这道题是 寻找旋转排序数组中的最小值 的延伸题目。
允许重复会影响算法的时间复杂度吗?会如何影响,为什么?
思路:
旋转排序数组 nums 可以被拆分为 2 个排序数组 nums1 , nums2 ,并且 nums1任一元素 >= nums2任一元素;因此,考虑二分法寻找此两数组的分界点 nums[i](即第 2 个数组的首个元素)。
设置 left, right 指针在 nums 数组两端,mid 为每次二分的中点:
当 nums[mid] > nums[right]时,mid 一定在第 1 个排序数组中,i 一定满足 mid < i <= right,因此执行 left = mid + 1;
当 nums[mid] < nums[right] 时,mid 一定在第 2 个排序数组中,i 一定满足 left < i <= mid,因此执行 right = mid;
当 nums[mid] == nums[right] 时,是此题对比 153题 的难点(原因是此题中数组的元素可重复,难以判断分界点 i 指针区间);
例如 [1, 0, 1, 1, 1] 和 [1, 1, 1, 0, 1],在 left = 0, right = 4, mid = 2 时,无法判断 mid 在哪个排序数组中。
我们采用 right = right - 1 解决此问题,证明:
此操作不会使数组越界:因为迭代条件保证了 right > left >= 0;
此操作不会使最小值丢失:假设 nums[right]是最小值,有两种情况:
若 nums[right]是唯一最小值:那就不可能满足判断条件 nums[mid] == nums[right],因为 mid < right(left != right 且 mid = (left + right) // 2 向下取整);
若 nums[right]不是唯一最小值,由于 mid < right 而 nums[mid] == nums[right],即还有最小值存在于 [left, right - 1] 区间,因此不会丢失最小值。
以上是理论分析,可以代入以下数组辅助思考:
[1, 2, 3]
[1, 1, 0, 1]
[1, 0, 1, 1, 1]
[1, 1, 1, 1]
时间复杂度 O(logN),在特例情况下会退化到 O(N)(例如 [1, 1, 1, 1])。
图解:
public int findMin2(int [] nums){ if(nums == null || nums.length == 0){ return -1; } if(nums[0] < nums[nums.length - 1]){ return nums[0]; } //以下操作说明数组已经被翻转 int left = 0,right = nums.length - 1; int mid = 0; while(left <= right){ mid = (left + right) >>> 1; if(nums[mid] > nums[right]){ left = mid + 1; } if(nums[mid] < nums[right]){ right = mid; }else{ right = right - 1; } } return left; } @Test public void testFindMin2() { int[] nums = {1,1,0,1,1,1,1,1,1,1,1,1,1,1,1}; System.out.println(findMin2(nums)); }
四 在旋转排序数组中搜索
1 在旋转排序数组中搜索(假设没有重复项)
LeetCode:33. 搜索旋转排序数组 https://leetcode-cn.com/problems/search-in-rotated-sorted-array/?utm_source=LCUS&utm_medium=ip_redirect_q_uns&utm_campaign=transfer2china
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [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
解析:
可分为如下两步实现:
(1)通过3.1解法找到旋转点,即数组中最小值,因此可以确定目标元素所在的数组。
(2)确定所在的数组后通过二分查找找到目标元素
//LeetCode:33. 搜索旋转排序数组 https://leetcode-cn.com/problems/search-in-rotated-sorted-array/?utm_source=LCUS&utm_medium=ip_redirect_q_uns&utm_campaign=transfer2china public int findNum(int[] nums,int num){ if(nums == null || nums.length == 0){ return -1; } if(nums[0] < nums[nums.length - 1]){//表示没有旋转 -- 直接使用二分查找 return binarySearch10(nums,num,0,nums.length - 1); } if(num > nums[0]){//表示目标元素在左边数组,即大数组 return binarySearch10(nums,num,0,reverseBinarySearch(nums) - 1); }else{//表示目标元素在右边数组,即小数组 return binarySearch10(nums,num,reverseBinarySearch(nums), nums.length - 1); } } //二分查找 public int binarySearch10(int[] nums,int num,int left,int right){ if(nums == null || nums.length == 0){ return -1; } int mid = 0; while(left <= right){ mid = (left + right) >>> 1; if(num == nums[mid]){ return mid; }else if(num > nums[mid]){ left = mid + 1; }else{ right = mid - 1; } } return -1; } //旋转数组二分查找 -- 找到最小的元素,即旋转点 public int reverseBinarySearch(int[] nums){ int left = 0,right = nums.length - 1,mid = 0; while(left <= right){ mid = (left + right) >>> 1; if(nums[mid - 1] > nums[mid]){ return mid; } if(nums[mid] > nums[mid + 1]){ return mid + 1; } if(nums[mid] > nums[0]){ left = mid + 1; }else{ right = mid - 1; } } return 0; } @Test public void testFindNum() { int[] nums = {6,7,9,0,1,2,3,4,5}; System.out.println(findNum(nums,7)); }
2 在旋转排序数组中搜索(假设有重复项)
LeetCode:81. 搜索旋转排序数组 II https://leetcode-cn.com/problems/search-in-rotated-sorted-array-ii/?utm_source=LCUS&utm_medium=ip_redirect_q_uns&utm_campaign=transfer2china
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [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
解析:
(1)如果nums[mid] == target,返回true
(2)当数组为[1,2,1,1,1],nums[mid] == nums[left] == nums[right] != target,需要left++, right --;
(3)当nums[left] <= nums[mid],说明是在左半边的递增区域
a. nums[left] <= target < nums[mid],说明target在left和mid之间,我们令right = mid - 1;
b. 不在之间, 我们令 left = mid + 1;
(4)当nums[mid] < nums[right],说明是在右半边的递增区域
a. nums[mid] < target <= nums[right],说明target在mid 和right之间,我们令left = mid + 1
b. 不在之间,我们令right = mid - 1;
public boolean binarySearch11(int[] nums,int num){ if(nums == null || nums.length == 0){ return false; } int left = 0,right = nums.length - 1 ,mid = 0; while(left <= right){ mid = (left + right) >>> 1; if(nums[mid] == num){ return true; } if(nums[mid] == nums[left]){ left ++; }else if(nums[mid] > nums[left]){//说明左边递增 if(num < nums[mid] && num > nums[left]){//表示在左边数组 right = mid - 1; }else{ left = mid + 1; } } if(nums[mid] == nums[right]){ right --; }else if(nums[mid] < nums[right]){//说明右边递增 if(num > nums[mid] && num < nums[right]){//表示在右边数组 left = mid + 1; }else{ right = mid - 1; } } } return false; } @Test public void testBinarySearch11() { int[] nums = {1,3,1,1,1,1,1,1}; System.out.println(binarySearch11(nums,3)); }