穷举法

这是一个非常好的起点!暴力枚举是解决算法问题的基础方法之一,尤其是在解空间有限的情况下。虽然暴力枚举通常不是最优解,但它是理解问题和验证复杂算法的关键工具。

以下是关于如何学习暴力枚举的详细指导:

什么是暴力枚举?

暴力枚举(Brute Force)是一种穷举法,即尝试所有可能的解,逐一验证每个解是否满足问题的条件。它的核心思想是:

  1. 生成所有可能的解
  2. 逐一验证每个解是否满足条件
  3. 返回满足条件的解(或最优解)

学习暴力枚举的步骤

理解问题的解空间

  • 解空间是指问题所有可能解的集合。
  • 在暴力枚举中,你需要明确解空间的大小,以及如何生成所有可能的解。

示例

  • 如果问题是“从数组 [1, 2, 3] 中选择两个数”,解空间是所有可能的数对:(1, 2), (1, 3), (2, 3)
  • 如果问题是“排列字符串 abc 的所有可能顺序”,解空间是所有排列:abc, acb, bac, bca, cab, cba

生成所有可能的解

  • 使用循环、递归或组合工具生成解空间。
  • 常见的生成方法:
    • 单层循环:用于简单的线性枚举。
    • 多层嵌套循环:用于枚举多维解空间。
    • 递归:用于生成排列、组合等复杂解空间。
    • 工具库:如 Python 的 itertools

示例

  • 单层循环

    for i in range(1, 5):  # 枚举 1 到 4
        print(i)
  • 多层嵌套循环

    for i in range(1, 4):  # 第一层
        for j in range(1, 4):  # 第二层
            print(i, j)  # 枚举所有 (i, j) 对
  • 递归生成排列

    def permute(nums, path=[]):
        if not nums:
            print(path)  # 输出一个排列
            return
        for i in range(len(nums)):
            permute(nums[:i] + nums[i+1:], path + [nums[i]])
     
    permute([1, 2, 3])  # 输出 [1, 2, 3] 的所有排列
  • 使用工具库

    from itertools import permutations
     
    for p in permutations([1, 2, 3]):
        print(p)  # 输出 [1, 2, 3] 的所有排列

验证解是否满足条件

  • 对每个生成的解,验证它是否满足题目要求。
  • 验证逻辑通常是一个条件判断(if 语句)。

示例

  • 验证一个数是否是偶数:

    for i in range(1, 10):
        if i % 2 == 0:  # 验证是否是偶数
            print(i)
  • 验证一个排列是否满足特定条件:

    from itertools import permutations
     
    for p in permutations([1, 2, 3]):
        if p[0] < p[1]:  # 验证第一个数小于第二个数
            print(p)

优化暴力枚举

暴力枚举的核心问题是效率低,尤其是当解空间很大时。因此,学习暴力枚举时,也需要掌握一些优化技巧:

  • 剪枝:在生成解的过程中,提前排除不可能的解。

    for i in range(1, 10):
        if i % 2 != 0:  # 剪枝:跳过奇数
            continue
        print(i)
  • 提前终止:如果找到一个解就可以结束枚举,则不需要继续。

    for i in range(1, 10):
        if i == 5:  # 找到解后终止
            print("Found:", i)
            break
  • 减少嵌套循环:通过数学公式或逻辑简化枚举过程。

学习暴力枚举的练习题

简单枚举

  • 题目:枚举 1 到 100 中所有的偶数。
    for i in range(1, 101):
        if i % 2 == 0:
            print(i)

多层枚举

  • 题目:枚举所有两位数的组合。
    for i in range(10, 100):
        for j in range(10, 100):
            print(i, j)

排列与组合

  • 题目:生成数组 [1, 2, 3] 的所有排列。
    from itertools import permutations
     
    for p in permutations([1, 2, 3]):
        print(p)

验证条件

  • 题目:从数组 [1, 2, 3, 4] 中选择两个数,要求它们的和为 5。
    for i in range(1, 5):
        for j in range(i + 1, 5):
            if i + j == 5:
                print(i, j)

复杂枚举

  • 题目:生成长度为 3 的二进制字符串。
    for i in range(2):
        for j in range(2):
            for k in range(2):
                print(f"{i}{j}{k}")

学习暴力枚举的资源

  1. 书籍

    • 《算法导论》(Introduction to Algorithms)—— Thomas H. Cormen
    • 《编程之美》—— Microsoft Research
  2. 在线平台

    • LeetCode:从简单题目开始,逐步挑战更复杂的题目。
    • HackerRank:适合练习基础算法和枚举问题。
    • Codeforces:适合练习竞赛题目。
  3. 工具

    • 使用 Python 的 itertools 库简化排列和组合的生成。
    • 使用可视化工具(如 VisuAlgo)帮助理解枚举过程。

学习路径建议

  1. 从简单问题开始

    • 练习单层循环和多层循环的枚举。
    • 理解如何生成解空间。
  2. 逐步增加复杂度

    • 学习排列、组合的生成方法。
    • 练习验证条件的逻辑。
  3. 优化暴力枚举

    • 学习剪枝和提前终止的技巧。
    • 理解如何减少不必要的计算。
  4. 结合实际问题

    • 在实际问题中应用暴力枚举,逐步积累经验。

总结

暴力枚举是算法学习的基础,掌握它可以帮助你更好地理解问题和验证复杂算法。学习暴力枚举的关键是:

  1. 理解解空间:明确所有可能的解。
  2. 生成解空间:使用循环、递归或工具库生成解。
  3. 验证解:逐一验证每个解是否满足条件。
  4. 优化枚举:通过剪枝和提前终止提高效率。

通过不断练习和总结,你会发现暴力枚举不仅简单,而且非常实用。如果你有具体的题目或问题,可以随时告诉我,我会帮助你分析和解答!

例题

LeetCode 1. 两数之和

给定一个整数数组 nums 和一个整数目标值 target ,请你在该数组中找出 和为目标值 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]

提示:

  • 只会存在一个有效答案

进阶: 你可以想出一个时间复杂度小于 的算法吗?

解法1: 穷举解空间

  1. 两层循环遍历数组,找到和为目标值的两个数。
def twoSum(self, nums: List[int], target: int) -> List[int]:
    n = len(nums)
    for i in range(n):
        for j in range(n): # 注意到这里没有排除 i==j 的情况, 例如 [3, 3], 6 会导致结构为 [0, 0]
            if nums[i] + nums[j] == target:
                return [i, j]
    return []
Bug

这个写法是错误的, 因为没有排除 i==j 的情况, 例如 [3, 3], 6 会导致结果为 [0, 0]

修复:

def twoSum(self, nums: List[int], target: int) -> List[int]:
    n = len(nums)
    for i in range(n):
        for j in range(n):
            if i == j: # 排除 i==j 的情况
                continue
            if nums[i] + nums[j] == target:
                return [i, j]
    return []
Success

这样就能得到正确答案啦 :), 但是时间复杂度是 的数量级, 因为我们需要遍历两遍数组

解法2: 减少搜索空间(剪枝思想)+穷举

注意到: 例如: [2, 7, 11], 9, 最多会比较 次, 实际上最多只需要比较: 2 + 7, 2 + 11, 7 + 11, 即次, 写成矩阵(表格):

Info
  1. 如下证明可以参考: 等差数列求和
  1. 这里写了矩阵这个术语不要感到意外, 此处仅是用来展示一个表格, 不是用来运算

如果直接遍历两遍, 相当于计算(判断)了9次, 实际上我们只需要又上小角落或左下角落的三个元素, 所以可以优化一下:

def twoSum(self, nums: List[int], target: int) -> List[int]:
    n = len(nums)
    for i in range(n):
        for j in range(i+1, n): # 优化: 从 i+1 开始
            if nums[i] + nums[j] == target:
                return [i, j]
    return []

这样我们相当于只计算了:

至此, 时间复杂度仍然是 的数量级, 只是比原来的大概少了 (可以明显看到), 大概是因为 如果找到了答案, 就不会继续遍历了, 因此 都不会是完整的遍历, 不会刚好少了这么多

对于这个方法的另外一个写法:

def twoSum(self, nums: List[int], target: int) -> List[int]:
    n = len(nums)
    for i in range(n):
        y = target - nums[i]
        if y in nums[i+1:]:
            return [i, nums.index(y, i+1)]
    return []
Warning

这个另外一个写法, 并不会降低时间复杂度, 甚至会增加时间复杂度, 因为 inindex 都是 的时间复杂度, 所以这个写法的时间复杂度是

解法3: 记录前缀信息(前缀和思想)+哈希表

从上面的解法可以想到: 如果将已经遍历过的数字和对应的索引作为值存储到一个字典中, 那么在遍历的时候, 可以直接判断 target - nums[i] 是否在字典中, 如果在, 那么就找到了答案, 这样就可以将时间复杂度降低到

Tip
  1. 字典的 inget 方法的时间复杂度是 , get也可以用类似列表获取索引那样使用dict[key] 这种语法
  2. 关于字典的定义(设计): 把 数组中的数字 作为key, 索引作为 value, 是因为 需要直接比较 是否有符合的数字, 且当符合时, 需要返回索引, 故这个设计是合理的
def twoSum(self, nums: List[int], target: int) -> List[int]:
    hashmap = {} # key: 数字, value: 索引
    for i, num in enumerate(nums):
        if target - num in hashmap: # 判断是否有符合的数字, O(1)
            return [hashmap[target - num], i] # 通过key拿到value, O(1)
        hashmap[num] = i
    return []