二分查找也称折半查找(Binary Search),它是一种效率较高的查找方法,前提是数据结构必须先排好序,可以在对数时间复杂度内完成查找。
二分查找事实上采用的就是一种分治策略,它充分利用了元素间的次序关系,可在最坏的情况下用O(log n)完成搜索任务。
它的基本思想是:假设数组元素呈升序排列,将n个元素分成个数大致相同的两半,取a[n/2]与欲查找的x作比较,如果x=a[n/2]则找到x,算法终止;如 果xa[n/2],则我们只要在数组a的右 半部继续搜索x。
二分查找问题也是面试中经常考到的问题。
用代码实现一个对int数组的二分查找
public static int binarySearch(int[] binary, int key){
// 定义初始查找范围,双指针
int low = 0;
int high = binary.length-1;
if (key < binary[low] && key > binary[high]){
return -1;
}
while (low < high ){
int mid = (low + high)/2;
// key 大于中间值,取后半部分
if(key > binary[mid]){
low = mid+1;
//key小于中间值,取前半部分
}else if (key < binary[mid]){
high = mid-1;
}else{
// key等于当前值
return mid;
}
}
return -1;
}
方法二:递归实现
/**
* 方法二:递归调用
* @param binary
* @param key
* @param fromIndex
* @param toIndex
* @return
*/
public static int binarySearch(int[] binary, int key, int fromIndex, int toIndex){
// 基本判断,当起始位置大于结束位置时,直接返回-1:特殊情况超出最大最小值,直接返回-1
if(key < binary[fromIndex] || key > binary[toIndex] || fromIndex > toIndex){
return -1;
}
// 计算中间位置
int mid = (fromIndex + toIndex)/2;
// 判断中间位置元素和key的大小关系,更改搜索范围,递归调用
if (binary[mid] < key){
return binarySearch(binary, key, fromIndex, mid -1);
}else if (binary[mid] > key){
return binarySearch(binary, key, mid+1, toIndex);
}else {
return mid;
}
}
递归调用,使用空间换取时间。递归调用过程中,栈会一层层的开辟使用,直到最底层返回return。
总结一下二分查找:
因此,二分查找方法适用于不经常变动而查找频繁的有序列表。使用条件:查找序列是顺序结构,有序。
编写一个高效的算法来判断 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
示例 3:
输入:matrix = [], target = 0
输出:false
提示:
既然这是一个查找元素的问题,并且数组已经排好序,我们自然可以想到用二分查找是一个高效的查找方式。
输入的 m x n 矩阵可以视为长度为 m x n的有序数组:
行列坐标为(row, col)的元素,展开之后索引下标为idx = row * n + col;反过来,对于一维下标为idx的元素,对应二维数组中的坐标就应该是:
row = idx / n; col = idx % n;
/**
* 方法一:二分查找
* @param matrix
* @param target
* @return
*/
public static boolean searchMatrix(int[][] matrix, int target) {
// 如果长度为0,则必定不存在
int rowLength = matrix.length;
// 获取列长度
int colLength = matrix[0].length;
if (rowLength==0 )
return false;
int left = 0;
int right = rowLength * colLength -1 ;
// 二分查找,定义左右指针,左指针小于等于右指针
while ( left <= right) {
// 中间指针
int midIndex = (left + right)/2;
// 获取当前值
int midElement = matrix[midIndex / colLength ][ midIndex % colLength ];
if (midElement < target){
left = midIndex + 1;
}else if (midElement > target){
right = midIndex - 1;
}else{
return true;
}
};
return false;
}
复杂度分析
给定一个包含 n + 1 个整数的数组 nums,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。假设只有一个重复的整数,找出这个重复的数。
示例 1:
输入: [1,3,4,2,2]
输出: 2
示例 2:
输入: [3,1,3,4,2]
输出: 3
说明:
怎样证明 nums 中存在至少一个重复值?其实很简单,这是“抽屉原理”(或者叫“鸽子洞原理”)的简单应用。
这里,nums 中的每个数字(n+1个)都是一个物品,nums 中可以出现的每个不同的数字(n个)都是一个 “抽屉”。把n+1 个物品放入n个抽屉中,必然至少会有一个抽屉放了2个或者2个以上的物品。所以这意味着nums中至少有一个数是重复的。
首先我们想到,最简单的办法就是,遍历整个数组,挨个统计每个数字出现的次数。
用一个HashMap保存每个数字对应的count数量,就可以直观地判断出是否重复了。
代码如下:
/**
* 方法一:使用HashMap保存每个数出现的次数
* @param nums
* @return
*/
public int findDuplicate(int[] nums){
Map map = new HashMap<>();
// 遍历所有元素,统计count值
for (int num : nums) {
// 判断当前 num 是否在 map 中出现
if(map.containsKey(num)){
// 如果出现过,num就是重复值
return num;
}else {
// 没出现过,添加到map
map.put(num,num);
}
}
return -1;
}
也可以直接保存到一个Set里,就知道这个元素到底有没有了。
/**
* 方法二:使用Set保存每个数
* @param nums
* @return
*/
public int findDuplicate2(int[] nums){
Set set = new HashSet<>();
// 遍历所有元素,统计count值
for (int num : nums) {
// 判断当前 num 是否在 set 中出现
if(set.contains(num)){
// 如果出现过,num就是重复值
return num;
}else {
// 没出现过,添加到set
set.add(num);
}
}
return -1;
}
复杂度分析
时间复杂度:O(n),我们只对数组做了一次遍历,在HashMap和HashSet中查找的复杂度是O(1)。
空间复杂度:O(n),我们需要一个HashMap或者HashSet来做额外存储,最坏情况下,这需要线性的存储空间。
尽管时间复杂度较小,但以上两种保存元素的方法,都用到了额外的存储空间,这个空间复杂度不能让我们满意。
本方法是先在原数组上排序。
排序之后,所有重复的数会排在一起;这样,只要我们遍历的时候发现连续两个元素相等,就可以输出结果了。
/**
* 方法三:先排序,然后找相邻的相同元素
* @param nums
* @return
*/
public int findDuplicate3(int[] nums){
Arrays.sort(nums);
// 遍历数组元素,遇到跟前一个相同的,就返回
for (int i = 0; i < nums.length; i++) {
if(nums[i] == nums[i-1]){
return nums[i];
}
}
return -1;
}
复杂度分析
这道题目中数组其实是很特殊的,我们可以从原始的 [1, N] 的自然数序列开始想。现在增加到了N+1个数,根据抽屉原理,肯定会有重复数。对于增加重复数的方式,整体应该有两种可能:
例如:1~9之间的10个数的数组,重复数是6:
1,2,5,6,6,6,6,6,7,9
本来最简单(重复数出现两次,其它1~9的数都出现一次)的是
1,2,3,4,5,6,6,7,8,9
现在没有3、4和8,所以6会多出现3次。
我们可以发现一个规律:
用数学化的语言描述就是:
我们把对于1~N内的某个数i,在数组中小于等于它的所有元素的个数,记为count[i]。
则:当i属于[1, target-1]范围内,count[i] <= i;当i属于[target, N]范围内,count[i] > i。
所以要找target,其实就是要找1~N中这个分界的数。所以我们可以对1~N的N个自然数进行二分查找,它们可以看作一个排好序的数组,但不占用额外的空间。
/**
* 方法四:二分查找
* @param nums
* @return
*/
public int findDuplicate4(int[] nums){
int left = 1;
int right = nums.length-1;
while (left <= right){
// 计算中间的值
int mid = ( left + right ) / 2;
// 对当前的mid计算count值
int count = 0;
for (int i = 0; i < nums.length; i++) {
if (nums[i] < mid) count++;
}
// 判断count的mid本身的大小关系
if ( count <= mid )
left = mid + 1; // count小于等于mid自身,说明mid比target小,左指针右移
else
right = mid;
// 左右指针重合时,找到target
if (left == right){
return mid;
}
}
return -1;
}
复杂度分析
这是一种比较特殊的思路。把nums看成是顺序存储的链表,nums中每个元素的值是下一个链表节点的地址。
那么如果nums有重复值,说明链表存在环,本问题就转化为了找链表中环的入口节点,因此可以用快慢指针解决。
比如数组
[3,6,1,4,6,6,2]
保存为:
整体思路如下:
第二次相遇时,应该有:
慢指针总路程 = 环外0到入口 + 环内入口到相遇点 (可能还有 + 环内m圈)
快指针总路程 = 环外0到入口 + 环内入口到相遇点 + 环内n圈
并且,快指针总路程是慢指针的2倍。所以:
环内n-m圈 = 环外0到入口 + 环内入口到相遇点。
把环内项移到同一边,就有:
环内相遇点到入口 + 环内n-m-1圈 = 环外0到入口
这就很清楚了:从环外0开始,和从相遇点开始,走同样多的步数之后,一定可以在入口处相遇。所以第二阶段的相遇点,就是环的入口,也就是重复的元素。
/**
* 方法五:快慢指针
* @param nums
*/
public int finDuplicate5(int[] nums){
// 定义快慢指针
int fast = 0,low = 0;
// 第一阶段:寻找链表中的环
do{
// 快指针一次走两步,慢指针一次走一步
low = nums[low];
fast = nums[nums[fast]];
}while ( fast != low );
// 第二阶段:寻找环在链上的入口节点
int ptr1 = 0,ptr2 = low;
while ( ptr1 != ptr2){
ptr1 = nums[ptr1];
ptr2 = nums[ptr2];
}
return ptr1;
}
复杂度分析
通过快慢指针循环检测这样的巧妙方法,实现了在不额外使用内存空间的前提下,满足线性时间复杂度O(n)。