算法-搜索-二分搜索

404 阅读7分钟

搜索区间

[startIndex,endIndex)

image-20201205210737204

const nums = [3, 31, 4, 9];
for (let i = 0; i < nums.length; i++) {
  const element = nums[i];
  console.log(`==============>element`);
  console.log(element);
}

上面我们可以很容易看出i变化范围应该是:i=0i < nums.length

用数学符号表示就应该是

[0,nums.length)

这里左边是[闭区间,因为我们确实是用到0了,很好理解。

右边是 )是开区间,因为如果i == nums.length访问数组,即nums[nums.length]一定会数组越界的。

搜索区间定义

现在我们引入一个搜索区间的概念:下标i遍历完整个数组时下标i变化范围用数学符号中的开闭区间来表示,称为搜索区间。

我自己瞎定义的

假设,我们把i的搜索区间范围中的起点设为startIndex,终点设为endIndex

根据开闭区间组合,就有以下四种类型的搜索区间:

  1. [startIndex,endIndex]
  2. [startIndex,endIndex)
  3. (startIndex,endIndex]
  4. (startIndex,endIndex)

在上面我们最普通的遍历方式,使用到了[startIndex,endIndex)这种搜索区间,为了更加理解,我们把剩下三种搜索区间的遍历方式也写一下

[startIndex,endIndex]

[startIndex,endIndex)中,我们讲到的 [0,nums.lenght)

  • let i = 0,起点
  • i < nusm.length,终点
  • =表示[闭区间

  • <表示)区间

我们现在要完成[startIndex,endIndex]就应该是

  • let i = 0
  • i <= nums.length ?
// 错误写法
const nums = [3, 31, 4, 9];
for (let i = 0; i <= nums.length; i++) {
  const element = nums[i];
  console.log(`==============>element`);
  console.log(element);
}

这个使用当 i == nums.length时,是会进入遍历的,即存在访问nums[nums.length]的情况,而这种情况会导致数组访问越界。为了避免这种情况,我们还要把i <= nums.length修改成i <= nums.length -1

这样i的最大值就是i == nums.length -1 ,即把nums[nums.length] 这种情况,转换成了nums[nums.length -1 ]

[startIndex,endIndex]的写法应该就是

  • let i = 0
  • i <= nums.length -1
// 正确
const nums = [3, 31, 4, 9];
for (let i = 0; i <= nums.length - 1; i++) {
  const element = nums[i];
  console.log(`==============>element`);
  console.log(element);
}

(startIndex,endIndex]

const nums = [3, 31, 4, 9];
for (let i = nums.length - 1; i >= 0; i--) {
  const element = nums[i];
  console.log(`==============>element`);
  console.log(element);
}

这里我们采用从后面往前遍历,是因为我们总喜欢把[闭区间的值作为第一个来遍历,不然太麻烦了

这种在防止数组塌陷等问题还是经常用到的,即从后面开始遍历

(startIndex,endIndex)

const nums = [3, 31, 4, 9];
for (let i = nums.length - 1; i > -1; i--) {
  const element = nums[i];
  console.log(`==============>element`);
  console.log(element);
}

很少,遍界值一遍要通过step步伐值来确定,这里的step就是1

[)[]总结

我们在二分搜索时谈论的一般都是这两个区间,我们来总结以下这两者的差异

// [startIndex,endIndex)
const nums = [3, 31, 4, 9];
for (let i = 0; i < nums.length; i++) {
  const element = nums[i];
  console.log(`==============>element`);
  console.log(element);
}

// [startIndex,endIndex]
const nums = [3, 31, 4, 9];
for (let i = 0; i <= nums.length - 1; i++) {
  const element = nums[i];
  console.log(`==============>element`);
  console.log(element);
}

都是从let i = 0开始,所以都是[

为了满足),只需要i < nums.length即可

为了满足]i就要满足i <= nums.length -1

因为要保证数组访问不越界,所以要 减一

while循环

[)

我们在使用for循环的时候,遍历数组使用的标志是使用let i = 0来返回的,而使用while循环的时候,我们会先定义一个搜索区间,接只使用搜索区间的第一个值来读取数组的值,比如

  1. 定义搜索区间
let startIndex = 0;
let endIndex = nums.length;
  1. 通过startIndex访问数组
  2. startIndex自行递增

最终代码如下:

// [startIndex,endIndex)
const nums = [3, 31, 4, 9];
for (let i = 0; i < nums.length; i++) {
  const element = nums[i];
  console.log(`==============>element`);
  console.log(element);
}

let startIndex = 0;
let endIndex = nums.length;
while (startIndex < endIndex) {
  const element = nums[startIndex];
  console.log(`==============>element`);
  console.log(element);
  startIndex++;
}

这里的知识点主要在第11行的 while (startIndex < endIndex)这个判断条件上。

有了上面搜索区间的铺垫,当我们看到

let startIndex = 0;
let endIndex = nums.length;

里面想到的是[startIndex, endIndex)这种左闭右开的搜索区间,那么遍历的条件应该就是i < nums.length,而此时的i已经用startIndex来表示,而num.length也用endIndex来表示了

所以就有while (startIndex < endIndex)作为循环条件了。

[]

同理,当我们定义了

let startIndex = 0;
let endIndex = nums.length - 1;

就会里面想到[startIndex,endIndex]双闭区间,在for中的循环条件就应该是

let i = 0 ; i <= nums.length - 1; i++

所以这里转换过来就是

while(startIndex <= endIndex)

最终代码如下

// [startIndex,endIndex]
const nums = [3, 31, 4, 9];
for (let i = 0; i <= nums.length - 1; i++) {
  const element = nums[i];
  console.log(`==============>element`);
  console.log(element);
}

let startIndex = 0;
let endIndex = nums.length - 1;
while (startIndex <= endIndex) {
  const element = nums[startIndex];
  console.log(`==============>element`);
  console.log(element);
  startIndex++;
}

二分搜索(Binary Search)

下面复制于 Binary Search

In computer science, binary search, also known as half-interval search, logarithmic search, or binary chop, is a search algorithm that finds the position of a target value within a sorted array. Binary search compares the target value to the middle element of the array; if they are unequal, the half in which the target cannot lie is eliminated and the search continues on the remaining half until it is successful. If the search ends with the remaining half being empty, the target is not in the array.

在计算机科学中,二分搜索又称半区间搜索、对数搜索或二进制斩,是一种在排序数组中寻找目标值位置的搜索算法。二进制搜索将目标值与数组中的中间元素进行比较,如果它们不相等,则剔除目标不能所在的一半,继续对剩余的一半进行搜索,直到搜索成功。如果搜索结束,剩余一半为空,则说明目标不在数组中。

中文系机器翻译。

Binary Search

Complexity

Time Complexity: O(log(n)) - since we split search area by two for every next iteration.

代码

export default function binarySearch(
  sortedArray: number[],
  seekElement: number
): number {
  // These two indices will contain current array (sub-array) boundaries.
  let startIndex = 0;
  let endIndex = sortedArray.length - 1;

  // Let's continue to split array until boundaries are collapsed
  // and there is nothing to split anymore.
  while (startIndex <= endIndex) {
    // Let's calculate the index of the middle element.
    const middleIndex = startIndex + Math.floor((endIndex - startIndex) / 2);

    // If we've found the element just return its position.
    if (sortedArray[middleIndex] === seekElement) {
      return middleIndex;
    }

    // Decide which half to choose for seeking next: left or right one.
    if (sortedArray[middleIndex] < seekElement) {
      // Go to the right half of the array.
      startIndex = middleIndex + 1;
    } else {
      // Go to the left half of the array.
      endIndex = middleIndex - 1;
    }
  }

  // Return -1 if we have not found anything.
  return -1;
}

binarySearch的简化版

解析

循环条件

首先,我们不管二分搜索里面的算法如何,既然代码中使用了``startIndexendIndex`来使用数组,我们就要确保两件事

  • 保证不会访问数组越界
  • 保证每个元素都能被遍历到

于是如果我们这般定义

let startIndex = 0;
let endIndex = sortedArray.length - 1;

我们就立马想到了[]这种双闭的搜索区间,那么while的循环条件一定是

while(startIndex <= endIndex)

不知道为啥的,再往上翻翻吧。。。。

那假设我们这么定义呢?

let startIndex = 0;
let endIndex = sortedArray.length;

搜索区间就应该是[),那么while的循环条件一定是

while(startIndex < endIndex)

区间变化

// If we've found the element just return its position.
if (sortedArray[middleIndex] === seekElement) {
  return middleIndex;
}

// Decide which half to choose for seeking next: left or right one.
if (sortedArray[middleIndex] < seekElement) {
  // Go to the right half of the array.
  startIndex = middleIndex + 1;
} else {
  // Go to the left half of the array.
  endIndex = middleIndex - 1;
}

这里的startIndexendIndex的变化为啥需要加一减一呢?

首先,根据

let startIndex = 0;
let endIndex = sortedArray.length - 1;

我们知道他的搜索区间应该是[]双闭合的。

然后,我们根据前面的判断

// If we've found the element just return its position.
if (sortedArray[middleIndex] === seekElement) {
  return middleIndex;
}

知道下标middleIndex我们已经判断过了,这个值是已使用且不再需要的。

那么,我们下次开始如果是

startIndex = middleIndex

根据我们的[]双闭合区间,就会再次使用到startIndex即使用到这个这次循环已经判断过的midddleIndex值了。

同理可得endIndex = middleIndex - 1

那这个时候,如果我们的算法一开始是

let startIndex = 0;
let endIndex = sortedArray.length;

那搜索区间就应该是[),前面的startIndex是一样的,为了避免不重复使用middleIndex,我们应该使得

startIndex = middleIndex + 1;

但是endIndex因为是)开区间,我们就算使用过了middleIndex了,但是因为开区间的性质,我们是可以直接放过去的,且必须是不能减一的,就有

endIndex = middleIndex;

因为如果减一的话

endIndex = middleIndex - 1 ;
  1. middleIndex - 1这个值我们没用过
  2. )开区间的性质,导致我们没法使用到这个值

[)二分搜索

export default function binarySearch(
  sortedArray: number[],
  seekElement: number
): number {
  // These two indices will contain current array (sub-array) boundaries.
  let startIndex = 0;
-  let endIndex = sortedArray.length - 1;
+	 let endIndex = sortedArray.length;

  // Let's continue to split array until boundaries are collapsed
  // and there is nothing to split anymore.
-  while (startIndex <= endIndex) {
+	 while (startIndex < endIndex) {
    // Let's calculate the index of the middle element.
    const middleIndex = startIndex + Math.floor((endIndex - startIndex) / 2);

    // If we've found the element just return its position.
    if (sortedArray[middleIndex] === seekElement) {
      return middleIndex;
    }

    // Decide which half to choose for seeking next: left or right one.
    if (sortedArray[middleIndex] < seekElement) {
      // Go to the right half of the array.
      startIndex = middleIndex + 1;
    } else {
      // Go to the left half of the array.
-      endIndex = middleIndex - 1;
+			 endIndex = middleIndex;
    }
  }

  // Return -1 if we have not found anything.
  return -1;
}

这里我们发现 条件

  1. if (sortedArray[middleIndex] === seekElement)
  2. if (sortedArray[middleIndex] > seekElement)

前者处理方式是

return middleIndex;

后者的处理方式是

endIndex = middleIndex;

最后返回的是middleIndex,而endIndex又会等于middleIndex,那如果这个值被找到,最后一定会有endIndex == middleIndex这个条件成立,所以我们可以直接返回endIndex, 即

export default function binarySearch(
sortedArray: number[],
 seekElement: number
): number {
  let startIndex = 0;
  let endIndex = sortedArray.length;

  while (startIndex < endIndex) {
    const middleIndex = startIndex + Math.floor((endIndex - startIndex) / 2);

    if (sortedArray[middleIndex] < seekElement) {
      startIndex = middleIndex + 1;
    } else {
      // 包含了 >= 两种情况
      endIndex = middleIndex;
    }
  }

  // 如果搜索不到
  if (endIndex === sortedArray.length || sortedArray[endIndex] !== target) {
    return -1;
  }

  return endIndex;
}

这里有个额外的逻辑处理,就是找不到的情况:

我们知道最后返回的是endIndex,实际上就是middleIndex这个值

而这个值的搜索区间就是[startIndex,endIndex)

所以如果搜索不到的情况就会有

这个目标值小于传递进来的数组任一元素,则返回0

这个目标值大于传递进来的数组中任一元素,则返回sortedArray.length

所以最后应该加一个判断

if (endIndex === sortedArray.length || sortedArray[endIndex] !== target) {
  return -1;
}
return endIndex;

值边界

左侧边界

现在我们写了两个版本的二分

  • []
const arr = [3, 9, 9, 9, 9, 9, 31, 45];
const target = 9;

export default function binarySearch(
  sortedArray: number[],
  seekElement: number
): number {
  let startIndex = 0;
  let endIndex = sortedArray.length - 1;

  while (startIndex <= endIndex) {
    const middleIndex = startIndex + Math.floor((endIndex - startIndex) / 2);

    if (sortedArray[middleIndex] === seekElement) {
      return middleIndex;
    }

    if (sortedArray[middleIndex] < seekElement) {
      startIndex = middleIndex + 1;
    } else {
      endIndex = middleIndex - 1;
    }
  }

  return -1;
}
const ret = binarySearch(arr, target);
console.log(`==============>ret`);
console.log(ret); // 3

  • [)
const arr = [3, 9, 9, 9, 9, 9, 31, 45];
const target = 9;

export default function binarySearch(
  sortedArray: number[],
  seekElement: number
): number {
  let startIndex = 0;
  let endIndex = sortedArray.length;

  while (startIndex < endIndex) {
    const middleIndex = startIndex + Math.floor((endIndex - startIndex) / 2);

    if (sortedArray[middleIndex] < seekElement) {
      startIndex = middleIndex + 1;
    } else {
      // 包含了 >= 两种情况
      endIndex = middleIndex;
    }
  }

  if (endIndex == sortedArray.length || sortedArray[endIndex] !== target) {
    return -1;
  }
  return endIndex;
}
const ret = binarySearch(arr, target);
console.log(`==============>ret`);
console.log(ret); // 1

我们发现两种写法,在一个有序数据中有重复元素时,返回的值是不一样的

[]这种返回3还是挺好理解的,因为mid是一个中间的数,所以很容易在一群9中间,就被命中了,一旦命中了就返回,所以会返回一个比较中间的数

[)这种返回的却是一第一个9的下标,那是当mid命中的时候,while还在继续

因为

  1. 数组有序(升序)
  2. mid一直在变小

后续的sortedArray[middleIndex]一定是在变小或者不变的,即命中后,sortedArray[middleIndex]只能是小于或等于目标值,这样就会不断触发startIndex往右靠,且endIndex也会往左靠,又因为找到的终止条件是startIndex === endIndex,所以就会出先endIndex在符合条件的最左边等着startIndex过来的情况。

右侧边界

既然歪打正着有这种左侧边界的值,我们能不能反向利用,写一个获取右侧边界的值呢?

在上面我们说过命中后

  1. startInex向右走
  2. endIndex向左走

最后就会出现sortedArray[startIndex]sortedArray[endIndex]都命中的情况,就会一直触发

if(sortedArray[middleIndex] === seekElement){
  endIndex = middleIndex;
}

因为一直触发的阶段时,就可以假定startIndex是不变的了,那么依赖于midendIndex只能是不断变小了,所以就走到左侧了。

那我们要获取右侧的值,我们就要在这个最后一个阶段,即不断触发时,做文章

要让mid变大,只有让starIndex或者endIndex变大,如果endIndex变大,就会有可能导致startIndex一直追不上endIndex导致无法终止,所以我们的思路应该是让startIndex变大

if (sortedArray[middleIndex] === seekElement) {
  startIndex = middleIndex + 1;
}

所以最后startIndex就会在命中值的右侧,我们只要减个1就能得到右侧边界了

export default function binarySearch(
sortedArray: number[],
 seekElement: number
): number {
  let startIndex = 0;
  let endIndex = sortedArray.length;

  while (startIndex < endIndex) {
    const middleIndex = startIndex + Math.floor((endIndex - startIndex) / 2);

    if (sortedArray[middleIndex] < seekElement) {
      startIndex = middleIndex + 1;
    } else if (sortedArray[middleIndex] > seekElement) {
      endIndex = middleIndex;
    } else if (sortedArray[middleIndex] === seekElement) {
      startIndex = middleIndex + 1;
    }
  }

  if (sortedArray[startIndex - 1] !== target) return -1;
  return startIndex - 1;
}

因为mid的变化范围一定是搜索区间的范围,在这里就是 [)

那现在这里startIndex的范围应该就会因为startIndex = middleIndex + 1变为 [],所以

  • 最小值是0

  • 最大值是sortedArray.length