记忆有啥好处,真的那么有用吗?

Posted

技术标签:

【中文标题】记忆有啥好处,真的那么有用吗?【英文标题】:What is memoization good for and is it really all that helpful?记忆有什么好处,真的那么有用吗? 【发布时间】:2011-03-15 15:07:33 【问题描述】:

互联网上有一些用于各种不同语言的自动记忆库;但如果不知道它们的用途、使用地点以及它们的工作原理,就很难看到它们的价值。使用记忆化有哪些令人信服的论据,记忆化在哪些问题领域特别突出?在这里特别感谢不知情的人提供的信息。

【问题讨论】:

不难看出它们的价值。 What is memoization? 的可能重复项 【参考方案1】:

在我看来,斐波那契和阶乘计算并不是最好的例子。当您拥有以下条件时,记忆就会真正发挥作用:

    相关计算的潜在输入范围很大,但范围仍然受到明显限制和已知 您提前知道该程序的任何实际使用只会使用一小部分可能的输入来进行计算(斐波那契和阶乘失败) 您不知道在运行时将使用哪些特定的输入,因此需要记住哪些特定的结果(斐波那契和阶乘在某种程度上也失败了)

显然,如果您确实知道所有可能的输入,并且空间允许,您可以考虑用查找替换该函数(例如,对于具有已知发电机)。

...甚至比 #2 更好的是,如果 对于程序的任何特定运行,您可以立即说“潜在输入的范围将被限制为满足这些条件的子集... ”。

请注意,其中很多可能是概率性的(或直观的)——当然,有人可能会尝试所有 10^13 种可能的输入来进行魔法计算,但您知道实际上他们不会。如果他们这样做了,记忆的开销实际上对他们没有好处。但你可能会认为这是可以接受的,或者在这种情况下允许绕过记忆。


这是一个示例,我希望它不会过于复杂(或笼统)而无法提供信息。

在我编写的某些固件中,程序的一部分从 ADC 读取,该 ADC 可以是从 0x0000xFFF 的任意数字,并计算程序其他部分的输出。此计算还采用一组用户可调整的数字,但这些数字仅在程序启动时读取。这个计算在第一次运行时就非常成功。

提前创建查找表是荒谬的。输入域是 [0x000, ..., 0xFFF] 和(大范围的浮点值)和(另一个大范围...)和 ... 的笛卡尔积。不,谢谢。

但没有用户要求或期望设备在条件快速变化时运行良好,他们很多宁愿在情况稳定时运行更好。因此,我在反映这些要求的计算行为上进行了权衡:我希望当事情稳定时这个计算又好又快,而我不在乎什么时候不稳定。

鉴于典型用户期望的“缓慢变化条件”的定义,ADC 值将稳定到特定值并保持在其稳定值的大约 0x010 范围内。哪个值取决于条件。

因此可以为这 16 个潜在输入存储计算结果。如果环境条件的变化确实比预期的要快,则从最近读取的“最远”ADC 将被丢弃(例如,如果我将 0x210 缓存到 0x21F,然后读取 0x222,我会删除 0x210结果)。

这里的缺点是,如果环境条件发生很大变化,那么已经很慢的计算运行速度会慢一些。我们已经确定这是一个不寻常的用例,但如果后来有人透露,实际上他们确实想在异常不稳定的条件下操作它,我可以实现一种绕过记忆的方法。

【讨论】:

我现在也添加了一个示例。 您可以使用最近最少使用的缓存做得更好。它将与您现在拥有的大致相同,只是 16 可以是任何 16。 @DeadMG - 大概需要动态分配的内存?对于嵌入式系统,我倾向于避免这种情况。 :) @detly:不,一点也不。您可以使用静态分配的数组。事实上,无论您要存储 memoize 结果的任何地方都可以。 @DeadMG - 啊哈,我刚刚查了一下:P 我明白你现在的意思了。知道这一点很有用,干杯。【参考方案2】:

这里流行的阶乘答案是一个玩具答案。是的,记忆化对于重复调用该函数很有用,但这种关系是微不足道的——在“print factorial(N) for 0..M”的情况下,你只是重用了最后一个值。

这里的许多其他示例只是“缓存”。这很有用,但它忽略了 memoization 这个词对我带来的令人敬畏的算法含义。

更有趣的是,递归函数的单次调用的不同分支会遇到相同的子问题,但采用非平凡的模式,因此实际上对某个缓存进行索引实际上是有用的。

例如,考虑 n 维整数数组,其绝对值总和为 k。例如。对于 n=3,k=5 [1,-4,0], [3,-1,1], [5,0,0], [0,5,0] 将是一些示例。令 V(n,k) 为给定 n,k 的可能唯一数组的数量。它的定义是:

V(n,0)=1; V(0,k)=0; V(n,k) = V(n-1,k) + V(n,k-1) + V(n-1,k-1);

对于 n=3,k=5,此函数给出 102。

如果没有记忆,即使是相当小的数字,计算起来也会很快变得很慢。如果您将处理可视化为一棵树,每个节点调用 V() 扩展为三个子节点,您将有 186,268,135,991,213,676,920,832 V(n,0)=1 在 V(32,32) 的计算中离开......天真地实现这个函数在可用硬件上很快变得无法计算。

但是树中的许多子分支是彼此完全重复的,尽管不是以某种像阶乘函数那样容易被消除的微不足道的方式。通过记忆化,我们可以合并所有这些重复的分支。事实上,使用记忆 V(32,32) 仅执行 V() 1024 (n*m) 次,这是 10^21 倍的加速(显然随着 n,k 的增长而变大)左右作为交换对于相当少量的内存。 :) 我发现这种对算法复杂性的根本性改变远比简单的缓存更令人兴奋。它可以使棘手的问题变得容易。

因为 python 数字自然是 bignums,所以您可以在 python 中使用字典和元组键在仅 9 行中使用 memoization 来实现这个公式。试一试,在没有记忆的情况下试一试。

【讨论】:

您说“这里的许多其他算法只是缓存”,然后“更有趣的是……”然后您继续描述……缓存?然后,在将其他答案称为“玩具”(包括使用斐波那契的答案)之后,您描述了一个类似于斐波那契(或我的网格问题)的示例序列。很高兴你的回答被接受了,但我个人认为你没有带来任何新的东西。 :) 呃,很抱歉没有更清楚地区分。当我开始写作时,斐波那契的答案还没有出现,而且您实际上并没有以有人可以尝试的方式解释您的网格示例。否则我可能不会回应。 :) 虽然在我看来,第一名投票的答案仍然不是最有趣或最准确的。阶乘真的是一个玩具,我认为我证明了这一说法。我对“缓存”和记忆化之间区别的看法是复杂性的变化——就像你用“荒谬”到“易处理”所得出的区别一样。干杯。 这个答案被接受了,因为它直接与(记住递归中过去的“分支答案”)与其应用并在此处演示的算法相关:***.com/questions/3242597/… 也许我今天醒来的时候心情不好,“比缓存更有趣”让我动心了。您的回答确实更详细地说明了对复杂性的影响,这很好 - 我的手是波浪形的。我看到了你现在想要做出的区分。您的示例确实比阶乘更好地显示了它(我只是假设您看到了 fib() 示例,因为您的帖子在 4 小时后发布)。 +1 :) @Gmaxwell 非常感谢您的帮助!最近有人把这个问题和答案重新引起了我的注意;在将缓存添加到程序后,它的性能在今天得到了显着提升。【参考方案3】:

Memoization 是一种存储子问题答案的技术,因此程序以后不需要重新解决相同的子问题。

这通常是使用Dynamic Programming 解决问题的一项重要技术。

想象一下,枚举从网格左上角到网格右下角的所有路径。许多路径相互重叠。您可以记住为网格上的每个点计算的解决方案,从右下角构建,回到左上角。这将计算时间从“荒谬”降低到“易于处理”。

另一个用途是:列出数字 0 到 100 的阶乘。你不想计算 100!使用100 * 99 * ... * 1。您已经计算了99!,因此重复使用该答案并将100 乘以99! 的答案。您可以记住每个步骤的答案(从 1 到 100),以节省大量计算。

对于一个数据点,对于我上面的网格求解问题(问题来自编程挑战):

记忆:

real  0m3.128s
user  0m1.120s
sys   0m0.064s

非记忆​​>(我杀了,因为我厌倦了等待......所以这是不完整的)

real 24m6.513s
user 23m52.478s
sys   0m6.040s

【讨论】:

如果你有一个 m×n 网格,假设你的路径只能向下和向右,则有 (m+n-2)!/((m-1)!(n- 1)!)。即使 m 和 n 仅为 10 左右,也有大约 50K 条路径。这是很多输出! (如果允许向左和向上,则路径的数量是无限的,除非您不允许循环。) @Paul Hanbury:是的。我不会给出这个问题的答案......但它是> 1000亿。有了记忆,它在 1 秒内就解决了 :) (这只是向下和向右移动)。 对不起,如果答案是数字,我想我已经给了它。我以为你的意思是打印出所有的路径。 @Paul :实际上,您的方程只产生 35M 条路径 :) 我的理由如下:从左上角到右下角的任意路径中,将有 m-1 个方向向下移动,n-1 个方向向右移动。如果我有一个装有 m-1 个标为“右”的球和 n-1 个标为“下”的球的瓮,我从瓮中随机选择球而无需更换。按照网格上的指示,当所有球都用完时,我将从左上角移动到右下角。【参考方案4】:

记忆化在子问题的解决方案可以重复使用的问题上大放异彩。简单地说,它是一种缓存形式。我们以阶乘函数为例。

3!它本身就是一个问题,但它也是 n! 的一个子问题!其中 n > 3 例如4! = 4 * 3! 计算阶乘的函数可以在记忆化中表现得更好,因为它只会计算 3!一次并将结果内部存储在哈希表中。每当遇到3!再次它会在表中查找值而不是重新计算它。

可以重复使用子问题解决方案(越频繁越好)的任何问题都可以使用记忆化。

【讨论】:

值得怀疑。我怀疑你的哈希表查找会比重新计算 3 更昂贵!。 @Jonathan:10 个呢!? 50!? @Jonathan:当然,3!重新计算速度更快。是20!?是100!?是200!?我会随时接受O(1) @Jonathan :你有一个疯狂的想法,我们提倡记忆化作为所有问题的解决方案。使用 bignum,不再有整数溢出!严肃点。这些都是小的重点问题。我(我们)认识到记忆化的限制,并且知道我们不能无限增长缓存......但感谢您指出。 @Jonathan Allen - 通过不使用由醉酒狒狒编写的记忆实现来缓解这种危险,它具有限制存储数据量等高级功能。【参考方案5】:

记忆以时间换空间。

当应用于本质上是多重递归的问题时,记忆化可以将指数时间(或更差)转化为线性时间(或更好)。代价一般是O(n)空间。

典型的例子是计算斐波那契数列。教科书的定义是递归关系:

F(n) = F(n-1) + F(n-2)

天真地实现了,看起来是这样的:

int fib(int n) 
  if (n == 0) 
    return 0;
  
  else if (n == 1) 
    return 1;
  
  else 
    return fib(n-1) + fib(n-2);
  

您可以看到运行时间随着 n 呈指数增长,因为每个部分和都被计算多次。

使用 memoization 实现,它看起来像这样(笨拙但实用):

int fib(int n) 
  static bool initialized = false;
  static std::vector<int> memo;

  if (!initialized) 
    memo.push_back(0);
    memo.push_back(1);
    initialized = true;
  

  if (memo.size() > n) 
    return memo[n];
  
  else 
    const int val = fib(n-1) + fib(n-2);
    memo.push_back(val);
    return val;
  

在我的笔记本电脑上对这两个实现进行计时,对于 n = 42,naive 版本需要 6.5 秒。记忆的版本需要 0.005 秒(所有系统时间——也就是说,它是 I/O 绑定的)。对于 n = 50,memoized 版本仍然需要 0.005 秒,而 naive 版本最终在 5 分 7 秒后完成(不管它们都溢出了 32 位整数)。

【讨论】:

@Jonathan:这是一个简单的例子。对于生产代码,您将转到 BigInts 并添加一个包装器,一次性检查 n > 0,但除此之外我没有发现任何问题。显然你不会长时间使用幼稚的版本(我想你会很快发现自己的记忆)。轻点……【参考方案6】:

记忆化可以从根本上加速算法。经典的例子是斐波那契数列,其中递归算法非常慢,但记忆自动使其与迭代版本一样快。

【讨论】:

静态编译成数组更快。 @Jonathan:你喜欢拖钓,不是吗?【参考方案7】:

一种记忆形式的用途之一是在游戏树分析中。在分析非平凡的博弈树(想想国际象棋、围棋、桥牌)中,计算位置的价值是一项非平凡的任务,并且可能需要大量时间。一个幼稚的实现会简单地使用这个结果然后丢弃它,但是所有强大的玩家都会存储它并在情况再次出现时使用它。你可以想象,在国际象棋中有无数种到达同一位置的方法。

要在实践中实现这一点需要无休止的实验和调整,但可以肯定地说,如果没有这种技术,计算机国际象棋程序就不会是今天的样子。

在 AI 中,这种记忆的使用通常被称为“转置表”。

【讨论】:

【参考方案8】:

Memoization 本质上是缓存给定输入的函数的返回值。如果您要使用相同的输入多次重复函数调用,这很有用,特别是如果函数需要一些时间来执行。当然,由于数据必须存储在某个地方,记忆化将使用更多的内存。这是使用 CPU 和使用 RAM 之间的权衡。

【讨论】:

【参考方案9】:

在将数据从一个系统迁移到另一个系统 (ETL) 时,我一直使用记忆。这个概念是,如果一个函数总是为同一组输入返回相同的输出,那么缓存结果可能是有意义的——特别是如果计算该结果需要一段时间。在进行 ETL 时,您通常会在大量数据上多次重复相同的操作,而性能通常很关键。当性能不是问题或可以忽略不计时,记住您的方法可能没有意义。像任何事情一样,为工作使用正确的工具。

【讨论】:

如何处理哈希表中的线程安全和无限增长? 线程安全不是问题,即使您有数以百万计的数据行,在这数百万行中可能只有几千个不同的组合需要记忆。在正确设置的 ETL 环境中,无论如何您都应该有足够的 RAM。【参考方案10】:

我认为大多数人都已经了解了记忆的基础知识,但我会给你一些实际的例子,这些例子可以用来做一些漂亮的惊人事情(恕我直言):

    在 C# 中,您可以反映一个函数并为其创建一个委托,然后您可以动态调用该委托……但这真的很慢!它比直接调用该方法慢大约 30 倍。如果您记住方法调用,则可以使调用几乎与直接调用方法一样快。 在遗传编程中,它可以减少对种群中成百上千的样本重复调用具有相似输入参数的相同函数的开销。 在表达式树的执行中:如果您已经记住了表达式树,则不必继续重新评估它...

当然还有更多可以使用记忆的实际示例,但这些只是其中的一小部分。

在my blog 中我分别讨论了memoization 和reflection,但我将发布另一篇关于在反射方法上使用记忆的文章...

【讨论】:

【参考方案11】:

作为如何使用记忆化来提高算法性能的示例,对于这个特定的测试用例,以下运行速度大约快 300 倍。之前,需要 ~200 秒; 2/3 已记住。


class Slice:

    __slots__ = 'prefix', 'root', 'suffix'

    def __init__(self, prefix, root, suffix):
        self.prefix = prefix
        self.root = root
        self.suffix = suffix

################################################################################

class Match:

    __slots__ = 'a', 'b', 'prefix', 'suffix', 'value'

    def __init__(self, a, b, prefix, suffix, value):
        self.a = a
        self.b = b
        self.prefix = prefix
        self.suffix = suffix
        self.value = value

################################################################################

class Tree:

    __slots__ = 'nodes', 'index', 'value'

    def __init__(self, nodes, index, value):
        self.nodes = nodes
        self.index = index
        self.value = value

################################################################################

def old_search(a, b):
    # Initialize startup variables.
    nodes, index = [], []
    a_size, b_size = len(a), len(b)
    # Begin to slice the sequences.
    for size in range(min(a_size, b_size), 0, -1):
        for a_addr in range(a_size - size + 1):
            # Slice "a" at address and end.
            a_term = a_addr + size
            a_root = a[a_addr:a_term]
            for b_addr in range(b_size - size + 1):
                # Slice "b" at address and end.
                b_term = b_addr + size
                b_root = b[b_addr:b_term]
                # Find out if slices are equal.
                if a_root == b_root:
                    # Create prefix tree to search.
                    a_pref, b_pref = a[:a_addr], b[:b_addr]
                    p_tree = old_search(a_pref, b_pref)
                    # Create suffix tree to search.
                    a_suff, b_suff = a[a_term:], b[b_term:]
                    s_tree = old_search(a_suff, b_suff)
                    # Make completed slice objects.
                    a_slic = Slice(a_pref, a_root, a_suff)
                    b_slic = Slice(b_pref, b_root, b_suff)
                    # Finish the match calculation.
                    value = size + p_tree.value + s_tree.value
                    match = Match(a_slic, b_slic, p_tree, s_tree, value)
                    # Append results to tree lists.
                    nodes.append(match)
                    index.append(value)
        # Return largest matches found.
        if nodes:
            return Tree(nodes, index, max(index))
    # Give caller null tree object.
    return Tree(nodes, index, 0)

################################################################################

def search(memo, a, b):
    # Initialize startup variables.
    nodes, index = [], []
    a_size, b_size = len(a), len(b)
    # Begin to slice the sequences.
    for size in range(min(a_size, b_size), 0, -1):
        for a_addr in range(a_size - size + 1):
            # Slice "a" at address and end.
            a_term = a_addr + size
            a_root = a[a_addr:a_term]
            for b_addr in range(b_size - size + 1):
                # Slice "b" at address and end.
                b_term = b_addr + size
                b_root = b[b_addr:b_term]
                # Find out if slices are equal.
                if a_root == b_root:
                    # Create prefix tree to search.
                    key = a_pref, b_pref = a[:a_addr], b[:b_addr]
                    if key not in memo:
                        memo[key] = search(memo, a_pref, b_pref)
                    p_tree = memo[key]
                    # Create suffix tree to search.
                    key = a_suff, b_suff = a[a_term:], b[b_term:]
                    if key not in memo:
                        memo[key] = search(memo, a_suff, b_suff)
                    s_tree = memo[key]
                    # Make completed slice objects.
                    a_slic = Slice(a_pref, a_root, a_suff)
                    b_slic = Slice(b_pref, b_root, b_suff)
                    # Finish the match calculation.
                    value = size + p_tree.value + s_tree.value
                    match = Match(a_slic, b_slic, p_tree, s_tree, value)
                    # Append results to tree lists.
                    nodes.append(match)
                    index.append(value)
        # Return largest matches found.
        if nodes:
            return Tree(nodes, index, max(index))
    # Give caller null tree object.
    return Tree(nodes, index, 0)

################################################################################

import time
a = tuple(range(50))
b = (48, 11, 5, 22, 28, 31, 14, 18, 7, 29, 49, 44, 47, 36, 25, 27,
     34, 10, 38, 15, 21, 16, 35, 20, 45, 2, 37, 33, 6, 30, 0, 8, 13,
     43, 32, 1, 40, 26, 24, 42, 39, 9, 12, 17, 46, 4, 23, 3, 19, 41)

start = time.clock()
old_search(a, b)
stop = time.clock()

print('old_search() =', stop - start)

start = time.clock()
search(, a, b)
stop = time.clock()

print('search() =', stop - start)

参考:How can memoization be applied to this algorithm?

【讨论】:

【参考方案12】:

记忆只是缓存的一个花哨的词。如果您的计算比从缓存中提取信息更昂贵,那么这是一件好事。问题是 CPU 速度快,内存速度慢。所以我发现使用 memoization 通常比只重做计算要慢得多。

当然,还有其他可用的技术确实可以为您带来显着的改进。如果我知道循环的每次迭代都需要 f(10),那么我会将其存储在一个变量中。由于没有缓存查找,这通常是一个胜利。

编辑

继续,随心所欲地给我投反对票。这不会改变您需要进行真正的基准测试而不是盲目地开始将所有内容放入哈希表中的事实。

如果您在编译时知道值的范围,就说因为您使用的是 n!并且 n 是一个 32 位的 int,那么使用静态数组会更好。

如果您的值范围很大,比如任何双精度值,那么您的哈希表可能会变得如此之大,以至于成为一个严重的问题。

如果与给定对象一起反复使用相同的结果,则将该值与对象一起存储可能是有意义的。

就我而言,我发现任何给定迭代的输入在 90% 以上的时间都与上一次迭代相同。这意味着我只需要保留最后一个输入和最后一个结果,并且只有在输入更改时才重新计算。这比对该算法使用记忆化快一个数量级。

【讨论】:

当您谈论 100 的阶乘时,我认为“CPU 快而内存慢”这句话会变得微不足道。那么您的速度比较如何? 如果你只是用“它对密集计算更有用,而不是快速计算”之类的东西替换那个位,我会反转别人给你的 -1。 “CPU 快,内存慢”——你在比较苹果和橘子。 CPU 进行长时间计算所需的时间可以任意长,而该结果所需的空间量和备忘录查找时间可以最少。 -1 表示基于以下假设的语句:memoization 算法是由白痴编写的,人们将使用 memoization 在生产软件中计算斐波那契。 你抨击记忆,然后提供一个“更好”的解决方案,这反过来又是记忆的定义......你想说什么? “你想要的都给我投反对票,但这不会改变记忆化很糟糕的事实,你应该做记忆化”??

以上是关于记忆有啥好处,真的那么有用吗?的主要内容,如果未能解决你的问题,请参考以下文章

在Qt中我们使用更多的指针,有啥好处吗?

Root手机后有什么好处吗?我应该如何Root自己的手机?

在 OpenMP 并行代码中,memset 并行运行有啥好处吗?

使用 GLuint 而不是 unsigned int 有啥好处?

斐讯路由器刷华硕固件有用吗?有啥功能呢

自学C语言真的有用吗?