[1] 两数之和

13 阅读32分钟

题目描述

给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出和为目标值 target 的那两个整数,并返回它们的数组下标。

你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。

你可以按任意顺序返回答案。

判题标准

系统会用以下代码来测试你的题解:

int[] nums = [...]; // 输入数组
int[] expectedNums = [...]; // 长度正确的期望答案
int target = ...; // 目标值
int[] result = twoSum(nums, target); // 调用
assert result.length == 2;
assert nums[result[0]] + nums[result[1]] == target;

示例

示例 1:

输入:nums = [2,7,11,15], target = 9
输出:[0,1]
解释:因为 nums[0] + nums[1] == 9 ,返回 [0, 1]

示例 2:

输入:nums = [3,2,4], target = 6
输出:[1,2]

示例 3:

输入:nums = [3,3], target = 6
输出:[0,1]

提示

  • 2 <= nums.length <= 10^4
  • -10^9 <= nums[i] <= 10^9
  • -10^9 <= target <= 10^9
  • 只会存在一个有效答案

进阶

你可以想出一个时间复杂度小于 O(n²) 的算法吗?

代码运行环境说明

本文档中的所有代码均基于 C++11 标准编写,需要包含以下头文件:

  • <vector>: 用于使用 std::vector 容器
  • <unordered_map>: 用于使用 std::unordered_map 哈希表
  • <algorithm>: 用于使用 std::find 等 STL 算法
  • <utility>: 用于使用 std::pair 数据结构

编译时请确保使用支持 C++11 及以上标准的编译器,例如:

g++ -std=c++11 two-sum.cpp -o two-sum

解题思路

本题提供了多种解法,每种都有其独特的优势和适用场景。我们可以将这些解法分为以下几类:

  1. 算法本质实现:展示算法核心思想,不依赖STL库函数
  2. STL应用实现:利用STL库函数简化代码实现
  3. 其他特殊解法:展示不同的编程思想和技巧

解法比较与选择

算法本质实现 vs STL应用实现

  1. 算法本质实现

    • 优点:有助于理解算法核心思想,不依赖外部库,便于移植到其他语言
    • 缺点:代码相对复杂,需要手动处理更多细节
    • 适用场景:学习算法原理、面试手写代码、嵌入式等受限环境
  2. STL应用实现

    • 优点:代码简洁,开发效率高,经过充分测试
    • 缢点:依赖特定库,可能隐藏实现细节
    • 适用场景:实际项目开发、快速原型开发

推荐解法

在实际应用中,推荐使用方法二(哈希表一次遍历),理由如下:

  1. 时间复杂度O(n),空间复杂度O(n),均为较优
  2. 只需要一次遍历,效率高
  3. 逻辑清晰,易于理解和实现
  4. 无边界漏洞,适用于各种边界情况

极端测试用例

为了验证各种解法在边界条件下的正确性和鲁棒性,我们整理了以下极端测试用例:

1. 最小数组

输入:nums = [1,2], target = 3 预期输出:[0,1] 说明:测试解法对最小数组的处理能力

2. 最大负数和最大正数

输入:nums = [-1000000000, 1000000000, 1], target = 0 预期输出:[0,1] 说明:测试解法对边界数值的处理能力

3. 相同元素

输入:nums = [3,3], target = 6 预期输出:[0,1] 说明:测试解法对相同元素的处理能力

4. 多个满足条件的组合

输入:nums = [1,2,3,4,5], target = 6 预期输出:[1,3][0,4] 说明:测试解法是否能正确返回一个有效答案

5. 最大数组长度

输入:nums = [1,2,3,...,10000], target = 19999 预期输出:[9998,9999] 说明:测试解法在最大输入规模下的性能和稳定性

6. 无解情况

输入:nums = [1,2,3], target = 10 预期输出:[] 说明:测试解法对无解情况的处理能力

测试建议

  1. 对于每种解法,都应该使用以上所有测试用例进行验证
  2. 特别关注边界值和相同元素这两种边界情况
  3. 对于递归解法,要注意测试大规模数据是否会引发栈溢出
  4. 对于STL相关解法,要验证其在极端输入下的行为是否符合预期
  5. 对于需要大量计算的解法(如暴力解法),要注意其在大规模数据下的性能表现

算法本质实现

这类解法展示了算法的核心思想,不依赖STL库函数,有助于理解算法本质。

方法一:暴力解法

// 【算法本质实现 - 双重循环】

std::vector<int> twoSum1(std::vector<int>& nums, int target) {
    // 外层循环遍历数组中的每个元素,作为第一个加数
    for (int i = 0; i < nums.size(); i++) {
        // 内层循环从 i+1 开始遍历,避免重复计算和使用相同元素
        // i之前的元素已与i匹配过,无需再次检查
        for (int j = i + 1; j < nums.size(); j++) {
            // 检查两个元素之和是否等于目标值
            if (nums[i] + nums[j] == target) {
                return {i, j};
            }
        }
    }
    return {};
}
  • 所需头文件<vector>
  • 时间复杂度:O(n²)
  • 空间复杂度:O(1)
  • 代码行数:6行
  • 数据结构复杂度:无复杂数据结构

直接双重循环遍历所有可能的组合。

算法详解

  1. 外层循环遍历数组中的每个元素
  2. 内层循环遍历当前元素之后的所有元素
  3. 检查两个元素之和是否等于目标值
  4. 如果找到匹配项,返回两个元素的索引

关键易错点

  • 内层循环应从 i+1 开始,避免使用相同元素两次
  • 需要正确处理边界条件,确保不越界访问

推荐理由

  • 思路简单直接,易于理解
  • 空间复杂度最优O(1)
  • 适用于小数据集

方法二:哈希表(一次遍历)- 推荐解法

// 【算法本质实现 - 哈希表一次遍历】

std::vector<int> twoSum(std::vector<int>& nums, int target) {
    // 创建一个哈希表用于存储已遍历的元素及其索引
    std::unordered_map<int, int> map;
    
    // 遍历数组中的每个元素
    for (int i = 0; i < nums.size(); i++) {
        // 在哈希表中查找当前元素的补数(target - nums[i])
        auto iter = map.find(target - nums[i]);
        // 如果找到补数,说明找到了两个和为target的元素
        if (iter != map.end()) {
            // 返回补数的索引和当前元素的索引
            return {iter->second, i};
        }
        // 将当前元素及其索引存入哈希表,供后续元素查找
        map[nums[i]] = i;
    }
    
    return {};
}
  • 所需头文件<vector>, <unordered_map>
  • 时间复杂度:O(n)
  • 空间复杂度:O(n)
  • 代码行数:12行
  • 数据结构复杂度:使用哈希表

边遍历边存储,同时查找是否存在匹配的元素。这是推荐在 LeetCode 上提交的解法。

算法详解

  1. 创建一个哈希表用于存储已遍历的元素及其索引
  2. 遍历数组中的每个元素
  3. 对于当前元素,计算其补数(target - nums[i])
  4. 在哈希表中查找补数是否存在
  5. 如果存在,返回补数的索引和当前元素的索引
  6. 如果不存在,将当前元素及其索引存入哈希表

关键易错点

  • 必须先查找再插入,避免使用相同元素两次
  • 返回结果时要注意索引顺序

推荐理由

  • 时间复杂度最优O(n)
  • 逻辑清晰,易于实现
  • 适用于各种规模的数据

方法三:双指针法

// 【算法本质实现 - 排序+双指针】

std::vector<int> twoSum3(std::vector<int>& nums, int target) {
    // 创建索引和值的配对,保存原始索引信息
    // 因为排序后元素位置会改变,需要保存原始索引
    std::vector<std::pair<int, int>> indexed_nums;
    for (int i = 0; i < nums.size(); i++) {
        indexed_nums.push_back({nums[i], i});
    }
    
    // 按值排序,使数组有序,为双指针法创造条件
    std::sort(indexed_nums.begin(), indexed_nums.end());
    
    // 使用双指针分别指向数组的开头和结尾
    int left = 0;
    int right = indexed_nums.size() - 1;
    
    // 当左指针小于右指针时继续循环
    while (left < right) {
        // 计算两个指针指向元素的和
        int sum = indexed_nums[left].first + indexed_nums[right].first;
        // 如果和等于目标值,找到答案
        if (sum == target) {
            // 返回对应的原始索引
            return {indexed_nums[left].second, indexed_nums[right].second};
        } 
        // 如果和小于目标值,左指针右移增大和
        else if (sum < target) {
            left++;
        } 
        // 如果和大于目标值,右指针左移减小和
        else {
            right--;
        }
    }
    
    return {};
}
  • 所需头文件<vector>, <algorithm>, <utility>
  • 时间复杂度:O(n log n)
  • 空间复杂度:O(n)
  • 代码行数:26行
  • 数据结构复杂度:使用pair和排序算法

需要先对数组排序,然后使用双指针技巧。注意这种方法需要保存原始索引信息。

算法详解

  1. 创建一个包含值和原始索引的配对数组
  2. 按值对配对数组进行排序
  3. 使用双指针分别指向数组的开头和结尾
  4. 计算两个指针指向元素的和
  5. 如果和等于目标值,返回对应的原始索引
  6. 如果和小于目标值,左指针右移
  7. 如果和大于目标值,右指针左移

关键易错点

  • 需要保存原始索引信息,因为排序会改变元素位置
  • 注意处理相同元素的情况

推荐理由

  • 展示了排序和双指针的结合应用
  • 适用于已排序或可排序的数组

方法四:哈希表(两次遍历)

// 【算法本质实现 - 哈希表两次遍历】

std::vector<int> twoSum4(std::vector<int>& nums, int target) {
    // 创建一个哈希表用于存储元素及其索引
    std::unordered_map<int, int> map;
    
    // 第一次遍历:构建完整的哈希表
    for (int i = 0; i < nums.size(); i++) {
        map[nums[i]] = i;
    }
    
    // 第二次遍历:查找目标值
    for (int i = 0; i < nums.size(); i++) {
        // 计算当前元素的补数
        int complement = target - nums[i];
        // 检查补数是否存在且不是当前元素本身
        // map.count(complement)检查补数是否存在
        // map[complement] != i 确保不使用相同元素两次
        if (map.count(complement) && map[complement] != i) {
            return {i, map[complement]};
        }
    }
    
    return {};
}
  • 所需头文件<vector>, <unordered_map>
  • 时间复杂度:O(n)
  • 空间复杂度:O(n)
  • 代码行数:18行
  • 数据结构复杂度:使用哈希表

先建立完整的哈希表,然后再遍历查找。

算法详解

  1. 第一次遍历数组,将所有元素及其索引存入哈希表
  2. 第二次遍历数组,对每个元素计算其补数
  3. 在哈希表中查找补数是否存在
  4. 如果存在且不是当前元素本身,返回两个元素的索引

关键易错点

  • 需要检查找到的元素不是当前元素本身
  • 两次遍历增加了常数因子

推荐理由

  • 思路清晰,易于理解
  • 适用于需要先处理完整数据再进行查询的情况

方法五:二分查找法

// 【算法本质实现 - 排序+二分查找】

std::vector<int> twoSum5(std::vector<int>& nums, int target) {
    // 创建索引和值的配对,保存原始索引信息
    std::vector<std::pair<int, int>> indexed_nums;
    for (int i = 0; i < nums.size(); i++) {
        indexed_nums.push_back({nums[i], i});
    }
    
    // 按值排序,为二分查找创造条件
    std::sort(indexed_nums.begin(), indexed_nums.end());
    
    // 对每个元素,使用二分查找寻找其补数
    for (int i = 0; i < indexed_nums.size(); i++) {
        // 计算当前元素的补数
        int complement = target - indexed_nums[i].first;
        // 设置二分查找的左右边界
        int left = i + 1;  // 从当前元素之后开始查找,避免使用相同元素
        int right = indexed_nums.size() - 1;
        
        // 二分查找过程
        while (left <= right) {
            // 计算中间位置,避免溢出的写法
            int mid = left + (right - left) / 2;
            // 如果找到补数,返回对应的原始索引
            if (indexed_nums[mid].first == complement) {
                return {indexed_nums[i].second, indexed_nums[mid].second};
            } 
            // 如果中间值小于补数,调整左边界
            else if (indexed_nums[mid].first < complement) {
                left = mid + 1;
            } 
            // 如果中间值大于补数,调整右边界
            else {
                right = mid - 1;
            }
        }
    }
    
    return {};
}
  • 所需头文件<vector>, <algorithm>, <utility>
  • 时间复杂度:O(n log n)
  • 空间复杂度:O(n)
  • 代码行数:32行
  • 数据结构复杂度:使用pair、排序和二分查找

先排序,然后对每个元素使用二分查找寻找其补数。

算法详解

  1. 创建一个包含值和原始索引的配对数组
  2. 按值对配对数组进行排序
  3. 遍历排序后的数组
  4. 对每个元素,计算其补数
  5. 使用二分查找在剩余元素中寻找补数
  6. 如果找到,返回对应的原始索引

关键易错点

  • 二分查找的边界条件处理
  • 需要保存原始索引信息

推荐理由

  • 展示了排序和二分查找的结合应用
  • 适用于已排序或可排序的数组

方法六:暴力解法(变体)

// 【算法本质实现 - 双重循环变体】

std::vector<int> twoSum6(std::vector<int>& nums, int target) {
    // 外层循环遍历数组中的每个元素
    for (int i = 0; i < nums.size(); i++) {
        // 内层循环从 i+1 开始遍历,避免重复计算和使用相同元素
        for (int j = i + 1; j < nums.size(); j++) {
            // 检查两个元素的和是否等于目标值
            if (nums[i] + nums[j] == target) {
                return {i, j};
            }
        }
    }
    return {};
}
  • 所需头文件<vector>
  • 时间复杂度:O(n²)
  • 空间复杂度:O(1)
  • 代码行数:9行
  • 数据结构复杂度:无复杂数据结构

这是暴力解法的一个变体实现,与方法一本质上相同,只是分类不同。

算法详解

  1. 使用双重循环遍历所有可能的元素对
  2. 检查每对元素的和是否等于目标值
  3. 如果找到匹配项,返回两个元素的索引

关键易错点

  • 内层循环应从 i+1 开始,避免使用相同元素两次
  • 与方法一本质上相同,只是分类不同

推荐理由

  • 适用于教学演示不同分类方法
  • 本质上与方法一相同

其他特殊解法

这些解法展示了不同的编程思想和技巧。

方法七:集合法

// 【特殊解法 - 集合+映射结合】

std::vector<int> twoSum7(std::vector<int>& nums, int target) {
    // 创建一个集合用于快速检查元素是否存在
    std::unordered_set<int> seen;
    // 创建一个映射用于保存元素及其索引
    std::unordered_map<int, int> index_map;
    
    // 遍历数组中的每个元素
    for (int i = 0; i < nums.size(); i++) {
        // 计算当前元素的补数
        int complement = target - nums[i];
        // 检查补数是否已存在(在seen集合中)
        if (seen.count(complement)) {
            // 如果存在,返回补数的索引和当前元素的索引
            return {index_map[complement], i};
        }
        // 将当前元素添加到集合和映射中
        seen.insert(nums[i]);
        index_map[nums[i]] = i;
    }
    
    return {};
}
  • 所需头文件<vector>, <unordered_set>, <unordered_map>
  • 时间复杂度:O(n)
  • 空间复杂度:O(n)
  • 代码行数:19行
  • 数据结构复杂度:同时使用集合和映射

使用set检查元素是否存在,同时用map保存索引信息。

算法详解

  1. 创建一个集合用于检查元素是否存在
  2. 创建一个映射用于保存元素及其索引
  3. 遍历数组中的每个元素
  4. 对于当前元素,计算其补数
  5. 在集合中检查补数是否存在
  6. 如果存在,返回补数的索引和当前元素的索引
  7. 如果不存在,将当前元素添加到集合和映射中

关键易错点

  • 需要同时维护集合和映射两个数据结构
  • 必须先查找再插入,避免使用相同元素两次

推荐理由

  • 展示了集合和映射的结合使用
  • 适用于需要去重或检查元素存在性的情况

方法八:递归法

// 【特殊解法 - 递归实现】

std::vector<int> twoSum8(std::vector<int>& nums, int target) {
    // 创建一个哈希表用于存储已遍历的元素及其索引
    std::unordered_map<int, int> map;
    // 调用递归辅助函数开始处理
    return twoSumRecursive(nums, target, 0, map);
}

std::vector<int> twoSumRecursive(std::vector<int>& nums, int target, int index, std::unordered_map<int, int>& map) {
    // 基本情况:如果索引超出数组范围,说明未找到答案
    if (index >= nums.size()) {
        return {};
    }
    
    // 计算当前元素的补数
    int complement = target - nums[index];
    // 在哈希表中查找补数是否存在
    if (map.count(complement)) {
        // 如果存在,返回补数的索引和当前元素的索引
        return {map[complement], index};
    }
    
    // 将当前元素及其索引存入哈希表
    map[nums[index]] = index;
    // 递归处理下一个元素
    return twoSumRecursive(nums, target, index + 1, map);
}
  • 所需头文件<vector>, <unordered_map>
  • 时间复杂度:O(n)
  • 空间复杂度:O(n) - 递归栈空间
  • 代码行数:23行(包括递归函数)
  • 数据结构复杂度:使用哈希表和递归调用栈

使用递归实现哈希表一次遍历的逻辑。

算法详解

  1. 创建一个哈希表用于存储已遍历的元素及其索引
  2. 使用递归函数遍历数组中的每个元素
  3. 对于当前元素,计算其补数
  4. 在哈希表中查找补数是否存在
  5. 如果存在,返回补数的索引和当前元素的索引
  6. 如果不存在,将当前元素及其索引存入哈希表
  7. 递归处理下一个元素

关键易错点

  • 递归终止条件是索引超出数组范围
  • 递归深度与数组长度成正比,可能导致栈溢出

递归深度风险

  • 递归深度与数组长度成正比,最深可达O(n)
  • 当数组长度接近或超过系统栈深度时(如题目提示中的10⁴上限),会触发栈溢出,导致程序崩溃
  • 在实际应用中,对于大规模数据,应避免使用递归解法
  • 这是递归解法的主要限制,也是不推荐在生产环境中使用的原因

推荐理由

  • 展示了递归思维
  • 适用于教学演示递归技巧

方法九:反向遍历哈希表法

// 【特殊解法 - 反向遍历】

std::vector<int> twoSum9(std::vector<int>& nums, int target) {
    // 创建一个哈希表用于存储已遍历的元素及其索引
    std::unordered_map<int, int> map;
    
    // 从数组末尾开始向前遍历(反向遍历)
    for (int i = nums.size() - 1; i >= 0; i--) {
        // 在哈希表中查找当前元素的补数
        auto iter = map.find(target - nums[i]);
        // 如果找到补数,说明找到了两个和为target的元素
        if (iter != map.end()) {
            // 返回当前元素的索引和补数的索引
            return {i, iter->second};
        }
        // 将当前元素及其索引存入哈希表
        map[nums[i]] = i;
    }
    
    return {};
}
  • 所需头文件<vector>, <unordered_map>
  • 时间复杂度:O(n)
  • 空间复杂度:O(n)
  • 代码行数:15行
  • 数据结构复杂度:使用哈希表

从数组末尾开始遍历构建哈希表并查找。

算法详解

  1. 创建一个哈希表用于存储已遍历的元素及其索引
  2. 从数组末尾开始向前遍历
  3. 对于当前元素,计算其补数
  4. 在哈希表中查找补数是否存在
  5. 如果存在,返回当前元素的索引和补数的索引
  6. 如果不存在,将当前元素及其索引存入哈希表

关键易错点

  • 遍历方向与常规方法相反
  • 返回结果时要注意索引顺序

推荐理由

  • 展示了不同遍历顺序的实现
  • 适用于特定遍历顺序需求

方法十:滑动窗口法

// 【特殊解法 - 滑动窗口】

std::vector<int> twoSum10(std::vector<int>& nums, int target) {
    // 从窗口大小为2开始,逐步增加窗口大小
    // 窗口大小为1无法找到两个数,所以从2开始
    for (int window_size = 2; window_size <= nums.size(); window_size++) {
        // 对于每个窗口大小,遍历所有可能的窗口位置
        for (int i = 0; i <= nums.size() - window_size; i++) {
            // 检查窗口内是否有两个数之和等于target
            // j从窗口起始位置开始
            for (int j = i; j < i + window_size - 1; j++) {
                // k从j+1开始,确保不重复检查同一对元素
                for (int k = j + 1; k < i + window_size; k++) {
                    // 如果找到和等于目标值的元素对,返回它们的索引
                    if (nums[j] + nums[k] == target) {
                        return {j, k};
                    }
                }
            }
        }
    }
    return {};
}
  • 所需头文件<vector>
  • 时间复杂度:O(n⁴)
  • 空间复杂度:O(1)
  • 代码行数:18行
  • 数据结构复杂度:四重循环,无复杂数据结构

通过改变窗口大小和位置来查找满足条件的两个数。

算法详解

  1. 从窗口大小为2开始,逐步增加窗口大小
  2. 对于每个窗口大小,遍历所有可能的窗口位置
  3. 在每个窗口内,检查所有可能的元素对
  4. 如果找到和等于目标值的元素对,返回它们的索引

关键易错点

  • 时间复杂度较高,不适用于大数据集
  • 多层循环增加了实现复杂度

推荐理由

  • 展示了滑动窗口思想
  • 适用于教学演示不同算法思想

方法十一:值范围映射数组法

// 【特殊解法 - 数组映射】

std::vector<int> twoSum11(std::vector<int>& nums, int target) {
    // 由于数值范围很大,这种方法在实际中并不适用
    // 但为了演示思想,我们假设数值范围较小
    const int OFFSET = 100000; // 偏移量,处理负数,将负数映射到非负索引
    const int MAX_VAL = 200000; // 最大可能值
    
    // 创建映射数组,记录每个值最后出现的位置
    // 数组索引对应数值+offset后的值,数组值对应原数组中的索引
    std::vector<int> valueToIndex(MAX_VAL + 1, -1);
    
    // 遍历原数组
    for (int i = 0; i < nums.size(); i++) {
        // 计算当前元素的补数,并加上偏移量使其非负
        int complement = target - nums[i] + OFFSET;
        // 检查补数是否在有效范围内且已存在
        if (complement >= 0 && complement <= MAX_VAL && valueToIndex[complement] != -1) {
            // 如果存在,返回补数的索引和当前元素的索引
            return {valueToIndex[complement], i};
        }
        // 将当前元素值加上偏移量后存入映射数组
        valueToIndex[nums[i] + OFFSET] = i;
    }
    
    return {};
}
  • 所需头文件<vector>
  • 时间复杂度:O(n)
  • 空间复杂度:O(范围)
  • 代码行数:21行
  • 数据结构复杂度:使用大数组进行映射

使用数组作为哈希表的替代,通过值映射到数组索引来实现快速查找。

算法详解

  1. 创建一个足够大的数组,用于存储值到索引的映射
  2. 通过偏移量处理负数,将所有值映射到非负范围
  3. 遍历数组,对每个元素计算其补数
  4. 检查补数在映射数组中是否存在
  5. 如果存在,返回补数的索引和当前元素的索引
  6. 如果不存在,将当前元素及其索引存入映射数组

关键易错点

  • 需要处理负数,通过偏移量将其映射到非负范围
  • 数组大小必须足够大以容纳所有可能的值
  • 当数值范围很大时,这种方法会消耗大量内存

推荐理由

  • 展示了数组作为哈希表替代的思路
  • 适用于数值范围较小的情况
  • 在某些特定场景下可能比哈希表更快

局限性

  • 当数值范围很大时,会消耗大量内存
  • 不适用于数值范围未知或过大的情况
  • 在本题中,由于数值范围达到2×10^9,这种方法不实用

方法十二:逆向遍历 + 临时缓存法

// 【特殊解法 - 逆向遍历+缓存】

std::vector<int> twoSum12(std::vector<int>& nums, int target) {
    // 创建一个哈希表作为临时缓存
    std::unordered_map<int, int> cache;
    
    // 从后向前遍历数组(逆向遍历)
    for (int i = nums.size() - 1; i >= 0; i--) {
        // 计算当前元素的补数
        int complement = target - nums[i];
        
        // 检查补数是否已在缓存中
        if (cache.count(complement)) {
            // 注意返回顺序,确保索引小的在前
            // cache[complement]是补数的索引,一定小于当前索引i
            return {i, cache[complement]};
        }
        
        // 将当前元素存入缓存
        cache[nums[i]] = i;
    }
    
    return {};
}
  • 所需头文件<vector>, <unordered_map>
  • 时间复杂度:O(n)
  • 空间复杂度:O(n)
  • 代码行数:19行
  • 数据结构复杂度:使用哈希表

从后向前遍历数组,使用临时缓存存储已遍历的元素。

算法详解

  1. 创建一个哈希表作为临时缓存
  2. 从数组末尾开始向前遍历
  3. 对于当前元素,计算其补数
  4. 在缓存中查找补数是否存在
  5. 如果存在,返回当前元素索引和补数索引(注意索引顺序)
  6. 如果不存在,将当前元素及其索引存入缓存

关键易错点

  • 遍历方向与常规方法相反
  • 返回结果时需要注意索引顺序,确保小索引在前
  • 与方法九的区别在于返回索引的顺序处理

推荐理由

  • 展示了不同遍历方向的实现
  • 适用于特定场景或面试中的思维拓展

方法十三:差值标记法

// 【特殊解法 - 差值预存】

std::vector<int> twoSum13(std::vector<int>& nums, int target) {
    // 创建一个映射,存储差值到索引的映射
    // 与常规方法不同,这里存储的是target-nums[i]而不是nums[i]
    std::unordered_map<int, int> diffMap;
    
    // 遍历数组中的每个元素
    for (int i = 0; i < nums.size(); i++) {
        // 检查当前元素是否是之前某个元素需要的差值
        // 这与常规方法的思路相反:常规方法是找target-nums[i]是否存在
        // 这里是检查nums[i]是否是之前存储的某个差值
        if (diffMap.count(nums[i])) {
            // 如果是,返回之前元素的索引和当前元素的索引
            return {diffMap[nums[i]], i};
        }
        
        // 将当前元素需要的差值存入映射
        // 存储的是差值(target-nums[i])到索引的映射
        int diff = target - nums[i];
        diffMap[diff] = i;
    }
    
    return {};
}
  • 所需头文件<vector>, <unordered_map>
  • 时间复杂度:O(n)
  • 空间复杂度:O(n)
  • 代码行数:20行
  • 数据结构复杂度:使用哈希表

通过预先计算并存储每个元素需要的差值来实现查找。

算法详解

  1. 创建一个哈希表用于存储差值到索引的映射
  2. 遍历数组中的每个元素
  3. 检查当前元素是否是之前某个元素需要的差值
  4. 如果是,返回之前元素的索引和当前元素的索引
  5. 如果不是,计算当前元素需要的差值并存入哈希表

关键易错点

  • 存储的是差值而不是元素值本身
  • 与常规哈希表方法的思维角度不同
  • 需要正确处理查找和存储的逻辑关系

推荐理由

  • 提供了不同的思维角度来解决问题
  • 展示了逆向思维在算法设计中的应用
  • 逻辑与常规哈希表方法等价,但表达方式不同

方法十四:位运算模拟加法

// 【特殊解法 - 位运算实现】

// 辅助函数:使用位运算实现两个整数相加
// 这种方法模拟了CPU进行加法运算的底层原理
int bitAdd(int a, int b) {
    // 当进位为0时,加法运算完成
    while (b != 0) {
        // 计算进位:两个位都为1时产生进位
        int carry = (a & b) << 1;
        // 不带进位的加法:异或运算相当于不考虑进位的加法
        a = a ^ b;
        // 更新进位,继续下一轮运算
        b = carry;
    }
    return a;
}

std::vector<int> twoSum14(std::vector<int>& nums, int target) {
    // 使用暴力解法遍历所有元素对
    for (int i = 0; i < nums.size(); i++) {
        for (int j = i + 1; j < nums.size(); j++) {
            // 使用位运算模拟加法来检查两数之和
            // bitAdd函数替代了普通的加法运算符+
            if (bitAdd(nums[i], nums[j]) == target) {
                return {i, j};
            }
        }
    }
    return {};
}
  • 所需头文件<vector>
  • 时间复杂度:O(n²)
  • 空间复杂度:O(1)
  • 代码行数:24行(包括辅助函数)
  • 数据结构复杂度:使用位运算,无复杂数据结构

使用位运算来模拟整数加法操作,然后进行比较。

算法详解

  1. 实现一个辅助函数bitAdd,使用位运算模拟两个整数的加法
  2. 在bitAdd函数中,使用异或运算(^)计算不带进位的加法
  3. 使用与运算(&)和左移运算(<<)计算进位
  4. 重复这个过程直到没有进位为止
  5. 在主函数中使用暴力解法遍历所有元素对
  6. 使用bitAdd函数计算两个元素的和,并与目标值比较

关键易错点

  • 位运算加法的实现需要正确处理进位
  • 该方法的时间复杂度与暴力解法相同,只是加法操作的实现方式不同
  • 位运算在某些情况下可能比普通加法稍快,但在这个问题中优势不明显

推荐理由

  • 展示了位运算在算术运算中的应用
  • 适用于教学演示位运算技巧
  • 提供了不同的实现思路

STL应用实现

这类解法利用STL库函数简化代码实现,适合在实际项目中快速解决问题。

方法十五:STL find函数法

// 【STL应用实现 - find函数】

std::vector<int> twoSum15(std::vector<int>& nums, int target) {
    // 遍历数组中的每个元素
    for (int i = 0; i < nums.size(); i++) {
        // 使用STL的find函数在剩余元素中查找补数
        // 搜索范围从当前元素之后开始,避免使用相同元素
        auto it = std::find(nums.begin() + i + 1, nums.end(), target - nums[i]);
        // 如果找到补数(迭代器不等于end())
        if (it != nums.end()) {
            // 计算找到元素的索引
            // 使用std::distance计算迭代器距离起始位置的距离
            int j = std::distance(nums.begin(), it);
            // 返回两个元素的索引
            return {i, j};
        }
    }
    return {};
}
  • 所需头文件<vector>, <algorithm>
  • 时间复杂度:O(n²)
  • 空间复杂度:O(1)
  • 代码行数:15行
  • 数据结构复杂度:使用STL算法函数

使用STL的find函数在剩余元素中查找补数。

算法详解

  1. 遍历数组中的每个元素
  2. 对于当前元素,计算其补数
  3. 使用STL的find函数在当前元素之后的元素中查找补数
  4. 如果找到,计算其索引并返回两个元素的索引

关键易错点

  • find函数的搜索范围应从当前元素之后开始
  • 需要使用std::distance计算迭代器距离以获取索引

推荐理由

  • 展示了STL算法的实际应用
  • 代码简洁,易于理解
  • 适用于教学演示STL使用

解法综合对比分析

为了更好地理解各种解法的特点和适用场景,我们对所有解法进行综合对比分析:

解法编号解法名称时间复杂度空间复杂度所需头文件代码行数数据结构复杂度优缺点适用场景
方法一暴力解法O(n²)O(1)<vector>6行无复杂数据结构优点:思路简单,空间复杂度最优
缺点:时间复杂度高
小数据集(n < 100)或内存敏感环境
方法二哈希表(一次遍历)O(n)O(n)<vector>, <unordered_map>12行使用哈希表优点:时间复杂度最优,逻辑清晰
缺点:空间复杂度O(n)
一般情况下的最优解,尤其适用于大数据集
方法三双指针法O(n log n)O(n)<vector>, <algorithm>, <utility>26行使用pair和排序算法优点:展示了排序和双指针技巧
缺点:需要额外空间保存索引
数组已排序或可排序时
方法四哈希表(两次遍历)O(n)O(n)<vector>, <unordered_map>18行使用哈希表优点:思路清晰
缺点:需要两次遍历
需要先处理完整数据再查询时
方法五二分查找法O(n log n)O(n)<vector>, <algorithm>, <utility>32行使用pair、排序和二分查找优点:展示了排序和二分查找技巧
缺点:需要额外空间保存索引
数组已排序或可排序时
方法六暴力解法(变体)O(n²)O(1)<vector>9行无复杂数据结构优点:思路简单
缺点:时间复杂度高
教学演示不同分类方法
方法七集合法O(n)O(n)<vector>, <unordered_set>, <unordered_map>19行同时使用集合和映射优点:展示了集合和映射结合使用
缺点:需要维护两个数据结构
需要去重或检查元素存在性时
方法八递归法O(n)O(n)<vector>, <unordered_map>23行使用哈希表和递归调用栈优点:展示了递归思维
缺点:递归深度风险
教学演示递归技巧
方法九反向遍历哈希表法O(n)O(n)<vector>, <unordered_map>15行使用哈希表优点:展示了不同遍历顺序
缺点:无明显优势
特定遍历顺序需求
方法十滑动窗口法O(n⁴)O(1)<vector>18行四重循环,无复杂数据结构优点:展示了滑动窗口思想
缺点:时间复杂度极高
教学演示不同算法思想
方法十一值范围映射数组法O(n)O(范围)<vector>21行使用大数组进行映射优点:数组访问速度快
缺点:内存消耗大
数值范围较小的特定场景
方法十二逆向遍历+临时缓存法O(n)O(n)<vector>, <unordered_map>19行使用哈希表优点:展示了不同遍历方向
缺点:无明显优势
特定遍历顺序需求
方法十三差值标记法O(n)O(n)<vector>, <unordered_map>20行使用哈希表优点:提供了不同思维角度
缺点:思维转换成本高
教学演示逆向思维
方法十四位运算模拟加法O(n²)O(1)<vector>24行使用位运算,无复杂数据结构优点:展示了位运算应用
缺点:时间复杂度高
教学演示位运算技巧
方法十五STL find函数法O(n²)O(1)<vector>, <algorithm>15行使用STL算法函数优点:使用STL算法,代码简洁
缺点:时间复杂度较高
教学演示STL使用

常见错误总结

在实现这些解法的过程中,新手容易犯一些常见错误,以下是主要的错误类型和避免方法:

1. 边界条件处理错误

错误示例

// 错误:内层循环从j=0开始,可能导致使用相同元素两次
for (int i = 0; i < nums.size(); i++) {
    for (int j = 0; j < nums.size(); j++) {  // 错误的起始位置
        if (i != j && nums[i] + nums[j] == target) {
            return {i, j};
        }
    }
}

调试过程: 使用测试用例 [3,3],target=6 时,可能会返回 [0,0] 或其他错误结果。

正确做法

// 正确:内层循环从j=i+1开始
for (int i = 0; i < nums.size(); i++) {
    for (int j = i + 1; j < nums.size(); j++) {  // 正确的起始位置
        if (nums[i] + nums[j] == target) {
            return {i, j};
        }
    }
}

避免方法

  • 仔细分析题目要求,确保不使用相同元素两次
  • 使用具体例子手动执行代码验证逻辑正确性

2. 哈希表使用误区

错误示例

// 错误:先插入再查找,可能导致使用相同元素两次
std::vector<int> twoSum(std::vector<int>& nums, int target) {
    std::unordered_map<int, int> map;
    
    for (int i = 0; i < nums.size(); i++) {
        map[nums[i]] = i;  // 先插入
        int complement = target - nums[i];
        if (map.count(complement)) {  // 后查找
            return {map[complement], i};
        }
    }
    return {};
}

调试过程: 使用测试用例 [3,3],target=6 时,会返回 [0,0],因为元素3在查找时找到了自己。

正确做法

// 正确:先查找再插入
std::vector<int> twoSum(std::vector<int>& nums, int target) {
    std::unordered_map<int, int> map;
    
    for (int i = 0; i < nums.size(); i++) {
        int complement = target - nums[i];
        if (map.count(complement)) {  // 先查找
            return {map[complement], i};
        }
        map[nums[i]] = i;  // 后插入
    }
    return {};
}

避免方法

  • 理解哈希表查找和插入的逻辑关系
  • 通过小例子验证操作顺序

3. 递归深度风险

潜在问题: 对于大规模数据(如题目提示中的10⁴上限),递归解法可能导致栈溢出。

调试过程: 在处理大规模数据时,程序出现栈溢出错误或异常终止。

避免方法

  • 评估递归深度与输入规模的关系
  • 对于大规模数据,优先考虑迭代解法
  • 在生产环境中谨慎使用递归处理大规模数据

4. 索引重复使用错误

错误示例

// 错误:未检查找到的元素是否是当前元素本身
std::vector<int> twoSum(std::vector<int>& nums, int target) {
    std::unordered_map<int, int> map;
    
    // 第一次遍历:构建哈希表
    for (int i = 0; i < nums.size(); i++) {
        map[nums[i]] = i;
    }
    
    // 第二次遍历:查找目标值
    for (int i = 0; i < nums.size(); i++) {
        int complement = target - nums[i];
        if (map.count(complement)) {  // 未检查索引是否相同
            return {i, map[complement]};
        }
    }
    return {};
}

调试过程: 使用测试用例 [3,2,4],target=6 时,可能会错误地返回 [0,0]。

正确做法

// 正确:检查找到的元素不是当前元素本身
std::vector<int> twoSum(std::vector<int>& nums, int target) {
    std::unordered_map<int, int> map;
    
    // 第一次遍历:构建哈希表
    for (int i = 0; i < nums.size(); i++) {
        map[nums[i]] = i;
    }
    
    // 第二次遍历:查找目标值
    for (int i = 0; i < nums.size(); i++) {
        int complement = target - nums[i];
        if (map.count(complement) && map[complement] != i) {  // 检查索引是否不同
            return {i, map[complement]};
        }
    }
    return {};
}

避免方法

  • 仔细阅读题目要求
  • 使用多种测试用例验证代码正确性

实际性能考量

  1. 对于小数据集(n < 100),暴力解法可能因为没有哈希开销而更快
  2. 对于大数据集,哈希表解法优势明显
  3. STL 的 unordered_map 虽然平均复杂度是 O(1),但常数因子较大
  4. 在生产环境中,可以考虑根据数据规模选择不同算法的混合策略