第19章处理空间限制
当分析本书中各种算法的效率时,我们专注于它们运行的速度,即它们的时间复杂度。然而,另一个衡量效率的指标也同样有用,即算法消耗的内存量,即空间复杂度。
空间复杂度在内存有限的情况下变得尤为重要。如果你有大量数据,或者在为内存有限的小型设备编程,空间复杂度就变得很重要。
在理想的情况下,我们总是希望使用既快速又内存高效的算法。然而,在某些情况下,我们无法两者兼得,需要在两者之间做出选择。每种情况都需要仔细分析,知道何时需要优先考虑速度,何时需要优先考虑内存。
空间复杂度的大O
很有趣的是,计算机科学家使用大O符号来描述空间复杂度,就像他们描述时间复杂度一样。在我介绍大O符号的文章《大O符号:没错!》中,我描述了大O符号的“关键问题”。对于时间复杂度来说,这个关键问题是:如果有N个数据元素,算法会执行多少步骤?而对于空间复杂度,我们只需要重新构思这个关键问题。在内存消耗方面,关键问题是:如果有N个数据元素,算法会消耗多少单位的内存?以下是一个简单的例子。
假设我们正在编写一个JavaScript函数,该函数接受一个字符串数组,并返回其中所有字符串的大写形式的数组。例如,函数会接受类似[“tuvi”, “leah”, “shaya”, “rami”]的数组,并返回[“TUVI”, “LEAH”, “SHAYA”, “RAMI”]。以下是编写此函数的一种方式:
function makeUppercase(array) {
let newArray = [];
for(let i = 0; i < array.length; i++) {
newArray[i] = array[i].toUpperCase();
}
return newArray;
}
在这个makeUpperCase()
函数中,我们接受一个数组。然后,我们创建了一个名为newArray
的全新数组,并用原始数组中每个字符串的大写形式填充它。
当此函数完成时,我们计算机内存中会有两个数组。一个是原始数组,包含[“tuvi”, “leah”, “shaya”, “rami”],另一个是newArray
,包含[“TUVI”, “LEAH”, “SHAYA”, “RAMI”]。
当我们从空间复杂度的角度分析这个函数时,我们可以看到这个函数创建了一个包含N个元素的全新数组。这是另外一个包含N个元素的原始数组之外的内存消耗。
因此,让我们回到我们的关键问题:如果有N个数据元素,算法会消耗多少单位的内存?因为我们的函数生成了额外的N个数据元素(在newArray
中),我们会说这个函数的空间复杂度为O(N)。
这在下面的图表中应该会看起来非常熟悉:
请注意,这个图表与我们在先前章节中描述O(N)的图表完全相同,唯一的区别是垂直轴现在代表的是消耗的内存,而不是时间。
现在,让我们介绍一个更节省内存的makeUppercase()
函数的替代版本:
function makeUppercase(array) {
for(let i = 0; i < array.length; i++) {
array[i] = array[i].toUpperCase();
}
return array;
}
在这个第二个版本中,我们没有创建任何新的数组。相反,我们在原始数组中就地修改每个字符串,逐个将它们转换为大写形式。然后我们返回修改后的数组。
这在内存消耗方面是一种显著的改进,因为我们的新函数根本不消耗任何额外的内存。
我们如何在大O符号表示法中描述这种情况呢?
回想一下,对于时间复杂度,O(1)的算法意味着无论数据有多大,其速度始终保持恒定。同样地,在空间复杂度方面,O(1)表示算法消耗的内存是恒定的,不论数据有多大。
我们修改后的makeUppercase
函数无论原始数组包含四个元素还是一百个元素,都会消耗恒定数量的额外空间(零!)。因此,这个函数被认为具有O(1)的空间效率。
值得强调的是,在使用大O表示空间复杂度时,我们只计算算法生成的新数据。即使我们的第二个makeUppercase
函数处理了作为函数参数传递进来的N个数据元素(即数组),但我们没有将这些N个元素纳入到大O描述中,因为原始数组始终存在,我们只关注算法消耗的额外空间。这个额外空间更正式地称为辅助空间。
然而,需要了解的是,有些参考资料在计算空间复杂度时会包括原始输入,这也是可以接受的。我们并没有将其纳入考虑范围,当你在其他资源中看到空间复杂度的描述时,需要确定它是否包含原始输入。
现在让我们比较一下makeUppercase()
的两个版本在时间和空间复杂度上的差异:
版本 时间复杂度 空间复杂度
版本 #1 O(N) O(N)
版本 #2 O(N) O(1)
两个版本在时间复杂度上都是O(N),因为它们针对N个数据元素执行了N步操作。然而,相较于版本 #1 的O(N)空间复杂度,第二个版本更具内存效率,因为它的空间复杂度为O(1)。
结果表明,版本 #2 在空间方面比版本 #1 更高效,而且没有牺牲任何速度,这是一个很好的优势。
时间和空间之间的权衡
这是一个接受数组并返回其中是否包含重复值的函数(你可能会从《使用大O加速你的代码》中认识到这个函数):
function hasDuplicateValue(array) {
let existingValues = {};
for(let i = 0; i < array.length; i++) {
if(!existingValues[array[i]]) {
existingValues[array[i]] = true;
} else {
return true;
}
}
return false;
}
版本 #2 以一个名为 existingValues 的空哈希表开始。然后,我们遍历数组中的每个项目,当遇到每个新项目时,我们将其存储为 existingValues 哈希表中的一个键(我们将值任意地设置为 true)。然而,如果我们遇到已经是哈希表键的项目,就返回 true,因为这意味着我们找到了重复值。
关于这两种算法哪个更有效率,这取决于你考虑的是时间还是空间。就时间而言,版本 #2 更高效,因为它只有 O(N),而版本 #1 是 O(N^2)。然而,就空间而言,版本 #1 比版本 #2 更高效。版本 #2 最多消耗 O(N) 的空间,因为它创建了一个哈希表,可能包含传递给函数的数组中的所有 N 个值。但是,版本 #1 除了原始数组之外不消耗任何额外的内存,因此空间复杂度为 O(1)。
让我们看一下 hasDuplicateValue() 这两个版本的完全对比:
版本 | 时间复杂度 | 空间复杂度 |
---|---|---|
版本 #1 | O(N^2) | O(1) |
版本 #2 | O(N) | O(N) |
可以看出,就内存而言,版本 #1 更高效,但就原始速度而言,版本 #2 更快。那么,我们该如何决定选择哪种算法呢?
答案当然是取决于具体情况。如果我们需要应用程序运行非常快速,并且有足够的内存来处理,那么版本 #2 可能更可取。另一方面,如果我们处理的是需要节约内存并且速度不是最重要的情况,那么版本 #1 可能是正确的选择。就像所有技术决策一样,当存在权衡时,我们需要考虑全局。
让我们来看看这个相同函数的第三个版本,看看它与前两个版本相比如何:
function hasDuplicateValue(array) {
array.sort((a, b) => (a < b) ? -1 : 1);
for(let i = 0; i < array.length - 1; i++) {
if (array[i] === array[i + 1]) {
return true;
}
}
return false;
}
这个实现,我们称之为版本 /#3,首先对数组进行排序。然后遍历数组中的每个值,并检查它是否与下一个值相同。如果相同,我们就找到了重复值。然而,如果我们遍历到数组末尾,没有两个连续的值是相同的,那么我们知道该数组不包含重复项。
让我们分析一下版本 #3 的时间和空间效率。就时间复杂度而言,这个算法是 O(N log N)。我们可以假设 JavaScript 的排序算法是一个需要 O(N log N) 的算法,因为已知速度最快的排序算法通常需要这么多的时间。数组遍历额外的 N 步骤在排序步骤旁边显得微不足道,所以速度方面的总体复杂度为 O(N log N)。
空间复杂度稍微复杂一些,因为不同的排序算法消耗的内存不同。书中最早遇到的一些算法,比如冒泡排序和选择排序,不需要额外空间,因为所有排序都在原地进行。有趣的是,速度更快的排序算法会因为某些原因而占用一些空间,你很快就会明白。大多数快速排序的实现实际上会占用 O(log N) 的空间。
所以,让我们看看版本 #3 与前两个版本相比如何:
版本 | 时间复杂度 | 空间复杂度 |
---|---|---|
版本 #1 | O(N^2) | O(1) |
版本 #2 | O(N) | O(N) |
版本 #3 | O(N log N) | O(log N) |
结果显示,版本 #3 在时间和空间之间取得了一个有趣的平衡。在时间上,版本 #3 比版本 #1 快,但比版本 #2 慢。在空间上,它比版本 #2 更高效,但比版本 #1 更低效。
那么,何时应该使用版本 #3 呢?如果我们关心时间和空间两者,这可能是一个很好的选择。
最终,在每种情况下,我们需要知道我们能接受的最低速度和内存限制是什么。一旦我们了解了我们的限制,我们就可以从各种算法中进行选择,以便为我们的速度和内存需求找到可接受的效率。
到目前为止,你已经看到我们的算法在创建额外数据(例如新数组或哈希表)时会消耗额外空间。然而,即使算法不做任何这些事情,它也有可能消耗空间。如果我们没有预料到这一点,这可能会给我们带来麻烦。
递归的隐藏消耗
我们在这本书中已经处理了相当多的递归算法。让我们看一个简单的递归函数:
function recurse(n) {
if (n < 0) { return; }
console.log(n);
recurse(n - 1);
}
这个函数接受一个数字 n 并从 n 倒数到 0,在此过程中打印每个递减的数字。
这是一个相当直接的递归示例,看起来似乎没有什么害处。它的速度是 O(N),因为函数会根据参数 n 运行相同次数的递归调用。并且它不创建任何新的数据结构,所以看起来不会占用额外的空间。
或者呢?
我在《递归中的递归》一节中解释了递归是如何运行的。你了解到,每当一个函数递归调用自身时,一个项就会被添加到调用栈中,这样计算机在内部函数完成后可以返回到外部函数。
如果我们将数字 100 传递给我们的 recurse
函数,它将在执行 recurse(99)
之前添加 recurse(100)
。然后在执行 recurse(98)
之前添加 recurse(99)
。
当调用 recurse(-1)
时,调用栈将达到最高点,并且调用栈中将有 101 个项,从 recurse(100)
到 recurse(0)
。
现在,即使调用栈最终会被解除,但我们需要足够的内存来首先存储这 100 个项到我们的调用栈中。
因此,我们的递归函数占用了 O(N) 的空间。在这种情况下,N 是传递给函数的数字;如果我们传递数字 100,我们需要暂时在调用栈中存储 100 个函数调用。
从所有这些中出现了一个重要的原则:每个递归调用所占用的空间是一个单位。这就是递归如何悄悄地消耗内存的方式;即使我们的函数没有显式创建新的数据,递归本身也向调用栈添加数据。
为了正确计算递归函数占用的空间,我们总是需要弄清楚调用栈在最高点时有多大。
对于我们的 recurse
函数,调用栈的大小将大致等于数字 n。
起初,这可能看起来有点琐碎。毕竟,我们的现代计算机可以处理一些调用栈上的项,对吗?让我们来看看。
当我在我时尚、最新的笔记本电脑上将数字 20,000 传递给 recurse
函数时,我的计算机无法处理它。现在,20,000 看起来并不是一个非常大的数字。但当我运行 recurse(20000)
时发生了什么:
我的计算机从 20000 打印到 5387,然后以以下消息终止:
RangeError: Maximum call stack size exceeded
因为递归持续了从 20000 到约 5000(我向下舍入了 5387)的范围,我们可以推断出当计算机耗尽内存时,调用栈的大小大约为 15,000。原来我的计算机无法容忍包含超过 15,000 项的调用栈。
对递归来说,这是一个巨大的限制,因为我不能在大于 15,000 的数字上使用我美丽的 recurse
函数!
让我们与一个简单的循环方法进行对比:
function loop(n) {
while (n >= 0) {
console.log(n);
n--;
}
}
这个函数使用基本的循环来完成相同的目标,而不是使用递归。
因为这个函数不使用递归,并且不占用任何额外的内存,所以它可以在巨大的数字上运行,而不会导致计算机内存耗尽。这个函数可能在处理巨大数字时会花费一些时间,但它会完成工作,而不像递归函数那样会过早放弃。
有了这个理解,我们现在可以理解为什么快速排序被说成占用 O(log N) 的空间了。快速排序进行 O(log N) 次递归调用,因此在其最高点,调用栈的大小是 log(N)。
因此,当我们能够使用递归来实现一个函数时,我们需要权衡递归的优势和劣势。递归允许我们使用从上到下的“神奇”思维方式,就像你在“学习递归写作”一节中学到的那样,但我们也需要确保我们的函数能够完成工作。如果我们正在处理大量数据,甚至只是像 20,000 这样的数字,递归可能不适用。
再次强调,这并不是贬低递归。这意味着我们需要在每种情况下权衡每种算法的优缺点。
总结
你现在已经学会了如何从各个角度衡量我们算法的效率,包括时间和空间。你已经具备了分析能力,可以权衡每个算法,并做出明智的决策,选择哪种方法用于我们自己的应用程序。
既然你现在能够做出自己的决策,是时候进入我们旅程的最后一章了。在这一章中,我将提供一些关于如何优化你自己的代码的最后建议,并带你通过一些实际场景,我们将一起进行优化。
练习
-
以下是我们在《Word Builder》中遇到的算法,描述其空间复杂度为大 O 记号:
function wordBuilder(array) { let collection = []; for(let i = 0; i < array.length; i++) { for(let j = 0; j < array.length; j++) { if (i !== j) { collection.push(array[i] + array[j]); } } } return collection; }
-
以下是一个反转数组的函数,请描述其空间复杂度为大 O 记号:
function reverse(array) { let newArray = []; for (let i = array.length - 1; i >= 0; i--) { newArray.push(array[i]); } return newArray; }
-
创建一个新函数,用于反转数组,但只占用 O(1) 的额外空间。
-
以下是针对一系列数字数组进行加倍处理的三个不同实现函数。请填写下表,描述这三个版本在时间和空间复杂度方面的效率:
function doubleArray1(array) { let newArray = []; for(let i = 0; i < array.length; i++) { newArray.push(array[i] * 2); } return newArray; } function doubleArray2(array) { for(let i = 0; i < array.length; i++) { array[i] *= 2; } return array; } function doubleArray3(array, index=0) { if (index >= array.length) { return; } array[index] *= 2; doubleArray3(array, index + 1); return array; }
请填写下表,描述这三个版本的时间复杂度和空间复杂度:
| 版本 | 时间复杂度 | 空间复杂度 |
|----|---------|---------|
| Version #1 | ? | ? |
| Version #2 | ? | ? |
| Version #3 | ? | ? |
答案
-
空间复杂度为 O(N^2)。因为函数创建了名为 collection 的数组,最终会存储 (N^2) 个字符串。
-
这个实现占用了 O(N) 的空间,因为它创建了一个包含 N 个项的 newArray。
-
下面的实现使用了这种算法:我们在原地交换第一个项和最后一个项。然后,我们在原地交换第二个项和倒数第二个项。接着,我们继续在原地交换第三个项和倒数第三个项,以此类推。由于所有操作都是原地进行的,并且不创建任何新的数据,因此空间复杂度为 O(1)。
function reverse(array) { for (let i = 0; i < array.length / 2; i++) { [array[i], array[(array.length - 1) - i]] = [array[(array.length - 1) - i], array[i]]; } return array; }
(虽然我们可能创建一个临时变量来完成每次交换,但在算法的整个过程中我们始终只有这一个数据。)
-
下面是填写完成的表格:
| 版本 | 时间复杂度 | 空间复杂度 | |----|---------|---------| | Version #1 | O(N) | O(N) | | Version #2 | O(N) | O(1) | | Version #3 | O(N) | O(N) |
这三个版本都以数组中的数字数量为准运行相同数量的步骤,因此它们的时间复杂度都是 O(N)。
- 版本 #1 创建了一个全新的数组来存储加倍后的数字,数组长度与原始数组相同,因此占用了 O(N) 的空间。
- 版本 #2 在原始数组上直接修改,没有额外的空间占用,因此空间复杂度为 O(1)。
- 版本 #3 也在原始数组上修改,但是由于函数使用了递归,其调用栈在峰值时有 N 个调用,占用了 O(N) 的空间。