二分查找,二分边界查找算法,indexOf方法模拟

前言

左边界,右边界认识二分查找

二分查找作为程序员的一项基本技能,是面试官最常使用来考察程序员基本素质的算法之一,也是解决很多查找类题目的常用方法,它可以达到 O(log n) 的时间复杂度。

一般而言,当一个题目出现以下特性时,你就应该立即联想到它可能需要使用二分查找:

  1. 待查找的数组有序或者部分有序
  2. 要求时间复杂度低于O(n),或者直接要求时间复杂度为O(log n)

二分查找有很多种变体,使用时需要注意查找条件,判断条件和左右边界的更新方式,三者配合不好就很容易出现死循环或者遗漏区域,本篇中我们将介绍常见的几种查找方式的模板代码,包括:

  1. 标准的二分查找
  2. 二分查找左边界
  3. 二分查找右边界

本文的内容来自于笔者个人的总结,事实上二分查找有很多种等价的写法,本文只是列出了笔者认为的最容易理解和记忆的方法。

标准二分查找

首先给出标准二分查找的模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function search(arr, target) {
var left = 0;
var right = arr.length - 1;
while(left <= right) {
var mid = left + ((right - left) >> 1);
if(arr[mid] == target) {
return mid;
} else if(arr[mid] > target) {
right = mid - 1;
} else {
left = mid + 1;
}
}
return -1;
}

var index=search([1, 2, 3, 4, 5,6], 5);
console.log(index)

  • 循环条件: left <= right
  • 中间位置计算: mid = left + ((right -left) >> 1)
  • 左边界更新:left = mid + 1
  • 右边界更新: right = mid - 1
  • 返回值: mid / -1

这里有几点需要注意:

  1. 我们的循环条件中包含了 left == right的情况,则我们必须在每次循环中改变 leftright的指向,以防止进入死循环
  2. 循环终止的条件包括:

    • 找到了目标值
    • left > right (这种情况发生于当left, mid, right指向同一个数时,这个数还不是目标值,则整个查找结束。)

3.left + ((right -left) >> 1) 其实和 (left + right) / 2是等价的,这样写的目的一个是为了防止 (left + right)出现溢出,一个是用右移操作替代除法提升性能。
4.left + ((right -left) >> 1) 对于目标区域长度为奇数而言,是处于正中间的,对于长度为偶数而言,是中间偏左的。因此左右边界相遇时,只会是以下两种情况:

  • left/mid , right (left, mid 指向同一个数,right指向它的下一个数)
  • left/mid/right (left, mid, right 指向同一个数)

即因为mid对于长度为偶数的区间总是偏左的,所以当区间长度小于等于2时,mid 总是和 left在同一侧。

二分查找左边界

既然要寻找左边界,搜索范围就需要从右边开始,不断往左边收缩,也就是说即使我们找到了nums[mid] == target, 这个mid的位置也不一定就是最左侧的那个边界,我们还是要向左侧查找,所以我们在nums[mid]偏大或者nums[mid]就等于目标值的时候,继续收缩右边界,算法模板如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function search(arr, target) {
var left = 0;
var right = arr.length - 1;
while(left < right) {
var mid = left + ((right - left) >> 1);
if(arr[mid] < target) {
left = mid + 1;
} else {
right = mid;
}
}
console.log(left,right,mid)
return arr[left] == target ? left : -1;
}

var index = search([1, 2, 3, 4, 5, 5, 5, 5, 5, 5, 6], 5);
console.log(index)
  • 循环条件: left < right
  • 中间位置计算: mid = left + ((right -left) >> 1)
  • 左边界更新:left = mid + 1
  • 右边界更新: right = mid
  • 返回值: nums[left] == target ? left : -1

与标准的二分查找不同:

首先,这里的右边界的更新是right = mid,因为我们需要在找到目标值后,继续向左寻找左边界。

其次,这里的循环条件是left < right
因为在最后leftright相邻的时候,midleft处于相同的位置(前面说过,mid偏左),则下一步,无论怎样,left, mid, right都将指向同一个位置,如果此时循环的条件是left <= right,则我们需要再进入一遍循环,此时,如果nums[mid] < target还好说,循环正常终止;否则,我们会令right = mid,这样并没有改变left,mid,right的位置,将进入死循环。

事实上,我们只需要遍历到leftright相邻的情况就行了,因为这一轮循环后,无论怎样,left,mid,right都会指向同一个位置,而如果这个位置的值等于目标值,则它就一定是最左侧的目标值;如果不等于目标值,则说明没有找到目标值,这也就是为什么返回值是nums[left] == target ? left : -1

二分查找右边界

有了寻找左边界的分析之后,再来看寻找右边界就容易很多了,毕竟左右两种情况是对称的嘛,关于使用场景这里就不再赘述了,大家对称着理解就好。我们直接给出模板代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function search(arr, target) {
var left = 0;
var right = arr.length - 1;
while(left < right) {
var mid = left + ((right - left) >> 1) + 1;
if(arr[mid] > target) {
right = mid - 1;
} else {
left = mid;
}
}
console.log(left, right, mid)
return arr[right] == target ? right : -1;
}

var index = search([1, 2, 3, 4, 5, 5, 5, 5, 5, 5, 6], 5);
console.log(index)
  • 循环条件: left < right
  • 中间位置计算: mid = left + ((right -left) >> 1) + 1
  • 左边界更新:left = mid
  • 右边界更新: right = mid - 1
  • 返回值: nums[left] == target ? left : -1

这里大部分和寻找左边界是对称着来写的,唯独有一点需要尤其注意——中间位置的计算变了,我们在末尾多加了1。这样,无论对于奇数还是偶数,这个中间的位置都是偏右的。

对于这个操作的理解,从对称的角度看,寻找左边界的时候,中间位置是偏左的,那寻找右边界的时候,中间位置就应该偏右呗,但是这显然不是根本原因。根本原因是,在最后leftright相邻时,如果mid偏左,则left, mid指向同一个位置,right指向它们的下一个位置,在nums[left]已经等于目标值的情况下,这三个位置的值都不会更新,从而进入了死循环。所以我们应该让mid偏右,这样left就能向右移动。这也就是为什么我们之前一直强调查找条件,判断条件和左右边界的更新方式三者之间需要配合使用。

右边界的查找一般来说不会单独使用,如有需要,一般是需要同时查找左右边界。

二分查找左右边界

前面我们介绍了左边界和右边界的查找,那么查找左右边界就容易很多了——只要分别查找左边界和右边界就行了。

二分查找极值

二分查找还有一种有趣的变体是二分查找极值点,之前我们使用nums[mid]去比较的时候,常常是和给定的目标值target比,或者和左右边界比较,在二分查找极值点的应用中,我们是和相邻元素去比,以完成某种单调性的检测。

二分查找、二分边界查找算法的模板代码总结

idnexOf模拟

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var a='abcdefghijkl'
var b='ghi'
var aL=a.length;
var bL=b.length;

function indexOf(str,item){
for(var i=0;i<=aL-bL;i++){
if(str.slice(i,bL+i)==item){
return i
}
}
}

console.log(indexOf(a,b))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var a = 'abcdefghijkl'
var b = 'ghi'

function indexOf(s,sub) {
var sIndex=0,subIndex=0,sLength=s.length,subLength=sub.length;
while(sIndex < sLength && subIndex < subLength) {
if(s[sIndex] == sub[subIndex]) {
subIndex++;
sIndex++;
} else {
sIndex = sIndex - subIndex + 1;
subIndex = 0;
}
}

var index = subIndex == subLength ? (subLength > 1 ? sIndex - subLength : sIndex - 1) : -1;
return index;
}

console.log(indexOf(a,b))
1
2
3
4
5
6
7
8
9
function idnexOf(a,b){
var reg=new RegExp(`${b}`,'gi');
var c=reg.exec(a);
console.log(c ? c.index : -1)
}

var a = 'abcdefghijkl'
var b = 'jkl'
idnexOf(a,b)

输出所有的位置


大数据小内存排序,小内存大数据排序

有一个队列结构(FIFO),如何对里面的元素进行从小到大排序,元素可以多次进出,而且允许有一个额外空间能暂存一个元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* 只能使用 unshift\pop
* 只有 x 用于暂存元素。
*/
// 去队列顶部元素
function peak(list) {
return list[list.length - 1]
}
function sort(list) {
const len = list.length
let x = list.pop()

for(let i = 0; i < len - 1; i++) {
for (let j = 0; j < len - i - 1; j++) {
if (x > peak(list)) {
list.unshift(x)
x = list.pop()
} else {
list.unshift(list.pop())
}
}
list.unshift(x)
for (let k = 0; k < i; k++) {
list.unshift(list.pop())
}
if(i !== len - 2) x = list.pop()
}
return list
}
const input = [1, 12, 45, 4, 5, 7, 1, 2, 9, 6]
sort(input) // [ 1, 1, 2, 4, 5, 6, 7, 9, 12, 45 ]

嵌套两个循环,如果以代码中 x > peak(list) 比较作为最耗时的步骤,假设队列长度为 n,那么总共需要做 (n - 1) * n / 2 次比较。可见其时间复杂度为 O(n2)。