组合和的记忆与非记忆时间复杂度分析

Posted

技术标签:

【中文标题】组合和的记忆与非记忆时间复杂度分析【英文标题】:Memoized vs non-memoized time complexity analysis for combination sums 【发布时间】:2018-08-12 00:11:47 【问题描述】:

我试图理解为什么使用lru_cache 来解决这个问题会导致代码的性能变慢。

The question本质上是返回所有加起来达到某个目标的组合。

我正在使用 lru_cache 装饰器进行记忆 (docs),这是我的解决方案:

from functools import lru_cache

def combinationSum(candidates, target):
    return dfs(tuple(candidates), 0, target)

@lru_cache(maxsize=None)
def dfs(candidates, i, target):
    if target < 0:
        return []

    if target == 0:
        return [[]]

    if i == len(candidates):
        return []

    final_results = []
    for j in range(i, len(candidates)):

        results = dfs(candidates, j, target - candidates[j])

        for x in results:
            final_results.append([candidates[j]] + x)

    return final_results

似乎当 lru_cache 装饰器被注释掉时,这个算法的运行速度几乎提高了 50%。这似乎有点违反直觉,因为我认为应该降低解决方案的时间复杂度,即使增加了从记忆中检索结果的函数调用开销。

对于记忆的解决方案,我认为时间复杂度应该是O(n^2*k*2^n),其中n 是数组的长度,k 是从0target 范围内的所有数字。

这是我的分析(需要一点帮助验证):

time complexity 
= possible states for memoization x work done at each step
= (n * k) * (n * maximum size of results)
= n * k * n * 2^n

我在如何分析递归解决方案的时间复杂度方面还缺少一些知识,我可以在这方面寻求帮助!

编辑:

我使用range(1, 10000) 作为测试输入,以下是基准测试:

# with lru_cache
$ time python3 combination_sum.py
CacheInfo(hits=59984, misses=49996, maxsize=None, currsize=49996)

real    0m4.031s
user    0m3.996s
sys     0m0.024s

# without lru_cache
$ time python3 combination_sum.py

real    0m0.073s
user    0m0.060s
sys     0m0.010s

【问题讨论】:

请给出您使用的具体参数。缓存是有益还是有害,很大程度上取决于缓存命中率,如果不知道您使用的参数,就不可能猜测您获得的命中率。 candidates 是什么类型? 较低的复杂性并不能保证较短的时间。这只是意味着它在较大的值下增长得更慢。对于较小的值,需要更长的时间是完全有效的。 @skyboyer 候选人是一个整数列表 @TimPeters 我已经用我正在使用的输入更新了问题! 【参考方案1】:

你没有给出两个参数,它们都很重要。通过选择 specific 对,我可以使任何一个版本都比另一个版本快得多。如果您将range(1, 10000) 作为candidates 传递,那么每次缓存查找都必须(除其他外)进行9999 次比较,以确定候选对象始终相同——这是巨大的开销。试试,例如,

combinationSum(range(1, 1000), 45) # not ten thousand, just one thousand

对于缓存版本更快的情况。之后:

>>> dfs.cache_info()
CacheInfo(hits=930864, misses=44956, maxsize=None, currsize=44956)

如果您不考虑进行缓存查找的费用,那么“分析”是徒劳的,而且您显然正在尝试 缓存查找非常昂贵的情况。字典查找是预期的情况O(1),但隐藏的常数因子可以任意大,具体取决于相等测试的成本(对于涉及N元素元组的键,建立相等至少需要N比较)。

这应该是一项重大改进:将candidates 排除在参数列表之外。它是不变的,所以真的没有必要通过它。然后缓存只需要存储快速比较的(i, target) 对。

编辑:实际更改

这是另一个没有传入candidates 的代码版本。对于

combinationSum(range(1, 10000), 45)

在我的盒子上,它至少快了 50 倍。还有另一个重大变化:当target 减少到零以下时,不要进行递归调用。大量缓存条目正在记录 (j, negative_integer) 参数的空列表结果。在上述情况下,此更改将最终缓存大小从 449956 减少到 1036 - 并将命中数从 9444864 减少到 6853。

def combinationSum(candidates, target):

    @lru_cache(maxsize=None)
    def dfs(i, target):
        if target == 0:
            return [[]]
        assert target > 0
        if i == n:
            return []
        final_results = []
        for j in range(i, n):
            cand = candidates[j]
            if cand <= target:
                results = dfs(j, target - cand)
                for x in results:
                    final_results.append([cand] + x)
        return final_results

    n = len(candidates)
    result = dfs(0, target)
    print(dfs.cache_info())
    return result

【讨论】:

我真是个傻瓜,在我解决的所有问题中,我完全忽略了candidates比较的时间复杂度>target 传递像5 这样的小值时,递归树也很容易被修剪,但是当传入更大的值时,记忆的效果变得更加明显。 现在我对为什么记忆解决方案对于更大的输入空间更快,我如何分析普通递归解决方案的时间复杂度来比较它们有了更多的直觉?【参考方案2】:

尝试对结果运行以下命令

>>>dfs.cache_info()

你应该得到这样的结果

CacheInfo(hits=2, misses=216, maxsize=None, currsize=216)

因为你的函数参数很长,所以它们与缓存的值不经常匹配,我在这里责怪目标参数,重组程序可能会大大提高命中率。

【讨论】:

我已经用缓存信息结果更新了问题!是的,重组可能会奏效,但我想弄清楚使用 memoization 对解决方案的影响——以及为什么运行时会因 memoization 而变慢

以上是关于组合和的记忆与非记忆时间复杂度分析的主要内容,如果未能解决你的问题,请参考以下文章

再学并查集

试图证明/反驳算法的复杂性分析

POJ 1191 棋盘分割 (区间DP,记忆化搜索)

思维导图时间序列分析

[记忆向]工作中一些实用的Linux命令组合(持续)

[记忆向]工作中一些实用的Linux命令组合(持续)