二分查找用法小结(Java实现)
参考文章:繁著的简书文章
一、二分法定义
二分查找也称折半查找(Binary Search)
,它是一种效率较高的查找方法。但是,折半查找要求线性表必须采用顺序存储结构,而且表中元素按关键字有序排列。二分查找的基本思想是将n
个元素分成大致相等的两部分,取a[n/2]
与x
做比较,如果x=a[n/2]
,则找到x
,算法中止;如果x<a[n/2]
,则只要在数组a
的左半部分继续搜索x
,如果x>a[n/2]
,则只要在数组a
的右半部搜索x
。
二、基本二分及其变形用法
1. 基本的二分查找
二分思想就是每次都找中间的那个数,如果中间那个数小于待查找的数,那么找右半边,否则找左半边,然后一直循环上述操作,直到找到或者区间长度为0
为止。
假设num = {1, 1, 11, 13, 13, 16, 18, 18, 20}
,target = 16
,查找过程如下:
代码实现:
public static int baseBinarySearch(int[] num, int target) {
int left = 0, right = num.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2; // 防止数太大溢出
if (target == num[mid]) {
return mid;
} else if (target > num[mid]) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
2. 如果有多个与target相等,返回第一个与target相等的值的下标,如果找不到target则返回-1
示例1
:num = {1, 1, 11, 13, 13, 16, 18, 18, 20}
,target = 1
,返回0
。
示例2
:num = {1, 1, 11, 13, 13, 16, 18, 18, 20}
,target = 13
,返回3
。
示例3
:num = {1, 1, 11, 13, 13, 16, 18, 18, 20}
,target = 14
,返回-1
。
这其实就是上一个的变形,只是当找到与target
相等的值的时候不直接返回下标,而是令right = mid - 1
继续循环,因为它左边可能还有与target
相等的。当循环终止后,要取left
作为结果而不是right
,因为循环倒数第二轮的时候left
一定是等于right
并且都是指向答案的(至于为什么,多多实验就知道了),此时mid = (left + right) / 2 = left = right
,num[mid] = target
导致下一轮right = mid - 1
,即向左移了一位,所以最后一轮left
一定是等于right + 1
,可看如下图示,最后如果left
小于数组的长度并且等于target
就返回left
,否则返回-1
。
假设num = {1, 1, 11, 13, 13, 16, 18, 18, 20}
,target = 1
,查找过程如下:
public static int findFirstEquality(int[] num, int target) {
int left = 0, right = num.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (target > num[mid]) { // 这里不能取等号,因为当target等于中点值时,我们是要找到它左边的第一个,如果中点这个数没有重复,那么答案就是这个中点,如果有重复,那么答案只能在左边,故right要左移,而不是left右移
left = mid + 1;
} else {
right = mid - 1;
}
}
if (left < num.length && num[left] == target)
return left; // 取left而不是取right是因为,循环倒数第二轮的时候left肯定会等于right,而且都是指向答案,而如果这个值不等于target直接返回-1,否则right会左移一位,所以用left
return -1;
}
3. 查找小于target且最接近target的数的下标,没有则返回-1
示例1
:num = {1, 1, 11, 13, 13, 16, 18, 18, 20}
,target = 1
,返回-1
。
示例2
:num = {1, 1, 11, 13, 13, 16, 18, 18, 20}
,target = 13
,返回2
。
示例3
:num = {1, 1, 11, 13, 13, 16, 18, 18, 20}
,target = 14
,返回4
。
有了上一题的理解,这个就更好理解了,在上一题中我们找到了target
的最左下标,那么这个下标减1
即本题答案,上一题中最后取的答案是left
,同时也分析了最后一定是right = left - 1
,所以right
就是本题答案,只需要判断right是否大于0
即可。
public static int findLessThan(int[] num, int target) {
int left = 0, right = num.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (target > num[mid]) {
left = mid + 1;
} else {
right = mid - 1;
}
}
if (right >= 0) return right;
return -1;
}
4. 如果有多个与target相等,返回最后一个与target相等的值的下标,如果找不到target则返回-1
示例1
:num = {1, 1, 11, 13, 13, 16, 18, 18, 20}
,target = 1
,返回1
。
示例2
:num = {1, 1, 11, 13, 13, 16, 18, 18, 20}
,target = 13
,返回4
。
示例3
:num = {1, 1, 11, 13, 13, 16, 18, 18, 20}
,target = 14
,返回-1
。
与第二题反着来就行。
public static int findLastEquality(int[] num, int target) {
int left = 0, right = num.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (target >= num[mid]) {
left = mid + 1;
} else {
right = mid - 1;
}
}
if (right >= 0 && num[right] == target)
return right;
return -1;
}
5. 查找大于target且最接近target的数的下标,没有则返回-1
示例1
:num = {1, 1, 11, 13, 13, 16, 18, 18, 20}
,target = 1
,返回2
。
示例2
:num = {1, 1, 11, 13, 13, 16, 18, 18, 20}
,target = 13
,返回5
。
示例3
:num = {1, 1, 11, 13, 13, 16, 18, 18, 20}
,target = 14
,返回5
。
与第三题反着来就行。
public static int findMoreThan(int[] num, int target) {
int left = 0, right = num.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (target >= num[mid]) {
left = mid + 1;
} else {
right = mid - 1;
}
}
if (left < num.length)
return left;
return -1;
}
6. 旋转数组查找最小值
旋转数组:即将有序数组的前面一部分整体移到数组末尾。
无重复数的旋转数组
示例1
:num = {5, 6, 9, 10, 1, 2, 3}
,返回4
。
示例2
:num = {10, 14, 1, 2, 5, 8}
,返回2
。
示例3
:num = {100, 1000, 10000, 1, 10}
,返回3
。
二分找中点,然后中点值与右边界值比较(要注意右边界是nums[nums.length - 1]
而不是nums[right]
),要是大于右边界,说明最小值一定还在右边,故左指针右移,否则右指针左移。
public static int findMin(int[] nums) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] > nums[nums.length - 1]) { // 如果nums[mid]大于右边界点,说明最小值在mid的右边
left = mid + 1;
} else {
right = mid - 1;
}
}
return left; // 不需要判断left是否越界,因为倒数第二次循环时候left一定等于right并且指向答案,那么最后一次循环是right左移,故取left不取right。
}
有重复数的旋转数组
示例1
:num = {5, 6, 9, 9, 10, 1, 1, 2, 3}
,返回5
。
示例2
:num = {10, 14, 1, 2, 5, 10}
,返回2
。
示例3
:num = {100, 1000, 1000, 10000, 1, 10}
,返回4
。
上题的变形,当有重复值的时候(如{3, 4, 5, 1, 2, 3}
左指针值等于右指针值),我们除去一个重复值即可,故就让左指针右移进一步判断。
public static int findMin(int[] nums) {
int left = 0, right = nums.length - 1;
while (left <= right) {
if (left != right && nums[left] == nums[nums.length - 1]) {
left++;
continue;
}
int mid = left + (right - left) / 2;
if (nums[mid] > nums[nums.length - 1]) { // 如果nums[mid]大于右边界点,说明最小值在mid的右边
left = mid + 1;
} else {
right = mid - 1;
}
}
return left;
}
7. 旋转数组搜索
无重复数的旋转数组
示例1
:num = {5, 19, 21, 1, 2, 3}
,target = 1
,返回3
。
示例2
:num = {10, 14, 1, 2, 5}
,target = 1
,返回2
。
示例3
:num = {100, 1000, 1000, 10000, 1, 10}
,target = 2
,返回-1
。
每次取mid
的时候,数组都会被分成两段,并且一定有一段是有序的(比如{5, 19, 21, 1, 2, 3}
,被分成{5, 19}
和{1, 2, 3}
,无论怎么分一定会有一段是有序的),如果左半边有序,且target
在里面,那么就继续二分左半边,否则二分右半边;如果右半边有序,且target
在里面,那么就继续二分右半边,否则二分左半边。
public static int search(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
} else if (nums[left] <= nums[mid]) { // 如果左半边有序
if (nums[left] <= target && target < nums[mid]) // 如果target在其中,就二分左半边
right = mid - 1;
else
left = mid + 1;
} else { // 如果右半边有序
if (nums[mid] < target && target <= nums[right]) // 如果target在其中,就二分右半边
left = mid + 1;
else
right = mid - 1;
}
}
return -1;
}
有重复数的旋转数组
示例1
:num = {5, 6, 9, 9, 10, 1, 1, 2, 3}
,target = 1
,返回true
。
示例2
:num = {10, 14, 1, 2, 5, 10}
,target = 1
,返回true
。
示例3
:num = {100, 1000, 1000, 10000, 1, 10}
,target = 2
,返回false
。
上题变形,和上上题一样,加个判断就行。
public static boolean search(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
if (left != right && nums[left] == nums[nums.length - 1]) {
left++;
continue;
}
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return true;
} else if (nums[left] <= nums[mid]) {
if (nums[left] <= target && target < nums[mid])
right = mid - 1;
else
left = mid + 1;
} else {
if (nums[mid] < target && target <= nums[right])
left = mid + 1;
else
right = mid - 1;
}
}
return false;
}
三、实例题目
1. 连续1的最长个数
题意:给定一个只包含0
和1
的数组,进行k
次0
变1
操作,问变k
次后最长连续1
串长度。
示例1
:num = {1, 0, 0, 1, 0, 1, 1},k = 1
,答案为4
,将num[4]
变成1
。
示例2
:num = {1, 0, 0, 1, 0, 1, 1},k = 2
,答案为5
,将num[2]
和num[4]
变成1
。
思路1
:暴力法,两层循环,第一层循环代表左端点,然后第二层循环代表右端点不断向右试探,直到不满足条件(变完了k
个0
),这个时间复杂度为
O
(
N
2
)
O(N^2)
O(N2),肯定不是理想的解法。
public static int solve(int[] num, int k) {
int res = 0;
int len = num.length;
for (int i = 0; i < len; ++i) {
int zeroSum = 0;
for (int j = i; j < len; ++j) {
if (num[j] == 0)
zeroSum++;
if (zeroSum <= k) {
res = Math.max(res, j - i + 1);
}
}
}
return res;
}
思路2
:动态规划法,写出来是为了下面的解法做铺垫,我们利用一个二维数组zeroSum[][]
存放i
到j
的0
的个数,zeroSum[i][j]
代表num[i] ~ num[j]
的0
的个数,zeroSum[i][j] = zeroSum[i][j - 1] + (num[j] == 0 ? 1 : 0)
时间复杂度为
O
(
N
2
)
O(N^2)
O(N2)。
public static int solve(int[] num, int k) {
int res = 0;
int len = num.length;
int[][] zeroSum = new int[len][len];
for (int i = 0; i < len; ++i)
for (int j = i; j < len; ++j)
if (i == j) zeroSum[i][j] = (num[j] == 0 ? 1 : 0);
else zeroSum[i][j] = zeroSum[i][j - 1] + (num[j] == 0 ? 1 : 0);
for (int i = 0; i < len; ++i)
for (int j = i; j < len; ++j)
if (zeroSum[i][j] <= k) res = Math.max(res, j - i + 1);
return res;
}
思路3
:优化动态规划 + 二分法,大多数人都不会想到用二分法来求解,其实一般只要数据具有单调性,并且所问的问题诸如最多、最少、最大、最小、最长等等的,一般都可以利用二分法来求解。
在第一个解法中,右端点的查找重复算了很多次0
变1
,而第二个解法的二维数组导致时间复杂度下不来,其实可以优化为一维数组,故用一维数组zeroSum[]
来存放0
的个数,zeroSum[i]
代表num[0] ~ num[i]
的0
的个数,这样存的好处是可以快速求出num[i] ~ num[j]
的0
的个数,比如num[2] ~ num[5]
中0
的个数等于zeroSum[5] - zeroSum[1]
。同样两层循环,第一层也是遍历左端点,不同的是右端点利用二分来查找,我们可以将第一层循环的i
当成是已知的,然后需要的是右端点在满足k
个0
的条件下尽可能走的远,那么就相当于在一个数组中找一个数的右边界(这个数组即{zeroSum[j] - zeroSum[i] | j ∈ [i, num.length)}
,解法参考上边二分查找的第四题),时间复杂度为
O
(
N
l
o
g
2
N
)
O(Nlog_2N)
O(Nlog2N),实现代码如下。
二分法实现:
public static int solve(int[] num, int k) {
int res = 0;
int len = num.length;
int[] zeroSum = new int[len]; // zeroSum[i]代表num[0]~num[i]有多少个0
for (int i = 0; i < len; ++i) { // 初始化zeroSum数组
if (i == 0) zeroSum[i] = (num[i] == 0 ? 1 : 0);
else zeroSum[i] = zeroSum[i - 1] + (num[i] == 0 ? 1 : 0);
}
for (int i = 0; i < len; ++i) { // 遍历左端点
int left = i, right = len - 1; // 二分查找右端点
while (left <= right) {
int mid = left + (right - left) / 2; // 防止数据溢出
if (zeroSum[mid] - (i == 0 ? 0 : zeroSum[i - 1]) <= k) { // i如果等于0,要注意边界问题
left = mid + 1;
} else {
right = mid - 1;
}
}
res = Math.max(res, right - i + 1); // 更新答案
}
return res;
}
思路4
:双指针法(可理解为滑动窗口),思路一的优化版,即不重复计算0
的个数,初始化left
、right
指针,固定left,right
不断向右试探,但是循环一次后right
不会重新初始化,而是利用上一轮循环结果,这样就大大提高了效率。
双指针实现:
public static int solve4(int[] num, int k) {
int res = 0;
int tmp = 0; // left到right之间0的个数
int len = num.length;
for (int left = 0, right = 0; left < len; ++left) {
while (right < len && tmp + (num[right] == 0 ? 1 : 0) <= k) // right不断向右试探,直到不满足条件为止
tmp += (num[right++] == 0 ? 1 : 0);
res = Math.max(res, right - left); // 更新答案
tmp -= (num[left] == 0 ? 1 : 0); // 当前一轮循环结束,要减去左端点,给下一轮循环腾出0的空间
}
return res;
}
2. 贪吃的小Q(腾讯2018年笔试题)
测试链接:https://www.nowcoder.com/questionTerminal/d732267e73ce4918b61d9e3d0ddd9182
题目:
小Q的父母要出差N天,走之前给小Q留下了M块巧克力。小Q决定每天吃的巧克力数量不少于前一天吃的一半,
但是他又不想在父母回来之前的某一天没有巧克力吃,请问他第一天最多能吃多少块巧克力。
输入描述:
每个输入包含一个测试用例。
每个测试用例的第一行包含两个正整数,表示父母出差的天数N(N<=50000)和巧克力的数量M(N<=M<=100000)。
输出描述:
输出一个数表示小Q第一天最多能吃多少块巧克力。
示例
输入
3 7
输出
4
思路:
要求第一天吃的最多,那么第一天之后的必须都吃最小值,即前一天的一半(向上取整)。
注意到,题目问的又是最多,并且数据也有单调性(蛋糕数量累加是递增的),那么二分又来了,我们二分试探第一天吃的数量,然后按照这个值把N
天吃的累加起来(表示为sum
),再和M
比较,如果是sum <= M
,说明第一天应该还可以多吃一点,这就等价于找数组的右边界。
AC
代码:
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
run();
}
private static void run() {
Scanner scanner = new Scanner(System.in);
int n = scanner.nextInt();
int m = scanner.nextInt();
int left = 0, right = m;
while (left <= right) {
int mid = left + (right - left) / 2;
int tmp = sum(mid, n);
if (tmp <= m) {
left = mid + 1;
} else {
right = mid - 1;
}
}
if (right > 0) {
System.out.println(right);
}
scanner.close();
}
private static int sum(int init, int day) { // 初始值,吃day天,总共吃多少,每天按照前一天的一半算(向上取整)。
int res = 0;
for (int i = 0; i < day; ++i) {
res += init;
init = (int) Math.ceil(init * 1.0 / 2);
}
return res;
}
}
3. 闹钟叫醒去上课(字节跳动2020秋招笔试题)
字节跳动2020秋招第一题:https://blog.csdn.net/hzj1998/article/details/99285786