二分查找(Java 版)

本文是二分查找算法的总结归纳
更多请参照算法刷题套路和模板的GitHub仓库

简介

二分查找也称折半查找(Binary Search),它是一种效率较高的查找方法。但是,二分查找要求线性表必须采用顺序存储结构,而且表中元素按关键字有序排列。

二分查找算法是典型的「减治思想」的应用,我们使用二分查找将待查找的区间逐渐缩小,以达到「缩减问题规模」的目的。

比如查找升序数组nums里的目标值target(这里只讨论升序数组,降序数组是一样的道理):

int [] nums = { 0, 1, 2, 3, 4, 5, 6, 7 };

约定

我们把待查找区间的左边界下标设为left,右边界下标设为right,中间位置下标设为mid

// 待查找区间的左边界下标
int left = 0;
// 待查找区间的右边界下标
int right = a.length - 1;

// 中间位置下标取法有两种
// 下取整,不写成 (right + left) / 2 是为了防止溢出
int mid = left + (right - left) / 2; 
// 上取整
int mid = left + (right - left + 1) / 2; 

// 中间位置下标求值的优化
// 1.使用右移位运算符,右移 1 位相当于除以 2
// 下取整
int mid = left + (right - left) >> 1; 
// 上取整
int mid = left + (right - left + 1) >> 1; 

// 2.使用无符号右移位运算符(貌似仅 Java 有),参考 JDK 源码 Arrays.binarySearch() 的写法,
//   left + right 即使是在整型溢出以后,仍然能够得到正确的结果
// 下取整
int mid = (right + left) >>> 1;
// 上取整
int mid = (right + left + 1) >>> 1;

打印数组的共通ArrayUtil

public class ArrayUtil {

	/**
	 * 打印整型数组
	 * @param arrays
	 */
	public static void printArray(int[] arrays) {

		StringBuilder sBuilder = new StringBuilder();

		sBuilder.append("{ ");
		for (int i : arrays) {
			sBuilder.append(i + ", ");
		}
		// 删除多余的", "
		sBuilder.delete(sBuilder.length() - 2, sBuilder.length());
		sBuilder.append(" }");

		System.out.println(sBuilder);
	}
}

一、模板 1:while (left <= right)

1、思路:在循环体内部查找元素(解决简单问题时有用),即考虑下一轮目标元素应该在哪个区间

把待查找区间[left, right]分为 3 个部分:

  • mid位置(只有 1 个元素);
  • [left, mid - 1]里的所有元素;
  • [mid + 1, right]里的所有元素;

于是,二分查找就是不断地在区间[left, right]里根据中间元素nums[mid]target的大小关系来不断缩小查找区间,最终找到target的下标:

  • nums[mid] == target时,返回mid
  • nums[mid] > target时,由于数组升序,mid以及mid右边的所有元素都大于target,下一轮目标元素一定在区间[left, mid - 1]里,因此设置right = mid - 1
  • nums[mid] < target时,由于数组升序,mid以及mid左边的所有元素都小于target,下一轮目标元素一定在区间[mid + 1, right]里,因此设置left = mid + 1

2、图解

nums[mid] == target
nums[mid] > target
nums[mid] < target

3、代码实现

public class BinarySearch {

	public static void main(String[] args) {

		int[] nums = { 0, 1, 2, 3, 4, 5, 6, 7 };
		int target = 2;

		System.out.print("数组 nums:");
		ArrayUtil.printArray(nums);

		System.out.println("目标值 target:" + target);
		System.out.println("模板 1 下标:" + binarySearch1(nums, target));
	}

	/**
	 * 二分查找法<br>
	 *   <li>模板 1:while (left <= right)</li><br>
	 * @param nums 待查找数组
	 * @param target 待查找目标值
	 * @return 目标值在数组中的下标<br>
	 *         未查找到就返回 -1
	 */
	public static int binarySearch1(int[] nums, int target) {

		// 特殊用例判断
		int len = nums.length;
		if (len == 0) {
			return -1;
		}

		// 在 [left, right] 区间里查找 target
		int left = 0;
		int right = len - 1;
		while (left <= right) {
			// 为了防止 left + right 整形溢出,写成如下形式
			int mid = left + (right - left) / 2;

			// 找到目标值,直接返回
			if (nums[mid] == target) {
				return mid;
			} else if (nums[mid] > target) {
				// [mid, right]都大于目标值,下一轮查找区间:[left, mid - 1]
				right = mid - 1;
			} else {
				// 此时:nums[mid] < target
				// [left, mid]都小于目标值,下一轮查找区间:[mid + 1, right]
				left = mid + 1;
			}
		}

		return -1;
	}
}

运行结果:

数组 nums:{ 0, 1, 2, 3, 4, 5, 6, 7 }
目标值 target:2
模板 1 下标:2

二、模板 2:while (left < right),推荐使用

1、思路:在循环体内部排除元素(解决复杂问题时非常有用),即考虑中间元素 nums[mid] 在什么情况下不是目标元素

把待查找区间[left, right]分为 2 个部分:

  • 不存在目标元素(if分支);
  • 可能存在目标元素(else分支,包含mid);

与模版 1 同样,二分查找就是不断地在区间[left, right]里根据中间元素nums[mid]target的大小关系来不断缩小查找区间,最终找到target的下标:
①、中间位置下取整

  • nums[mid] < target时,mid以及mid左边元素都小于target,下一轮目标元素一定在区间[mid + 1, right]里,因此设置left = mid + 1

②、中间位置上取整

  • nums[mid] > target时,mid以及mid右边元素都小于target,下一轮目标元素一定在区间[left, mid - 1]里,因此设置right = mid - 1

Tips:先写if else分支,再决定是中间位置是上取整(target在左边)还是下取整(target在右边)。

特征:

  • while (left < right),这里使用严格小于 < 表示的临界条件是:当区间里的元素只有 2 个时,依然可以执行循环体。换句话说,退出循环的时候一定有 left == right 成立,这一点在定位元素下标的时候极其有用。

2、图解

模板2:while (left < right)

3、代码实现

public class BinarySearch {

	public static void main(String[] args) {

		int[] nums = { 0, 1, 2, 3, 4, 5, 6, 7 };
		int target = 2;

		System.out.print("数组 nums:");
		ArrayUtil.printArray(nums);

		System.out.println("目标值 target:" + target);
		System.out.println("模板 2(下取整)下标:" + binarySearch2_floor(nums, target));
		System.out.println("模板 2(上取整)下标:" + binarySearch2_ceil(nums, target));
	}

	/**
	 * 二分查找法<br>
	 *   <li>模板 2(下取整):while (left < right)</li><br>
	 * @param nums 待查找数组
	 * @param target 待查找目标值
	 * @return 目标值在数组中的下标<br>
	 *         未查找到就返回 -1
	 */
	public static int binarySearch2_floor(int[] nums, int target) {

		// 特殊用例判断
		int len = nums.length;
		if (len == 0) {
			return -1;
		}

		// 在 [left, right] 区间里查找 target
		int left = 0;
		int right = len - 1;
		while (left < right) {
			// 选择中间位置时下取整
			int mid = left + (right - left) / 2;

			if (nums[mid] < target) {
				// [left, mid]都小于目标值,下一轮查找区间是 [mid + 1, right]
				left = mid + 1;
			} else {
				// 此时:nums[mid] >= target
				// [mid, right]都大于等于目标值,下一轮查找区间是 [left, mid]
				right = mid;
			}
		}

		// 退出循环的时候 left == right,程序只剩下一个元素没有看到。
		// 视情况,是否需要单独判断 left(或者 right)这个下标的元素是否符合题意。
		return nums[left] == target ? left : -1;
	}

	/**
	 * 二分查找法<br>
	 *   <li>模板2(上取整):while (left < right)</li><br>
	 * @param nums 待查找数组
	 * @param target 待查找目标值
	 * @return 目标值在数组中的下标<br>
	 *         未查找到就返回 -1
	 */
	public static int binarySearch2_ceil(int[] nums, int target) {

		// 特殊用例判断
		int len = nums.length;
		if (len == 0) {
			return -1;
		}

		// 在 [left, right] 区间里查找 target
		int left = 0;
		int right = len - 1;
		while (left < right) {
			// 选择中间位置时上取整
			int mid = left + (right - left + 1) / 2;

			if (nums[mid] > target) {
				// [mid, right]都大于目标值,下一轮查找区间是 [left, mid - 1]
				right = mid - 1;
			} else {
				// nums[mid] <= target
				// [left, mid]都小于等于目标值,下一轮查找区间是 [mid, right]
				left = mid;
			}
		}

		// 退出循环的时候 left == right,程序只剩下一个元素没有看到。
		// 视情况,是否需要单独判断 left(或者 right)这个下标的元素是否符合题意。
		return nums[left] == target ? left : -1;
	}
}

运行结果:

数组 nums:{ 0, 1, 2, 3, 4, 5, 6, 7 }
目标值 target:2
模板 2(下取整)下标:2
模板 2(上取整)下标:2

三、模板 3:while (left + 1 < right)

如果已经掌握了模板 2,就无需掌握这个模板,仅作了解。

1、与模版 2 的区别

这一版代码和模板 2 没有本质区别,一个显著的标志是:循环可以继续的条件是 while (left + 1 < right),这说明在退出循环的时候,一定有 left + 1 == right 成立,也就是退出循环以后,区间有 2 个元素,即 [left, right]

2、优缺点

  • 优点:不用理解模板 2 在分支出现 left = mid 的时候中间位置上/下取整的行为;
  • 缺点:while (left + 1 < right) 写法相对于 while (left <= right)while (left < right) 来说并不自然;由于退出循环以后,区间一定有两个元素,需要思考哪一个元素才是需要找的,即「后处理」一定要做,有些时候还会有先考虑 left 还是 right 的区别。

3、代码实现

public class BinarySearch {

	public static void main(String[] args) {

		int[] nums = { 0, 1, 2, 3, 4, 5, 6, 7 };
		int target = 2;

		System.out.print("数组 nums:");
		ArrayUtil.printArray(nums);

		System.out.println("目标值 target:" + target);
		System.out.println("模板 3 下标:" + binarySearch3(nums, target));
	}

	/**
	 * 二分查找法<br>
	 *   <li>模板 3:while (left + 1 < right)</li><br>
	 * @param nums 待查找数组
	 * @param target 待查找目标值
	 * @return 目标值在数组中的下标<br>
	 *         未查找到就返回 -1
	 */
	public static int binarySearch3(int[] nums, int target) {

		// 特殊用例判断
		int len = nums.length;
		if (len == 0) {
			return -1;
		}

		// 在 [left, right] 区间里查找 target
		int left = 0;
		int right = len - 1;
		while (left + 1 < right) {
			// 选择中间位置时下取整
			int mid = left + (right - left) / 2;

			if (nums[mid] == target) {
				return mid;
			} else if (nums[mid] < target) {
				left = mid;
			} else {
				right = mid;
			}
		}

		if (nums[left] == target) {
			return left;
		}
		if (nums[right] == target) {
			return right;
		}

		return -1;
	}
}

运行结果:

数组 nums:{ 0, 1, 2, 3, 4, 5, 6, 7 }
目标值 target:2
模板 3 下标:2
posted @ 2020-05-11 10:02  ageovb  阅读(332)  评论(0编辑  收藏  举报