第12章动态规划
在上一章中,你学会了如何递归地编写代码以及如何使用递归解决一些相当复杂的问题。
然而,尽管递归可以解决一些问题,但如果不正确使用,它也可能产生新的问题。事实上,递归往往是一些最慢的大O时间复杂度的罪魁祸首,比如O(2^N)。
不过,好消息是,许多这些问题是可以避免的。在本章中,你将学习如何识别递归代码中一些最常见的速度陷阱,并学习如何用大O表示这类算法。更重要的是,你将学习到修复这些问题的技巧。
还有更多好消息:本章中的技术实际上非常简单。让我们看看如何使用这些简单但有效的方法,将我们的递归噩梦转化为递归幸福。
不必要的递归调用
这是一个用于找到数组中最大数的递归函数:
def max(array)
return array[0] if array.length == 1
if array[0] > max(array[1, array.length - 1])
return array[0]
else
return max(array[1, array.length - 1])
end
end
每次递归调用的本质是将一个单独的数字(array[0])与数组剩余部分的最大数进行比较。(为了计算数组剩余部分的最大数,我们调用了正在使用的 max 函数,这使得函数成为了一个递归函数。)
我们通过条件语句进行比较。条件语句的前半部分是:
if array[0] > max(array[1, array.length - 1])
return array[0]
这段代码表示,如果单个数字(array[0])大于已经确定的剩余数组部分的最大数(max(array[1, array.length - 1])),那么根据定义,array[0] 必定是最大的数字,因此我们将其返回。
条件语句的后半部分是:
else
return max(array[1, array.length - 1])
这段代码表示,如果 array[0] 不大于剩余数组部分的最大数,那么剩余数组部分的最大数必然是整个数组的最大数,我们将其返回。
虽然这段代码能够工作,但它包含了一个隐藏的低效性。仔细观察,会发现代码中两次出现了 max(array[1, array.length - 1])
这个短语,分别在条件语句的两个部分中。
问题在于,每次提到 max(array[1, array.length - 1])
,都会触发整个一系列的递归调用。
接下来,我们来分析这个问题。假设数组是 [1, 2, 3, 4]。
首先,我们会将 1 与剩余数组 [2, 3, 4] 的最大值进行比较。而后,将 2 与剩余数组 [3, 4] 的最大值进行比较,依此类推,直到比较到最后一个数字 4,这时会触发一个最后的递归调用。
但是,为了更好地理解代码的执行过程,我们将从“底部”开始分析调用链。
Max 递归演示
当我们调用 max([4])
时,函数会直接返回数字 4。这是因为我们的基本情况是当数组只包含一个元素时,根据以下代码行:
return array[0] if array.length == 1
这是很简单的一次函数调用:
沿着调用链往上走,我们看看调用 max([3, 4])
时会发生什么。在条件语句的第一部分中(if array[0] > max(array[1, array.length - 1])
),我们将 3 与 max([4])
进行比较。但调用 max([4])
本身就是一个递归调用。下图描述了 max([3, 4])
调用 max([4])
的情况:
请注意箭头旁边的标签“1st”,表示这个递归调用是由 max([3, 4])
条件语句的第一部分触发的。
完成了这一步之后,我们的代码可以将 3 与 max([4])
的结果进行比较。由于 3 不大于该结果(4),我们触发条件语句的第二部分(return max(array[1, array.length - 1])
)。在这种情况下,我们返回 max([4])
。
但当我们的代码返回 max([4])
时,会触发 max([4])
的实际函数调用。这是我们第二次触发 max([4])
调用:
如你所见,函数 max([3, 4])
最终调用了 max([4])
两次。当然,如果可以的话,我们更愿意避免这样做。如果我们已经计算出 max([4])
的结果,为什么还要再次调用相同的函数来得到相同的结果呢?
然而,当我们向调用链的上一级移动时,这个问题会变得更加严重。
当我们调用 max([2, 3, 4])
时会发生什么呢?
在条件语句的第一部分中,我们将 2 与 max([3, 4])
进行比较,而我们已经确定 max([3, 4])
是这样的:
因此,max([2, 3, 4])
调用 max([3, 4])
如下:
但这才只是 max([2, 3, 4])
条件语句的第一部分。在条件语句的第二部分,我们会再次调用 max([3, 4])
:
糟糕了!
让我们大胆尝试向调用链的顶部移动,调用 max([1, 2, 3, 4])
。当所有操作都完成后,我们得到的结果如下图所示。
因此,当我们调用 max([1, 2, 3, 4])
时,我们实际上触发了 max
函数 15 次。
通过向函数开头添加语句 puts "RECURSION"
,我们可以通过视觉方式观察到这一点:
def max(array)
puts "RECURSION"
# 省略剩余代码
end
然后运行我们的代码,就会在终端上看到单词“RECURSION”打印出来 15 次。
现在,我们确实需要其中一些调用,但并非全部。例如我们确实需要计算 max([4])
,但只需一次这样的函数调用就足以得到计算结果。然而在这里,我们却调用了这个函数八次。
针对大O的小修复
很好,通过一个简单的修改,我们可以消除所有这些额外的递归调用。我们将在代码中仅调用一次 max
,并将结果保存到一个变量中:
def max(array)
return array[0] if array.length == 1
# 计算数组剩余部分的最大值,并将其存储在一个变量中:
max_of_remainder = max(array[1, array.length - 1])
# 将第一个数字与此变量进行比较:
if array[0] > max_of_remainder
return array[0]
else
return max_of_remainder
end
end
通过实现这个简单的修改,我们最终只调用了四次 max
。尝试添加 puts "RECURSION"
行并运行代码。
这里的诀窍在于,我们只进行了每个必要的函数调用一次,并将结果保存在一个变量中,这样我们就不必再次调用该函数了。我们最初的函数和略作修改后的函数之间的效率差别是非常明显的。
递归的效率
在我们改进后的 max
函数中,该函数会根据数组中的值数量递归调用自身相应次数。我们称之为 O(N)。直到这一点,我们所见过的 O(N) 案例都涉及循环,循环运行了 N 次。然而,我们也可以将大 O 原则应用到递归中。
正如你所记得的,大 O 回答了一个关键问题:如果有 N 个数据元素,算法需要执行多少步骤?
由于改进后的 max
函数对数组中的 N 个值运行了 N 次,因此其时间复杂度为 O(N)。即使函数本身包含了多个步骤,比如五步,其时间复杂度也将是 O(5N),但会被简化为 O(N)。
然而,在第一个版本中,函数在每次运行时都调用了自身两次(除了基本情况)。让我们看看这在不同数组大小下的情况。
以下表格展示了在各种大小的数组上调用 max
的次数:
数组大小 | max 调用次数 |
---|---|
1 | 1 |
2 | 3 |
3 | 7 |
4 | 15 |
5 | 31 |
你能看出规律吗?当我们增加一个数据时,算法的步骤数量大致翻倍。就像我们在密码破解器的讨论中所学到的那样(见第7章),这是 O(2N) 的模式。我们已经知道这是一个非常慢的算法。
然而,改进后的 max
函数只会对数组中的元素数量进行调用。这意味着我们的第二个 max
函数的效率为 O(N)。
这是一个重要的教训:避免额外的递归调用对于保持递归的速度至关重要。乍看之下对我们代码的微小更改——仅仅是将计算结果存储在一个变量中——最终改变了我们函数的速度,从 O(2^N) 变为了 O(N)。
重叠子问题
斐波那契数列是一个无限延伸的数学序列,其表现为:
0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55…
该序列从0和1开始,每个后续数字是该序列前两个数字的和。例如,数字55是由前两个数字相加而得出,这两个数字分别是21和34。
以下Python函数返回斐波那契数列中的第N个数字。例如,如果我们将数字10传递给函数,它将返回55,因为55是该系列中的第十个数字。(0被视为该系列中的第0个数字。)
def fib(n):
# 基本情况是序列中的前两个数字:
if n == 0 or n == 1:
return n
# 返回前两个斐波那契数的和:
return fib(n - 2) + fib(n - 1)
该函数的关键行是:
return fib(n - 2) + fib(n - 1)
这行代码计算了斐波那契数列中前两个数字的和。这是一个很漂亮的递归函数。
然而,此时你的脑海里可能已经响起了警铃,因为我们的函数调用了两次自身。
让我们以计算第六个斐波那契数为例。函数fib(6)同时调用了fib(4)和fib(5),如第190页上的图表所示。
正如我们所见,一个函数调用自身两次很容易导致O(2^N)。实际上,这是fib(6)所做的所有递归调用:
你必须承认,O(2^N)看起来可能会让人感到恐慌。
虽然一个简单的改变可以优化本章的第一个例子,但优化我们的斐波那契序列并不那么简单。
这是因为我们不能仅仅保存一个变量中的单个数据。我们需要计算fib(n - 2)和fib(n - 1)(因为每个斐波那契数字都是这两个数字的和),而仅存储一个数字的结果并不能独立给出另一个数字的结果。
这是计算机科学家称之为重叠子问题的情况。让我们解释一下这个术语。
当一个问题通过解决更小版本的同一问题来解决时,较小的问题被称为子问题。这个概念并不新鲜——在我们讨论递归的过程中,我们经常遇到这个概念。在斐波那契数列的情况下,我们首先计算序列中较小的数字来得出每个数字。这些较小数字的计算就是子问题。
然而,使这些子问题重叠的是fib(n - 2)和fib(n - 1)实际上在彼此之间进行了许多相同的函数调用。也就是说,fib(n - 1)最终进行了一些与fib(n - 2)之前所做的计算相同的操作。例如,你可以在前面的图表中看到,fib(4)和fib(5)都调用了fib(3)(以及许多其他重复的调用)。
通过记忆化实现的动态规划
幸运的是,我们有解决方案,那就是通过一种称为动态规划的方法。动态规划是优化具有重叠子问题的递归问题的过程。
(不要过于关注“动态”这个词。关于这个术语是如何产生的存在一些争议,并且我即将展示的技术并没有明显的动态特征。)
使用动态规划优化算法通常采用以下两种技术之一。
第一种技术被称为“记忆化”。这不是打字错误。发音为meh-moe-ih-ZAY-shun,记忆化是一种简单但聪明的技术,用于减少重叠子问题的递归调用。
基本上,记忆化通过记住先前计算的函数结果来减少递归调用。(在这方面,记忆化确实像与之类似的单词“记忆”。)
在我们的斐波那契数列示例中,当首次调用fib(3)时,函数执行其计算并返回数字2。然后,在继续执行之前,函数将这个结果存储在哈希表中。哈希表会像这样:
{3: 2}
这表明fib(3)的结果是数字2。
类似地,我们的代码会记录遇到的所有新计算结果。例如,在遇到fib(4)、fib(5)和fib(6)后,我们的哈希表将如下所示:
{
3: 2,
4: 3,
5: 5,
6: 8
}
现在我们有了这个哈希表,可以利用它来避免未来的递归调用。具体做法如下:
如果没有记忆化,fib(4)通常会调用fib(3)和fib(2),这两者又会进行各自的递归调用。现在我们有了这个哈希表,可以采用不同的方法。例如,fib(4)不再直接调用fib(3),而是首先检查哈希表,看看fib(3)的结果是否已经计算过。只有当哈希表中不存在3这个键时,函数才会继续调用fib(3)。
记忆化攻击了重叠子问题的核心。重叠子问题的整个问题在于我们一遍又一遍地计算相同的递归调用。而有了记忆化,每次进行新的计算时,我们都将其存储在哈希表中以供将来使用。这样,我们只在以前从未进行过计算的情况下才进行计算。
好的,这听起来都很不错,但有一个突出的问题。每个递归函数如何访问这个哈希表呢?
答案是:我们将哈希表作为第二个参数传递给函数。因为哈希表是内存中的特定对象,我们可以将其从一个递归调用传递到下一个递归调用,即使在进行调用过程中我们对其进行了修改。即使在展开调用堆栈时,哈希表也是如此。即使在原始调用时哈希表可能是空的,但到原始调用结束执行时,同一个哈希表可能已经装满了数据。
实现记忆化
要将哈希表传递下去,我们修改函数以接受两个参数,其中第二个参数是哈希表。我们称这个哈希表为memo,就像是记忆化的意思一样:
def fib(n, memo):
第一次调用函数时,我们传入数字和一个空的哈希表:
fib(6, {})
每次fib调用自身时,它也会将哈希表一同传递,这样哈希表就会在调用过程中被填充。
以下是函数的剩余部分:
def fib(n, memo):
if n == 0 or n == 1:
return n
# 检查哈希表(称为memo),查看fib(n)是否已经计算过
if not memo.get(n):
# 如果n不在memo中,使用递归计算fib(n),然后将结果存储在哈希表中
memo[n] = fib(n - 2, memo) + fib(n - 1, memo)
# 现在,fib(n)的结果肯定在memo中了,所以我们返回它
return memo[n]
让我们逐行分析这段代码。
再次,我们的函数现在接受两个参数,即n和memo哈希表:
def fib(n, memo):
我们还可以将memo设置为默认值,这样我们在第一次调用时就不需要显式地传入一个空的哈希表了:
def fib(n, memo={}):
无论如何,0和1的基本情况保持不变,不受记忆化的影响。
在进行任何递归调用之前,我们的代码首先检查给定n是否已经计算过fib(n):
if not memo.get(n):
(如果n的计算结果已经在哈希表中,我们只需用return memo[n]
返回结果。)
只有当n的计算尚未进行时,我们才进行计算:
memo[n] = fib(n - 2, memo) + fib(n - 1, memo)
在这里,我们将计算结果存储在memo哈希表中,以便我们不必再次计算它。
还要注意,每次调用fib函数时,我们都将memo作为参数传递。这是共享memo哈希表的关键。
正如你所看到的,算法的核心部分保持不变。我们仍然使用递归来解决问题,因为计算fib的本质仍然是fib(n - 2) + fib(n - 1)。然而,如果我们要计算的数字是新的,我们将结果存储在一个哈希表中;如果我们要计算的数字之前已经计算过一次,我们只需从哈希表中获取结果,而不是重新计算它。
当我们绘制出记忆化版本中的递归调用时,得到以下结果:
在这个图中,被方框包围的每个调用表示结果是从哈希表中检索出来的。
那么,我们的函数现在的时间复杂度是多少呢?我们来看看不同N的递归调用次数:
我们可以看到,对于N,我们进行了(2N) - 1次调用。由于在大O表示法中我们会忽略常数项,所以这是一个O(N)的算法。
这比O(2^N)有了巨大的改进。记忆化真是太棒了!
通过自底向上实现的动态规划
我之前提到动态规划可以通过两种技巧之一实现。我们已经了解了其中一种技巧——记忆化(memoization),它相当巧妙。
第二种技巧被称为自底向上(going bottom-up),相对来说没那么花哨,甚至可能看起来不像是一种技巧。自底向上实际上就是放弃递归,采用其他方法(比如循环)来解决同样的问题。
之所以将自底向上视为动态规划的一部分,是因为动态规划意味着将原本可以通过递归解决的问题,并确保它不会为了重叠的子问题而进行重复调用。使用迭代(即循环),从技术上讲,是实现这一目标的一种方式。
自底向上在问题更适合使用递归来自然解决时,就显得更像是一种“技巧”。生成斐波那契数列就是一个递归方法很简洁优雅的例子。使用迭代来解决相同问题可能需要更多脑力,因为迭代方法可能不太直观。(想象一下用循环解决前面章节中的楼梯问题。嗯,有点复杂。)
我们来看看如何为我们的斐波那契函数实现自底向上的方法。
自底向上斐波那契函数
在下面的自底向上方法中,我们从斐波那契数列的前两个数字开始:0和1。然后我们使用传统的迭代方式来构建这个序列:
def fib(n):
if n == 0:
return 0
# a and b start with the first two numbers in the
# series, respectively:
a = 0
b = 1
# Loop from 1 until n:
for i in range(1, n):
# a and b each move up to the next numbers in the series.
# Namely, b becomes the sum of b + a, and a becomes what b used to be.
# We utilize a temporary variable to make these changes:
temp = a
a = b
b = temp + a
return b
在这里,我们分别将变量a和b初始化为0和1,因为它们是斐波那契数列的前两个数字。
然后,我们开始一个循环,以便计算序列中的每个数字,直到达到n:
for i in range(1, n):
为了计算序列中的下一个数字,我们需要将前两个数字相加。我们将temp赋值为倒数第二个值,将a赋值为最近的值:
temp = a
a = b
序列中的新数字,我们将其重新分配给b,是这两个前值的和:
b = temp + a
因为我们的代码是简单的从1到N的循环,所以我们的代码需要N个步骤。与记忆化方法一样,这是O(N)的时间复杂度。
记忆化VS自底向上
现在您已经了解了动态规划的两种主要技术:记忆化和自底向上。其中一种技术比另一种更好吗?通常情况下,这取决于问题以及为什么首先使用递归。如果递归为特定问题提供了一种简洁而直观的解决方案,您可能希望坚持使用它,并使用记忆化来处理任何重叠的子问题。然而,如果迭代方法同样直观,则可以选择使用它。
值得指出的是,即使使用了记忆化,递归与迭代相比仍带有一些额外的开销。具体来说,在任何递归中,计算机都需要在调用栈中跟踪所有的调用,这会消耗内存。记忆化本身还需要使用哈希表,这也将在计算机上占用额外的空间(有关此内容,请参阅处理空间约束)。
总的来说,自底向上通常是更好的选择,除非递归解决方案更直观。在递归更直观的情况下,您可以保持递归,并通过记忆化使其更快。
总结
现在你已经能够编写高效的递归代码,也就掌握了一种超能力。你即将遇到一些非常高效但也复杂的算法,其中许多都依赖于递归的原理。
练习
-
以下函数接受一个数字数组并返回它们的和,只要某个特定数字不使总和超过100。如果添加某个数字会使总和超过100,那么忽略该数字。然而,这个函数做了不必要的递归调用。请修复代码以消除不必要的递归。
def add_until_100(array) return 0 if array.length == 0 if array[0] + add_until_100(array[1, array.length - 1]) > 100 return add_until_100(array[1, array.length - 1]) else return array[0] + add_until_100(array[1, array.length - 1]) end end
-
以下函数使用递归来计算数学序列中的第N个数字,这个数学序列称为“Golomb序列”。然而,它效率非常低!使用记忆化来优化它。(你不必真正了解Golomb序列的工作原理来完成这个练习。)
def golomb(n) return 1 if n == 1 return 1 + golomb(n - golomb(golomb(n - 1))); end
-
下面是上一章节中“Unique Paths”问题的一个解决方案。使用记忆化来提高它的效率。
def unique_paths(rows, columns) return 1 if rows == 1 || columns == 1 return unique_paths(rows - 1, columns) + unique_paths(rows, columns - 1) end
答案
- 这里的问题在于函数内部有两次递归调用。我们可以轻松地将其减少到一次:
def add_until_100(array)
return 0 if array.length == 0
sum_of_remaining_numbers = add_until_100(array[1, array.length - 1])
if array[0] + sum_of_remaining_numbers > 100
return sum_of_remaining_numbers
else
return array[0] + sum_of_remaining_numbers
end
end
- 这是经过记忆化处理的版本:
def golomb(n, memo={})
return 1 if n == 1
if !memo[n]
memo[n] = 1 + golomb(n - golomb(golomb(n - 1, memo), memo), memo)
end
return memo[n]
end
- 要在这里实现记忆化,我们需要将行数和列数组合成一个键。我们可以将键简单地设置为
[rows, columns]
的数组:
def unique_paths(rows, columns, memo={})
return 1 if rows == 1 || columns == 1
if !memo[[rows, columns]]
memo[[rows, columns]] = unique_paths(rows - 1, columns, memo) +
unique_paths(rows, columns - 1, memo)
end
return memo[[rows, columns]]
end