JavaScript中的记忆法介绍

114 阅读8分钟

JavaScript中的记忆法介绍

函数是任何编程语言的重要构建块。作为一个开发者,你会经常在你的项目中使用函数。函数的一个重要方面是它们是可重复使用的。你可以在你的程序中的任何地方调用它们。一个函数可以返回其他函数,也可以将一个函数作为其参数。当你有一个广泛的程序时,有可能我们会不止一次地重复使用一个函数。

这样一来,一个程序的计算可能取决于执行另一个函数的结果。这意味着我们每次执行程序时,都会反复运行和调用这些函数,返回结果,并将其传递给相应的计算。

执行这样的函数是低效的,特别是对于一个需要进行长时间繁重计算的广泛系统来说。Memoization的概念就可以拯救这种昂贵的计算。

什么是记忆化?

Memoization是一种自上而下、深度优先的优化技术,用于存储以前执行的计算。每当程序需要这些计算的结果时,程序将不必再次执行该计算。相反,它将重新使用之前执行的计算结果。这样,程序就不必重复进行昂贵的计算。一个昂贵的函数是一个需要花费一些时间来执行的函数。

这个概念是相对于函数式编程的应用而言的。在很多情况下,你会在一个程序中重复使用函数。有了Memoization的概念,当一个函数被调用时,其结果将被暂时存储。任何需要这个函数结果的计算都不必再次执行这个函数。相反,它将重新使用之前执行的存储结果。

在这种情况下,我们可以说Memoization是一种缓存昂贵的函数调用结果的技术,通过在相同的输入再次发生时返回缓存的结果来加速计算机程序。这样,Memoization将记住并检索这些结果,而不需要每次都重新计算这些值。

Memoization的重要性

  • 它是一种优化技术,通过缓存函数调用的结果来提高性能。它存储以前的结果,然后,在你的程序中,只要需要,它就会检索这些结果。这可以减少执行时间,提高CPU性能。

  • 记忆化函数应该是一个纯函数。这意味着函数的执行不会发生变化。当调用某个输入时,无论该函数被调用多少次,它应该总是返回相同的值。

  • 假设你有一个函数,不是执行一次,不是两次,而是多次,为什么不把这个函数的结果记下来。这样一来,你只执行这个函数一次。这使你的程序更有性能效率。

何时使用记忆化

  • 当一个函数是纯函数时。一个纯函数在调用时总是返回相同的值。如果一个函数是不纯的,它每次执行时都会返回不同的值。缓存这种值可能会导致意外的返回值。

  • 重度计算函数。每当一个程序有昂贵的计算时,缓存结果将大大增加你的程序性能。有了Memoization,函数不必重新计算它的值,然而每次调用时都会返回相同的结果。

  • 远程API调用。当反复进行API调用时,使用Memoization将使你免于对服务器进行重复的调用。当你进行第一次调用时,你已经知道了结果,因此没有必要再进行同样的调用来获得同样的结果。

  • 用重复的输入值来调用自己的函数,即递归函数。

Memoization如何工作

让我们来看看现实生活中的情况。假设你正在阅读一本封面舒适、花哨、吸引人的小说。一个陌生人经过,问你这本书的标题和作者是什么。你有可能会翻开书,阅读书名和作者的名字,然后回答那个陌生人。

这本书很有吸引力,更多的人就会喜欢了解这本书。当另一个人路过,问你那本书的细节时,你不会再重新看这本书的作者和书名。如果你不记得了,你会再查一下这些细节。这时,你的记忆中可能有这本书的细节。

如果有第三个人问你关于这本书的细节,你会从记忆中得到这些信息。即使有一百个人问你,这本书的细节也不会改变。

这同样适用于Memoization的概念。当一个函数被调用时,Memoization会在将结果返回给函数调用者之前储存函数的结果。这样,当另一个调用者指向这个函数的结果时,Memoization将返回存储(缓存)在内存中的结果,而这个函数将不会被反复执行。记忆化通过缓存基于其输入的结果来减少多余的函数调用。

Memoization的概念由两个主要的子概念支持,即。

  1. [Closure]- Closure是将一个函数与对相应状态(词法环境)的引用包裹在一起(封闭)的组合。换句话说,Closure允许你从内部函数访问外部函数的域。在JavaScript中,闭包在每次创建函数时都会生成,在创建函数时。

  2. [高阶函数]- 高阶函数接受另一个函数作为参数或返回一个函数作为其输出。

使用Memoization技术缓存函数值

为了理解Memoization的概念如何在JavaScript中应用,让我们深入研究一些例子。

这里有一个函数的例子,它接受一个数字作为参数,并返回所提供数字的平方。

const clumsysquare = num =>{
  let result = 0;
  for (let i = 1; i <= num; i++) {
    for (let j = 1; j <= num; j++) {
      result ++;
    }
  }
  return result;
}
console.log(clumsysquare(4));
console.log(clumsysquare(10));
console.log(clumsysquare(12));
console.log(clumsysquare(17));
console.log(clumsysquare(20));

你会发现,每当你调用这个函数时,它都会重新执行,然后返回一个平方值。

执行过程是直接的,也是快速的。计算是直截了当的。真正的问题发生在你想进行繁重(昂贵)的计算时。

const clumsysquare = num =>{
  let result = 0;
  for (let i = 1; i <= num; i++) {
    for (let j = 1; j <= num; j++) {
      result ++;
    }
  }
  return result;
}
console.log(clumsysquare(190));
console.log(clumsysquare(799));
console.log(clumsysquare(4000));
console.log(clumsysquare(7467));
console.log(clumsysquare(9666));

这是不高效的。相反,我们可以应用Memoization的概念,在第一次调用时存储clumsysquare() 的结果。现在,每当我们用相同的输入调用这个函数时,程序将不必再次执行它。

让我们在我们的 JavaScript 程序中实现 Memoization。要做到这一点,程序会执行第一个实例clumsysquare() ,我们会存储它的值,然后在程序中多次重复使用它。

// function that takes a function and returns a function
const memoize = (func) => {
  // a cache of results
  const results = {};
  // return a function for the cache of results
  return (...args) => {
    // a JSON key to save the results cache
    const argsKey = JSON.stringify(args);
    // execute `func` only if there is no cached value of clumsysquare()
    if (!results[argsKey]) {
      // store the return value of clumsysquare()
      results[argsKey] = func(...args);
    }
    // return the cached results
    return results[argsKey];
  };
};

// wrap clumsysquare() in memoize()
const clumsysquare = memoize(num => {
    let result = 0;
    for (let i = 1; i <= num; i++) {
        for (let j = 1; j <= num; j++) {
            result++;
        }
    }
    return result;
});

console.log(clumsysquare(190));
console.log(clumsysquare(799));
console.log(clumsysquare(4000));
console.log(clumsysquare(7467));
console.log(clumsysquare(9666));

与不使用Memoization的概念时相比,上面的例子执行得更快。你可以选择使用另一个函数来存储缓存,如上图所示。或者,你也可以选择将结果存储在一个变量中。

让我们检查一下这个例子。

const memoizedValue = [];
const clumsysquare = (num) => {
  if ((memoizedValue[num] == !undefined)) {
    return memoizedValue[num];
  }

  let result = 0;
  for (let i = 1; i <= num; i++) {
    for (let j = 1; j <= num; j++) {
      result++;
    }
  }

  memoizedValue[num] = result;
  return result;
};

console.log(clumsysquare(190));
console.log(clumsysquare(799));
console.log(clumsysquare(4000));
console.log(clumsysquare(7467));
console.log(clumsysquare(9666));

通过性能测试Memoization

让我们用正常的函数流程来测试函数的执行时间,并与备忘录化函数进行比较。

下面的例子将计算出每当调用一个函数clumsysquare() ,所需要的时间。

使用默认的函数流

const clumsysquare = (num) => {
  let result = 0;
  for (let i = 1; i <= num; i++) {
    for (let j = 1; j <= num; j++) {
      result++;
    }
  }
  return result;
};

console.time("First call");
console.log(clumsysquare(9467));
console.timeEnd("First call");

// use the same value two times
console.time("Second call");
console.log(clumsysquare(9467));
console.timeEnd("Second call");

console.time("Third call");
console.log(clumsysquare(9467));
console.timeEnd("Third call");

输出

89624089
First call: 130.173ms
89624089
Second call: 145.090ms
89624089
Third call: 166.594ms

执行每个函数所需的时间增加。每一次函数调用都会重新启动执行过程。

使用备忘录化的函数

const memoize = (func) => {
  const results = {};
  return (...args) => {
    const argsKey = JSON.stringify(args);
    if (!results[argsKey]) {
      results[argsKey] = func(...args);
    }
    return results[argsKey];
  };
};

const clumsysquare = memoize((num) => {
  let result = 0;
  for (let i = 1; i <= num; i++) {
    for (let j = 1; j <= num; j++) {
      result++;
    }
  }
  return result;
});

console.time("First call");
console.log(clumsysquare(9467));
console.timeEnd("First call");

// use the same value two times
console.time("Second call");
console.log(clumsysquare(9467));
console.timeEnd("Second call");

console.time("Third call");
console.log(clumsysquare(9467));
console.timeEnd("Third call");

注意到当函数被第二次调用时,时间的执行时间急剧减少。这是因为在return result ,我们已经缓存了clumsysquare()

输出

89624089
First call: 132.389ms
89624089
Second call: 0.091ms
89624089
Third call: 0.085ms

递归函数。一个记忆化的用例

递归是一个编程概念,当一个函数多次调用自己时就会应用。一个函数会有一个明确的中断条件,表明它何时应该停止调用自己。递归采用了循环的概念。循环发生在一个数字迭代数次,直到一个指定的条件被满足。

一个伟大的递归用例是斐波那契数列。斐波那契将之前的两个数字串联起来,将它们相加,预测出序列中的下一个斐波那契项。

斐波那契数列的第一个项是。

Fibonacci sequence

图片来源

每个数字都是前两个数字之和。以下是序列的构建方式。

Fibonacci sequence

斐波那契重复计算相同的数字。这就成了一种多余的计算。此外,随着你产生更多的斐波那契项,程序可能会变慢。

下面是一个斐波那契的例子,它生成了斐波那契序列中的第n个斐波那契项。请记住,函数的执行应该是快速、良好的书写、稳定和可靠的。

const fibonacci = (n) => {
    // if n is equal to 1, return the first term 1
    if (n == 1) {
      return 1;
    }
    // if n is equal 2, return the second term 1
    else if (n == 2) {
      return 1;
    }

    // else n is greater than two, return the sum of the previous two terms
    else 
      return fibonacci(n - 1) + fibonacci(n - 2);
};
// print the fifth term in the sequence
console.log(fibonacci(5));

这个程序很直接。这里我们记录了第五项,也就是5。

然而,当我们想生成更高的第n个斐波那契项时,程序变得越来越慢。

const fibonacci = (n) => {
    // if n is equal to 1, return the first term 1
    if (n == 1) {
      return 1;
    }
    // if n is equal 2, return the second term 1
    else if (n == 2) {
      return 1;
    }

    // else n is larger than two, return the sum of the previous two terms
    else 
      return fibonacci(n - 1) + fibonacci(n - 2);
};
// print the fiftieth term in the sequence
console.log(fibonacci(50));

这个计算过程很长,大约需要133315.439ms。

这就是递归函数在幕后的工作情况。以第五项为例。这就是第五项的计算方式,fibonacci(5) = fibonacci(4) + fibonacci(3)

但是fibonacci(4) ,必须返回fibonacci(3) + fibonacci(2)

程序需要计算Fibonacci(3) 。它又调用Fibonacci(2) + Fibonacci(1) ,返回Fibonacci(3)

Recursive fibonacci sequence

你会明白的。这个函数一次又一次地调用自己,直到满足第五个计算项。这是一个很大的计算量。程序会重复之前在返回之前的斐波那契条款时调用的斐波那契调用。

你可以想象要产生第50个项所需的计算的数量。在这种情况下,它将是递归的,因为该函数会重复计算,直到满足第50项的条件。

相反,我们可以将函数的结果记忆下来。

const memoize = (func) => {
  const results = {};
  return (...args) => {
    const argsKey = JSON.stringify(args);
    if (!results[argsKey]) {
      results[argsKey] = func(...args);
    }
    return results[argsKey];
  };
};

const fibonacci = memoize((n) => {
  // if n is equal to 1 return the first term 1
  if (n == 1) {
    return 1;
  }
  // if n is equal 2 1 return the second term 1
  else if (n == 2) {
    return 1;
  }

  // else n is larger than two, return the sum of the previous two terms
  else 
  return fibonacci(n - 1) + fibonacci(n - 2);
});
// print the fifth term in the sequence
console.log(fibonacci(50));

使用记忆化的函数,这需要大约8.079ms的时间来返回第50项,这比上面的例子相对要快。

每个函数的调用都将是一个缓存。例如,在这种情况下,Fibonacci(5) 只计算Fibonacci(4) + Fibonacci(3) ,因为其他术语已经被调用和缓存了。以后的任何调用都不必重复之前的任何计算。

结论

Memoization是一个编程概念,可以应用于任何编程语言。它的基本目标是优化你的程序。这主要体现在一个程序正在进行大量的计算时。Memoization将缓存这些计算结果,这样在执行需要先前执行的计算的繁重操作时就不必紧张了。