查找算法详解

16 阅读16分钟

1. 线性查找

思想

从数据结构的一端开始,按顺序遍历每个元素,直到找到目标或遍历完所有元素。适用于无序有序的任意序列。

步骤

  1. 从第一个元素开始
  2. 将当前元素与目标值比较
  3. 若相等则返回当前下标,结束
  4. 否则移动到下一个元素,重复步骤 2–3
  5. 若遍历结束仍未找到,返回 -1

Kotlin 实现

/**
 * 线性查找
 * @param arr 待查找数组
 * @param target 目标值
 * @return 目标下标,未找到返回 -1
 */
fun linearSearch(arr: IntArray, target: Int): Int {
    for (i in arr.indices) {
        if (arr[i] == target) return i
    }
    return -1
}

时间复杂度与空间复杂度

维度复杂度说明
时间复杂度O(n)最坏遍历整个表
空间复杂度O(1)仅用少量变量

优化空间

  • 哨兵:在表尾追加 target,循环只做比较、不必判越界,减少分支,常数优化。
  • 有序表提前终止:若表有序且 arr[i] > target 可立即返回 -1。
  • 适用场景:无序或小规模时用线性;大规模无序用哈希,有序用二分。

哨兵写法示例(需可写副本时使用;会临时修改数组末尾,调用方应传入副本或保证可修改):

fun linearSearchWithSentinel(arr: IntArray, target: Int): Int {
    val n = arr.size
    if (n == 0) return -1
    val last = arr[n - 1]
    arr[n - 1] = target  // 暂存原末尾,用 target 当哨兵
    var i = 0
    while (arr[i] != target) { i++ }
    arr[n - 1] = last     // 恢复
    return if (i < n - 1 || last == target) i else -1
}

2. 二分查找

思想

有序数组中,每次与中间元素比较,根据比较结果缩小一半查找范围,直到找到或区间为空。前提是数组有序

步骤

  1. 设左边界 left = 0,右边界 right = arr.size - 1
  2. left > right,则未找到,返回 -1
  3. 计算中点 mid(实现时用 left + (right - left) / 2 防溢出)
  4. arr[mid] == target,返回 mid
  5. arr[mid] > target,在左半部分查找:right = mid - 1,回到步骤 2
  6. arr[mid] < target,在右半部分查找:left = mid + 1,回到步骤 2

Kotlin 实现

/**
 * 二分查找(迭代版)
 * 要求数组有序
 */
fun binarySearch(arr: IntArray, target: Int): Int {
    var left = 0
    var right = arr.size - 1
    while (left <= right) {
        val mid = left + (right - left) / 2  // 防溢出
        if (arr[mid] == target) return mid
        if (arr[mid] > target) { right = mid - 1 } else { left = mid + 1 }
    }
    return -1
}

/**
 * 二分查找(递归版)
 */
fun binarySearchRecursive(arr: IntArray, target: Int, left: Int = 0, right: Int = arr.size - 1): Int {
    if (left > right) return -1
    val mid = left + (right - left) / 2
    if (arr[mid] == target) return mid
    if (arr[mid] > target) return binarySearchRecursive(arr, target, left, mid - 1)
    return binarySearchRecursive(arr, target, mid + 1, right)
}

时间复杂度与空间复杂度

维度复杂度说明
时间复杂度O(log n)每次折半
空间复杂度O(1),递归 O(log n)迭代 O(1),递归为栈空间

优化空间

  • 防溢出:用 mid = left + (right - left) / 2 代替 (left + right) / 2
  • 避免重复比较:若需找「第一个 ≥ target」等边界,可先缩区间再判相等,减少分支。
  • 递归改迭代:面试/工程更常用迭代,避免栈溢出。

常见变形:下界查找(第一个 ≥ target 的下标,常考):

/** 返回第一个 >= target 的下标,若均小于 target 则返回 arr.size */
fun lowerBound(arr: IntArray, target: Int): Int {
    var left = 0
    var right = arr.size
    while (left < right) {
        val mid = left + (right - left) / 2
        if (arr[mid] < target) left = mid + 1 else right = mid
    }
    return left
}

/** 返回第一个 > target 的下标。等值区间为 [lowerBound, upperBound)。 */
fun upperBound(arr: IntArray, target: Int): Int {
    var left = 0
    var right = arr.size
    while (left < right) {
        val mid = left + (right - left) / 2
        if (arr[mid] <= target) left = mid + 1 else right = mid
    }
    return left
}

3. 分块查找

思想

索引顺序查找:将表分成若干块,块内元素可无序,块间有序(第 i 块的最大值 < 第 i+1 块的最小值)。先查索引表确定目标所在块,再在该块内做顺序查找。是顺序查找与二分思想之间的折中,数据结构课常考。

前提提醒:必须保证“块间有序”(例如第 i 块最大值 < 第 i+1 块最小值),否则用索引表定位块可能出错。

步骤

  1. 建立索引:每块记录该块的最大关键字(起始下标可由块号 × 块长得到,故索引表通常只存每块最大值)
  2. 在索引表中确定 target 所在块:找到第一个「块最大值 ≥ target」的块
  3. 在该块对应的区间内进行顺序查找
  4. 找到返回下标,否则返回 -1

Kotlin 实现

/**
 * 分块查找:索引表 + 块内顺序查找
 * 约定:块间有序(块 i 最大值 < 块 i+1 最小值),块内无序
 * @param arr 原数组
 * @param blockSize 每块大小
 * @param indexMax 索引表,存每块的最大值
 */
fun blockSearch(
    arr: IntArray,
    target: Int,
    blockSize: Int = 3,
    indexMax: IntArray? = null  // 不传则内部自动建索引
): Int {
    require(blockSize > 0) { "blockSize must be positive" }
    val index = indexMax ?: buildBlockIndex(arr, blockSize)
    // 1. 在索引表中确定块
    var block = -1
    for (i in index.indices) {
        if (target <= index[i]) {
            block = i
            break
        }
    }
    if (block == -1) return -1
    // 2. 块内顺序查找
    val start = block * blockSize
    val end = minOf(start + blockSize, arr.size)
    for (i in start until end) {
        if (arr[i] == target) return i
    }
    return -1
}

/** 根据数组与块大小构建索引表(每块最大值) */
fun buildBlockIndex(arr: IntArray, blockSize: Int): IntArray {
    val blockCount = (arr.size + blockSize - 1) / blockSize
    return IntArray(blockCount) { b ->
        val start = b * blockSize
        val end = minOf(start + blockSize, arr.size)
        (start until end).maxOfOrNull { arr[it] } ?: 0
    }
}

时间复杂度与空间复杂度

维度复杂度说明
时间复杂度O(s + n/s)找块 O(s),块内 O(n/s);s=√n 时 O(√n)
空间复杂度O(s)索引表,s 为块数

优化空间

  • 索引表用二分:若索引表按块最大值有序,找块可二分,时间降为 O(log s + n/s)。
  • 块大小:取 s = √n 时查找约 O(√n);也可按 I/O 块大小或缓存行调整。
  • 块内有序:块内再有序时,块内可二分,进一步降为 O(log s + log(n/s))。

4. 哈希查找

思想

通过哈希函数将关键字映射到表的下标,直接在对应位置(或拉链/探测后的位置)查找。平均情况下一次或常数次比较即可命中,是“以空间换时间”的典型,笔试面试必考。

说明:本文 Kotlin 代码示例实现的是“开放定址(线性探测)”。“链地址法”仅在概念上提及。

实现约束:示例代码用 -1 表示空位,因此 key 不应取 -1(否则会与“空”冲突)。

步骤

  1. 建表:根据关键字 key 计算 hash = H(key),若发生冲突则按既定策略处理(开放定址或链地址)
  2. 查找:计算 hash = H(target),根据冲突处理策略在表中定位
  3. 若该位置存在且关键字等于 target,查找成功;否则按冲突策略继续找或判定不存在

Kotlin 实现

/**
 * 哈希查找:简单除留余数法 + 线性探测
 * 表内存 key,-1 表示空
 */
class HashTable(private val capacity: Int) {
    private val keys = IntArray(capacity) { -1 }

    private fun hash(key: Int): Int = (key % capacity + capacity) % capacity

    fun insert(key: Int): Boolean {
        var h = hash(key)
        var step = 0
        while (step < capacity) {
            if (keys[h] == -1 || keys[h] == key) {
                keys[h] = key
                return true
            }
            h = (h + 1) % capacity
            step++
        }
        return false // 表满
    }

    /** 查找:返回 key 的下标,未找到返回 -1 */
    fun search(target: Int): Int {
        var h = hash(target)
        var step = 0
        while (step < capacity) {
            if (keys[h] == -1) return -1
            if (keys[h] == target) return h
            h = (h + 1) % capacity
            step += 1
        }
        return -1
    }
}

时间复杂度与空间复杂度

维度复杂度说明
时间复杂度平均 O(1),最坏 O(n)冲突多时退化为线性探测序列
空间复杂度O(m)m 为散列表长度

优化空间

  • 负载因子:α = count/m(count 为关键字个数,m 为表长;此处的 count 不是“数组长度 n”),一般 α < 0.75 时扩容,减少冲突。
  • 冲突策略:链地址法实现简单、稳定;开放定址省指针但易聚集,可改用二次探测或双哈希。
  • 哈希函数:好的散列(如 MurmurHash、取模质数)减少碰撞,提升平均 O(1)。
  • 表长取质数:除留余数法时 m 取质数可减轻取模造成的聚集。

5. 插值查找

思想

在有序数组中,根据目标值在数值范围内的相对位置估算目标位置,而不是总取中点。数据分布较均匀时比二分查找更快。

步骤

  1. left = 0right = arr.size - 1
  2. left > right 或 target 不在闭区间 [arr[left], arr[right]] 内,返回 -1
  3. 用插值公式计算探测位置: pos = left + (target - arr[left]) * (right - left) / (arr[right] - arr[left])
  4. 将 pos 截断到区间内(posSafe),比较 arr[posSafe] 与 target
  5. arr[posSafe] == target,返回 posSafe
  6. arr[posSafe] > target,在左半部分查找:right = posSafe - 1,回到步骤 2;否则在右半部分查找:left = posSafe + 1

Kotlin 实现

/**
 * 插值查找
 * 适用于有序且分布均匀的数组
 */
fun interpolationSearch(arr: IntArray, target: Int): Int {
    var left = 0
    var right = arr.size - 1
    while (left <= right && target >= arr[left] && target <= arr[right]) {
        if (left == right) {
            return if (arr[left] == target) left else -1
        }
        // 分母为 0 时区间内值相同,直接判等
        if (arr[right] == arr[left]) {
            return if (arr[left] == target) left else -1
        }
        val pos = left + (target - arr[left]) * (right - left) / (arr[right] - arr[left])
        val posSafe = pos.coerceIn(left, right)
        if (arr[posSafe] == target) return posSafe
        if (arr[posSafe] > target) right = posSafe - 1 else left = posSafe + 1
    }
    return -1
}

时间复杂度与空间复杂度

维度复杂度说明
时间复杂度平均 O(log log n),最坏 O(n)分布均匀时很快;单调等差等最坏 O(n)
空间复杂度O(1)仅用少量变量

优化空间

  • 边界与除零arr[right]==arr[left] 时插值公式分母为 0,需单独判断或取 mid。
  • 适用数据:关键字均匀分布时优于二分;分布不均时可能不如二分,可退化用二分。
  • pos 越界:计算出的 pos 需 coerceIn(left, right) 防止越界或震荡。

6. 斐波那契查找

思想

利用斐波那契数列分割有序数组:用斐波那契数 F(k) 将当前待查区间分成前、中、后三段(长度与 F(k-1)、F(k-2) 相关),根据与 target 的比较在某一侧继续,相当于按黄金比例取点。与黄金分割相关,适合顺序访问代价较高的存储。

步骤

  1. 找到不小于 n 的最小斐波那契数 F(k),令 offset = -1
  2. 循环:若 k > 0
    • i = min(offset + F(k-2), n-1)
    • arr[i] == target,返回 i
    • arr[i] > target,在左半部分:k = k - 2offset 不变(在逻辑左段)
    • arr[i] < target,在右半部分:k = k - 1offset = i
  3. 循环结束后,若 offset + 1 < narr[offset + 1] == target,返回 offset + 1,否则返回 -1(实现中对应 k == 0 的收尾判断)

Kotlin 实现

/**
 * 斐波那契查找
 * 要求数组有序,利用斐波那契数列分割
 */
fun fibonacciSearch(arr: IntArray, target: Int): Int {
    val n = arr.size
    if (n == 0) return -1
    val fib = mutableListOf(1, 1)
    while (fib.last() < n) {
        fib.add(fib[fib.size - 1] + fib[fib.size - 2])
    }
    var k = fib.size - 1
    var offset = -1
    while (k > 0) {
        val i = (offset + fib.getOrElse(k - 2) { 0 }).coerceIn(0, n - 1)
        if (arr[i] == target) return i
        if (arr[i] > target) { k -= 2 } else { offset = i; k -= 1 }
    }
    if (k == 0 && offset + 1 < n && arr[offset + 1] == target) {
        return offset + 1
    }
    return -1
}

时间复杂度与空间复杂度

维度复杂度说明
时间复杂度O(log n)按斐波那契缩小区间
空间复杂度O(log n)预存斐波那契数列(约 log n 项)

优化空间

  • 斐波那契表缓存:数列可预计算为静态表,避免每次建表。
  • 只存必要项:只需到 F(k) ≥ n 的几项,空间仍为 O(log n)。
  • 与二分对比:理论常数略优(黄金分割),但实现复杂,工程上二分更常用。

7. 跳跃查找

思想

在有序数组中,以固定步长(常取 √n)向前跳跃,直到当前块末尾元素 ≥ target,再在该块内做线性查找。块数约为 √n,块内长度约为 √n,总时间 O(√n)。

步骤

  1. 确定步长 step = √n(n 为数组长度)
  2. 从下标 0 开始令 i=0,当 i < narr[i] < target 时执行 i += step,直到 i >= narr[i] >= target
  3. 在区间 start = max(0, i-step)end = min(i, n) 内顺序查找(实现中对应 start = i - stepend = minOf(i, n)
  4. 找到返回下标,否则返回 -1

Kotlin 实现

/**
 * 跳跃查找
 * 要求数组有序,步长取 √n
 */
fun jumpSearch(arr: IntArray, target: Int): Int {
    val n = arr.size
    if (n == 0) return -1
    val step = kotlin.math.sqrt(n.toDouble()).toInt().coerceAtLeast(1)
    var i = 0
    // 跳跃到目标可能所在的块
    while (i < n && arr[i] < target) {
        i += step
    }
    // 在块内线性查找
    val start = (i - step).coerceAtLeast(0)
    for (j in start until minOf(i, n)) {
        if (arr[j] == target) return j
        if (arr[j] > target) break
    }
    return -1
}

时间复杂度与空间复杂度

维度复杂度说明
时间复杂度O(√n)跳 O(√n) 步 + 块内 O(√n)
空间复杂度O(1)仅用少量变量

优化空间

  • 步长:取 √n 时总时间 O(√n);也可按块数或块长灵活调整。
  • 块内改进:块内有序时可在块内二分,整体 O(√n) 跳 + O(log √n) = O(√n) 主导。
  • 与二分对比:实现简单,但有序表一般直接用二分更优。

8. 指数查找

思想

先以 1、2、4、8… 为下标找到第一个 ≥ target 的位置,再在该区间内做二分查找。适合无界或很大的有序序列,或目标靠近开头时。

步骤

  1. arr[0] == target,返回 0
  2. i = 1,若 i < narr[i] < target,则 i *= 2,重复直到 arr[i] >= target 或 i 超出范围(越界时右端按 n-1 处理)
  3. 在区间 [i/2, min(i, n-1)] 上执行二分查找
  4. 返回二分查找结果

Kotlin 实现

/**
 * 指数查找
 * 要求数组有序,适合目标靠近开头或数组很大
 */
fun exponentialSearch(arr: IntArray, target: Int): Int {
    val n = arr.size
    if (n == 0) return -1
    if (arr[0] == target) return 0
    var i = 1
    while (i < n && arr[i] < target) {
        i *= 2
    }
    val left = i / 2
    val right = minOf(i, n - 1)
    return binarySearchInRange(arr, target, left, right)
}

private fun binarySearchInRange(arr: IntArray, target: Int, left: Int, right: Int): Int {
    var l = left
    var r = right
    while (l <= r) {
        val mid = l + (r - l) / 2
        if (arr[mid] == target) return mid
        if (arr[mid] > target) r = mid - 1 else l = mid + 1
    }
    return -1
}

时间复杂度与空间复杂度

维度复杂度说明
时间复杂度O(log n)指数定界 + 区间内二分
空间复杂度O(1)仅用少量变量

优化空间

  • 目标靠前:当 target 在前部时,指数扩界很快,再二分,常数优于纯二分。
  • 无界/流式:在长度未知或很大的有序序列上,先指数再二分是常用模式。
  • 与纯二分:随机位置 target 时总时间仍 O(log n),与二分同量级。

总结对比

时间复杂度与空间复杂度一览

算法时间复杂度空间复杂度前提条件
线性查找O(n)O(1)
二分查找O(log n)O(1),递归 O(log n)有序
分块查找O(√n)(s=√n 时)O(√n)块间有序
哈希查找平均 O(1),最坏 O(n)O(m)
插值查找平均 O(log log n),最坏 O(n)O(1)有序、分布均匀更优
斐波那契查找O(log n)O(log n)有序
跳跃查找O(√n)O(1)有序
指数查找O(log n)O(1)有序

优化空间汇总

算法可优化点简述
线性查找哨兵减少分支;有序表可提前终止
二分查找防溢出写法;递归改迭代省栈
分块查找索引表二分找块;块内有序可块内二分
哈希查找负载因子、表长取质数、哈希函数与冲突策略
插值查找处理分母为 0、pos 越界;分布不均时退化二分
斐波那契查找斐波那契表缓存;工程上多用二分
跳跃查找步长与块内有序时块内二分
指数查找目标靠前或无界序列时优于纯二分

综合对比与选用建议

算法适用场景常考选用建议
线性查找无序、小规模★★★默认暴力方案
二分查找有序、通用★★★有序表首选
分块查找块内无序、块间有序★★★动态插入多、不便全序时
哈希查找关键字精确查找、无序★★★要平均 O(1) 时首选
插值查找有序且分布均匀★★大数据且均匀时考虑
斐波那契查找有序、理论/扩展了解即可,工程少用
跳跃查找有序、顺序访问友好★★链表式存储时考虑
指数查找有序、目标靠前或无界★★无界或流式有序时用

一句话选型:无序小表用线性;有序用二分;要平均 O(1) 用哈希;块间有序用分块;其余按场景选插值/跳跃/指数,斐波那契作扩展了解。

选型决策:无序 → 规模小用线性、否则哈希;有序 → 块间有序用分块、否则二分。

常见考点与易错点

考点/易错点说明
二分边界left <= rightleft < rightmid ± 1 的取法要一致,否则死循环或漏元素;防溢出用 left + (right - left) / 2
二分适用前提必须有序;若题目说“有序+查找”优先想二分及其变形(下界/上界)。
哈希负载因子α = count/m(count 为关键字个数,m 为表长);过大冲突多,过小浪费空间;常考扩容时机(如 α > 0.75)。
哈希冲突开放定址(线性/二次/双哈希)与链地址法区别、优缺点;线性探测易产生聚集。
分块条件块间有序、块内无序;索引表存块最大键或区间;找块可顺序可二分。
插值查找适用均匀分布;分母为 0 要特判;分布不均时可能退化为 O(n)。
返回值约定未找到统一返回 -1 还是 throw、返回 null 等,代码与题目约定一致。
空表与单元素各算法对 n=0、n=1 的处理要正确,避免越界或除零。
二分下界/上界lowerBound:第一个 ≥ target;upperBound:第一个 > target。等值区间为 [lowerBound, upperBound)。
查找与排序结合无序表若需多次查找,可先排序再二分,总代价 O(n log n) + k·O(log n);或直接用哈希实现单次 O(1)。

最好 / 平均 / 最坏时间复杂度速查

算法最好平均最坏
线性查找O(1)O(n)O(n)
二分查找O(1)O(log n)O(log n)
分块查找O(1)O(√n)O(√n)
哈希查找O(1)O(1)O(n)
插值查找O(1)O(log log n)O(n)
斐波那契查找O(1)O(log n)O(log n)
跳跃查找O(1)O(√n)O(√n)
指数查找O(1)O(log n)O(log n)

运行提示

本文以“算法方法”形式给出 Kotlin 代码。若你要在本地运行验证:把需要的方法复制到同一个 .kt 文件里,并自行添加一个 main() 调用测试即可(同一文件只能有一个 main)。