Jay Wengrow - A Common-Sense Guide to Data Structures and Algorithms【自译】第11章

第11章学习如何以递归方式编写代码

在前一章中,你学会了什么是递归以及它的工作原理。然而,在我自己的学习过程中,即使我理解了递归的工作原理,我仍然很难编写自己的递归函数。

通过刻意练习和注意不同的递归模式,我发现了一些技巧,帮助我更轻松地学会“以递归方式编写代码”,我想和你分享。在此过程中,你也将发现递归发挥作用的其他领域。

请注意,本章我们不会讨论递归的效率问题。实际上,递归可能会对算法的时间复杂度产生非常负面的影响,但这将是下一章的主题。目前,我们只专注于培养递归思维方式。

递归类别:重复执行

在处理各种递归问题的过程中,我开始发现这些问题可以分为不同的“类别”。一旦我掌握了某一类问题的有效解决技巧,当我遇到属于同一类别的另一个问题时,我就能够应用同样的技巧来解决它。

我发现最容易处理的类别是目标是反复执行任务的问题。

前一章中的 NASA 航天器倒计时算法就是一个很好的例子。代码打印了诸如 10、9、8 一直到 0 的数字。虽然函数打印的数字每次都不同,但我们将代码的本质归结为它是在重复执行一个任务——即打印一个数字。

这是我们 JavaScript 实现的算法:

function countdown(number) {
console.log(number);
if(number === 0) { // number being 0 is the base case
return;
} else {
countdown(number - 1);
}
}

我发现对于这类问题,函数中的最后一行代码是一个简单而单一的函数调用。在前面的代码片段中,这体现为 countdown(number - 1)。这一行只做了一件事:进行下一次递归调用。
前一章中的目录打印算法也是另一个例子。这个函数反复执行打印目录名称的任务。我们的 Ruby 代码如下:

def find_directories(directory)
Dir.foreach(directory) do |filename|
if File.directory?("#{directory}/#{filename}") &&
filename != "." && filename != ".."
puts "#{directory}/#{filename}"
# 递归调用子目录的这个函数:
find_directories("#{directory}/#{filename}")
end
end
end

同样,在这里,代码的最后一行是 find_directories(“#{directory}/#{filename}”),简单地调用了递归函数,再次触发了它。

递归技巧:传递额外参数

让我们尝试解决另一个属于“重复执行”类别的问题。我们将编写一个算法,它接受一个数字数组,并将数组中的每个数字都加倍。请注意,我们不会生成一个新数组;相反,我们将直接在原数组上进行修改。

这个算法同样是一个重复执行任务的例子。具体来说,我们反复地对数字进行加倍。我们从第一个数字开始,将其加倍。然后移至第二个数字,将其加倍,以此类推。

[!原地修改]
让我们来看一下“原地修改”的概念,以防有不清楚的地方。
对于数据的操作,通常有两种基本方法。让我们以数组中的值加倍为例。如果我有数组 [1, 2, 3, 4, 5],想要将其“加倍”以生成数组 [2, 4, 6, 8, 10],我可以采取以下两种方法中的一种。

第一种选择是创建一个包含“加倍”数据的新数组,但保持原始数组不变。看下面的代码:

a = [1, 2, 3, 4, 5]
b = double_array(a)

由于 double_array 函数创建并返回一个全新的数组,如果我们检查 a 和 b 的值,会得到:

a # [1, 2, 3, 4, 5]
b # [2, 4, 6, 8, 10]

原始数组 a 未被修改,而 b 包含一个全新的数组。

第二种选择称为原地修改,意味着函数实际上更改了传入函数的原始数组。

对于原地修改,如果我们现在检查 a 和 b,会发现:

a # [2, 4, 6, 8, 10]
b # [2, 4, 6, 8, 10]

这个原地修改函数实际上改变了 a,而 b 实际上只是指向了与 a 完全相同的数组。

我们选择是创建一个新数组还是修改原始数组取决于我们,并取决于我们项目的上下文。我们将在“处理空间约束”中更多地讨论原地算法。

让我们试着用 Python 编写这个算法,我们将其命名为 double_array()。我们知道我们函数的最后一行将是递归调用,所以让我们先包含这行代码:

def double_array(array):
    double_array(array)

接下来,我们需要添加实际加倍数字的代码。但是我们要加倍哪个数字呢?让我们试着加倍第一个数字:

def double_array(array):
    array[0] *= 2
    double_array(array)

好的,我们已经将索引为 0 的数字加倍了,但是如何继续加倍索引为 1 的数字呢?

如果我们使用循环而不是递归,我们将使用一个变量来跟踪索引,并不断地将其增加 1,如下所示:

def double_array(array):
    index = 0
    while (index < len(array)):
        array[index] *= 2
        index += 1

然而,在我们的递归版本中,函数的唯一参数是数组。我们需要一种方式来跟踪和增加一个索引。我们该如何做呢?

现在,让我们进行下一个尝试…

让我们传入额外的参数!

让我们修改函数的开头,使其接受两个参数:数组本身和一个索引来进行跟踪:

def double_array(array, index):

就目前而言,当我们调用这个函数时,我们需要传入数组和起始索引 0:

double_array([1, 2, 3, 4, 5], 0)

一旦我们将索引作为函数参数传入,我们现在有了一种在每次递归调用时增加和跟踪索引的方法。以下是实现此功能的代码:

def double_array(array, index):
    array[index] *= 2
    double_array(array, index + 1)

在每个递归调用中,我们再次传入数组作为第一个参数,同时传递一个递增的索引。这样我们就能够像在传统循环中一样跟踪索引了。

不过,我们的代码目前还不完美。一旦索引超过数组的末尾并尝试对不存在的数字进行加倍,我们的函数就会报错。为了解决这个问题,我们需要一个基本情况:

def double_array(array, index):
    # 基本情况:当索引超过数组的末尾时
    if index >= len(array):
        return
    array[index] *= 2
    double_array(array, index + 1)

我们可以使用以下代码测试这个函数:

array = [1, 2, 3, 4]
double_array(array, 0)
print(array)

我们的递归函数现在已经完成。但是,如果您的编程语言支持默认参数,我们可以让事情更加简洁。

目前,我们需要这样调用该函数:

double_array([1, 2, 3, 4, 5], 0)

诚然,将 0 作为第二个参数传递并不是很美观——这只是为了保持索引而已。毕竟,我们始终希望将我们的索引从 0 开始。

但是,我们可以使用默认参数来让我们可以以原始方式调用函数:

def double_array(array, index=0):
    # 基本情况:当索引超过数组的末尾时
    if (index >= len(array)):
        return
    array[index] *= 2
    double_array(array, index + 1)

我们更新的内容只是设置了一个默认参数 index=0。这样,第一次调用函数时,我们不需要传入索引参数。但是,我们仍然可以在所有后续调用中使用索引参数。

在编写递归函数时使用额外的函数参数的“技巧”是一种常见且方便的技术。

递归类别:计算

在前一部分,我们讨论了递归函数的第一类——即重复执行任务的函数。在本章的其余部分,我将详细介绍第二类:基于子问题进行计算。

有许多函数的目标是执行计算。返回两个数字之和的函数,或者在数组中找到最大值的函数,都是这样的例子。这些函数接收某种输入,并返回涉及该输入的计算结果。

在《递归地递归》中,我们发现递归发挥作用的另一个领域是需要处理具有任意深度级别的问题。递归发挥作用的第二个领域是能够基于当前问题的子问题进行计算。

在我定义什么是子问题之前,让我们回顾一下上一章的阶乘问题。就像你所学到的那样,6的阶乘是:
6 * 5 * 4 * 3 * 2 * 1

要编写一个计算数字阶乘的函数,我们可以使用一个经典的循环,从1开始逐步构建。也就是说,我们将2乘以1,然后将3乘以结果,然后是4,以此类推,直到达到6。

在Ruby中,这样的函数可能如下所示:

def factorial(n)
  product = 1
  (1..n).each do |num|
    product *= num
  end
  return product
end

但是,我们也可以以不同的方式解决这个问题,即基于子问题计算阶乘。

子问题是对较小输入应用的同一个问题的版本。我们来将这个概念应用到我们的情况中。

如果你考虑一下,阶乘(6)将是6乘以阶乘(5)的结果。

因为阶乘(6)是:
6 * 5 * 4 * 3 * 2 * 1
而阶乘(5)是:
5 * 4 * 3 * 2 * 1
所以我们可以得出结论,阶乘(6)等于:
6 * 阶乘(5)。
也就是说,一旦我们得到阶乘(5)的结果,我们只需将该结果乘以6,就能得到阶乘(6)的答案。

这是上一章中的实现:

def factorial(number)
  if number == 1
    return 1
  else
    return number * factorial(number - 1)
  end
end

再次强调的关键行是 return number * factorial(number - 1),其中我们计算结果为 number 乘以我们的子问题,即 factorial(number - 1)

两个计算方法

我们已经看到在编写进行计算的函数时,有两种潜在的方法:我们可以尝试从“自底向上”构建解决方案,或者我们可以采用“自顶向下”的方式,根据问题的子问题进行计算。事实上,计算机科学文献在递归策略方面使用“自底向上”和“自顶向下”这两个术语。事实上,这两种方法都可以通过递归实现。虽然我们之前使用经典循环展示了自底向上的方法,但我们也可以使用递归来实现自底向上的策略。

为了实现这一点,我们需要使用传递额外参数的技巧,如下所示:

def factorial(n, i=1, product=1)
  return product if i > n
  return factorial(n, i + 1, product * i)
end

在这个实现中,我们有三个参数。n和以前一样,是我们正在计算阶乘的数字。i是一个简单的变量,从1开始,在每次递归调用中递增一个,直到达到n。最后,product是我们存储计算结果的变量,我们不断地将每个连续数字相乘后的结果存储在其中。我们在连续的调用中不断地传递product,以便在计算过程中跟踪它的值。

虽然我们可以使用递归这种方法来实现自底向上的方法,但这并不是特别简洁,与使用经典循环相比并没有太大的优势。

当采用自底向上的方法时,无论我们使用循环还是递归,我们都采用相同的计算策略。计算方法是相同的。

但是,要采用自顶向下的方法,我们需要使用递归。而因为递归是实现自顶向下策略的唯一方法之一,这也是使递归成为强大工具的关键因素之一。

自顶向下递归:一种全新的思维方式

这带我们来到本章的核心观点:递归在实现自顶向下策略时表现出色,因为自顶向下提供了解决问题的新心态。也就是说,递归的自顶向下方法允许我们以完全不同的方式思考问题。

具体来说,当我们采用自顶向下的方式时,我们可以在脑海中“推迟问题”。这样做可以让我们从一些琐碎的细节中解脱出来,这些细节在采用自底向上方法时通常需要考虑。

为了说明我的意思,让我们再次看一下我们自顶向下阶乘实现的关键代码行:

return number * factorial(number - 1)

这行代码基于 factorial(number - 1) 进行计算。当我们编写这行代码时,我们是否需要理解它所调用的 factorial 函数的工作原理呢?技术上来说,我们不需要。每当我们编写调用另一个函数的代码时,我们都假设该函数会返回正确的值,而无需了解其内部工作原理。

同样地,当我们根据调用阶乘函数来计算答案时,我们无需了解阶乘函数的工作方式;我们只需期待它返回正确的结果。当然,奇怪的是,我们是编写阶乘函数的人!这行代码存在于阶乘函数本身内部。但这就是自顶向下思维的优点所在:在某种程度上,我们可以解决问题,甚至不知道如何解决这个问题。

当我们“以递归的方式”来实现自顶向下策略时,我们可以稍微放松一下大脑。我们甚至可以选择忽略计算实际运作的细节。我们可以说:“让我们只依赖子问题来处理细节。”

自顶向下的思考过程

如果你以前没有做过很多自顶向下的递归,学会用这种方式思考需要时间和实践。然而,我发现解决自顶向下问题时,以下三点思考方法会有所帮助:

  1. 假设你要编写的函数已经被其他人实现了。
  2. 确定问题的子问题。
  3. 看看在子问题上调用函数会发生什么,然后由此展开思考。

虽然这些步骤目前听起来有些模糊,但通过下面的例子,它们会变得更加清晰。

数组总和

比如,我们要编写一个名为 sum 的函数,它计算给定数组中所有数字的总和。例如,如果我们将数组 [1, 2, 3, 4, 5] 传递给函数,它将返回 15,这些数字的总和。

首先,我们假设 sum 函数已经被实现。这需要一定的悬念,因为我们知道我们正在编写这个函数!但让我们试着放下这个想法,假装 sum 函数已经能正常工作。

接下来,让我们确定子问题。这可能更多的是一种艺术而不是科学,但通过实践你会变得更擅长。在我们的例子中,我们可以说子问题是数组 [2, 3, 4, 5],也就是数组中除了第一个数字以外的所有数字。

最后,让我们看看将 sum 函数应用到子问题会发生什么。如果 sum 函数“已经能正常工作”,并且子问题是 [2, 3, 4, 5],那么当我们调用 sum([2, 3, 4, 5]) 时会发生什么呢?好吧,我们得到了 2 + 3 + 4 + 5 的总和,即 14。

因此,要解决我们要找出 [1, 2, 3, 4, 5] 总和的问题,我们只需将第一个数字 1 加到 sum([2, 3, 4, 5]) 的结果中。

在伪代码中,我们可以这样写:
return array[0] + sum(数组的其余部分)

在 Ruby 中,我们可以这样写:
return array[0] + sum(array[1, array.length - 1])
(在许多编程语言中,array[x, y] 的语法返回从索引 x 到索引 y 的数组。)

信不信由你,我们已经完成了!除了基本情况,我们稍后再讨论,我们的 sum 函数可以这样写:

def sum(array)
  return array[0] + sum(array[1, array.length - 1])
end

请注意,我们并没有考虑如何将所有数字相加。我们所做的只是想象别人已经为我们编写了 sum 函数,并将其应用到子问题上。我们将问题推迟到了将来,但在这样做的过程中,我们解决了整个问题。

我们唯一需要做的最后一件事就是处理基本情况。也就是说,如果每个子问题都递归地调用它自己的子问题,我们最终会达到 sum([5]) 的子问题。这个函数最终会尝试将 5 加到数组的剩余部分,但在数组中已经没有其他元素了。

为了处理这个情况,我们可以添加基本情况:

def sum(array)
  # 基本情况:数组中只有一个元素:
  return array[0] if array.length == 1
  return array[0] + sum(array[1, array.length - 1])
end

然后我们就完成了。

字符串反转

让我们尝试另一个例子。我们将编写一个反转函数,用于反转字符串。所以,如果函数接受参数 “abcde”,它将返回 “edcba”。

首先,让我们确定子问题。同样,这需要实践,但很常见的做法是尝试处理手头问题的次最小版本。因此,对于字符串 “abcde”,我们假设子问题是 “bcde”。这个子问题与原始字符串减去第一个字符是一样的。

接下来,假设有人已经帮我们实现了反转函数。多么好的人啊!

现在,如果反转函数对我们可用,并且我们的子问题是 “bcde”,那意味着我们可以直接调用 reverse(“bcde”),这将返回 “edcb”。一旦我们做到了这一点,处理字符 “a” 就轻而易举了。我们只需要将它放在字符串的末尾。

因此,我们可以写成:

def reverse(string)
  return reverse(string[1, string.length - 1]) + string[0]
end

我们的计算就是在子问题上调用反转函数的结果,然后将第一个字符添加到末尾。

再一次地,除了基本情况,我们已经完成了。我知道,这太神奇了。

基本情况发生在字符串只有一个字符时,因此我们可以添加以下代码来处理它:

def reverse(string)
  # 基本情况:字符串只有一个字符
  return string[0] if string.length == 1
  return reverse(string[1, string.length - 1]) + string[0]
end

然后,我们就完成了。

数X

我们进展得很顺利,所以让我们尝试另一个例子。我们将编写一个名为 count_x 的函数,用于返回给定字符串中“x”的数量。如果我们的函数接收字符串 “axbxcxd”,它将返回3,因为该字符串中有三个字符“x”。

首先,让我们确定子问题。与前面的例子类似,我们会认为子问题是原始字符串减去其第一个字符。因此,对于 “axbxcxd”,子问题是 “xbxcxd”。如果我们的第一个字符也是“x”,我们将添加1到我们的子问题结果中(如果第一个字符不是“x”,我们不需要将任何东西添加到子问题结果中)。

所以,我们可以写成:

def count_x(string)
  if string[0] == "x"
    return 1 + count_x(string[1, string.length - 1])
  else
    return count_x(string[1, string.length - 1])
  end
end

这个条件语句很简单。如果第一个字符是“x”,我们将1添加到子问题的结果中。否则,我们返回子问题的结果。

同样,在基本情况下,我们基本上已经完成了。我们只需要处理基本情况。我们可以说基本情况是字符串只有一个字符时。但这会导致一些尴尬的代码,因为我们实际上有两个基本情况,因为单个字符可能是“x”,也可能不是“x”:

def count_x(string)
  # 基本情况:
  if string.length == 1
    if string[0] == "x"
      return 1
    else
      return 0
    end
  end
  if string[0] == "x"
    return 1 + count_x(string[1, string.length - 1])
  else
    return count_x(string[1, string.length - 1])
  end
end

幸运的是,我们可以使用另一个简单的技巧来简化这个过程。在许多语言中,调用 string[1, 0] 将返回一个空字符串。

考虑到这一点,我们实际上可以更简单地编写我们的代码:

def count_x(string)
  # 基本情况:一个空字符串
  return 0 if string.length == 0
  if string[0] == "x"
    return 1 + count_x(string[1, string.length - 1])
  else
    return count_x(string[1, string.length - 1])
  end
end

在这个新版本中,我们说当字符串为空(string.length == 0)时,基本情况发生。我们返回0,因为在空字符串中永远不会有“x”。

当字符串只有一个字符时,函数将向下一个函数调用的结果添加1或0。这个下一个函数调用是 count_x(string[1, 0]),因为 string.length - 1 是0。由于 string[1, 0] 是一个空字符串,这最终调用就是基本情况,返回0。

现在我们完成了。

值得一提的是,在许多语言中调用 array[1, 0] 也会返回一个空数组,因此在前面的两个例子中,我们也可以使用相同的技巧。

接下来,让我们假设 count_x 已经被实现了。如果我们在子问题上调用 count_x,即调用 count_x("xbxcxd"),我们得到的是3。针对这一点,我们只需将其添加到…
返回上一步,将字符串的第一个字符加上去,就可以得到原始字符串的结果。因此,我们可以写成:

def count_x(string)
  # 基本情况:一个空字符串
  return 0 if string.length == 0
  if string[0] == "x"
    return 1 + count_x(string[1, string.length - 1])
  else
    return count_x(string[1, string.length - 1])
  end
end

这样的代码结构基于递归的思维方式,解决了“x”计数问题。

楼梯问题

我们现在学会了使用一种新的思维策略来解决特定的计算问题,即自顶向下的递归。然而,你可能仍然持怀疑态度并问:“为什么我们需要这种新的思维策略?我到目前为止一直能够用循环来解决这些问题。”

的确,对于更简单的计算问题,你可能不需要一种新的思维策略。然而,当涉及到更复杂的函数时,你可能会发现递归的思维方式会使编写代码变得更容易。对我来说确实如此!

这里有一个我最喜欢的例子。有一个著名的问题,被称为“楼梯问题”,大致描述如下:

假设有一个有 N 个台阶的楼梯,一个人能够一次爬一步、两步或三步。有多少种不同的可能“路径”能够让某人到达顶部?编写一个函数来计算 N 个台阶的路径数量。

下面的图片展示了一个五级台阶跳跃的三种可能路径。

在这里插入图片描述

这只是众多可能路径中的三种。

首先,让我们用自下而上的方法来解决这个问题。也就是说,我们将从最简单的情况逐步解决到更复杂的情况。

显然,如果只有一级台阶,就只有一种可能的路径。

两级台阶有两种路径。一个人可以爬一步两次,或者一次跳上两级台阶。我将其表示为:
1, 1
2
三级台阶,有四条可能的路径:
1, 1, 1
1, 2
2, 1
3
四级台阶有七个选项:
1, 1, 1, 1
1, 1, 2
1, 2, 1
1, 3
2, 1, 1
2, 2
3, 1
接下来,试着列出一个五级台阶的所有组合。
这并不容易!而这只是五级台阶。想象一下对于 11 级台阶有多少种组合。

现在,让我们来解决问题:我们如何编写代码来计算所有路径?

如果没有递归的思维方式,很难理解如何计算这个问题的算法。然而,采用自顶向下的思维方式,问题就会变得令人惊讶地简单。

对于一个 11 级的台阶,首先想到的子问题是一个 10 级的台阶。我们现在就从这个问题入手。如果我们知道爬一个 10 级的台阶有多少种可能的路径,我们能不能以此为基础来计算一个 11 级台阶的路径?

首先,我们确实知道爬一个 11 级的台阶至少需要和爬一个 10 级的台阶相同的步数。也就是说,我们已经有了所有到达第 10 级台阶的路径,从那里,一个人可以再爬一步就到了顶部。

然而,这还不能是完整的解决方案,因为我们知道某人还可以从第 9 级和第 8 级直接跳到顶部。

如果我们进一步思考,我们会意识到,如果你选择的任何路径都包括从第 10 级台阶到第 11 级台阶,你就不会选择包括从第 9 级到第 11 级台阶的路径。反之亦然,如果你选择从第 9 级台阶直接跳到第 11 级台阶,你就不会选择包括经过第 10 级台阶的路径。

因此,我们知道到达顶部的路径数至少包括到达第 10 级台阶的路径数以及到达第 9 级台阶的路径数。

而且,由于也可以一次跳三步,所以我们还需要包括从第 8 级台阶到达顶部的路径数。

我们已经确定,到达顶部的步数至少是到达第 10 级、第 9 级和第 8 级的所有路径的总和。
然而,进一步思考后,显然除此之外没有其他可能的路径通向顶部。毕竟,无法从第 7 级直接跳到第 11 级。因此,我们可以得出结论,对于 N 级台阶,路径数为:

number_of_paths(n - 1) + number_of_paths(n - 2) + number_of_paths(n - 3)

除了基本情况外,这将是我们函数的代码!

def number_of_paths(n)
    number_of_paths(n - 1) + number_of_paths(n - 2) + number_of_paths(n - 3)
end

这似乎太美好了,几乎是我们所需要的全部代码。但这是真的。剩下的只是处理基本情况。

楼梯问题的基本情况

确定此问题的基本情况稍微有些棘手。因为当这个函数的 n 为 3、2 或 1 时,函数将在 n 为 0 或更小的情况下调用自身。例如,number_of_paths(2) 调用了 number_of_paths(1)、number_of_paths(0) 和 number_of_paths(-1)。

我们处理这个问题的一种方法是通过“硬编码”所有底部情况:

def number_of_paths(n)
    return 0 if n <= 0
    return 1 if n == 1
    return 2 if n == 2
    return 4 if n == 3
    return number_of_paths(n - 1) + number_of_paths(n - 2) + number_of_paths(n - 3)
end

另一种确定基本情况的方法是聪明地“操纵”系统,使用奇怪但有效的基本情况,这些基本情况恰好能计算出正确的数字。让我给你看看我的意思。

我们知道我们绝对希望 number_of_paths(1) 的结果是 1,因此我们将从以下基本情况开始:

return 1 if n == 1

现在,我们知道希望 number_of_paths(2) 返回 2,但我们不必明确创建该基本情况。相反,我们可以利用这样一个事实:number_of_paths(2) 将计算为 number_of_paths(1) + number_of_paths(0) + number_of_paths(-1)。因为 number_of_paths(1) 返回 1,如果我们使 number_of_paths(0) 也返回 1,并且 number_of_paths(-1) 返回 0,我们将得到 2 的总和,这正是我们想要的。
所以,我们可以添加以下基本情况:

return 0 if n < 0
return 1 if n == 1 || n == 0

让我们转向 number_of_paths(3),它将返回 number_of_paths(2) + number_of_paths(1) + number_of_paths(0) 的总和。我们知道希望结果为 4,所以让我们看看数学是否成立。我们已经操纵了 number_of_paths(2) 返回 2。number_of_paths(1) 将返回 1,number_of_paths(0) 也将返回 1,所以我们最终得到总和为 4,正是我们需要的。
我们的完整函数也可以写成:

def number_of_paths(n)
    return 0 if n < 0
    return 1 if n == 1 || n == 0
    return number_of_paths(n - 1) + number_of_paths(n - 2) + number_of_paths(n - 3)
end

虽然这比我们之前的版本不太直观,但我们只需两行代码就可以涵盖所有基本情况。

正如你所看到的,自顶向下的递归方法使得解决这个问题比以往更容易。

变位词生成

结束我们的对话,让我们解决我们迄今为止最复杂的递归问题。我们将运用我们递归工具箱中的所有内容来实现这个功能。

我们要编写一个函数,返回给定字符串的所有anagram(字母重新排列形成的单词)的数组。Anagram是对字符串中所有字符进行重新排序。例如,字符串"abc"的anagram有:

[“abc”,
“acb”,
“bac”,
“bca”,
“cab”,
“cba”]

现在,假设我们要收集字符串"abcd"的所有anagram。让我们用我们的自顶向下思维来解决这个问题。

可以说"abcd"的子问题是"abc"。问题是:如果我们有一个工作正常的anagrams函数,可以返回"abc"的所有anagram,我们如何使用它们来生成"abcd"的所有anagram?思考一下,看看能否想出任何方法。

这是我想到的方法之一。(当然还有其他方法。)

如果我们有了"abc"的所有六个anagram,我们可以将"d"放入"abc"的每个anagram中的每个可能位置,从而得到"abcd"的每个排列组合:

在这里插入图片描述

以下是这个算法的Ruby实现。你会注意到,它当然比本章中先前的例子要复杂得多:

def anagrams_of(string)
    # 基本情况:如果字符串只有一个字符,
    # 返回一个包含单个字符的数组:
    return [string[0]] if string.length == 1
    # 创建一个数组来保存所有的anagram:
    collection = []
    # 找出从第二个字符到末尾的子字符串的所有anagram。
    # 例如,如果字符串是"abcd",子字符串是"bcd",
    # 我们将找到"bcd"的所有anagram:
    substring_anagrams = anagrams_of(string[1, string.length - 1])
    # 遍历每个子字符串的anagram
    substring_anagrams.each do |substring_anagram|
        # 遍历子字符串的每个索引,从0到字符串末尾的下一个索引:
        (0..substring_anagram.length).each do |index|
            # 创建一个子字符串anagram的副本:
            copy = String.new(substring_anagram)
            # 将我们字符串的第一个字符插入到子字符串anagram副本中。
            # 其位置取决于当前循环中的索引。
            # 然后,将新字符串添加到我们的anagram集合中:
            collection << copy.insert(index, string[0])
        end
    end
    # 返回所有anagram的整个集合:
    return collection
end

这段代码并不简单,让我们来逐步分析。现在,我们将跳过基本情况。

我们首先创建一个空数组来收集所有anagram的集合:

collection = []

这个数组就是我们最终会在函数末尾返回的内容。

接下来,我们获取了子字符串的所有anagram的数组。这个子字符串是问题的子问题,即从第二个字符到末尾的字符串。例如,如果字符串是"hello",子字符串就是"ello":

substring_anagrams = anagrams_of(string[1, string.length - 1])

请注意我们如何使用自顶向下的思维方式假设anagrams_of函数已经可以在子字符串上工作。

然后,我们遍历每个子字符串的anagram:

substring_anagrams.each do |substring_anagram|

在继续之前,值得注意的是,这一点上我们同时使用了循环和递归。使用递归并不意味着你必须完全从代码中消除循环!我们使用每种工具,以最自然的方式来帮助我们解决手头的问题。

对于每个子字符串的anagram,我们遍历所有索引,创建一个子字符串anagram的副本,并将字符串的第一个字符(子字符串中唯一没有的字符)插入到那个索引中。每次操作后,我们就创建了一个新的anagram,并将其添加到我们的集合中:

(0..substring_anagram.length).each do |index|
    copy = String.new(substring_anagram)
    collection << copy.insert(index, string[0])
end

完成后,我们返回anagram的集合。基本情况是当子字符串只包含一个字符时,只有一个anagram—就是字符本身!

变位词生成的效率

顺便说一下,让我们停下来分析一下我们的anagram生成算法的效率,因为我们会发现一些有趣的东西。事实上,生成anagram的时间复杂度是我们之前还没有遇到过的新的Big O类别。

如果我们考虑我们生成了多少个anagram,我们会注意到一个有趣的模式。

对于一个包含三个字符的字符串,我们创建以其中三个字符开头的排列。然后,每个排列从剩余的两个字符中选取其中间字符,并从剩下的最后一个字符中选取其最后一个字符。这就是3 * 2 * 1,也就是六个排列。对于其他字符串长度,我们得到:

4个字符:4 * 3 * 2 * 1个anagram
5个字符:5 * 4 * 3 * 2 * 1个anagram
6个字符:6 * 5 * 4 * 3 * 2 * 1个anagram

你认识到这个模式了吗?这是一个阶乘!

也就是说,如果字符串有六个字符,那么anagram的数量就是6的阶乘。这就是6 * 5 * 4 * 3 * 2 * 1,计算结果为720。

阶乘的数学符号是叹号。所以,6的阶乘表示为6!,而10的阶乘表示为10!。

请记住,Big O表示关键问题的答案:如果有N个数据元素,算法需要多少步骤?在我们的情况下,N将是字符串的长度。

对于长度为N的字符串,我们产生N!个anagram。在Big O表示法中,这表示为O(N!)。这也被称为阶乘时间。

O(N!)是我们在本书中遇到的最慢的Big O类别之一。让我们看一下它与其他“慢速”Big O类别在下表中的对比情况。

在这里插入图片描述

尽管O(N!)非常慢,但我们在这里没有更好的选择,因为我们的任务是生成所有的anagram,而对于一个N个字符的单词,就有N!个anagram。

无论如何,递归在这个算法中起着关键作用,这是一个重要的例子,说明了递归如何用来解决复杂问题。

总结

学会编写使用递归的函数确实需要练习。但是你现在掌握了一些技巧和技术,这将让学习过程变得更容易。

不过,我们的递归之旅还没有结束。尽管递归是解决各种问题的好工具,但如果不小心使用,它实际上会大大减慢代码的速度。在下一章中,你将学习如何使用递归来保持代码简洁高效。

练习

  1. 使用递归编写一个函数,接受一个字符串数组,并返回所有字符串中字符的总数。例如,如果输入数组是[“ab”, “c”, “def”, “ghij”],则输出应该是10,因为总共有10个字符。

  2. 使用递归编写一个函数,接受一个数字数组,并返回一个只包含偶数的新数组。

  3. 有一个被称为“三角数”的数列。该模式从1开始,依次为1、3、6、10、15、21,以此类推,其中第N个数字是N加上前一个数字。例如,数列中的第7个数字是28,因为它是7(即N)加上21(数列中的前一个数字)。编写一个函数,接受一个数字N,并从该系列中返回正确的数字。也就是说,如果函数传入数字7,函数应返回28。

  4. 使用递归编写一个函数,接受一个字符串并返回包含字符“x”的第一个索引。例如,字符串"abcdefghijklmnopqrstuvwxyz"的索引23处是一个“x”。为了简单起见,假设字符串肯定至少有一个“x”。

  5. 这个问题被称为“唯一路径”问题:假设你有一个行和列的网格。编写一个函数,接受行数和列数,并计算从左上角到右下角的可能“最短”路径数量。例如,以下是具有三行七列的网格的情况。你想从“S”(起点)到“F”(终点)。

在这里插入图片描述

"最短"路径指的是在每一步,你要么向右移动一步,

在这里插入图片描述

要么向下移动一步。

在这里插入图片描述

你的函数应该计算最短路径的数量。

答案

  1. 让我们称我们的函数为 character_count。第一步是假设 character_count 函数已经实现了。

    接下来,我们需要识别子问题。如果我们的问题是数组 ["ab", "c", "def", "ghij"],那么我们的子问题可以是减去一个字符串的相同数组。让我们明确地说,我们的子问题是减去第一个字符串的数组,即 ["c", "def", "ghij"]
    现在,让我们看看当我们将“已经实现”的函数应用于子问题时会发生什么。如果我们调用 character_count(["c", "def", "ghij"]),我们会得到一个返回值为 8,因为总共有八个字符。

    因此,为了解决我们的原始问题,我们只需将第一个字符串 (“ab”) 的长度加上调用子问题的 character_count 函数的结果。
    以下是一个可能的实现:

def character_count(array)
  # 可替代的基本情况:
  # return array[0].length if array.length == 1
  # 基本情况:当数组为空时:
  return 0 if array.length == 0
  return array[0].length + character_count(array[1, array.length - 1])
end

请注意,我们将基本情况设定为数组为空的情况,这时字符串字符数为零。我们在注释中提到了一个同样可行的基本情况,即数组只包含一个字符串时。在这种情况下,我们返回这个单个字符串的长度。

  1. 首先,让我们假设 select_even 函数已经起作用了。接下来,让我们确定子问题。如果我们尝试在示例数组 [1, 2, 3, 4, 5] 中选择所有偶数,我们可以说子问题是数组中第一个数以外的所有数字。因此,让我们假设 select_even([2, 3, 4, 5]) 已经起作用并返回了 [2, 4]

    由于数组中的第一个数字是 1,我们实际上不想做任何事情,只需返回 [2, 4]。但是,如果数组中的第一个数字是 0,我们希望返回带有 0 的 [2, 4]
    基本情况可以是数组为空的情况。
    以下是一个可能的实现:

def select_even(array)
  return [] if array.empty?
  if array[0].even?
    return [array[0]] + select_even(array[1, array.length - 1])
  else
    return select_even(array[1, array.length - 1])
  end
end
  1. 三角数的定义是 n 加上模式中的前一个数字。如果我们的函数名是 triangle,我们可以简单地表示为 n + triangle(n - 1)。基本情况是当 n 为 1 时。
def triangle(n)
  return 1 if n == 1
  return n + triangle(n - 1)
end
  1. 让我们假设我们的函数 index_of_x 已经被实现了。接下来,我们说子问题是字符串减去其第一个字符。例如,如果我们的输入字符串是 “hex”,那么子问题就是 “ex”。
    现在,index_of_x("ex") 将返回 1。为了计算原始字符串中的 “x” 的索引,我们需要将此结果加 1,因为字符串前面的额外 “h” 使 “x” 的索引向下移动了一个位置。
def index_of_x(string)
  return 0 if string[0] == 'x'
  return index_of_x(string[1, string.length - 1]) + 1
end
  1. 这个练习类似于“楼梯问题”。让我们分解一下:

    从起始位置开始,我们只有两种移动选择。我们可以向右移动一个空格或向下移动一个空格。

    这意味着唯一最短路径的总数将是从 S 右侧的路径数 + 从 S 下方的路径数。
    从 S 右侧到 S 的路径数相当于在一个六列三行的网格中计算路径数,如下所示:

在这里插入图片描述

从 S 下方到 S 的路径数相当于在一个七列两行的网格中计算路径数:

在这里插入图片描述

递归允许我们优美地表达这个问题:

  return unique_paths(rows - 1, columns) + unique_paths(rows, columns - 1)

我们现在需要添加基本情况。可能的基本情况包括当只有一行或一列时,因为在这种情况下,只有一条可用的路径。

这是完整的函数实现,用来计算从网格的起始位置到达目标位置的唯一最短路径的总数。

def unique_paths(rows, columns)
  return 1 if rows == 1 || columns == 1
  return unique_paths(rows - 1, columns) + unique_paths(rows, columns - 1)
end
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值