穷举法
这是一个非常好的起点!暴力枚举是解决算法问题的基础方法之一,尤其是在解空间有限的情况下。虽然暴力枚举通常不是最优解,但它是理解问题和验证复杂算法的关键工具。
以下是关于如何学习暴力枚举的详细指导:
什么是暴力枚举?
暴力枚举(Brute Force)是一种穷举法,即尝试所有可能的解,逐一验证每个解是否满足问题的条件。它的核心思想是:
- 生成所有可能的解。
- 逐一验证每个解是否满足条件。
- 返回满足条件的解(或最优解)。
学习暴力枚举的步骤
理解问题的解空间
- 解空间是指问题所有可能解的集合。
- 在暴力枚举中,你需要明确解空间的大小,以及如何生成所有可能的解。
示例:
- 如果问题是“从数组
[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}")
学习暴力枚举的资源
-
书籍:
- 《算法导论》(Introduction to Algorithms)—— Thomas H. Cormen
- 《编程之美》—— Microsoft Research
-
在线平台:
- LeetCode:从简单题目开始,逐步挑战更复杂的题目。
- HackerRank:适合练习基础算法和枚举问题。
- Codeforces:适合练习竞赛题目。
-
工具:
- 使用 Python 的
itertools库简化排列和组合的生成。 - 使用可视化工具(如 VisuAlgo)帮助理解枚举过程。
- 使用 Python 的
学习路径建议
-
从简单问题开始:
- 练习单层循环和多层循环的枚举。
- 理解如何生成解空间。
-
逐步增加复杂度:
- 学习排列、组合的生成方法。
- 练习验证条件的逻辑。
-
优化暴力枚举:
- 学习剪枝和提前终止的技巧。
- 理解如何减少不必要的计算。
-
结合实际问题:
- 在实际问题中应用暴力枚举,逐步积累经验。
总结
暴力枚举是算法学习的基础,掌握它可以帮助你更好地理解问题和验证复杂算法。学习暴力枚举的关键是:
- 理解解空间:明确所有可能的解。
- 生成解空间:使用循环、递归或工具库生成解。
- 验证解:逐一验证每个解是否满足条件。
- 优化枚举:通过剪枝和提前终止提高效率。
通过不断练习和总结,你会发现暴力枚举不仅简单,而且非常实用。如果你有具体的题目或问题,可以随时告诉我,我会帮助你分析和解答!
例题
给定一个整数数组 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: 穷举解空间
- 两层循环遍历数组,找到和为目标值的两个数。
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 []这个写法是错误的, 因为没有排除 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 []这样就能得到正确答案啦 :), 但是时间复杂度是
解法2: 减少搜索空间(剪枝思想)+穷举
注意到:
例如: [2, 7, 11], 9, 最多会比较 2 + 7, 2 + 11, 7 + 11, 即
如果直接遍历两遍, 相当于计算(判断)了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 []这个另外一个写法, 并不会降低时间复杂度, 甚至会增加时间复杂度, 因为 in 和 index 都是
解法3: 记录前缀信息(前缀和思想)+哈希表
从上面的解法可以想到: 如果将已经遍历过的数字和对应的索引作为值存储到一个字典中, 那么在遍历的时候, 可以直接判断 target - nums[i] 是否在字典中, 如果在, 那么就找到了答案, 这样就可以将时间复杂度降低到
- 字典的
in和get方法的时间复杂度是 ,get也可以用类似列表获取索引那样使用dict[key] 这种语法 - 关于字典的定义(设计): 把 数组中的数字 作为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 []
