递归:概念、应用与问题解决
递归是一种强大的技术,它能将复杂的计算问题分解为更简单、通常规模更小的子问题。递归指的是在解决问题的过程中,相同的计算会反复出现。递归常常是思考问题最自然的方式,有些计算如果不使用递归,会变得非常困难。下面我们将通过几个具体的例子来深入了解递归。
1. 重温三角数
在之前的学习中,我们了解过如何打印三角形图案,例如:
[]
[][]
[][][]
[][][][]
现在,我们稍微修改这个例子,使用递归计算边长为
n
的三角形形状的面积,假设每个
[]
方块的面积为 1,这个值有时被称为第
n
个三角数。例如,从上面的三角形可以看出,第三个三角数是 6,第四个三角数是 10。
如果三角形的边长为 1,那么它由一个方块组成,面积为 1。我们可以先处理这个特殊情况:
def triangleArea(sideLength):
if sideLength == 1:
return 1
...
对于一般情况,假设我们知道较小三角形的面积,那么较大三角形的面积可以通过以下公式计算:
area = smallerArea + sideLength
如何得到较小三角形的面积呢?可以调用
triangleArea
函数:
smallerSideLength = sideLength - 1
smallerArea = triangleArea(smallerSideLength)
现在我们可以完成
triangleArea
函数:
def triangleArea(sideLength):
if sideLength == 1:
return 1
smallerSideLength = sideLength - 1
smallerArea = triangleArea(smallerSideLength)
area = smallerArea + sideLength
return area
下面是计算边长为 4 的三角形面积时的调用过程:
1.
triangleArea
函数以参数
sideLength
为 4 执行。
2. 它将
smallerSideLength
设置为 3,并调用
triangleArea
函数,参数为
smallerSideLength
。
3. 这个函数调用有自己的参数和局部变量,
sideLength
参数为 3,
smallerSideLength
变量为 2。
4.
triangleArea
函数再次被调用,参数为 2。
5. 在这次调用中,
sideLength
为 2,
smallerSideLength
为 1。
6.
triangleArea
函数被调用,参数为 1。
7. 这次调用返回 1。
8. 返回的值存储在
smallerArea
中,函数返回
smallerArea + sideLength = 1 + 2 = 3
。
9. 在这一层,
smallerArea
设置为 3,函数返回
smallerArea + sideLength = 3 + 3 = 6
。
10. 函数将
smallerArea
设置为 6,并返回
smallerArea + sideLength = 6 + 4 = 10
。
为了确保递归调用最终能够结束,需要满足两个条件:
- 每个递归调用必须以某种方式简化计算。
- 必须有特殊情况(有时称为基本情况)来直接处理最简单的计算。
triangleArea
函数通过不断减小边长的值来调用自身,最终边长会达到 1,此时有特殊情况来计算面积,因此该函数总是能成功。
实际上,我们需要小心处理输入。如果计算边长为 -1 的三角形面积,会导致无限递归。为了避免这种情况,我们应该在
triangleArea
函数中添加一个条件:
if sideLength <= 0:
return 0
递归并不是计算三角数的必要方法,三角形的面积等于
1 + 2 + 3 + ... + sideLength
的和,我们可以使用简单的循环来计算:
area = 0.0
for i in range(1, sideLength + 1):
area = area + i
许多简单的递归都可以用循环来计算,但对于更复杂的递归,如后面的例子,用循环实现会非常困难。实际上,在这种情况下,我们甚至不需要循环,前
n
个整数的和可以通过公式
n * (n + 1) / 2
计算,因此面积可以简单地计算为:
area = sideLength * (sideLength + 1) / 2
下面是完整的 Python 代码:
def main():
area = triangleArea(10)
print("Area:", area)
print("Expected: 55")
def triangleArea(sideLength):
if sideLength <= 0:
return 0
if sideLength == 1:
return 1
smallerSideLength = sideLength - 1
smallerArea = triangleArea(smallerSideLength)
area = smallerArea + sideLength
return area
main()
运行结果:
Area: 55
Expected: 55
2. 无限递归
无限递归是一种常见的编程错误,指的是函数不断地调用自己,没有结束的迹象。每次调用都需要计算机使用一定的内存进行记录,经过多次调用后,用于此目的的所有内存都会耗尽,程序会关闭并报告“栈溢出”。
无限递归通常是因为参数没有变得更简单,或者缺少特殊的终止情况。例如,如果
triangleArea
函数允许计算边长为 0 的三角形面积,并且没有特殊测试,函数会构造边长为 -1、-2、-3 等的三角形,导致无限递归。
3. 对象递归
如果你觉得函数自己调用自己很困惑,下面的面向对象的变体可能会有帮助。我们可以实现一个
Triangle
类,其中包含一个
getArea
方法:
class Triangle:
def __init__(self, sideLength):
self._sideLength = sideLength
def getArea(self):
if self._sideLength == 1:
return 1
smallerTriangle = Triangle(self._sideLength - 1)
smallerArea = smallerTriangle.getArea()
area = smallerArea + self._sideLength
return area
这里我们调用了不同对象的
getArea
方法,对很多人来说,在这种情况下递归就不那么令人惊讶了。
4. 递归解决问题:判断回文串
接下来,我们将通过一个更复杂的问题——判断一个句子是否为回文串,来展示如何递归地解决问题。回文串是指反转所有字符后与自身相等的字符串,例如 “A man, a plan, a canal—Panama!”、“Go hang a salami, I’m a lasagna hog” 和 “Madam, I’m Adam”。在测试回文串时,我们忽略大小写、空格和标点符号。
我们要实现一个
isPalindrome
函数:
def isPalindrome(text):
...
下面是具体的步骤:
1.
考虑简化输入的各种方法
:
- 移除第一个字符。
- 移除最后一个字符。
- 移除第一个和最后一个字符。
- 移除中间的一个字符。
- 将字符串切成两半。
这些简化后的输入都是回文测试的潜在输入。
2.
将简单输入的解决方案组合成原始问题的解决方案
:
- 对于回文测试,将字符串切成两半不是一个好方法。例如,将 “Madam, I’m Adam” 切成两半得到 “Madam, I” 和 “’m Adam”,第一个字符串不是回文串。
- 最有希望的简化方法是移除第一个和最后一个字符。如果较短的字符串是回文串,并且第一个和最后一个字符匹配(忽略大小写),那么原始字符串就是回文串。
- 如果最后一个字符不是字母,而第一个字符是字母,那么只移除最后一个字符。如果较短的字符串是回文串,那么添加一个非字母字符后仍然是回文串。
- 如果第一个字符不是字母,同理只移除第一个字符。
3.
找到最简单输入的解决方案
:
- 对于回文测试,最简单的字符串包括长度为 0、1 和 2 的字符串。
- 长度为 2 的字符串可以通过移除一个或两个字符来处理。
- 空字符串是回文串,因为它反转后与自身相等。
- 长度为 1 的字符串,无论字符是否为字母,都是回文串。
4.
通过组合简单情况和简化步骤实现解决方案
:
def isPalindrome(text):
length = len(text)
if length <= 1:
return True
else:
first = text[0].lower()
last = text[length - 1].lower()
if first.isalpha() and last.isalpha():
if first == last:
shorter = text[1 : length - 1]
return isPalindrome(shorter)
else:
return False
elif not last.isalpha():
shorter = text[0 : length - 1]
return isPalindrome(shorter)
else:
shorter = text[1 : length]
return isPalindrome(shorter)
下面是这个过程的 mermaid 流程图:
graph TD;
A[输入字符串 text] --> B{长度 <= 1};
B -- 是 --> C[返回 True];
B -- 否 --> D[获取第一个和最后一个字符并转换为小写];
D --> E{第一个和最后一个字符都是字母};
E -- 是 --> F{第一个和最后一个字符相等};
F -- 是 --> G[移除第一个和最后一个字符,递归调用];
F -- 否 --> H[返回 False];
E -- 否 --> I{最后一个字符不是字母};
I -- 是 --> J[移除最后一个字符,递归调用];
I -- 否 --> K[移除第一个字符,递归调用];
通过这些步骤,我们可以递归地判断一个字符串是否为回文串。这种递归的方法通过将问题不断简化,最终解决了复杂的问题。在实际应用中,递归可以帮助我们更自然地思考和解决许多问题。
5. 递归解决问题:查找文件
接下来,我们要解决一个任务:打印目录树中所有以给定扩展名结尾的文件的名称。目录树的顶层称为根目录,该目录的“子项”可以是文件或子目录,每个子目录的子项也可以是文件或子目录。由于 Python 中有一个库函数可以列出目录中的所有子项,并且有些子项本身可能是目录,所以我们自然会使用递归算法。
下面是解决这个问题的具体步骤:
1.
考虑简化输入的各种方法
:
- 我们的问题有两个输入:目录名和扩展名。显然,操作扩展名并不能简化问题。
- 而对于目录树,有一个明显的简化方法:
- 考虑目录树根级别的所有子项。
- 如果子项是目录,则以相同的方式检查该目录。
- 如果子项是文件,则检查它是否具有所需的扩展名。
2.
将简单输入的解决方案组合成原始问题的解决方案
:
- 我们的任务只是打印找到的文件,所以不需要组合结果。如果要生成找到的文件列表,则需要将根目录中的所有匹配项放入一个列表,并将所有子目录的结果添加到同一个列表中。
3.
找到最简单输入的解决方案
:
- 最简单的输入是一个不是目录的文件。在这种情况下,我们只需检查它是否以给定的扩展名结尾,如果是,则打印它。
4.
通过组合简单情况和简化步骤实现解决方案
:
- 简化步骤是查看文件和子目录:
- 对于目录
dir
的每个子项:
- 如果子项是目录,则递归地在该子项中查找具有相同扩展名的文件。
- 如果子项的名称以扩展名结尾,则打印该名称。
以下是 Python 代码实现:
import os
def find_files(dir, extension):
for child in os.listdir(dir):
child_path = os.path.join(dir, child)
if os.path.isdir(child_path):
find_files(child_path, extension)
elif child.endswith(extension):
print(child_path)
# 示例调用
directory = '/your/directory/path'
ext = '.txt'
find_files(directory, ext)
下面是这个查找文件过程的 mermaid 流程图:
graph TD;
A[输入目录 dir 和扩展名 extension] --> B[遍历 dir 中的每个子项 child];
B --> C{child 是目录};
C -- 是 --> D[递归调用 find_files(child, extension)];
C -- 否 --> E{child 以 extension 结尾};
E -- 是 --> F[打印 child 的完整路径];
E -- 否 --> B;
6. 递归的效率
递归虽然是一种强大的解决问题的方法,但在某些情况下,它可能会影响算法的效率。例如,在计算三角数时,递归方法会进行大量的重复计算。以计算第
n
个三角数为例,递归函数
triangleArea
会多次计算相同的子问题。
我们可以通过一个表格来对比递归和循环计算三角数的效率:
| 计算方法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
| ---- | ---- | ---- | ---- | ---- |
| 递归 | $O(n)$ | $O(n)$ | 代码简洁,易于理解 | 存在大量重复计算,可能导致栈溢出 |
| 循环 | $O(n)$ | $O(1)$ | 效率高,不会栈溢出 | 代码相对复杂 |
| 公式法 | $O(1)$ | $O(1)$ | 效率最高 | 只适用于特定问题 |
7. 排列组合问题
排列问题是指找出一个集合中所有可能的排列方式。例如,对于集合
[1, 2, 3]
,它的排列有
[1, 2, 3]
、
[1, 3, 2]
、
[2, 1, 3]
、
[2, 3, 1]
、
[3, 1, 2]
和
[3, 2, 1]
。
我们可以使用递归的方法来生成排列。以下是 Python 代码实现:
def permutations(lst):
if len(lst) == 0:
return [[]]
result = []
for i in range(len(lst)):
m = lst[i]
remLst = lst[:i] + lst[i+1:]
for p in permutations(remLst):
result.append([m] + p)
return result
# 示例调用
numbers = [1, 2, 3]
perms = permutations(numbers)
for perm in perms:
print(perm)
8. 回溯算法
回溯算法是一种通过尝试所有可能的解决方案来解决问题的算法。当发现当前的选择不能得到有效的解决方案时,它会回溯到上一步,尝试其他选择。
一个经典的回溯算法问题是汉诺塔问题。汉诺塔问题是指有三根柱子(A、B、C)和若干个大小不同的圆盘,初始时所有圆盘都在柱子 A 上,要求将所有圆盘从柱子 A 移动到柱子 C,每次只能移动一个圆盘,并且大盘不能放在小盘上面。
以下是汉诺塔问题的 Python 代码实现:
def hanoi(n, source, auxiliary, target):
if n == 1:
print(f"Move disk 1 from {source} to {target}")
return
hanoi(n - 1, source, target, auxiliary)
print(f"Move disk {n} from {source} to {target}")
hanoi(n - 1, auxiliary, source, target)
# 示例调用
n = 3
hanoi(n, 'A', 'B', 'C')
下面是汉诺塔问题的 mermaid 流程图:
graph TD;
A[输入圆盘数量 n,源柱子 source,辅助柱子 auxiliary,目标柱子 target] --> B{n == 1};
B -- 是 --> C[打印移动圆盘 1 从 source 到 target];
B -- 否 --> D[递归调用 hanoi(n - 1, source, target, auxiliary)];
D --> E[打印移动圆盘 n 从 source 到 target];
E --> F[递归调用 hanoi(n - 1, auxiliary, source, target)];
9. 相互递归
相互递归是指两个或多个函数相互调用的情况。例如,我们可以定义两个函数
even
和
odd
来判断一个数是偶数还是奇数:
def even(n):
if n == 0:
return True
else:
return odd(n - 1)
def odd(n):
if n == 0:
return False
else:
return even(n - 1)
# 示例调用
num = 5
print(f"{num} is even: {even(num)}")
print(f"{num} is odd: {odd(num)}")
通过以上这些例子,我们可以看到递归在不同场景下的应用。递归可以帮助我们更自然地思考和解决许多复杂的问题,但同时我们也需要注意它的效率问题。在实际编程中,我们应该根据具体问题选择合适的方法来解决问题。无论是简单的三角数计算,还是复杂的目录文件查找、排列组合、回溯算法等问题,递归都能发挥其独特的作用。
超级会员免费看
1886

被折叠的 条评论
为什么被折叠?



