0Pricing
Coding Interview Prep · 课时

子集与幂集

使用回溯和位掩码生成集合的所有子集,通过排序并跳过重复元素来处理重复项。

子集与幂集 是 CoddyKit 上的免费 Coding Interview Prep 课时。 这是第 2 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Coding Interview Prep 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Coding Interview Prep 课程共包含 4 节课。

子集与幂集

集合 S 的幂集是 S 的所有可能子集组成的集合,其中包括空集和 S 本身。包含 n 个元素的集合恰好有2ⁿ个子集。对于 [1, 2, 3],8 个子集为:[], [1], [2], [3], [1,2], [1,3], [2,3], [1,2,3]。这是一个基础的组合学问题,在面试题中经常出现,例如查找所有可能的组合、划分或选择。

# A set of n elements → 2^n subsets
for n in range(5):
    print(f'n={n}: {2**n} subsets')
# n=0: 1  (just the empty set)
# n=1: 2  ([], [x])
# n=2: 4  ([], [a], [b], [a,b])
# n=3: 8  (as enumerated above)
# n=4: 16

回溯生成子集

使用选择-探索-撤销选择模板。关键的设计决策是:在每次递归调用中,立即将当前部分路径添加到结果中(在选择更多元素之前)。这样,每个状态——空、部分和完整——都会作为有效子集被记录。推进 start 索引,只考虑位于最后选定元素右侧的元素,从而确保没有重复并保持顺序。

def subsets(nums):
    result = []
    def backtrack(start, path):
        result.append(list(path))   # every state is a valid subset
        for i in range(start, len(nums)):
            path.append(nums[i])    # CHOOSE
            backtrack(i + 1, path)  # EXPLORE (advance start)
            path.pop()              # UNCHOOSE
    backtrack(0, [])
    return result

print(subsets([1, 2, 3]))
# [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]

位掩码方法

回溯的另一种方法是位掩码:每个子集对应一个 n 位数字,其中第 i 位为 1 表示包含第 i 个元素。遍历从 0 到 2ⁿ - 1 的数字,并对每个数字提取各位来构建子集。这种方法是迭代式的,实际中通常更快,也很容易编码。不过,对于带约束的问题(例如和的上限),它的推广性不如回溯。

def subsets_bitmask(nums):
    n = len(nums)
    result = []
    for mask in range(1 << n):  # 0 to 2^n - 1
        subset = []
        for i in range(n):
            if mask & (1 << i):  # bit i is set
                subset.append(nums[i])
        result.append(subset)
    return result

print(subsets_bitmask([1, 2, 3]))
# Same 8 subsets, order may differ

迭代生成子集

迭代方法逐个元素构建幂集。从 [[] ](空集)开始。对于每个新元素,复制所有现有子集,并将新元素追加到每个副本中。处理完 n 个元素后,结果包含全部 2ⁿ 个子集。这与位掩码等价,但对于不熟悉位运算的人来说更易读。

def subsets_iterative(nums):
    result = [[]]  # start with empty set
    for num in nums:
        # For each existing subset, create a new subset with num added
        result += [subset + [num] for subset in result]
    return result

print(subsets_iterative([1, 2, 3]))
# After num=1: [[], [1]]
# After num=2: [[], [1], [2], [1,2]]
# After num=3: [[], [1], [2], [1,2], [3], [1,3], [2,3], [1,2,3]]

子集 II:处理重复元素

当输入包含重复元素时,朴素方法会生成重复子集。对于 [1, 2, 2],两个 2 都会独立地产生 [1, 2]。修复方法:先对数组排序,然后如果当前层的候选项等于前一个候选项,就跳过它。具体来说,在循环中:if i > start and nums[i] == nums[i-1]: continue。

def subsets_with_dups(nums):
    nums.sort()  # sort to group duplicates together
    result = []
    def backtrack(start, path):
        result.append(list(path))
        for i in range(start, len(nums)):
            # Skip duplicates at the same tree level
            if i > start and nums[i] == nums[i-1]:
                continue
            path.append(nums[i])
            backtrack(i + 1, path)
            path.pop()
    backtrack(0, [])
    return result

print(subsets_with_dups([1, 2, 2]))
# [[], [1], [1,2], [1,2,2], [2], [2,2]]  — no duplicate subsets

重复项跳过机制为何有效

条件 i > start and nums[i] == nums[i-1] 只会在同一递归层级(相同的 start)跳过重复项。它不会阻止在不同深度选择相同的值。对于 [1, 2, 2]:在第 0 层,我们包含第一个 2(索引 1),然后在下一层(start=2)包含第二个 2,从而形成 [2, 2]。但如果我们再次尝试在第 0 层包含第二个 2,该条件就会捕获并跳过它。

# Visual: [1, 2, 2] sorted
# Level 0 (start=0): pick nothing, pick 1, pick first-2, pick second-2 (SKIP)
# Level 1 after picking 1 (start=1): pick first-2, pick second-2 (SKIP)
# Level 2 after picking 1,first-2 (start=2): pick second-2
# → [1,2,2] is generated but only once

nums = [1, 2, 2]
nums.sort()
result_set = set(tuple(sorted(s)) for s in subsets_with_dups(nums[:]))
result_naive = set(tuple(sorted(s)) for s in subsets(nums))
print('With dedup:', sorted(result_set))
print('Same results:', result_set == result_naive)

def subsets(nums):
    result = []
    def bt(start, path):
        result.append(list(path))
        for i in range(start, len(nums)):
            path.append(nums[i]); bt(i+1, path); path.pop()
    bt(0, [])
    return result

def subsets_with_dups(nums):
    result = []
    def bt(start, path):
        result.append(list(path))
        for i in range(start, len(nums)):
            if i > start and nums[i] == nums[i-1]: continue
            path.append(nums[i]); bt(i+1, path); path.pop()
    bt(0, [])
    return result

print(len(subsets_with_dups([1,2,2])), 'unique subsets')  # 6

固定大小的子集(k-组合)

只生成大小恰好为 k 的子集(LeetCode 77:组合)时,需要增加一个提前终止条件:如果剩余元素无法将路径填充到大小 k,就进行剪枝。可剪枝的条件是 i > n - (k - len(path)):如果剩余元素数量不足,就提前停止。与生成所有 subsets 后再筛选相比,这能显著缩小搜索空间。

def combine(n, k):
    result = []
    def backtrack(start, path):
        if len(path) == k:
            result.append(list(path))
            return
        # Prune: need (k - len(path)) more elements from [start..n]
        # At most (n - start + 1) elements remain
        if n - start + 1 < k - len(path):
            return  # not enough elements left
        for i in range(start, n + 1):
            path.append(i)
            backtrack(i + 1, path)
            path.pop()
    backtrack(1, [])
    return result

print(combine(4, 2))  # [[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]]
print(len(combine(10, 3)))  # C(10,3) = 120

幂集的应用

幂集模式会出现在许多面试题变体中:(1)划分为两个相等的子集——检查是否存在元素和为 total/2 的子集。(2)两个子集的最大 XOR——尝试所有子集对。(3)选择 k 个项目的最小成本——枚举 k-子集。虽然直接枚举具有指数级复杂度,但其中许多问题在识别出结构后都可以使用 DP 解决。即使最终需要对其进行优化,幂集视角也能帮助您确定状态空间。

def max_subset_sum(nums, k):
    '''Maximum sum of any k elements (for comparison: O(n log n) alternative)'''
    # Backtracking approach: enumerate all k-subsets
    max_s = [float('-inf')]
    def bt(start, path, curr_sum):
        if len(path) == k:
            max_s[0] = max(max_s[0], curr_sum)
            return
        remaining_spots = k - len(path)
        for i in range(start, len(nums)):
            if len(nums) - i < remaining_spots: break  # prune
            bt(i+1, path+[nums[i]], curr_sum+nums[i])
    bt(0, [], 0)
    return max_s[0]

# Much faster: just sort and take top k
def max_subset_sum_fast(nums, k):
    return sum(sorted(nums, reverse=True)[:k])

nums = [3, 1, 4, 1, 5, 9, 2, 6]
print(max_subset_sum(nums, 3))       # 20 (9+6+5)
print(max_subset_sum_fast(nums, 3))  # 20

子集和检查

子集和要解决的问题是:数组中是否存在某个子集,其元素和等于目标值?这个问题可以通过回溯(指数级复杂度)或 DP(多项式复杂度)解决。回溯版本直观易懂,但输入规模较大时会变得不切实际。DP 版本(布尔表 dp[target+1])是面试中的首选方法。理解这两种方法有助于您说明其中的权衡:回溯可以给出所有解,而 DP 能够高效地回答判定问题。

# Backtracking version: finds a subset if it exists
def subset_sum_bt(nums, target):
    def bt(start, remaining):
        if remaining == 0: return True
        if remaining < 0 or start == len(nums): return False
        # Include nums[start]
        if bt(start + 1, remaining - nums[start]): return True
        # Exclude nums[start]
        return bt(start + 1, remaining)
    return bt(0, target)

# DP version: O(n * target) time
def subset_sum_dp(nums, target):
    dp = {0}
    for num in nums:
        dp |= {s + num for s in dp}
    return target in dp

print(subset_sum_bt([3, 1, 4, 1, 5], 6))  # True (1+5 or 1+1+4)
print(subset_sum_dp([3, 1, 4, 1, 5], 6))  # True

子集枚举的复杂度

生成所有 subsets 的 time 复杂度不可避免地为 O(n × 2ⁿ)——共有 2ⁿ 个子集,每个子集的平均大小为 n/2。当要求返回所有子集时,没有算法能够做得更好。对于只要求寻找具有某种性质的单个子集的问题(例如和最大的子集),应优先考虑 DP 或贪心方法。面试中的关键洞察是:始终先确认您需要枚举所有 subsets,还是只需判断任意子集是否满足条件——答案决定了指数级或多项式 time 是否可以接受。

import time

def count_subsets(n):
    nums = list(range(n))
    result = []
    def bt(start, path):
        result.append(None)  # count without storing
        for i in range(start, len(nums)):
            path.append(i); bt(i+1, path); path.pop()
    bt(0, [])
    return len(result)

for n in [10, 15, 20]:
    start = time.time()
    cnt = count_subsets(n)
    elapsed = time.time() - start
    print(f'n={n}: {cnt} subsets ({2**n} expected) in {elapsed:.3f}s')

三种方法比较

对于生成所有 subsets:回溯的通用性最强——可以轻松适应重复项和各种约束。位掩码简洁且快速,但受 n ≤ 30(整数大小)的限制。迭代方法直观,并且避免了递归开销。三种方法的输出规模都是 O(n × 2ⁿ)。在面试中,回溯能够体现您对递归决策过程的理解,并且这种理解可以推广到更困难的问题。讨论方法时,请提及这三种方法。

# All three approaches for [1,2,3]
nums = [1, 2, 3]

# 1. Backtracking
def bt(start, path, res):
    res.append(list(path))
    for i in range(start, len(nums)):
        path.append(nums[i]); bt(i+1, path, res); path.pop()
res1 = []; bt(0, [], res1)

# 2. Bit masking
res2 = [[nums[i] for i in range(len(nums)) if mask & (1<<i)]
        for mask in range(1<<len(nums))]

# 3. Iterative
res3 = [[]]
for num in nums:
    res3 += [s+[num] for s in res3]

print('All produce', len(nums)**2, '-ish subsets:',
      len(res1), len(res2), len(res3))  # all 8

快速检查

请测试您对本课中“数据结构 & 算法——编程面试准备”概念的理解。

课程回顾

本课介绍了:回溯通过在继续探索之前将每条部分路径添加到结果中来生成所有 subsets,重复项通过排序,并使用条件 i > start and nums[i] == nums[i-1] 在相同递归深度跳过重复值来处理,以及位掩码提供了一种简洁的迭代替代方案,其中每个子集都对应一个唯一的位掩码。接下来我们将学习排列和组合——这是约束不同但相关的枚举问题。

常见问题解答

「子集与幂集」课时是免费的吗?

是的 — 「子集与幂集」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Coding Interview Prep 课程的其余内容,请升级到 CoddyKit PRO。 Coding Interview Prep 课程共包含 4 节课。

「子集与幂集」这节课中我会学到什么?

使用回溯和位掩码生成集合的所有子集,通过排序并跳过重复元素来处理重复项。 你通过在浏览器中直接运行的动手代码来练习 Coding Interview Prep,全天候 AI 导师会在你学习这节课的过程中回答你的问题。

学习 Coding Interview Prep 需要有经验吗?

无需任何先前经验。CoddyKit 上的 Coding Interview Prep 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 2 节课,共 4 节。

「子集与幂集」课时需要多长时间?

大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。

我能在这节 Coding Interview Prep 课中编写并运行代码吗?

能。每节 Coding Interview Prep 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。

此课程中的所有课时

  1. 回溯模板:选择、探索、撤销
  2. 子集与幂集
  3. 排列与组合
  4. N 皇后与约束传播
← 返回 Coding Interview Prep