经典算法入门:深入解析“两数之和”(Two Sum)问题

大家好!今天我们来聊聊一个在编程面试和算法学习中都占据着重要地位的经典问题——“两数之和”(Two Sum)。这通常是 LeetCode 上的第一道题目,也是许多初学者接触算法的起点。别看它简单,里面却蕴含着重要的算法思想和优化技巧。

问题描述

“两数之和”问题的描述通常是这样的:

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

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

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

示例:

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

输入:nums = [3, 2, 4], target = 6
输出:[1, 2]
解释:因为 nums[1] + nums[2] == 6 ,返回 [1, 2] 。

解法一:暴力枚举法

最直观、最容易想到的方法就是“暴力枚举”。我们可以检查数组中每一对可能的组合,看看它们的和是否等于 target。

思路:

  1. 使用两层循环。

  2. 外层循环遍历第一个数 nums[i](从索引 0 到 n-1)。

  3. 内层循环遍历第二个数 nums[j](从索引 i + 1 到 n-1,注意 j 从 i+1 开始是为了避免重复使用同一个元素,也避免了重复的组合,如 [0,1] 和 [1,0])。

  4. 在内层循环中,判断 nums[i] + nums[j] 是否等于 target。

  5. 如果等于,就返回 [i, j]。

Python 代码实现:

def two_sum_brute_force(nums, target):
    """
    暴力枚举法解决两数之和问题
    """
    n = len(nums)
    for i in range(n):
        for j in range(i + 1, n): # j 从 i+1 开始
            if nums[i] + nums[j] == target:
                return [i, j]
    return [] # 如果没有找到,返回空列表

复杂度分析:

  • 时间复杂度:O(n^2) - 因为我们需要两层嵌套循环来检查所有可能的数对,其中 n 是数组的长度。

  • 空间复杂度:O(1) - 我们只需要常数级别的额外空间来存储索引变量。

优点: 思路简单,易于理解和实现。
缺点: 时间复杂度较高,当数组规模很大时,效率会很低。

解法二:哈希表(字典)优化

暴力枚举法的主要瓶颈在于查找另一个数(target - nums[i])是否存在时,需要再次遍历数组。我们能不能用一种更快速的方式来查找这个“另一半”呢?答案是肯定的,那就是使用哈希表(在 Python 中是字典 dict)。

思路:

  1. 创建一个空的哈希表(字典),用于存储我们遍历过的数字及其对应的索引。键(key)是数字本身,值(value)是该数字的索引。

  2. 遍历数组 nums 中的每个元素 num 及其索引 i。

  3. 对于当前元素 num,计算我们需要寻找的“另一半” complement = target - num。

  4. 检查 complement 是否已经存在于哈希表中:

    • 如果存在,说明我们找到了符合条件的两个数,一个是当前遍历到的 num(索引为 i),另一个是存储在哈希表中的 complement(其索引存储在哈希表的 value 中)。直接返回 [hash_table[complement], i]。

    • 如果不存在,说明到目前为止还没找到能与 num 配对的数。将当前的 num 及其索引 i 存入哈希表中,即 hash_table[num] = i,以便后续的元素查找。

  5. 如果遍历完整个数组都没有找到,返回空列表或根据题目要求处理。

为什么这样可行?

当我们遍历到 nums[i] 时,我们查找的是 target - nums[i]。如果这个数在哈希表中,说明它在 i 之前已经被遍历过并存入了哈希表。这样,我们就只用一次遍历即可找到答案。

Python 代码实现:

def two_sum_hash_map(nums, target):
    """
    使用哈希表(字典)优化解决两数之和问题
    """
    seen = {} # 创建一个空字典,用于存储数字及其索引
    for i, num in enumerate(nums): # 同时获取索引和值
        complement = target - num
        if complement in seen: # 检查 complement 是否在字典的键中
            return [seen[complement], i] # 如果在,返回存储的索引和当前索引
        seen[num] = i # 如果不在,将当前数字和索引存入字典
    return [] # 如果没有找到,返回空列表

复杂度分析:

  • 时间复杂度:O(n) - 我们只需要遍历数组一次。哈希表的插入和查找操作平均时间复杂度都是 O(1)。

  • 空间复杂度:O(n) - 在最坏的情况下,我们可能需要将数组中的所有元素都存入哈希表中,因此需要 O(n) 的额外空间。

优点: 时间效率大大提高,达到了线性时间复杂度。
缺点: 需要额外的空间来存储哈希表。

补充:解法三:“前后指针”法(适用于已排序数组)

我们刚才讨论的暴力法和哈希表法适用于未排序的数组。但如果题目给定的输入数组 nums 保证是已排序的(通常是升序),那么“前后指针”法就成了一个非常优雅且空间效率极高的解决方案。

注意: 对于 LeetCode 第一题原版的“两数之和”,输入数组不保证有序,且要求返回的是原始索引。如果直接对原数组排序,会丢失原始索引信息。因此,标准解法通常是哈希表。但“前后指针”法是解决其变种问题(如 LeetCode 167. Two Sum II - Input array is sorted)的标准方法,并且有时可以通过一些技巧(如下文所述)应用于原问题,但复杂度会有所变化。

思路 (假设数组已排序):

  1. 初始化两个指针:left 指向数组的起始位置(索引 0),right 指向数组的末尾位置(索引 n-1)。

  2. 在一个 while left < right 的循环中执行以下操作:

    • 计算 current_sum = nums[left] + nums[right]。

    • 比较 current_sum 和 target:

      • 如果 current_sum == target,我们找到了目标对!返回 [left, right](或者根据题目要求返回 [left+1, right+1] 如果是 1-based 索引)。

      • 如果 current_sum < target,说明当前的和太小了。为了增大和,我们需要将 left 指针向右移动一位 (left += 1),指向一个更大的数。

      • 如果 current_sum > target,说明当前的和太大了。为了减小和,我们需要将 right 指针向左移动一位 (right -= 1),指向一个更小的数。

  3. 如果循环结束(left >= right)仍未找到和为 target 的两个数,则表示数组中不存在这样的组合,返回空列表或按题目要求处理。

为什么这种方法在已排序数组上有效?

  • 因为数组是排序的,向右移动 left 指针必然会遇到一个更大或相等的数,从而增大 current_sum。

  • 向左移动 right 指针必然会遇到一个更小或相等的数,从而减小 current_sum。

  • 这种单向逼近的方式保证了我们不会错过任何可能的解,并且每个元素最多只会被访问一次。

Python 代码实现 (适用于已排序数组 - LeetCode 167 风格):

def two_sum_sorted_two_pointers(nums, target):
    """
    使用前后指针法解决已排序数组的两数之和问题
    (注意:这适用于 nums 已排序的情况)
    """
    left, right = 0, len(nums) - 1
    while left < right:
        current_sum = nums[left] + nums[right]
        if current_sum == target:
            # LeetCode 167 要求返回 1-based 索引
            # return [left + 1, right + 1]
            # 如果是 0-based 索引,返回:
            return [left, right]
        elif current_sum < target:
            left += 1
        else: # current_sum > target
            right -= 1
    return [] # 没有找到

复杂度分析 (适用于已排序数组):

  • 时间复杂度:O(n) - left 和 right 指针总共最多移动 n 次。

  • 空间复杂度:O(1) - 只使用了常数级别的额外空间存储指针。

如何将“前后指针”法应用于原版“两数之和”(返回原始索引)?

如果坚持要用“前后指针”法解决需要返回原始索引的原版问题,你需要:

  1. 预处理: 创建一个新的数据结构(例如,元组列表 [(value, original_index), ...]),存储每个元素的值及其原始索引。

  2. 排序: 根据元素的值对这个新的数据结构进行排序。这步操作的时间复杂度是 O(n log n)。

  3. 前后指针查找: 在排序后的值上应用“前后指针”法找到和为 target 的两个值。

  4. 返回原始索引: 一旦找到目标值对,从它们对应的元组中提取原始索引并返回。

Python 代码实现 (应用于原版问题,返回原始索引):

def two_sum_original_with_sort_pointers(nums, target):
    """
    通过先排序再用前后指针法解决原版两数之和问题(返回原始索引)
    """
    # 1. 预处理:存储值和原始索引
    indexed_nums = []
    for i, num in enumerate(nums):
        indexed_nums.append((num, i)) # (值, 原始索引)

    # 2. 排序:根据值排序
    # 时间复杂度 O(n log n)
    indexed_nums.sort(key=lambda x: x[0])

    # 3. 前后指针查找
    left, right = 0, len(indexed_nums) - 1
    while left < right:
        current_sum = indexed_nums[left][0] + indexed_nums[right][0]
        if current_sum == target:
            # 4. 返回原始索引
            original_index1 = indexed_nums[left][1]
            original_index2 = indexed_nums[right][1]
            return [original_index1, original_index2]
        elif current_sum < target:
            left += 1
        else: # current_sum > target
            right -= 1
    return [] # 没有找到

复杂度分析 (应用于原版问题):

  • 时间复杂度:O(n log n) - 主要瓶颈在于排序步骤。前后指针扫描本身是 O(n)。

  • 空间复杂度:O(n) - 需要额外的 O(n) 空间来存储值和原始索引的列表 indexed_nums。(如果原地排序且不允许额外空间,则无法保留原始索引)。

对比与总结 (新增前后指针法):

解法时间复杂度空间复杂度适用场景/备注
暴力枚举法O(n^2)O(1)思路简单,但效率低
哈希表(字典)O(n)O(n)标准解法,时间最优,适用于未排序数组,返回原始索引
前后指针 (已排序)O(n)O(1)适用于已排序数组,空间最优
前后指针 (先排序)O(n log n)O(n)可用于未排序数组返回原始索引,但时间和空间不如哈希表法

结论:

  • 对于原版“两数之和”问题(未排序,返回原始索引),哈希表法是时间和空间综合最优的解法(O(n) 时间, O(n) 空间)。

  • 对于**“两数之和 II - 输入数组已排序”** 这类变种问题,前后指针法是最佳选择(O(n) 时间, O(1) 空间)。

  • 虽然可以通过先排序再用前后指针的方法解决原版问题,但其 O(n log n) 的时间复杂度和 O(n) 的空间复杂度通常不如哈希表法。

如果你有任何问题或者其他的解法,欢迎在评论区留言讨论!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值