青训营X豆包MarsCode 倍数关系子集问题思路及解析 | 豆包MarsCode AI 刷题

55 阅读7分钟

思路实现

思路

我们需要计算,数组中的元素能构成多少个“倍数子集”。倍数子集的定义是,如果子集内的任意元素都能互相整除(即存在倍数关系),那么这个子集就是一个有效的倍数子集。

  1. 排序:为了确保我们可以检查倍数关系,我们首先对数组进行排序。这样我们可以从小到大依次检查倍数关系。
  2. 动态规划(DP) :我们用一个 dp 数组来存储以 a[i] 为结尾的所有符合条件的子集数量。具体地,dp[i] 表示以 a[i] 为结尾的倍数子集的数量。
  3. 转移方程:对于每一个 a[i],我们检查它之前的所有元素 a[j](其中 j < i)。如果 a[i] % a[j] == 0,则可以将 a[i] 添加到由 a[j] 构成的子集中。这意味着我们可以通过将 a[i] 添加到所有符合条件的子集,来扩展这些子集。
  4. 最终结果:返回所有有效的倍数子集数量。注意,单个元素子集不算有效,因此我们需要从最终结果中减去 n(每个元素本身就是一个子集)。

解决步骤

  1. 初始化:初始化一个 dp 数组,长度为 n,每个 dp[i] 初始值为 1,表示每个元素至少可以形成一个只包含它自己的子集。
  2. 状态转移:遍历每个元素 a[i],并遍历它之前的所有元素 a[j],如果 a[i] % a[j] == 0,则更新 dp[i]
  3. 结果计算:最终的结果是所有 dp[i] 之和,但要去掉所有只包含一个元素的子集,因此从结果中减去 n

知识学习

1. 动态规划(Dynamic Programming, DP)

动态规划是一种用于求解最优化问题的技术,适用于那些可以分解为子问题且子问题的解可以被重复使用的问题。在这类问题中,我们通过将大问题拆解成小问题,并存储小问题的答案来避免重复计算,从而提高效率。

在本题中,使用动态规划来求解符合倍数关系的子集数量。每个子集的问题都可以通过前面已经计算出的子集来推导。具体而言,定义 dp[i] 为以 a[i] 为结尾的符合条件的倍数子集的数量。然后,我们遍历数组,更新每个 dp[i] 值,结合已知的信息来逐步计算出最终的结果。

动态规划的基本步骤:

  • 定义状态:我们定义 dp[i] 表示以 a[i] 为结尾的倍数子集数量。
  • 状态转移:对于每个元素 a[i],我们检查之前所有可能的元素 a[j],如果 a[i] 能被 a[j] 整除,即 a[i] % a[j] == 0,那么我们可以将 a[i] 添加到以 a[j] 为结尾的子集中,从而扩展这些子集。
  • 初始条件:每个元素自己至少可以形成一个子集,所以初始值为 1。

动态规划使得这个问题的计算能够避免重复计算,提升了效率。每个 dp[i] 都依赖于前面的 dp[j]j < i),这种“从小到大”的递推方式是动态规划中常见的技巧。

2. 排序(Sorting)

在该问题中,排序是一个非常重要的步骤。通过将数组排序,我们确保了从小到大的顺序检查倍数关系。这样做的好处是:

  • 我们可以确保在处理元素 a[i] 时,所有可能与它构成倍数关系的元素 a[j] (其中 j < i)已经被处理过。这样就避免了重复的计算,保证了子集的倍数关系。
  • 排序简化了判断倍数关系的逻辑。因为只需要从小到大的顺序遍历,就能在遇到某个元素时,确保它之前的所有可能的倍数都已经考虑过了。

排序算法通常的时间复杂度为 O(n log n),这是一个在大多数情况下可以接受的开销。

3. 取模运算(Modulo Operation)

为了避免结果过大,题目要求我们对最终结果取模,通常使用 MOD = 10^9 + 7 作为常见的取模数。取模操作是一种常见的技巧,用于防止计算过程中溢出或保持数值在一定范围内。

在动态规划中,任何时候更新 dp[i] 时,我们都需要对结果取模。这是因为在某些情况下,子集数量可能非常大,直接存储可能会导致数值溢出。通过取模,保持数值在可接受的范围内,同时不影响运算的正确性。

4. 组合数学(Combinatorics)

本题实际上是一个组合数学问题,特别是与子集生成有关。题目要求我们计算符合特定条件的倍数子集的数量,其中每个子集的元素之间必须满足倍数关系。组合数学中的子集和排列问题通常可以用动态规划、递推等方法解决。

在本题中,考虑每个元素时,我们不仅仅是在寻找单个子集,而是通过前面已经找到的子集来扩展它们,从而生成新的符合条件的子集。每个元素都可以“继承”之前符合条件的子集,因此是典型的动态规划应用场景。

5. 复杂度分析

在本题中,时间复杂度的主要来源是动态规划部分。具体来说,对于每个元素 a[i],我们都要检查它前面的所有元素 a[j],看它们是否能构成倍数关系。因此,时间复杂度为 O(n^2)。而排序的时间复杂度为 O(n log n)。因此,整个算法的时间复杂度是 O(n^2)

空间复杂度主要由 dp 数组和输入数组构成,因此空间复杂度为 O(n)

6. 优化和改进

尽管 O(n^2) 的时间复杂度在许多情况下是可以接受的,但如果我们能通过其他方法进一步优化,可能会提升效率。例如:

  • 分治法或树状数组:对于更大的数据集,可能可以通过其他更高级的算法,如分治法、树状数组等来进一步优化性能。
  • 哈希表优化:如果元素的范围较小,或者元素的频率分布有特殊性质,也可以考虑使用哈希表来优化状态转移过程,减少不必要的重复计算。

7. 实际应用

本题虽然是一个纯粹的算法问题,但其思想可以应用到许多实际问题中。例如,倍数关系广泛应用于数据分析、网络流量控制、数字信号处理等领域。在这些领域中,类似的子集问题可能会通过动态规划和排序来高效地解决。

总结来说,学习这个问题不仅能帮助掌握动态规划和组合数学的应用,还能为处理更复杂的优化问题打下基础。

代码

MOD = 10**9 + 7

def solution(n: int, a: list) -> int:
    # 排序数组,确保能够从小到大检查倍数关系
    a.sort()
    
    # dp[i]表示以a[i]为结尾的符合条件的子集数量
    dp = [0] * n
    total = 0
    
    for i in range(n):
        # 每个元素至少自己构成一个单独的子集
        dp[i] = 1
        
        # 遍历所有小于a[i]的元素
        for j in range(i):
            # 如果a[i]能够被a[j]整除,则可以将a[i]加到以a[j]为结尾的子集上
            if a[i] % a[j] == 0:
                dp[i] = (dp[i] + dp[j]) % MOD
        
        # 累加所有符合条件的子集数量
        total = (total + dp[i]) % MOD
    
    # 去掉所有只有一个元素的子集
    total = (total - n + MOD) % MOD
    
    return total

if __name__ == '__main__':
    print(solution(5, [1, 2, 3, 4, 5]) == 6)
    print(solution(6, [2, 4, 8, 16, 32, 64]) == 57)
    print(solution(4, [3, 6, 9, 12]) == 5)