什么是备忘录有用,它真的有用吗?

时间:2010-07-14 00:39:03

标签: generics performance demo memoization

互联网上有一些自动记忆库可用于各种不同的语言;但不知道它们的用途,使用方法以及它们的工作原理,很难看出它们的价值。使用memoization有哪些令人信服的论据,以及memoization特别闪耀的问题域是什么?这里特别感谢不知情的信息。

12 个答案:

答案 0 :(得分:22)

在我看来,斐波那契和因子计算并不是最好的例子。当你拥有以下内容时,备忘录真正发挥作用:

  1. 有关计算的大量潜在输入,但范围仍然明显受限且已知
  2. 你提前知道程序的任何实际使用只会在计算中使用一小部分可能的输入(Fibonacci和factorial使其失败)
  3. 您不知道将在运行时使用哪些特定的输入,因此需要记住哪些特定结果(Fibonacci和factorial也会失败,直到某一点)
  4. 显然,如果你知道所有可能的输入,并且空间允许,你可以考虑用查找替换该函数(我会这样做,例如,已知的嵌入式CRC32实现发生器)。

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

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


    这是一个例子,我希望它不是太复杂(或概括)来提供信息。

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

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

    但是,当条件变化很快时,没有用户要求或期望设备能够很好地工作,并且当事情稳定时,他们很多而不是更好。因此,我在反映这些要求的计算行为中进行权衡:我希望这些计算在事情稳定时很好而且快速,而且我不关心它们何时不是。

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

    因此,可以为这16个潜在输入记忆计算结果。如果环境条件变化的速度超过预期,那么从最近读取的“最远”ADC将被丢弃(例如,如果我已经将0x210缓存到0x21F,然后我读取0x222,则丢弃0x210结果)。

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

答案 1 :(得分:19)

这里流行的阶乘答案是一个玩具答案。是的,memoization对于重复调用该函数很有用,但这种关系很简单 - 在“打印因子(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时给出102,k = 5。

如果没有记忆,即使是相当适中的数字,这也很快就会变得很慢。如果将处理可视化为树,则每个节点调用V()扩展为三个子节点,你有186,268,135,991,213,676,920,832 V(n,0)= 1叶子计算V(32,32)...天真实现此功能在可用硬件上迅速变得无法计算。

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

因为python数字自然是bignums你可以在python中实现这个公式,使用字典和元组键只有9行的memoization。试一试,没有备忘录就试一试。

答案 2 :(得分:13)

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

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

想象一下,枚举从网格左上角到网格右下角的所有路径。很多路径彼此重叠。您可以记住为网格上的每个点计算的解决方案,从右下角,从右上角开始构建。这使得计算时间从“荒谬”变为“易处理”。

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

对于数据点,我的网格解决问题(问题来自编程挑战):

Memoized:

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

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

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

答案 3 :(得分:11)

记忆可以解决可以重复使用子问题的解决方案的问题。简单来说,它是一种缓存形式。让我们以阶乘函数为例。

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

子问题解决方案可以重复使用的任何问题(越频繁越好)是使用memoization的候选者。

答案 4 :(得分:6)

Memoization交换空间时间。

当应用于本质上是多递归的问题时,记忆可以将指数时间(或更差)转换为线性时间(或更好)。成本通常是O(n)空间。

经典的例子是计算Fibonacci序列。教科书定义是递归关系:

  

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,天真版本需要6.5秒。 memoized版本需要0.005秒(所有系统时间 - 也就是说,它的I / O限制)。对于n = 50,记忆版仍需要0.005秒,并且天真版本最终在5分钟后完成。 7秒(没关系,它们都溢出了32位整数)。

答案 5 :(得分:4)

记忆可以从根本上加速算法。典型的例子是Fibonocci系列,其中递归算法非常慢,但是memoization会自动使其与迭代版本一样快。

答案 6 :(得分:4)

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

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

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

答案 7 :(得分:2)

Memoization实质上是缓存给定输入的函数的返回值。如果您要使用相同的输入重复多次函数调用,这将非常有用,尤其是如果函数需要一些时间来执行。当然,由于数据必须存储在某处,因此memoization将使用更多内存。这是使用CPU和使用RAM之间的权衡。

答案 8 :(得分:1)

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

答案 9 :(得分:1)

我认为大多数人都已经介绍了备忘录的基础知识,但我会给你一些实际的例子,其中moization可以用来做一些漂亮的惊人的事物(imho):

  1. 在C#中你可以反映一个函数并为它创建一个委托,然后你可以动态调用委托......但这真的很慢!它比直接调用该方法慢约30倍。如果你记住方法调用,那么你可以使调用几乎与直接调用方法一样快。
  2. 在遗传编程中,它可以减少重复调用相同函数的开销,使用相似的输入参数来控制人口中数百和数千个样本。
  3. 在执行表达式树时:如果你已经记住它,你不必继续重新评估表达式树...
  4. 当然还有更多实用示例可以使用memoization,但这些只是少数。

    my blog我分别讨论memoizationreflection,但我将发布另一篇关于在反映方法上使用memoization的文章......

答案 10 :(得分:1)

作为如何使用memoization来提高算法性能的示例,对于此特定测试用例,以下运行速度大约 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?

答案 11 :(得分:-7)

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

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

编辑

继续向下,向我推送你想要的一切。这不会改变你需要做真正的基准测试而不是盲目地开始在哈希表中抛出所有内容的事实。

如果你在编译时知道你的值范围,比如因为你正在使用n!并且n是32位int,那么你最好使用静态数组。

如果你的值范围很大,比如说任何一个double,那么你的哈希表会变得如此之大以至于它成为一个严重的问题。

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

在我的案例中,我发现超过90%的时间任何给定迭代的输入与最后一次迭代相同。这意味着我只需要保留最后一个输入和最后一个结果,并且只有在输入改变时才重新计算。这比使用该算法的记忆快一个数量级。