搜索算法

这里讨论下在列表中搜寻指定目标值的算法。

线性搜索(顺序搜索)

最差也是最简单的算法,按顺序从列表第一个元素开始查找比较,直到找到匹配项为止。

该算法在最坏的情况下时间复杂度为: $O(n)$,在假设列表每个元素匹配概率相等时,平均查找长度 ASL 为 $(n+1)/2$。

function linearSearch(list, matchVal) {
  for (let i = 0; i < list.length; i++) {
    if (list[i] === matchVal) return i
  }
}

二分搜索(折半搜索)

Notice

二分搜索的前提条件是列表必须为有序列表,因此在搜索前需要对列表进行排序。

先将需要匹配的值 k 与列表中间的数相比较(如果列表长度为偶数,则选择下标较小的元素作为中间元素),中间元素将列表分成两个子列表,然后根据 k 与中间元素的比较的结果来选择对哪个子列表进行二分搜索。

使用递归调用:

function binarySearch(list, matchVal, start, end) {
  if (start > end) return -1
  start = start || 0
  end = end || list.length
  const midIndex = Math.floor((start + end) / 2)
  const midVal = list[midIndex]
  if (midVal === matchVal) {
    return midIndex
  } else if (midVal < matchVal) {
    start = midIndex + 1
  } else {
    end = midIndex - 1
  }
  return binarySearch(list, matchVal, start, end)
}

推荐使用 while 循环方式:

function binarySearch(list, matchVal) {
  let start = 0
  let end = list.length - 1
  while (start <= end) {
    const midIndex = Math.floor((start + end) / 2)
    const mid = list[midIndex]
    if (matchVal === mid) {
      return midIndex
    } else if (matchVal < mid) {
      end = midIndex - 1
    } else {
      start = mid + 1
    }
  }
  return void 0
}

二分搜索算法的时间复杂度为 $O(log n)$,在最坏的情况下需要进行 $log_2 {n + 1}$ 次步骤后得到结果。

时间复杂度推算步骤

设列表长度为 $n$,$f(n)$ 为得出结果所需步骤数。

第一次拆分:$f(n) = 1 + f(n/2)$。

第 s 次拆分:$f(n) = s + f(n/(s^2))$。

假如在第 s 次拆分之后,只剩下一个元素需要比较 $f(n) = s + f(1)$。

也就是 $n/{(s^2)} = 1$,$n = s^2$,$s = log2(n)$。

插值搜索

插值搜索基于二分搜索,也同样需要有序数组,对于插值搜索来说,比较适用于分布均匀的有序数组

Notice

分部均匀可以理解为数组相邻元素之间的差值是一样或相近的。

在二分搜素中,对于中间点的计算如下:

midIndex = start + (start + end) / 2

而插值搜索改进了这个计算为:

midIndex = start + (matchVal - list[start]) / (list[end] - list[start]) * (end - start)

计算过程中依赖于列表两端的值,因此才适用于搜索均匀分布列表。

function insertionSearch(list, matchVal) {
  let start = 0
  let end = list.length - 1
  while (start <= end) {
    const midIndex = start + ((matchVal - list[start]) / (list[end] - list[start])) * (end - start)
    const mid = list[midIndex]
    if (mid === matchVal) return midIndex
    if (mid > matchVal) {
      end = midIndex - 1
    } else {
      start = midIndex + 1
    }
  }
  return void 0
}

插值搜索的时间复杂度为 $O(log(log n))$,在最坏情况下为 $O(n)$。

Notice

在数据量较大的情况下,插值搜索比二分搜索更快,但若该有序列表并不是均匀分布的,那情况可能会相反。

跳跃搜索(块搜索)

跳跃搜索类似于二分搜索,因此同样要求列表是有序的。

跳跃搜索会预先设置一个步长 gap,通过每次跳过一定个数的元素来检查更少的元素,这比线性搜索要效率更高。

gap 步长是需要进行计算的,如果数组长度为 n,那么在最坏的情况下需要进行 n/gap 次跳跃,如果在最后一次比较中,匹配元素值小于最后一个值,那么就要跳回前一个值进行 gap-1 次线性搜索,那么什么时候 n/gap + gap - 1 将取得最小值呢?在 gap 为 $\sqrt{n}$ 的时候,举个例子:

有如下长度为 10 的列表,我们想要搜索的元素是 128:

list = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512]

跳跃搜索会将步长置为 Math.floor(Math.sqrt(10)) = 3。

将索引从 0 置为 3,再从 3 置为 6,再从 6 置为 9,这时发现 512 大于 128,因此索引从 9 跳回 6,开始进行线性搜索,进行两次对比后找到了 256。

function jumpSearch(list, matchVal) {
  const len = list.length
  const gap = Math.floor(Math.sqrt(len))
  let start = 0
  let end = gap
  while (list[Math.min(end, len) - 1] < matchVal) {
    start = end
    end += gap
    if (start > len) return false // 下一个起始索引已经超出数组,证明没有找到元素
  }
  let currentIndex = start
  while (currentIndex < Math.min(end, len)) {
    if (list[currentIndex] === matchVal) return currentIndex
    currentIndex++
  }
  return false
}

Notice

跳跃搜索的时间复杂度介于二分搜索和线性搜索之间,但如果我们要搜索的元素是很小或者是最小的元素,跳跃搜索的成本要比二分搜索小得多。

算法对比表

算法平均时间复杂度最坏时间复杂度最优时间复杂度空间复杂度前置条件
线性搜索(顺序搜索)$O(n/2)$$O(n)$$O(1)$$O(1)$
二分搜索(折半搜索)$O(log n)$$O(log n)$$O(1)$迭代:$O(1)$,递归:$O(log n)$数组有序
插值搜索$O(log(log n))$$O(n)$$O(1)$$O(1)$数组有序且均匀分布
跳跃搜索(块搜索)$O(\sqrt n)$$O(\sqrt n)$$O(1)$$O(1)$数组有序

参考文章