news 2026/10/21 16:20:00

两道经典数组算法题精讲:重复数字查找与二维数组搜索

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
两道经典数组算法题精讲:重复数字查找与二维数组搜索

1. 从两道题说起:为什么它们总被放在一起考

"找出数组中重复的数"和"二维数组中的查找",这两道题放在一起看,其实特别有意思。前者考的是你对原地算法和空间换时间的理解,后者考的是你对数据有序性的敏感度。它们都属于那种"看起来简单,写起来容易翻车"的典型题目。

我第一次接触这两道题的时候,心里想的是:这不就是遍历吗?一个用哈希表,一个逐行二分,能有多难?结果面试的时候被追问"能不能不用额外空间"、"时间复杂度能不能再降",当场就卡壳了。后来刷题刷多了才发现,这两道题背后藏着的东西远比表面看起来深——它们分别对应了数组类问题的两大核心思路:下标映射和有序性利用。

这篇文章我会把这两道题彻底拆开,从暴力解法一路讲到最优解,把每一步的思考过程、参数选择、边界处理都讲清楚。不管你是刚开始刷题的新手,还是想重新梳理思路的老手,应该都能从中找到有用的东西。尤其是那些"为什么这么想"的部分,我会尽量讲透,因为解题思路比代码本身重要得多。

2. 找出数组中重复的数:从哈希表到原地交换

2.1 题目描述与核心约束

先把题目说清楚。给定一个长度为 n 的数组 nums,里面所有数字都在 0 到 n-1 的范围内。数组中某些数字是重复的,但不知道有几个数字重复了,也不知道每个数字重复了几次。要求找出数组中任意一个重复的数字。

举个例子:nums = [2, 3, 1, 0, 2, 5, 3],长度是 7,所有数字都在 0 到 6 之间。这里面 2 和 3 都是重复的,返回其中任意一个就行。

这道题有一个非常关键的约束:数字范围是 0 到 n-1。很多人第一眼看到这个条件会忽略掉,觉得只是个普通的数组。但恰恰是这个条件,决定了你能不能写出空间复杂度 O(1) 的解法。如果数字范围不是 0 到 n-1,那原地交换的思路就完全不成立了。

注意:题目说"找出任意一个重复的数字"就够了,不需要找出所有重复的数字,也不需要统计重复次数。这个细节很重要,它意味着你不需要完整遍历整个数组,找到就可以直接返回。

2.2 思路一:排序后相邻比较

最直观的做法是什么?排序。排完之后,重复的数字一定相邻,扫一遍就行了。

def find_duplicate_sort(nums): nums.sort() for i in range(1, len(nums)): if nums[i] == nums[i - 1]: return nums[i] return -1

这个解法的时间复杂度是 O(n log n),空间复杂度取决于排序算法,Python 的 sort 是 Timsort,最坏情况需要 O(n) 的额外空间。

优点是简单,不容易写错。缺点是时间复杂度不够好,而且会修改原数组。如果面试官问你"能不能不修改原数组",这个解法就直接出局了。

2.3 思路二:哈希表记录出现次数

第二个想到的肯定是哈希表。遍历数组,用一个集合记录已经出现过的数字,如果当前数字已经在集合里了,直接返回。

def find_duplicate_hash(nums): seen = set() for num in nums: if num in seen: return num seen.add(num) return -1

时间复杂度 O(n),空间复杂度 O(n)。这是最通用的解法,不管数字范围是多少都能用。但问题也很明显:需要额外的 O(n) 空间。如果面试官说"空间复杂度能不能降到 O(1)",你就得换思路了。

2.4 思路三:原地交换,下标映射法

这才是这道题的精髓所在。

核心思想是这样的:因为数字范围是 0 到 n-1,所以如果没有重复数字,每个数字 i 应该正好放在下标 i 的位置上。也就是说,nums[i] == i 应该对所有 i 成立。

那如果有重复数字呢?说明某个位置上的数字不对,而另一个数字占了两个位置。我们可以通过不断交换,把每个数字放到它"应该在"的位置上。交换过程中如果发现目标位置上已经有了正确的数字,那就找到了重复。

具体流程是这样的:

  1. 从下标 0 开始遍历。
  2. 如果 nums[i] == i,说明这个位置已经对了,跳过。
  3. 如果 nums[i] != i,检查 nums[nums[i]] 是否等于 nums[i]。
    • 如果相等,说明 nums[i] 这个数字在它应该在的位置上已经有一个了,找到了重复,直接返回。
    • 如果不等,交换 nums[i] 和 nums[nums[i]],把 nums[i] 放到正确的位置上。
  4. 交换后继续在当前下标检查(因为换过来的新数字可能也不对)。
def find_duplicate_swap(nums): i = 0 while i < len(nums): if nums[i] == i: i += 1 continue if nums[nums[i]] == nums[i]: return nums[i] # 交换 nums[nums[i]], nums[i] = nums[i], nums[nums[i]] return -1

这个解法的时间复杂度是 O(n)。虽然有一个 while 循环里面套了交换操作,但每个数字最多被交换两次就能放到正确的位置上,所以总的交换次数是 O(n) 的。空间复杂度是 O(1),完全原地操作。

实操心得:写交换的时候一定要注意顺序。nums[nums[i]], nums[i] = nums[i], nums[nums[i]]这个写法在 Python 里是对的,因为右边先求值。但在 C++ 或 Java 里,你得用临时变量,否则会出错。我见过太多人在这里翻车。

2.5 二分查找的思路:不修改数组的 O(1) 空间解法

如果面试官加一个条件:"不能修改原数组,且空间复杂度 O(1)",那原地交换就不能用了。这时候可以用二分查找的思路。

思路是这样的:数字范围是 0 到 n-1,我们可以统计数组中落在某个区间 [left, right] 内的数字个数。如果个数大于区间长度,说明重复数字在这个区间内。然后缩小范围继续找。

def find_duplicate_binary(nums): left, right = 0, len(nums) - 1 while left < right: mid = (left + right) // 2 count = sum(1 for num in nums if left <= num <= mid) if count > mid - left + 1: right = mid else: left = mid + 1 return left

时间复杂度是 O(n log n),因为每次统计需要遍历整个数组,二分需要 log n 次。空间复杂度 O(1)。这个解法不修改原数组,但时间上比原地交换慢一些。

注意:二分查找解法有一个前提,就是数字范围必须是 0 到 n-1。如果范围变了,这个解法就不适用了。而且这个解法只能找到一个重复数字,不能保证找到所有。

2.6 三种解法的对比与选择

解法时间复杂度空间复杂度是否修改原数组适用场景
排序O(n log n)O(n)是快速实现,不要求最优
哈希表O(n)O(n)否通用,数字范围无限制
原地交换O(n)O(1)是数字范围 0~n-1,允许修改
二分查找O(n log n)O(1)否不允许修改数组,范围 0~n-1

实际面试中,我一般会先写哈希表解法,保证正确性,然后根据面试官的追问逐步优化。如果面试官直接问"能不能 O(1) 空间",那就上原地交换。如果再加"不能修改数组",那就上二分。

3. 二维数组中的查找:有序性带来的降维打击

3.1 题目描述与关键条件

题目是这样的:在一个二维数组中,每一行都按照从左到右递增的顺序排序,每一列都按照从上到下递增的顺序排序。请完成一个函数,输入这样的一个二维数组和一个整数,判断数组中是否含有该整数。

举个例子:

1 2 8 9 2 4 9 12 4 7 10 13 6 8 11 15

查找 7 返回 true,查找 5 返回 false。

这道题的关键在于每一行从左到右递增,每一列从上到下递增。注意,不是整个二维数组全局有序,而是行和列分别有序。这个条件比全局有序弱,但比完全无序强得多。

很多人第一反应是逐行二分查找。每一行都是有序的,对每一行做二分,时间复杂度是 O(m log n),其中 m 是行数,n 是列数。这个解法不算差,但还不是最优的。

3.2 暴力遍历与逐行二分

暴力遍历就是两层循环,时间复杂度 O(mn),基本不用考虑,除非数组特别小。

逐行二分稍微好一点:

def search_matrix_binary(matrix, target): for row in matrix: left, right = 0, len(row) - 1 while left <= right: mid = (left + right) // 2 if row[mid] == target: return True elif row[mid] < target: left = mid + 1 else: right = mid - 1 return False

时间复杂度 O(m log n),空间复杂度 O(1)。这个解法利用了行的有序性,但没有利用列的有序性。如果面试官追问"能不能利用列也有序这个条件",你就需要换思路了。

3.3 右上角起始的线性查找

这是这道题的最优解,也是最能体现"有序性利用"精髓的解法。

核心思想是:从右上角开始查找。为什么是右上角?因为右上角这个位置很特殊——它是当前行的最大值,同时是当前列的最小值。

具体流程:

  1. 从右上角 (0, n-1) 开始。
  2. 如果当前元素等于目标值,返回 true。
  3. 如果当前元素大于目标值,说明目标值不可能在这一列(因为这一列从上到下递增,当前元素已经是这一列最小的了,下面的只会更大),所以向左移动一列。
  4. 如果当前元素小于目标值,说明目标值不可能在这一行(因为这一行从左到右递增,当前元素已经是这一行最大的了,左边的只会更小),所以向下移动一行。
  5. 重复直到越界,返回 false。
def search_matrix(matrix, target): if not matrix or not matrix[0]: return False rows, cols = len(matrix), len(matrix[0]) row, col = 0, cols - 1 while row < rows and col >= 0: if matrix[row][col] == target: return True elif matrix[row][col] > target: col -= 1 else: row += 1 return False

时间复杂度是 O(m + n),因为每次要么向左一列,要么向下一行,最多走 m + n 步。空间复杂度 O(1)。这个解法同时利用了行和列的有序性,是最优解。

实操心得:为什么选右上角而不是左下角?其实左下角也可以,思路完全对称。左下角是当前行的最小值,当前列的最大值。如果当前元素大于目标值,向上移动;如果小于目标值,向右移动。但右上角更符合直觉,因为"从大到小排除列,从小到大排除行"这个逻辑比较顺。

3.4 为什么不能从左上角或右下角开始

这个问题我被问过好几次。从左上角开始,当前元素是全局最小值。如果目标值比它大,你既不能排除行也不能排除列,因为右边和下边都可能包含目标值。这就退化成暴力搜索了。

右下角同理,当前元素是全局最大值。如果目标值比它小,你既不能排除行也不能排除列,因为左边和上边都可能包含目标值。

所以关键点是:起始位置必须能够让你在每次比较后,明确排除一行或一列。右上角和左下角满足这个条件,左上角和右下角不满足。

3.5 边界条件与特殊输入处理

这道题有几个容易翻车的边界条件:

  • 矩阵为空:matrix = [],直接返回 false。
  • 矩阵只有一行或一列:逻辑仍然成立,但要注意行列索引不要越界。
  • 矩阵中所有元素相同:如果目标值等于这个元素,返回 true;否则在右上角比较后就会一直移动直到越界。
  • 目标值比矩阵中所有元素都小或都大:会一直移动直到越界,返回 false。
# 边界测试用例 assert search_matrix([], 5) == False assert search_matrix([[]], 5) == False assert search_matrix([[1]], 1) == True assert search_matrix([[1]], 2) == False assert search_matrix([[1, 2, 3]], 2) == True assert search_matrix([[1], [2], [3]], 2) == True

注意:Python 中if not matrix or not matrix[0]这个判断很关键。如果只写if not matrix,当 matrix 是[[]]的时候会通过检查,然后在len(matrix[0])那里报 IndexError。我踩过这个坑,排查了半天才发现是空行的问题。

4. 两道题的共通思维:有序性与下标映射

4.1 下标映射的本质

"找出数组中重复的数"的原地交换解法,本质上是利用了数字与下标的一一对应关系。因为数字范围是 0 到 n-1,所以每个数字都有一个"应该在"的位置。通过交换把数字放到正确的位置上,就能在 O(n) 时间内找到重复。

这种思路在很多数组类问题中都有应用。比如"缺失的第一个正数"、"找到所有数组中消失的数字"等,都是利用下标映射来做到 O(1) 空间。

核心条件是:元素的取值范围和数组长度有明确的对应关系。如果这个条件不满足,下标映射就用不了。

4.2 有序性利用的通用套路

"二维数组中的查找"利用的是行和列分别有序这个条件。通过从右上角开始,每次比较都能排除一行或一列,把 O(mn) 的暴力搜索降到 O(m+n)。

有序性利用的通用套路是:找到一个起始点,使得每次比较后能排除一部分搜索空间。一维有序数组的二分查找是这个套路的最简单形式,二维有序矩阵的右上角查找是它的二维扩展。

类似的题目还有"搜索旋转排序数组"、"寻找峰值"等,都是利用局部有序性来缩小搜索范围。

4.3 空间换时间的取舍

这两道题都涉及一个核心取舍:要不要用额外空间来换时间。

哈希表解法用 O(n) 空间换 O(n) 时间,原地交换用 O(1) 空间但也做到了 O(n) 时间。二分查找用 O(1) 空间但时间是 O(n log n)。

实际做题时,我一般会先问清楚约束条件:能不能修改原数组?空间复杂度有没有要求?时间复杂度有没有要求?根据这些条件来选择最合适的解法。

实操心得:面试的时候,不要一上来就写最优解。先写一个能跑的暴力解,然后逐步优化。这样既能保证有东西可写,又能展示你的思考过程。面试官更看重的是你的思路,而不是你能不能背出最优解的代码。

5. 常见问题与排查技巧实录

5.1 原地交换法为什么会死循环

原地交换法最容易出的问题就是死循环。我一开始写的时候,交换完之后没有正确处理当前下标,导致一直在同一个位置打转。

问题出在这里:交换之后,当前位置的数字变了,但你可能还需要继续检查这个位置。如果你直接i += 1,就会漏掉一些情况。

正确的做法是:交换之后不移动 i,继续在当前下标检查。只有当nums[i] == i的时候才移动 i。

# 错误写法:交换后直接 i += 1 while i < len(nums): if nums[i] != i: nums[nums[i]], nums[i] = nums[i], nums[nums[i]] i += 1 # 这里可能跳过需要检查的位置 # 正确写法:交换后不移动 i while i < len(nums): if nums[i] == i: i += 1 elif nums[nums[i]] == nums[i]: return nums[i] else: nums[nums[i]], nums[i] = nums[i], nums[nums[i]]

5.2 二维数组查找的越界问题

二维数组查找最常见的 bug 是数组越界。尤其是在矩阵为空、只有一行、只有一列的情况下,很容易出现 IndexError。

排查的时候,我一般会先加边界检查,然后用小规模的测试用例逐步验证。比如先用 1x1 的矩阵测试,再用 1xN 和 Nx1 的矩阵测试,最后用完整的矩阵测试。

问题现象可能原因排查方法
IndexError矩阵为空或空行检查not matrix or not matrix[0]
结果错误行列索引搞反了打印每次移动后的 row 和 col
死循环移动方向搞反了检查大于和小于时的移动逻辑
漏解起始位置选错了确认从右上角或左下角开始

5.3 二分查找的边界处理

二分查找的边界处理是出了名的容易出错。left <= right还是left < right?mid + 1还是mid?这些细节稍不注意就会导致死循环或漏解。

我的经验是:统一使用左闭右闭区间,即left <= right,right = mid - 1,left = mid + 1。这样不容易出错。

# 左闭右闭的标准写法 left, right = 0, len(nums) - 1 while left <= right: mid = (left + right) // 2 if nums[mid] == target: return mid elif nums[mid] < target: left = mid + 1 else: right = mid - 1 return -1

注意:mid = (left + right) // 2在 Python 中不会溢出,但在 C++ 或 Java 中,如果 left 和 right 都很大,可能会溢出。安全的写法是mid = left + (right - left) // 2。

5.4 面试中的追问与应对

这两道题在面试中经常会有追问。我整理了一些常见的追问和应对思路:

追问一:如果数组中有多个重复数字,怎么找出所有?

对于"找出数组中重复的数",如果需要找出所有重复数字,哈希表解法最直接。原地交换法也可以,但需要额外处理,因为找到一个重复后不能直接返回,要继续扫描。

追问二:如果数字范围不是 0 到 n-1 呢?

那原地交换和二分查找都不能用了,只能用哈希表或排序。这也是为什么面试官会先给出数字范围这个条件——它决定了你能用哪些解法。

追问三:二维数组如果行和列不是分别有序,而是全局有序呢?

如果整个二维数组全局有序(按行展开后是一个有序一维数组),那可以直接把二维数组当作一维数组做二分查找,时间复杂度 O(log(mn))。

追问四:如果二维数组的行数和列数非常大,内存放不下怎么办?

这是一个系统设计类的问题。可以考虑分块读取,或者用外部排序的思路。不过一般面试中不会问到这么深,除非是专门考系统设计的岗位。

6. 从刷题到实战:这些思路还能用在哪

6.1 数据去重与重复检测

"找出数组中重复的数"的思路在实际工作中非常常见。比如日志分析中检测重复请求、数据清洗中找出重复记录、用户行为分析中识别异常重复操作等。

哈希表解法是最通用的,适合大多数场景。如果数据量特别大,内存放不下,可以考虑用布隆过滤器做初步筛选,或者用外部排序后相邻比较。

原地交换法的思路在特定场景下也很有用。比如数据本身就是按某种规则编号的,你可以利用编号和存储位置的对应关系来快速检测重复。

6.2 有序矩阵的快速检索

"二维数组中的查找"的思路在数据库索引、图像处理、游戏地图寻路等场景中都有应用。

比如在一个按行按列都有序的评分矩阵中查找某个分数,或者在一个按时间和优先级排序的任务队列中查找特定任务,都可以用类似的思路。

核心思想是:利用数据的局部有序性,每次比较排除一部分搜索空间。这个思想不仅适用于二维数组,也适用于更高维度的有序结构。

6.3 算法思维的迁移

这两道题教给我们的不只是具体的解法,更是一种思维方式:

  • 观察数据特征:数字范围、有序性、重复性,这些特征决定了你能用什么算法。
  • 权衡时空复杂度:没有绝对最优的解法,只有最适合当前约束的解法。
  • 从暴力到最优:先写出能跑的解法,再逐步优化,这是解决所有算法问题的通用路径。

我在实际工作中遇到性能问题时,经常会回想这两道题的思路。比如一个查询很慢,我会先想:数据有没有有序性可以利用?能不能用空间换时间?能不能通过某种映射关系把 O(n) 降到 O(1)?

这些思维方式,比具体的代码更有价值。代码会过时,但思考问题的方式不会。

最后分享一个我刷题时的小习惯:每做完一道题,我会问自己三个问题——这道题的核心条件是什么?如果条件变了,解法要怎么调整?这道题的思路还能用在哪些地方?这三个问题能帮我把一道题吃透,而不是做完就忘。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 7:13:08

MES 交期管理为什么算不准?插单换线那天最见真章

在百度搜「MES 交期管理」&#xff0c;翻几页大多是通用制造业的排产介绍&#xff0c;专门讲半导体封测这一段的并不多。做封测 MES 的益普科技在项目里被追问得最多的一个问题是&#xff1a;系统都上了&#xff0c;给客户的承诺日期为什么还是靠车间主任拍脑袋&#xff1f;这个…

作者头像 李华
网站建设 2026/10/18 12:24:35

基于FSK调制的磁带数据存储方案:从编码到解码的完整实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/22 5:04:03

交叉验证:原理、变体与规避评估陷阱的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/18 10:51:44

基于MP2642GR-Z的串联电池主动均衡控制系统设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/21 16:29:17

TMS570LS0432片内FLASH读写:自建工程与FAPI驱动详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/21 15:34:51

棋盘格隐写:基于奇偶相位的双图隐藏与还原

摘要&#xff1a;本文介绍一种基于棋盘格奇偶相位的图像隐写方法&#xff1a;将原图映射到高亮度区间&#xff0c;将暗影图映射到低亮度区间&#xff0c;再按棋盘格交替写入同一张图。解码时分别提取奇相位和偶相位像素&#xff0c;即可还原原图与暗影图。文中包含编码、检测、…

作者头像 李华