原地哈希算法:O(1)空间复杂度解决数组查找问题
2026/9/7 7:41:47 网站建设 项目流程

如果你正在准备算法面试,或者刷过 LeetCode 的数组类题目,很可能遇到过这样的场景:题目要求找出数组中缺失的第一个正数,或者找出重复的数字,但附加条件往往是"时间复杂度 O(n),空间复杂度 O(1)"。

这种限制意味着你不能使用额外的哈希表来存储元素,也不能对数组进行排序(排序通常需要 O(n log n) 时间)。这时候,原地哈希(In-place Hashing)就成为了解决问题的关键技巧。

很多人第一次接触原地哈希时会感到困惑——既要使用哈希的思想来快速查找,又不能分配额外空间,这听起来像是矛盾的。但实际上,原地哈希的核心思想非常巧妙:利用数组本身作为哈希表,通过元素交换和位置映射来实现 O(1) 空间复杂度的查找

本文将带你深入理解原地哈希的原理,并通过 LeetCode 经典题目"缺失的第一个正数"(第41题)来掌握这一技巧的实际应用。

1. 原地哈希要解决的核心问题

1.1 传统哈希表的局限性

在常规算法中,当我们遇到需要快速查找元素是否存在的情况时,第一反应通常是使用哈希表(HashSet 或 HashMap)。比如要找出数组中缺失的数字,我们可以:

// 传统做法:使用额外空间 public int findMissingNumber(int[] nums) { Set<Integer> set = new HashSet<>(); for (int num : nums) { set.add(num); } for (int i = 1; i <= nums.length; i++) { if (!set.contains(i)) { return i; } } return -1; }

这种方法的时间复杂度是 O(n),但空间复杂度也是 O(n)。在面试中,面试官往往会追问:"能否在不使用额外空间的情况下解决?"

1.2 原地哈希的适用场景

原地哈希特别适用于以下类型的题目:

  1. 找出数组中缺失的最小正整数(LeetCode 41)
  2. 找出数组中重复的数字(LeetCode 287)
  3. 找出数组中消失的数字(LeetCode 448)
  4. 第一个缺失的正数等变体问题

这些题目的共同特点是:数组长度已知,元素范围有一定规律,可以通过位置映射来记录信息。

1.3 原地哈希的核心思想

原地哈希的基本思路是:让数组的索引与元素值建立对应关系。具体来说,对于值为 x 的元素,我们尝试将它放到数组中索引为 x-1 的位置上(假设数组索引从 0 开始)。

通过这种"物归原位"的方式,我们可以在遍历数组时,通过检查nums[i]是否等于i+1来判断数字是否存在。

2. 原地哈希的基本原理与核心概念

2.1 位置映射关系

原地哈希最核心的概念就是建立元素值与数组索引的映射关系。对于大多数原地哈希问题,我们使用以下映射规则:

元素值 x 应该位于数组索引 x-1 的位置

这意味着:

  • 数字 1 应该放在索引 0
  • 数字 2 应该放在索引 1
  • 数字 3 应该放在索引 2
  • ...
  • 数字 n 应该放在索引 n-1

2.2 交换策略

为了实现上述映射,我们需要遍历数组,对于每个位置 i:

  1. 如果nums[i]的值在有效范围内(通常是 1 到 n)
  2. 并且nums[i]不在它应该在的位置上
  3. 那么就将nums[i]与它应该在的位置上的元素交换

这个过程需要循环进行,因为交换过来的新元素可能也需要继续交换。

2.3 边界情况处理

在实际实现中,需要特别注意以下边界情况:

  1. 重复元素:当存在重复数字时,交换可能会陷入死循环
  2. 超出范围的数字:数字可能为负数、0,或者大于数组长度
  3. 原地交换:要确保交换操作不会破坏已经就位的元素

3. 环境准备与前置条件

3.1 编程语言选择

原地哈希算法与具体编程语言无关,本文以 Java 为例进行演示,但原理适用于所有主流编程语言。

3.2 基础代码框架

我们需要一个可以运行和测试的代码环境:

public class InPlaceHashing { public static void main(String[] args) { // 测试用例 int[] test1 = {1, 2, 0}; int[] test2 = {3, 4, -1, 1}; int[] test3 = {7, 8, 9, 11, 12}; System.out.println("测试1结果: " + firstMissingPositive(test1)); // 应输出 3 System.out.println("测试2结果: " + firstMissingPositive(test2)); // 应输出 2 System.out.println("测试3结果: " + firstMissingPositive(test3)); // 应输出 1 } public static int firstMissingPositive(int[] nums) { // 原地哈希算法实现 // 具体实现见下文 } }

3.3 理解题目要求

以 LeetCode 41题"缺失的第一个正数"为例:

  • 给定一个未排序的整数数组nums
  • 找出其中没有出现的最小的正整数
  • 时间复杂度必须是 O(n),空间复杂度必须是 O(1)

示例:

  • 输入:[1,2,0]→ 输出:3
  • 输入:[3,4,-1,1]→ 输出:2
  • 输入:[7,8,9,11,12]→ 输出:1

4. 原地哈希算法详细实现步骤

4.1 第一步:处理边界值和无效数字

在开始交换之前,我们需要先处理那些不在有效范围内的数字。对于寻找缺失正数的问题,我们只关心 1 到 n 之间的数字(n 是数组长度)。

// 第一步:预处理 - 识别需要处理的数字范围 // 我们只关心数字 1 到 n,其他数字可以忽略

4.2 第二步:实施原地交换

这是算法的核心部分。我们遍历数组,将每个数字放到它应该在的位置上。

// 核心交换逻辑 int n = nums.length; for (int i = 0; i < n; i++) { // 当当前数字在有效范围内,且不在正确位置上时,进行交换 while (nums[i] > 0 && nums[i] <= n && nums[nums[i] - 1] != nums[i]) { // 交换 nums[i] 和它应该在的位置上的元素 swap(nums, i, nums[i] - 1); } }

4.3 第三步:检查第一个不匹配的位置

交换完成后,我们再次遍历数组,找到第一个nums[i] != i + 1的位置。

// 检查第一个缺失的正数 for (int i = 0; i < n; i++) { if (nums[i] != i + 1) { return i + 1; } } // 如果所有位置都匹配,说明缺失的是 n+1 return n + 1;

4.4 完整的算法实现

将上述步骤组合起来,得到完整的原地哈希算法:

public class InPlaceHashing { // 交换数组中两个位置的元素 private static void swap(int[] nums, int i, int j) { int temp = nums[i]; nums[i] = nums[j]; nums[j] = temp; } public static int firstMissingPositive(int[] nums) { int n = nums.length; // 第一步:实施原地哈希 for (int i = 0; i < n; i++) { // 只有当数字在 1 到 n 范围内,且不在正确位置时,才进行交换 while (nums[i] > 0 && nums[i] <= n && nums[nums[i] - 1] != nums[i]) { swap(nums, i, nums[i] - 1); } } // 第二步:查找第一个不匹配的位置 for (int i = 0; i < n; i++) { if (nums[i] != i + 1) { return i + 1; } } // 如果所有位置都正确,缺失的就是 n+1 return n + 1; } }

5. 算法执行过程详细分析

5.1 示例1:[3, 4, -1, 1]的执行过程

让我们逐步分析这个例子的执行过程:

初始数组:[3, 4, -1, 1]

第一次遍历(i=0):

  • nums[0] = 3,应该在索引 2 的位置
  • 当前索引 2 的值是 -1,不相等,进行交换
  • 交换后:[-1, 4, 3, 1]

继续 i=0:

  • nums[0] = -1,不在 1-4 范围内,跳过

i=1:

  • nums[1] = 4,应该在索引 3 的位置
  • 当前索引 3 的值是 1,不相等,进行交换
  • 交换后:[-1, 1, 3, 4]

继续 i=1:

  • nums[1] = 1,应该在索引 0 的位置
  • 当前索引 0 的值是 -1,不相等,进行交换
  • 交换后:[1, -1, 3, 4]

i=2:

  • nums[2] = 3,应该在索引 2 的位置,已经在正确位置,跳过

i=3:

  • nums[3] = 4,应该在索引 3 的位置,已经在正确位置,跳过

最终数组:[1, -1, 3, 4]

检查结果:

  • 索引 0:1 = 0+1 ✓
  • 索引 1:-1 ≠ 1+1 → 第一个缺失的正数是 2

5.2 为什么使用 while 循环而不是 for 循环

在核心交换部分,我们使用while循环而不是简单的if判断,这是因为:

// 错误的做法:使用 if 判断 for (int i = 0; i < n; i++) { if (nums[i] > 0 && nums[i] <= n && nums[nums[i] - 1] != nums[i]) { swap(nums, i, nums[i] - 1); } } // 问题:交换后 nums[i] 位置的新元素可能也需要继续交换

使用while循环确保每个位置上的元素都被正确处理,直到它被放到正确位置或者是不需要处理的元素。

6. 时间复杂度与空间复杂度分析

6.1 时间复杂度分析

虽然代码中有嵌套循环(外层 for 循环,内层 while 循环),但总的时间复杂度仍然是 O(n)。这是因为:

  1. 每个元素最多被交换一次到正确位置
  2. 一旦元素被放到正确位置,就不会再被移动
  3. 总共最多进行 n 次交换操作

因此,尽管有嵌套循环,但总的操作次数是线性的。

6.2 空间复杂度分析

算法只使用了常数级别的额外空间(几个临时变量),没有使用任何与输入规模相关的额外数据结构,因此空间复杂度是 O(1)。

7. 常见问题与排查思路

7.1 死循环问题

问题现象:程序陷入无限循环,无法正常结束。

可能原因:存在重复元素时,如果没有正确处理交换条件,可能导致死循环。

解决方案:确保交换条件中包含nums[nums[i] - 1] != nums[i],这个条件防止了将相同元素反复交换。

// 正确的条件:确保不会交换相同的元素 while (nums[i] > 0 && nums[i] <= n && nums[nums[i] - 1] != nums[i]) { swap(nums, i, nums[i] - 1); }

7.2 数组越界问题

问题现象:出现ArrayIndexOutOfBoundsException

可能原因:在计算nums[i] - 1时,如果nums[i]是负数或0,或者大于数组长度,会导致索引越界。

解决方案:在访问数组前先检查索引的有效性。

// 安全的做法:先检查范围再访问 if (nums[i] > 0 && nums[i] <= n) { int targetIndex = nums[i] - 1; if (nums[targetIndex] != nums[i]) { swap(nums, i, targetIndex); } }

7.3 结果错误问题

问题现象可能原因排查方式解决方案
总是返回1没有正确处理交换打印交换过程中的数组状态检查while循环条件是否正确
返回n+1但实际有缺失交换逻辑错误单步调试查看每个元素的最终位置验证映射关系是否正确
对于特定测试用例失败边界情况未处理分析失败用例的特殊性增加对负数、0、大数的处理

7.4 调试技巧

当算法出现问题时,可以添加调试输出来观察执行过程:

public static int firstMissingPositiveWithDebug(int[] nums) { int n = nums.length; System.out.println("初始数组: " + Arrays.toString(nums)); for (int i = 0; i < n; i++) { System.out.println("处理索引 " + i + ", 当前值: " + nums[i]); while (nums[i] > 0 && nums[i] <= n && nums[nums[i] - 1] != nums[i]) { System.out.println("交换 " + nums[i] + " 到索引 " + (nums[i] - 1)); swap(nums, i, nums[i] - 1); System.out.println("交换后数组: " + Arrays.toString(nums)); } } // ... 其余代码不变 }

8. 原地哈希的变体与应用场景

8.1 找出数组中重复的数字(LeetCode 287)

题目要求:给定一个包含 n + 1 个整数的数组 nums,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。假设只有一个重复的数字,找出这个重复的数。

原地哈希解法:

public int findDuplicate(int[] nums) { int n = nums.length; for (int i = 0; i < n; i++) { // 将数字放到对应的位置 while (nums[i] != i + 1) { if (nums[i] == nums[nums[i] - 1]) { // 找到重复数字 return nums[i]; } swap(nums, i, nums[i] - 1); } } return -1; }

8.2 找到所有数组中消失的数字(LeetCode 448)

题目要求:给定一个范围在 1 ≤ a[i] ≤ n ( n = 数组大小 ) 的整型数组,数组中的元素一些出现了两次,另一些只出现一次。找到所有在 [1, n] 范围之间没有出现在数组中的数字。

原地哈希解法:

public List<Integer> findDisappearedNumbers(int[] nums) { int n = nums.length; List<Integer> result = new ArrayList<>(); // 使用原地哈希将数字放到正确位置 for (int i = 0; i < n; i++) { while (nums[i] != i + 1 && nums[i] != nums[nums[i] - 1]) { swap(nums, i, nums[i] - 1); } } // 遍历检查哪些位置上的数字不正确 for (int i = 0; i < n; i++) { if (nums[i] != i + 1) { result.add(i + 1); } } return result; }

8.3 不同问题的对比分析

问题类型核心思路特殊处理返回结果
缺失的第一个正数将1-n的数字放到正确位置忽略超出范围的数字第一个不匹配的位置
寻找重复数字交换过程中发现重复遇到重复立即返回重复的数字
消失的数字同缺失第一个正数收集所有不匹配位置所有缺失数字的列表

9. 最佳实践与工程建议

9.1 代码可读性优化

虽然原地哈希算法本身比较简洁,但我们可以通过一些技巧提高代码的可读性:

public class InPlaceHashing { private static void swap(int[] nums, int i, int j) { int temp = nums[i]; nums[i] = nums[j]; nums[j] = temp; } private static boolean shouldProcess(int num, int n) { return num > 0 && num <= n; } private static boolean isInCorrectPosition(int[] nums, int index) { return nums[index] == index + 1; } public static int firstMissingPositive(int[] nums) { int n = nums.length; // 更清晰的逻辑表达 for (int i = 0; i < n; i++) { while (shouldProcess(nums[i], n) && !isInCorrectPosition(nums, nums[i] - 1)) { swap(nums, i, nums[i] - 1); } } for (int i = 0; i < n; i++) { if (!isInCorrectPosition(nums, i)) { return i + 1; } } return n + 1; } }

9.2 边界情况测试

在实际项目中,应该充分测试各种边界情况:

public class InPlaceHashingTest { @Test public void testVariousCases() { // 正常情况 assertEquals(3, firstMissingPositive(new int[]{1, 2, 0})); assertEquals(2, firstMissingPositive(new int[]{3, 4, -1, 1})); assertEquals(1, firstMissingPositive(new int[]{7, 8, 9, 11, 12})); // 边界情况 assertEquals(1, firstMissingPositive(new int[]{})); // 空数组 assertEquals(2, firstMissingPositive(new int[]{1})); // 单元素 assertEquals(1, firstMissingPositive(new int[]{2})); // 单元素但缺失1 // 包含重复元素 assertEquals(3, firstMissingPositive(new int[]{1, 1, 2})); assertEquals(4, firstMissingPositive(new int[]{1, 2, 2, 3})); // 最大边界 assertEquals(6, firstMissingPositive(new int[]{1, 2, 3, 4, 5})); } }

9.3 性能优化考虑

虽然原地哈希已经是最优解,但在实际应用中还可以考虑:

  1. 提前终止:如果能在交换过程中提前发现结果,可以提前返回
  2. 内存局部性:连续的数组访问有利于缓存命中
  3. 避免不必要的交换:仔细设计交换条件,减少操作次数

9.4 面试技巧

在技术面试中讲解原地哈希时,建议:

  1. 先讲暴力解法:展示你理解问题的本质
  2. 分析限制条件:说明为什么需要 O(1) 空间复杂度
  3. 逐步推导:从简单例子开始,演示算法思路
  4. 处理边界情况:展示你的代码健壮性
  5. 分析复杂度:证明算法满足要求

原地哈希是算法面试中的高频考点,掌握这一技巧不仅能解决特定问题,更能体现你对空间复杂度的深刻理解和创造性解决问题的能力。通过本文的详细讲解和代码实践,你应该能够 confidently 应对相关的算法挑战。

建议将本文中的代码示例收藏备用,在实际遇到相关问题时快速参考。对于想要进一步深入学习的读者,可以尝试用原地哈希解决 LeetCode 上的相似题目,如第268题(缺失数字)、第442题(数组中重复的数据)等,巩固这一重要算法技巧。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询