
1. 查找算法从二分查找热词说起最近二分查找算法成了搜索榜上的高频词这其实并不意外。无论你是刚接触数据结构的初学者还是在职场摸爬滚打多年的老开发查找算法始终是绕不开的基础功。它不只是教科书里的考点更是你在写业务代码、做系统设计时躲不掉的底层操作——查用户信息要查找判断数据是否存在要查找甚至连数据库的索引原理都离不开查找的思想。我最早被查找算法虐到是几年前在面试中被问到一个看似简单的问题在10亿个有序整数中快速定位一个数你会怎么做当时我第一反应是遍历然后就被面试官的眼神劝退了。从那以后我才认认真真把查找算法从头到尾啃了一遍也终于在实战中体会到了它真正的分量。这篇博文我会从实际工程视角出发把查找算法的核心思路、典型实现、进阶变体和避坑经验一次讲透。内容会覆盖各种查找算法到底解决什么问题它们之间怎么选择二分查找为什么是神级算法怎么写出不出 bug 的二分从二分查找到二叉搜索树、哈希表、跳表它们之间是什么关系实际项目中查找算法的选型标准和真实踩坑记录无论你是刚入门的学生、准备面试的求职者还是想提升代码质量的工程师这篇内容都能给你一些不一样的视角。2. 查找算法全景图每个算法到底在什么场景下称王2.1 查找算法家族一览从暴力遍历到哈希直达查找问题的本质就是从一堆数据里找到目标元素的位置或者判断它是否存在。这个需求听起来简单但数据规模一上来怎么找就变成了一个策略性问题。我习惯把常见的查找算法分成四类每一类的核心思路完全不同无序线性查找最朴素的方式从第一个元素开始挨个比找不到就继续往后走。时间复杂度是 O(n)没有任何前置条件什么数据都能查但数据量大时效率很低。有序区间查找典型代表就是二分查找要求数据有序每次把搜索范围砍半时间复杂度 O(log n)。树形结构查找把数据组织成二叉树、B 树、B 树等结构利用树的分支特性缩小范围。数据库索引就是这类思路的代表。散列结构查找用哈希函数把关键字直接映射到存储位置理想情况下时间复杂度 O(1)。我做了个常用算法对比表方便你一眼看清它们的差异查找算法数据结构要求平均时间复杂度最坏时间复杂度空间复杂度适用场景顺序查找无O(n)O(n)O(1)小规模数据、无序数据二分查找有序数组O(log n)O(log n)O(1)静态有序数据、频繁查询插值查找有序数组O(log log n)O(n)O(1)数据分布均匀的有序数组二叉搜索树动态二叉树O(log n)O(n)O(n)动态插入删除的有序集合平衡树AVL/红黑自平衡二叉树O(log n)O(log n)O(n)需要稳定性能的动态场景哈希查找哈希表O(1)O(n)O(n)精确查找、kv 存储跳表查找有序链表索引O(log n)O(log n)O(n log n)有序集合、范围查询2.2 为什么二分查找是性价比之王在这么多算法里二分查找始终是我最推荐优先掌握的。原因很简单它把有序这个看似普通的前提发挥到了极致。你可以想想生活中的场景一本字典如果页内单词是无序排列的你只能从头翻到尾运气差的时候翻几百页才能找到目标。但如果字典按字母顺序排列你可以直接翻到中间看一眼当前页的字母范围立刻判断目标词在前半本还是后半本然后继续对折查找。每次操作排除掉一半的选项这就是二分查找的底层逻辑。二分查找的优点非常突出效率极高数据规模翻一倍查找次数只增加 1。比如 10 亿条数据顺序查找平均要查 5 亿次而二分查找最多只需要 30 次。空间占用极省不需要额外开辟存储空间直接在原数组上操作。实现简单但考验细节核心代码只有十几行但边界条件极其容易出错这也是它成为面试高频考点的原因。它也有明显局限要求数据有序且底层必须是支持随机访问的连续存储结构数组。如果你用链表存数据二分查找就施展不开了——因为链表取中间元素需要遍历时间复杂度直接退化到 O(n)。2.3 选型原则先问自己三个问题在实际工程中你不需要背下所有算法的实现但必须能回答下面三个问题才能做好选型1. 数据是静态还是动态如果数据构建后不经常变化比如历史订单表二分查找配合有序数组非常合适。如果数据频繁插入删除比如在线用户列表你需要的是二叉搜索树、跳表这类支持动态操作的结构或者干脆上哈希表。2. 你是精确查找还是范围查找精确查找用户 ID 为 1024 的记录是否存在哈希表是王者。范围查找找出价格在 100 到 200 之间的所有商品哈希表就无能为力了这时候有序结构数组二分、B 树、跳表才是正解。3. 内存够不够放哈希表为了降低冲突通常会浪费一部分空间用空间换时间。如果你处理的是海量数据且内存吃紧那就要考虑二分查找 有序数组或者落盘到数据库用 B 树索引。这几类算法各有各的舒适区选对了事半功倍选错了再牛的底层优化也救不回来。3. 二分查找深度拆解从原理到Bug-free实现3.1 核心原理减治思想与循环不变量二分查找在算法分类上属于减治法——每次迭代都把问题规模缩小一半但只处理其中一半。和分治法不同的是分治法会把问题拆成多个子问题都处理比如归并排序而二分查找只处理其中一个子问题所以复杂度更低。很多人写二分查找容易出 bug根本原因是没抓住循环不变量这个概念。循环不变量就是在循环过程中始终成立的性质你要明确自己定义的搜索区间是什么并且在每次迭代中都维持这个定义。我推荐一种极其不容易出错的写法使用左闭右开区间[left, right)作为循环不变量def binary_search(nums, target): left, right 0, len(nums) # 注意 right 指向数组末尾的下一个位置即 [left, right) 区间 while left right: mid left (right - left) // 2 if nums[mid] target: return mid elif nums[mid] target: left mid 1 # target 在右半部分收缩左边界 else: right mid # target 在左半部分收缩右边界注意是 right mid 而非 mid - 1 return -1 # 没找到为什么 right 初始值是 len(nums) 而不是 len(nums) - 1因为左闭右开区间的定义就是 right 本身不包含在搜索范围内这样就保证了区间始终是合法的。当 left 和 right 相等时区间为空循环自然结束不需要额外处理 left right 的边界情况。为什么 mid 要用 left (right - left) // 2而不是 (left right) // 2原因有两个一是防止整数溢出。left right 在极端情况下可能超过 int 的最大值这在 C、Java 里是真实存在的隐患二是这种写法更清晰表达了从 left 出发偏移一半距离的几何意义。3.2 手把手推演一个完整的二分查找过程我在纸上演算一个具体例子你跟着走一遍就彻底懂了。假设数组nums [1, 3, 5, 7, 9, 11, 13]目标是9。第 1 轮left0right7mid(07)//23nums[3]7。因为 7 9说明 target 在右半部分所以 leftmid14。第 2 轮left4right7mid4(7-4)//25nums[5]11。因为 11 9说明 target 在左半部分所以 rightmid5。第 3 轮left4right5mid4(5-4)//24nums[4]9命中整个过程只比较了 3 次就找到了目标。如果是 100 万条数据最多只需要 20 次比较这就是指数级碾压线性扫描的威力。3.3 三种区间写法的对比哪种最适合你网上关于二分查找的写法五花八门我总结下来主流有三种左闭右闭[left, right]、左闭右开[left, right)、左开右开(left, right)。别被它们吓到核心就是搞清楚循环退出条件和边界更新规则。我在项目里长期维护过三种写法的对比写法初始化循环条件left 更新right 更新典型坑点左闭右闭left0, rightn-1left rightmid1mid-1容易忘记更新 mid-1 导致死循环左闭右开left0, rightnleft rightmid1mid初始化时容易写成 n-1左开右开left-1, rightnleft1 rightmidmid边界更新难理解不推荐新手用我个人强烈推荐左闭右开。原因有两个一是配合 Python 的切片语法nums[left:right]语义完全一致心智负担小二是它在处理查找边界类问题时更灵活后面讲变体时你会体会到。4. 二分查找的进阶变体与相邻数据结构4.1 查找第一个/最后一个匹配元素实际工作中你经常会遇到数组里有重复元素的情况。比如一个有序数组[1, 3, 3, 3, 5, 7]你想知道数值 3 出现的第一个位置和最后一个位置。标准二分查找返回哪个 3不确定取决于 mid 命中的是哪一个。这时需要对二分查找做变体查找左边界时即使nums[mid] target也不要立即返回而是把 right 收缩到 mid继续向左探索查找右边界时即使命中也要把 left 扩展到 mid 1继续向右探索。def find_left(nums, target): left, right 0, len(nums) while left right: mid left (right - left) // 2 if nums[mid] target: left mid 1 else: right mid # 等于时也不返回继续找更左边的 return left # left 指向第一个 target 的位置 def find_right(nums, target): left, right 0, len(nums) while left right: mid left (right - left) // 2 if nums[mid] target: left mid 1 # 等于时继续向右 else: right mid return left - 1 # left 指向第一个 target 的位置减一得到最后一个 target 的位置这里有一个很实用的技巧把查找某个值的左边界理解为查找第一个大于等于 target 的位置把查找右边界理解为查找第一个大于 target 的位置减一。这样你的思维就统一起来了LeetCode 34 题在排序数组中查找元素的第一个和最后一个位置就能直接套用。4.2 旋转有序数组与二维矩阵查找面试中还有个经典题型一个有序数组在某个未知点被旋转了比如[4, 5, 6, 7, 0, 1, 2]怎么快速查找 target核心洞察是旋转之后数组虽然整体无序但从中间切一刀左半部分和右半部分中必有一半是有序的。你只需要判断 target 是否落在有序的那一半里然后相应地收缩区间。def search_rotated(nums, target): left, right 0, len(nums) - 1 while left right: mid left (right - left) // 2 if nums[mid] target: return mid if nums[left] nums[mid]: # 左半部分有序 if nums[left] target nums[mid]: right mid - 1 else: left mid 1 else: # 右半部分有序 if nums[mid] target nums[right]: left mid 1 else: right mid - 1 return -1这个题的难点在于判断哪半部分有序以及 target 落在哪个区间。把这两个判断做对了逻辑其实很清爽。二维矩阵查找也有类似玩法。如果矩阵的每一行从左到右递增、每一列从上到下递增你可以从右上角出发target 比当前值大就向下比当前值小就向左时间复杂度 O(m n)比暴力遍历每一行做二分还要快。4.3 二分答案法把优化问题转化为判定问题二分查找还有一个被低估的用途——二分答案法。当你要找的是一个满足条件的最大值最小或最小值最大问题时如果答案的取值空间是单调的就可以二分枚举答案然后用一个判定函数通常 O(n)去检查是否可行。举个实际例子有 n 本书每本厚度不同你要按顺序分成 m 组求所有分组方案中每组厚度最大值的最小值是多少。这类问题的常规思路是动态规划但用二分答案法更简单二分这个最大厚度的取值然后从第一本书开始贪心分组看能否在不超过该厚度的前提下凑出 m 组。如果能说明这个最大值可行尝试更小的值如果不能说明需要更大的值。我用这个思路解决过很多实际工程问题比如任务调度、资源分配、日志切分等场景。它的通用价值在于把复杂的找最优值问题转化为枚举值 验证可行性的简单组合。4.4 从二分到树状搜索二叉搜索树和跳表二分查找有一个硬伤——数据必须放在静态数组里插入删除代价太高。如果数据是动态变化的你需要支持频繁插入删除那就要靠树形结构。二叉搜索树BST的核心理念和二分查找一脉相承每个节点的左子树所有值都小于节点值右子树所有值都大于节点值。查找时从根出发每次都排除一半的树平均复杂度 O(log n)。但普通 BST 有退化风险如果按有序序列插入它会退化成一条链表查找效率变成 O(n)。所以工程上真正用的是自平衡版本AVL 树保证任意节点的左右子树高度差不超过 1红黑树用颜色规则保持近似平衡。我在实际项目里处理动态有序集合时优先用红黑树或跳表。跳表也是一个很巧妙的结构你可以把它理解为带有多级索引的有序链表。底层是完整的有序链表上面每两层抽一个节点作为索引这样查找过程就和二分查找一样从顶层开始逐层下降时间复杂度 O(log n)而且在并发场景下比红黑树更容易实现无锁操作。Redis 的有序集合zset就是跳表的典型应用。这些结构和二分查找共享同一个思想——利用有序性每步排除大量候选。理解了二分你理解这些进阶结构会容易得多。5. 哈希查找空间换时间的极致5.1 哈希函数与冲突处理的底层逻辑如果查找场景是精确匹配判断有没有、取某个 key 的 value哈希表是所有方案里速度最快的——理想情况 O(1)。它的核心用一个哈希函数把 key 映射到数组下标。比如存用户信息时把用户 ID 经过哈希计算得到一个整数再对数组长度取模直接定位到存储位置。看起来完美但有个经典问题哈希冲突。两个不同的 key 经过哈希函数计算后落到了同一个槽位怎么处理工程上最常用的两种冲突方案链地址法每个槽位挂一条链表或红黑树冲突的元素都挂在链表上。Java 的 HashMap 就是这种方案当链表长度超过 8 且数组长度超过 64 时自动转成红黑树防止极端情况下链表过长。开放寻址法冲突后按一定规则找下一个空位。典型代表是 Redis 的字典、Go 的 map早期实现好处是数据都在数组内缓存友好但删除操作比较麻烦。这里我想强调一个很多程序员忽略的参数负载因子load factor。它是元素个数与桶数组长度的比值决定了哈希表的性能曲线。负载因子太低空间浪费严重负载因子太高冲突变多性能下降。Java HashMap 默认 0.75就是经验上空间和时间的平衡点。5.2 哈希查找的实际代码Python dict 与自定义哈希在 Python 里字典和 set 就是哈希表的实现你直接用它就行。但理解背后的机制能帮你写出更高效的代码。举个最常见的场景判断数组中是否有重复元素。def has_duplicate(nums): seen set() for num in nums: if num in seen: return True seen.add(num) return False这段代码的时间复杂度是 O(n)因为你用 set 的哈希查找把历史元素中是否存在当前元素的查询降到了 O(1)。如果不用 set 而用列表num in seen是 O(n)总复杂度变成 O(n²)数据量稍大就卡死。另一个典型场景是两数之和问题给定数组和目标值找出两个数使得它们的和等于目标值。暴力解法是两层循环 O(n²)用哈希表一次遍历就能解决def two_sum(nums, target): seen {} for i, num in enumerate(nums): complement target - num if complement in seen: return [seen[complement], i] seen[num] i return [-1, -1]这个解法的时间复杂度降到 O(n)核心思想就是用哈希表记住我见过什么之后每次查找都是 O(1)。这种空间换时间的思路在工程中极其常见特别是需要频繁查重的场景。5.3 哈希表的坑如何避免哈希碰撞攻击哈希表虽然快但在真实项目中有一个潜在隐患恶意哈希碰撞。如果攻击者构造出大量哈希值相同取模后落在同一槽位的 key哈希表的查找会退化成 O(n)导致服务被拖慢。这里有一个真实案例某公司上线了一个接口接收用户提交的大量 key 并存入 HashMap。正常情况下读写都是 O(1)毫秒级返回结果某次活动期间接口突然 CPU 飙高、大量请求超时。排查后发现是攻击者构造了成百上千个哈希碰撞的 key把 HashMap 的某个桶打成了超长链表查询复杂度退化为 O(n)。应对方案有两层。第一层在应用层不要直接用对象的默认 hashCode 作为 HashMap 的 key改用安全哈希函数如 SHA-256 取前几位或者对 key 做二次哈希。第二层在框架层现在很多语言和框架已经内置了随机种子每次启动哈希种子不同攻击者无法提前构造碰撞数据比如 Java 的 HashMap 在 JDK 7 就加入了随机种子。这个案例提醒我越快的数据结构越要警惕它的退化条件。哈希表在理想情况是 O(1)但你没有主动控制 key 分布时很可能在某些极端输入下掉进 O(n) 的陷阱。6. 树形查找结构数据库索引背后的功臣6.1 BST、AVL、红黑树的取舍二叉搜索树在动态数据场景下是二分查找的升级版。它的查找逻辑和二分几乎一样但多了一个优势支持 O(log n) 的插入和删除。普通 BST 有个致命伤——插入顺序决定树的形状。如果数据是随机分布的树会比较平衡但如果按顺序插入比如 1、2、3...树会退化成链表。所以工程实现都会引入自平衡机制。AVL 树的策略最严格它要求任意节点的左右子树高度差不超过 1所以查找性能最稳定但插入删除时需要频繁旋转开销较大。红黑树的策略就聪明得多它不追求绝对平衡只要求最长路径不超过最短路径的两倍所以插入删除的旋转次数更少性能更均衡。Java 的 TreeMap、C 的 std::map 用的都是红黑树。我在项目里选型的经验是查找比例远大于写入比例时选 AVL写入和查找混合且要求稳定性能时选红黑树。如果你自己实现的话建议直接用现成的库不要自己造轮子。6.2 从 BST 到 B 树为什么数据库不用红黑树数据库索引用的是 B 树而不是红黑树这个知识点很多初学者理解不了。核心原因在于磁盘和内存的速度差距巨大。机械硬盘随机读一次需要 10ms 级别而内存随机读只需要 100ns 级别差了 5 个数量级。所以数据库的索引结构要尽量减少磁盘 IO 次数。B 树的设计就是为此服务的每个节点可以存上百上千个 key树的高度被压得很低比如千万级数据B 树通常只需要 3~4 层。一次查询最多访问 3~4 个节点也就是 3~4 次磁盘 IO。而红黑树每个节点只存 1 个 key树高大约是 log2(n)千万级数据树高 24 层意味着最多 24 次磁盘 IO性能差距是数量级的。B 树的另一个优势是内部节点不存数据只存 key 和指针所以内部节点能装下更多 key进一步降低树高。同时叶子节点用链表串起来非常适合范围查询比如查 id 从 100 到 200 的所有记录顺序读叶子节点就能搞定磁盘预读也很友好。所以如果你面试时遇到数据库为什么用 B 树回答核心就一个点B 树把树高压到极致用最少的磁盘 IO 次数完成查找同时天然支持高效范围查询。6.3 树形查找的代码实践用二叉搜索树实现有序集合如果需要在内存中维护一个有序集合同时支持增删查和范围遍历二叉搜索树是最自然的思路。Python 标准库里的bisect模块可以在有序列表上做二分查找但它不支持 O(log n) 的插入插入是 O(n)。如果你需要动态有序集合我通常会用sortedcontainers这个第三方库它的底层是类似 B 树的分块结构实测性能很好。不过如果你只是想在业务代码里快速实现一个有序 Map直接用标准库的bisect 列表也可以适合数据量小几千条以内的场景。代码很短import bisect # 维护一个有序列表 arr [] bisect.insort(arr, 5) bisect.insort(arr, 1) bisect.insort(arr, 3) # arr 现在是 [1, 3, 5] # 查找第一个大于等于 3 的位置 idx bisect.bisect_left(arr, 3) # 输出 1这个方案的优点是零依赖、逻辑简单适合原型和内部工具缺点是插入 O(n)大数据量性能撑不住。实际项目中数据量上了十万级别我会切换到真正的树结构或者直接上 Redis 的 zset。7. 高频考点与疑难杂症排查实录7.1 面试必问的五道查找算法题我在带新人、模拟面试时总结出了五道最高频的查找算法题几乎每家公司在考察算法基础时都会涉及1. 在排序数组中查找元素的第一个和最后一个位置LeetCode 34考查二分查找边界的处理能力核心是掌握bisect_left和bisect_right的变体写法。准备思路是理解左边界 第一个 target 的位置和右边界 第一个 target 的位置 - 1的统一视角。2. 搜索旋转排序数组LeetCode 33考查对有序数组部分有序的判断能力。准备思路是每次判断左半部分是否有序再决定收缩方向注意等号的处理。3. 两数之和LeetCode 1考查哈希表的空间换时间意识。准备思路是一边遍历一边把元素加入哈希表同时检查补数是否已经存在。4. 二叉搜索树的最近公共祖先LeetCode 235考查树形结构查找路径。准备思路是利用 BST 的有序性从根出发若 p 和 q 分别位于当前节点的左右两侧则当前节点就是 LCA。5. 寻找峰值LeetCode 162考查二分查找在无序数组中的应用。准备思路是峰值存在于上坡的终点通过比较 mid 和 mid1 决定往哪边走。这些题不是让你死记硬背答案而是通过它们理解二分查找的边界处理、哈希表的空间换时间思想以及树形结构的有序性在查找中如何发挥作用。7.2 死循环与越界的典型案例二分查找最常见的问题就是死循环和数组越界我把典型问题整理成速查表症状根因修复方法死循环区间收缩时 left 或 right 没有变化手动模拟一轮检查 left/right/mid 的值是否在向中间靠拢数组越界mid 出现在 [0, n) 之外确认 mid 计算基于 left 和 right 的差而不是盲目用 (leftright)//2漏掉边界元素循环条件用left right却忘记处理等号改用左闭右开区间循环条件left right等号情况自然处理结果偏 1对是否包含 right 的判断不统一明确区间定义并全程遵守只改一处会导致结果混乱处理重复元素时位置不对等于目标时立即返回想清楚要左边界还是右边界命中后继续收缩对应方向排查经验里最重要的一条出 bug 先画区间不要凭感觉改代码。把你当前的 left、right、mid 值和数组内容写出来手动走一遍循环立刻就能发现错误。7.3 实战踩坑为什么要警惕二分查找的理论完美写代码是一回事上生产环境是另一回事。二分查找在理论上很完美但我在工程实践中被它坑过几次分享出来给大家避雷。第一个坑是在链表上强行二分。有次我接手一个旧项目里面用双向链表存了一批有序数据数据量几万条。原开发者为了性能优化在链表上实现了二分查找——每次取 mid 都要从头遍历到中间节点。看起来是 O(log n) 次比较实际每次比较都要 O(n) 遍历最终复杂度是 O(n log n)比顺序查找还慢。找 Bug 的时候我才发现这是典型的算法选型脱离数据结构。链表根本没有随机访问能力二分查找必须用数组。第二个坑是浮点数二分精度控制。在数值计算中你需要用二分法求方程的近似根。如果条件写的是while left right由于浮点数永远不能精确相等可能陷入无限循环。正确的做法是判断区间长度是否足够小比如while right - left 1e-7。第三个坑是超大数组的 mid 计算隐患。在 C 里(left right) / 2在 left 和 right 都是很大的 int 时可能溢出导致 mid 变成负数直接内存越界。这个隐患我听说过不少公司出现过解决方式就是我前面强调的left (right - left) / 2。第四个坑是二分查找的缓存不友好。二分查找每次跳跃访问不同位置的数据对 CPU 缓存非常不友好。在小数据量几千到几万时顺序查找因为有很好的缓存局部性可能比二分查找更快。我在做低延迟系统时用 perf 测过数据量 1000 左右时顺序查找的耗时比二分查找低 30%。所以别迷信复杂度数据规模小的时候缓存友好的线性扫描才是王者。7.4 项目实战亿级用户 ID 的在线判重方案最后分享一个我真实的项目经历。当时我们需要在内存中维护一批约 1 亿个用户 IDint 类型要求实时判断新请求的 ID 是否在集合中且不能有误判内存占用也要控制。最初方案是直接用 Python 的 set 存储1 亿个 int 大概占用 3GB 左右内存对于一个单机服务来说有点紧张。后来我评估了三种方案方案一有序数组 二分查找。把 1 亿个 int 排序后放进数组一个 int 4 字节占用约 400MB内存占用大幅下降。每次查询用二分查找 O(log n)实测单次查询约 300ns吞吐量完全够用。方案二哈希表。内存占用 3GB 以上单次查询约 50ns性能最优但内存吃紧。方案三Bloom Filter。内存占用约 150MB但存在误判率业务要求不能有误判直接 pass。最终我选了方案一数组初始构建时离线排序之后只读不写完美契合二分查找的适用场景。上线后表现稳定查询延迟 P99 控制在 2ms 以内因为还有网络开销CPU 占用也很低。这个经历给我的经验是选查找算法不是选最快的而是选当前约束下最合适的。内存、性能、误判率、数据形态都是约束条件要综合考虑。有时候看起来很土的有序数组 二分查找反而是工程上的最优解。查找算法这块内容越往里挖越有意思。从二分查找这个基础点出发你能串起哈希表、二叉搜索树、B 树、跳表、Bloom Filter 一整条知识链。我个人在实际项目里最大的体会是别只背算法模板要理解每个算法的前提假设和适用边界。数组有序才能二分精确匹配才用哈希范围查询靠树形结构海量判重考虑概率结构——想清楚这些你才能在面对真实问题时快速选出正确答案。