线性搜索、二分搜索、哈希搜索

248 阅读5分钟

搜索算法是计算机科学中用于在数据结构中查找特定元素的算法。这些算法可以应用于数组、列表、树、图等数据结构。搜索算法通常分为两大类:线性搜索二分搜索,以及其他一些基于特定数据结构的搜索算法。

线性搜索(Linear Search)

线性搜索是一种简单直接的搜索方法,也称为顺序搜索,是一种在数据结构(通常是数组或列表)中查找特定值的简单搜索算法。它逐个检查数据结构中的每个元素,直到找到所需的元素或搜索完所有元素为止。

特点

  • 简单性:线性搜索不需要对数据结构进行预处理,如排序,因此实现起来非常简单。
  • 时间复杂度:在最坏的情况下,线性搜索的时间复杂度是O(n),其中n是数据结构中元素的数量。这意味着如果元素位于数据结构的末尾或不存在,搜索所有元素可能需要很长时间。
  • 空间复杂度:线性搜索的空间复杂度是O(1),因为它只需要一个额外的变量来存储当前的索引。

适用场景

  • 当数据结构未排序或不适合使用更高效的搜索算法时。
  • 当数据结构较小或搜索操作不频繁时。
function linearSearch<T>(array: T[], target: T, compare: (a: T, b: T) => boolean): number {
  for (let i = 0; i < array.length; i++) {
    // 比较
    if (compare(array[i], target)) {
      return i; // 找到目标元素的索引
    }
  }
  return -1; // 未找到目标元素
}

// 使用示例
const numbers = [3, 5, 1, 4, 2];
const target = 4;
const index = linearSearch(numbers, target, (a, b) => a === b);
console.log(`Target found at index: ${index}`);

二分搜索(Binary Search)

二分搜索是在有序数组中查找特定元素的一种算法。它通过比较数组中间的元素与目标值来工作,如果目标值等于中间元素,则搜索成功;如果目标值小于中间元素,则在左半部分继续搜索;如果目标值大于中间元素,则在右半部分继续搜索。

工作原理

  1. 初始化:首先,确定数组的中间位置。如果数组长度为奇数,则选择中间的元素;如果数组长度为偶数,则选择中间两个元素中的任意一个。

  2. 比较:将目标值与中间元素进行比较。

  3. 确定搜索范围

    • 如果目标值小于中间元素,搜索范围缩小到数组的左半部分。
    • 如果目标值大于中间元素,搜索范围缩小到数组的右半部分。
    • 如果目标值等于中间元素,搜索成功,返回中间元素的索引。
  4. 递归或迭代:重复上述步骤,直到找到目标值或搜索范围为空。

  5. 搜索结束:如果搜索范围为空,说明目标值不在数组中,返回一个表示未找到的特定值(通常是-1)。

算法特性

  • 时间复杂度:二分搜索的时间复杂度是O(log n),其中n是数组的长度。这是因为每次比较后,搜索范围都会减半。
  • 空间复杂度:二分搜索的空间复杂度取决于实现方式。递归实现的空间复杂度是O(log n),迭代实现的空间复杂度是O(1)
  • 前提条件:数组必须是有序的。对于未排序的数组,需要先进行排序,这可能会影响整体的性能。

优点与局限性

二分搜索在时间和空间方面都有较好的性能。

  • 二分搜索的时间效率高。在大数据量下,对数阶的时间复杂度具有显著优势。例如,当数据大小 𝑛=2^20 时,线性搜索需要 2^20=1048576 轮循环,而二分搜索仅需 log_2(⁡2^20)=20 轮循环。
  • 二分搜索无须额外空间。相较于需要借助额外空间的搜索算法(例如哈希搜索),二分搜索更加节省空间。

然而,二分搜索并非适用于所有情况,主要有以下原因。

  • 二分搜索仅适用于有序数据。若输入数据无序,为了使用二分搜索而专门进行排序,得不偿失。因为排序算法的时间复杂度通常为 𝑂(𝑛log⁡𝑛) ,比线性搜索和二分搜索都更高。对于频繁插入元素的场景,为保持数组有序性,需要将元素插入到特定位置,时间复杂度为 𝑂(𝑛) ,也是非常昂贵的。
  • 二分搜索仅适用于数组。二分搜索需要跳跃式(非连续地)访问元素,而在链表中执行跳跃式访问的效率较低,因此不适合应用在链表或基于链表实现的数据结构。
  • 小数据量下,线性搜索性能更佳。在线性搜索中,每轮只需 1 次判断操作;而在二分搜索中,需要 1 次加法、1 次除法、1 ~ 3 次判断操作、1 次加法(减法),共 4 ~ 6 个单元操作;因此,当数据量 𝑛 较小时,线性搜索反而比二分搜索更快。
function binarySearch<T>(array: T[], target: T): number {
  let start = 0;
  let end = array.length - 1;

  while (start <= end) {
    const mid = Math.floor(start + (end - start) / 2);
    if (array[mid] < target) {
      start = mid + 1
    } else if (array[mid] > target) {
      end = mid - 1;
    } else {
      return mid
    }
  }

  return -1; // 未找到目标元素
}

// 使用示例
const sortedNumbers = [1, 2, 3, 4, 5];
const target = 3;
const index = binarySearch(sortedNumbers, target);
console.log(`Target found at index: ${index}`);

由于 start 和 end 都是 number数字 类型,因此 start + end 可能会超出 number 类型的取值范围。为了避免大数越界,我们通常采用公式 𝑚=⌊start + (end − start) / 2⌋ 来计算中点。

哈希搜索 (Hash search)

哈希搜索是一种利用哈希表进行数据查找的方法。哈希表是一种通过哈希函数将键映射到表中一个位置来访问数据的数据结构,这使得哈希搜索具有很高的效率。

哈希搜索的关键概念

  1. 哈希函数(Hash Function) :我们通常会将单词转化成大数字,把大数字进行哈希化的代码实现放在一个函数中,该函数就称为哈希函数。一个好的哈希函数应尽可能减少冲突,并且分布均匀。
  2. 哈希冲突(Hash Collision) :不同键可能产生相同的哈希值,这种现象称为冲突。解决冲突的常见方法包括链地址法(Chaining)和开放地址法(Open Addressing)。
  3. 链地址法(Chaining) :在哈希表的每个槽(bucket)中存储一个数组或链表,所有映射到该槽的键值对都存储在这里。
  4. 开放地址法(Open Addressing) :当发生冲突时,通过探测序列在哈希表中寻找下一个空闲位置,通常的方法有线性探测二次探测再哈希法
  5. 动态扩容(Dynamic Resizing) :随着元素的增加,哈希表可能会变得过于拥挤,影响性能。动态扩容通过增加哈希表的大小并重新分配现有元素来解决这个问题,根据装填因子的大小来判断是否扩容。
哈希函数简单实现
hashFn(string, limit = 7) {

  // 质数
  const PRIME = 31;

  let hashCode = 0;

  // 霍纳法则计算 hashCode 
  for (let item of string) {
    hashCode = PRIME * hashCode + item.charCodeAt();
  }

  // 取余
  return hashCode % limit;
}

// 链地址法哈希表
class HashTable {
  constructor() {
    this.storage = [] // 哈希表存储数据的变量
    this.count = 0 // 当前存放的元素个数
    this.limit = 7 // 哈希表长度(初始设为质数 7)
  }

  // put(key, value) 往哈希表里添加数据
  put(key, value) {
    // 1、根据 key 获取要映射到 storage 里面的 index(通过哈希函数获取)
    const index = hashFn(key, this.limit)

    // 2、根据 index 取出对应的 bucket
    let bucket = this.storage[index]

    // 3、判断是否存在 bucket
    if (bucket === undefined) {
      bucket = [] // 不存在则创建
      this.storage[index] = bucket
    }

    // 4、判断是插入数据操作还是修改数据操作
    for (let i = 0; i < bucket.length; i++) {
      let tuple = bucket[i] // tuple 的格式:[key, value]
      if (tuple[0] === key) {
        // 如果 key 相等,则修改数据
        tuple[1] = value
        return // 修改完 tuple 里数据,return 终止,不再往下执行。
      }
    }

    // 5、bucket 新增数据
    bucket.push([key, value]) // bucket 存储元组 tuple,格式为 [key, value]
    this.count++

    // 仅做更新,不考虑扩容
    ...
  }

  // 根据 get(key) 获取 value
  get(key) {
    const index = hashFn(key, this.limit)
    const bucket = this.storage[index]

    if (bucket === undefined) {
      return null
    }

    for (const tuple of bucket) {
      if (tuple[0] === key) {
        return tuple[1]
      }
    }
    return null
  }
}

const ht = new HashTable()
ht.put('rose', 10)
ht.put('leopai', 24)
ht.put('james', 23)
ht.put('kobe', 8)

ht.get('kobe') // 10

image.png

对比总结

  • 数据顺序:线性搜索不要求数据有序,二分搜索要求数据有序,哈希搜索也不要求数据有序。
  • 效率:哈希搜索在理想情况下最快(平均时间复杂度为O(1)),二分搜索次之(时间复杂度为O(logn)),线性搜索最慢(时间复杂度为O(n))。
  • 空间使用:线性搜索不需要额外空间,二分搜索的空间复杂度取决于实现(递归或迭代),哈希搜索需要额外空间存储哈希表。
  • 实现复杂度:线性搜索实现最简单,二分搜索和哈希搜索实现相对复杂,尤其是哈希搜索需要处理哈希函数和冲突解决策略。

在选择搜索算法时,需要根据数据的特性、操作的频率以及性能要求来决定使用哪种搜索方法。例如,如果数据量很大且经常变动,可能需要使用哈希搜索;如果数据已经排序且主要是查找操作,二分搜索是一个好选择;而对于小规模或无序数据,线性搜索可能就足够使用。