前言
二分查找算法也称折半查找,是一种非常高效的工作于有序数组的查找算法。
1.二分查找基础版
需求:在有序数组 A A A 内,查找值 t a r g e t target target
- 如果找到返回索引
- 如果找不到返回 − 1 -1 −1
代码实现:
public static int binarySearchBasic(int[] a, int target) {
int i = 0, j = a.length - 1;
while (i <= j) {
int m = (i + j) / 2;
if (target < a[m]) {
//左边
j = m - 1;
} else if (a[m] < target) {
//右边
i = m + 1;
} else {
return m;
}
}
return -1;
}
问题1:为什么是 i < = j i <= j i<=j 意味着区间内有未比较的元素, 而不是 i < j i < j i<j ?
i == j 意味着 i,j 它们指向的元素也会参与比较
i < j 只意味着 m 指向的元素参与比较
问题2: ( i + j ) / 2 (i + j) / 2 (i+j)/2 有没有问题?
有可能超出正整数所表示的范围
2.二分查找改动版
public static int binarySearchAlternative(int[] a, int target) {
int i = 0, j = a.length;
while (i < j) {
int m = (i + j) >>> 1;
if (target < a[m]) {
j = m;
} else if (a[m] < target) {
i = m + 1;
} else {
return m;
}
}
return -1;
}
- i , j i,j i,j 对应着搜索区间 [ 0 , a . l e n g t h ) [0,a.length) [0,a.length)(注意是左闭右开的区间), i < j i<j i<j 意味着搜索区间内还有未比较的元素, j j j 指向的一定不是查找目标
- 如果某次要缩小右边界,那么 j = m j=m j=m,因为此时的 m m m 已经不是查找目标了
问题:为啥这次不加 i = = j i==j i==j 的条件了?
这回 j j j 指向的不是查找目标,如果还加 i = = j i==j i==j 条件,就意味着 j j j 指向的还会再次比较,找不到时,会死循环
3.衡量算法好坏
时间复杂度 比较查找算法与二分查找算法
查找算法
public static int search(int[] a, int k) {
for (int i = 0;i < a.length; i++) {
if (a[i] == k) {
return i;
}
}
return -1;
}
考虑最坏情况下(没找到)例如 [1,2,3,4]
查找 5
int i = 0
只执行一次i < a.length
受数组元素个数 n n n 的影响,比较 n + 1 n+1 n+1 次i++
受数组元素个数 n n n 的影响,自增 n n n 次a[i] == k
受元素个数 n n n 的影响,比较 n n n 次return -1
,执行一次
粗略认为每行代码执行时间是 t t t,假设 n = 4 n=4 n=4 那么
- 总执行时间是 ( 1 + 4 + 1 + 4 + 4 + 1 ) ∗ t = 15 t (1+4+1+4+4+1)*t = 15t (1+4+1+4+4+1)∗t=15t
- 可以推导出更一般地公式为, T = ( 3 ∗ n + 3 ) t T = (3*n+3)t T=(3∗n+3)t
二分查找算法(二分查找基础版),还是 [1,2,3,4]
查找 5
-
int i = 0, j = a.length - 1
各执行 1 次 -
i <= j
比较 f l o o r ( log 2 ( n ) + 1 ) floor(\log_{2}(n)+1) floor(log2(n)+1) 再加 1 次 -
(i + j) >>> 1
计算 f l o o r ( log 2 ( n ) + 1 ) floor(\log_{2}(n)+1) floor(log2(n)+1) 次 -
接下来
if() else if() else
会执行 3 ∗ f l o o r ( log 2 ( n ) + 1 ) 3* floor(\log_{2}(n)+1) 3∗floor(log2(n)+1) 次,分别为- if 比较
- else if 比较
- else if 比较成立后的赋值语句
-
return -1
,执行一次
结果:
- 总执行时间为 ( 2 + ( 1 + 3 ) + 3 + 3 ∗ 3 + 1 ) ∗ t = 19 t (2 + (1+3) + 3 + 3 * 3 +1)*t = 19t (2+(1+3)+3+3∗3+1)∗t=19t
- 更一般地公式为 ( 4 + 5 ∗ f l o o r ( log 2 ( n ) + 1 ) ) ∗ t (4 + 5 * floor(\log_{2}(n)+1))*t (4+5∗floor(log2(n)+1))∗t
注意:
左侧未找到和右侧未找到结果不一样
两个算法比较,可以看到
n
n
n 在较小的时候,二者花费的次数差不多
但随着
n
n
n 越来越大,比如说
n
=
1000
n=1000
n=1000 时,用二分查找算法也就是
54
t
54t
54t,而查找算法则需要
3003
t
3003t
3003t
4.二分查找平衡版
思想:
- 左闭右开的区间, i i i 指向的可能是目标,而 j j j 指向的不是目标
- 不奢望循环内通过
m
m
m 找出目标, 缩小区间直至剩 1 个, 剩下的这个可能就是要找的(通过
i
i
i)
- j − i > 1 j - i > 1 j−i>1 的含义是,在范围内待比较的元素个数 > 1
- 改变 i i i 边界时,它指向的可能是目标,因此不能 m + 1 m+1 m+1
- 循环内的平均比较次数减少了
- 时间复杂度 Θ ( l o g ( n ) ) \Theta(log(n)) Θ(log(n))
public static int binarySearchBalance(int[] a, int target) {
int i = 0, j = a.length;
while (1 < j - i) {
int m = (i + j) >>> 1;
if (target < a[m]) {
j = m;
} else {
i = m;
}
}
return (a[i] == target) ? i : -1;
}
5. Leftmost 与 Rightmost
有时我们希望返回的是最左侧的重复元素,如果用 Basic 二分查找
-
对于数组 [ 1 , 2 , 3 , 4 , 4 , 5 , 6 , 7 ] [1, 2, 3, 4, 4, 5, 6, 7] [1,2,3,4,4,5,6,7],查找元素4,结果是索引3
-
对于数组 [ 1 , 2 , 4 , 4 , 4 , 5 , 6 , 7 ] [1, 2, 4, 4, 4, 5, 6, 7] [1,2,4,4,4,5,6,7],查找元素4,结果也是索引3,并不是最左侧的元素
public static int binarySearchLeftmost(int[] a, int target) {
int i = 0, j = a.length - 1;
int candidate = -1;
while (i <= j) {
int m = (i + j) >>> 1;
if (target < a[m]) {
j = m - 1;
} else if (a[m] < target) {
i = m + 1;
} else {
candidate = m;
//继续向左
j = m - 1;
}
}
return candidate;
}
如果希望返回的是最右侧元素
public static int binarySearchRightmost(int[] a, int target) {
int i = 0, j = a.length - 1;
int candidate = -1;
while (i <= j) {
int m = (i + j) >>> 1;
if (target < a[m]) {
j = m - 1;
} else if (a[m] < target) {
i = m + 1;
} else {
candidate = m;
//继续向右
i = m + 1;
}
}
return candidate;
}
应用
对于 Leftmost 与 Rightmost,可以返回一个比 -1 更有用的值
Leftmost
- leftmost 返回值的另一层含义: < t a r g e t \lt target <target 的元素个数
- 小于等于中间值,都要向左找
public static int binarySearchLeftmost(int[] a, int target) {
int i = 0, j = a.length - 1;
while (i <= j) {
int m = (i + j) >>> 1;
if (target <= a[m]) {
j = m - 1;
} else {
i = m + 1;
}
}
return i;
}
Rightmost
- 大于等于中间值,都要向右找
public static int binarySearchRightmost(int[] a, int target) {
int i = 0, j = a.length - 1;
while (i <= j) {
int m = (i + j) >>> 1;
if (target < a[m]) {
j = m - 1;
} else {
i = m + 1;
}
}
return i - 1;
}
范围查询:
- 查询 x < 4 x \lt 4 x<4, 0.. l e f t m o s t ( 4 ) − 1 0 .. leftmost(4) - 1 0..leftmost(4)−1
- 查询 x ≤ 4 x \leq 4 x≤4, 0.. r i g h t m o s t ( 4 ) 0 .. rightmost(4) 0..rightmost(4)
- 查询 4 < x 4 \lt x 4<x,$rightmost(4) + 1 … \infty $
- 查询 4 ≤ x 4 \leq x 4≤x, l e f t m o s t ( 4 ) . . ∞ leftmost(4) .. \infty leftmost(4)..∞
- 查询 4 ≤ x ≤ 7 4 \leq x \leq 7 4≤x≤7, l e f t m o s t ( 4 ) . . r i g h t m o s t ( 7 ) leftmost(4) .. rightmost(7) leftmost(4)..rightmost(7)
- 查询 4 < x < 7 4 \lt x \lt 7 4<x<7, r i g h t m o s t ( 4 ) + 1.. l e f t m o s t ( 7 ) − 1 rightmost(4)+1 .. leftmost(7)-1 rightmost(4)+1..leftmost(7)−1
求排名: l e f t m o s t ( t a r g e t ) + 1 leftmost(target) + 1 leftmost(target)+1
- t a r g e t target target 可以不存在,如: l e f t m o s t ( 5 ) + 1 = 6 leftmost(5)+1 = 6 leftmost(5)+1=6
- t a r g e t target target 也可以存在,如: l e f t m o s t ( 4 ) + 1 = 3 leftmost(4)+1 = 3 leftmost(4)+1=3
求前任(predecessor): l e f t m o s t ( t a r g e t ) − 1 leftmost(target) - 1 leftmost(target)−1
- l e f t m o s t ( 3 ) − 1 = 1 leftmost(3) - 1 = 1 leftmost(3)−1=1,前任 a 1 = 2 a_1 = 2 a1=2
- l e f t m o s t ( 4 ) − 1 = 1 leftmost(4) - 1 = 1 leftmost(4)−1=1,前任 a 1 = 2 a_1 = 2 a1=2
求后任(successor): r i g h t m o s t ( t a r g e t ) + 1 rightmost(target)+1 rightmost(target)+1
- r i g h t m o s t ( 5 ) + 1 = 5 rightmost(5) + 1 = 5 rightmost(5)+1=5,后任 a 5 = 7 a_5 = 7 a5=7
- r i g h t m o s t ( 4 ) + 1 = 5 rightmost(4) + 1 = 5 rightmost(4)+1=5,后任 a 5 = 7 a_5 = 7 a5=7
求最近邻居:
- 前任和后任距离更近者
总结
二分查找性能
时间复杂度
- 最坏情况: O ( log n ) O(\log n) O(logn)
- 最好情况:如果待查找元素恰好在数组中央,只需要循环一次 O ( 1 ) O(1) O(1)
空间复杂度
- 需要常数个指针 i , j , m i,j,m i,j,m,因此额外占用的空间是 O ( 1 ) O(1) O(1)