递归的递归之书:第五章到第六章

103 阅读1小时+

五、分而治之算法

原文:Chapter 5 - Divide-and-Conquer Algorithms

译者:飞龙

协议:CC BY-NC-SA 4.0

分而治之算法是将大问题分解为更小的子问题,然后将这些子问题分解为更小的问题,直到变得微不足道。这种方法使递归成为一种理想的技术:递归情况将问题分解为自相似的子问题,基本情况发生在子问题被减少到微不足道的大小时。这种方法的一个好处是这些问题可以并行处理,允许多个中央处理单元(CPU)核心或计算机处理它们。

在本章中,我们将研究一些常见的使用递归进行分而治之的算法,例如二分查找、快速排序和归并排序。我们还将重新审视对整数数组求和,这次采用分而治之的方法。最后,我们将看一下更神秘的 Karatsuba 乘法算法,它是在 1960 年开发的,为计算机硬件的快速整数乘法奠定了基础。

二分查找:在按字母顺序排列的书架上找书

假设你有一个有 100 本书的书架。你记不得你有哪些书,也不知道它们在书架上的确切位置,但你知道它们是按标题字母顺序排列的。要找到你的书Zebras: The Complete Guide,你不会从书架的开头开始,那里有Aaron Burr Biography,而是朝书架的末尾。如果你还有关于 zebras、动物园和合子的书,你的斑马书就不会是书架上的最后一本书,但会很接近。因此,你可以利用书是按字母顺序排列的这一事实,以及Z是字母表的最后一个字母作为启发式,或者近似线索,向书架的末尾而不是开头寻找。

二分查找是一种在排序列表中定位目标项的技术,它通过反复确定项在列表的哪一半来进行。搜索书架的最公正的方法是从中间的一本书开始,然后确定你要找的目标书是在左半部分还是右半部分。

然后,您可以重复这个过程,如图 5-1 所示:查看您选择的一半中间的书,然后确定您的目标书是否在左侧四分之一还是右侧四分之一。您可以一直这样做,直到找到书,或者找到书应该在的地方但却没有找到,并宣布书不存在于书架上。

书架的图画,标签将书分成两个部分,其中一个被突出显示。在第二个书架图中,突出显示的部分被进一步分成两半。在第三个书架图中,只有一个书被突出显示。

图 5-1:二分搜索反复确定范围的哪一半包含排序数组中的目标项。

这个过程的扩展效率很高;将要搜索的书籍数量加倍只会增加搜索过程的一步。对于有 50 本书的书架进行线性搜索需要 50 步,对于有 100 本书的书架进行线性搜索需要 100 步。但对于有 50 本书的书架进行二分搜索只需要 6 步,而对于有 100 本书的书架只需要 7 步。

让我们对我们的二分搜索实现提出三个递归问题:

  1. 什么是基本情况?搜索长度为 1 的项目范围。

  2. 传递给递归函数调用的参数是什么?我们正在搜索的列表范围的左右端的索引。

  3. 这个参数如何接近基本情况?每次递归调用时,范围的大小减半,因此最终变为一个项目长。

检查我们binarySearch.py程序中的以下binarySearch()函数,它在排序值haystack的排序列表中定位值needle

Python

def binarySearch(needle, haystack, left=None, right=None):
    # By default, `left` and `right` are all of `haystack`:
    if left is None:
        left = 0 # `left` defaults to the 0 index.
    if right is None:
        right = len(haystack) - 1 # `right` defaults to the last index.

    print('Searching:', haystack[left:right + 1])

    if left > right: # BASE CASE
         return None # The `needle` is not in `haystack`.

    mid = (left + right) // 2
    if needle == haystack[mid]: # BASE CASE
         return mid # The `needle` has been found in `haystack`
    elif needle < haystack[mid]: # RECURSIVE CASE
         return binarySearch(needle, haystack, left, mid - 1)
    elif needle > haystack[mid]: # RECURSIVE CASE
         return binarySearch(needle, haystack, mid + 1, right)

print(binarySearch(13, [1, 4, 8, 11, 13, 16, 19, 19]))

binarySearch.html程序有这个 JavaScript 等价物:

JavaScript

<script type="text/javascript">
function binarySearch(needle, haystack, left, right) {
    // By default, `left` and `right` are all of `haystack`:
    if (left === undefined) {
        left = 0; // `left` defaults to the 0 index.
    }
    if (right === undefined) {
        right = haystack.length - 1; // `right` defaults to the last index.
    }

 document.write("Searching: [" + 
    haystack.slice(left, right + 1).join(", ") + "]<br />");

    if (left > right) { // BASE CASE
         return null; // The `needle` is not in `haystack`.
    }

    let mid = Math.floor((left + right) / 2);
    if (needle == haystack[mid]) { // BASE CASE
         return mid; // The `needle` has been found in `haystack`.
    } else if (needle < haystack[mid]) { // RECURSIVE CASE
         return binarySearch(needle, haystack, left, mid - 1);
    } else if (needle > haystack[mid]) { // RECURSIVE CASE
         return binarySearch(needle, haystack, mid + 1, right);
    }
}

document.write(binarySearch(13, [1, 4, 8, 11, 13, 16, 19, 19]));
</script>

当您运行这些程序时,将搜索列表[1, 4, 8, 11, 13, 16, 19, 19]中的13,输出如下:

Searching: [1, 4, 8, 11, 13, 16, 19, 19]
Searching: [13, 16, 19, 19]
Searching: [13]
4

目标值13确实在列表中的索引4处。

代码计算了由leftright索引定义的范围的中间索引(存储在mid中)。起初,这个范围是整个项目列表的长度。如果mid索引处的值与needle相同,则返回mid。否则,我们需要弄清楚我们的目标值是在范围的左半部分(在这种情况下,要搜索的新范围是leftmid - 1)还是在右半部分(在这种情况下,要搜索的新范围是mid + 1end)。

我们已经有一个可以搜索这个新范围的函数:binarySearch()本身!对新范围进行递归调用。如果我们最终到达搜索范围的右端在左端之前的点,我们知道我们的搜索范围已经缩小到零,我们的目标值找不到。

请注意,递归调用返回后,代码不执行任何操作;它立即返回递归函数调用的返回值。这个特性意味着我们可以为这个递归算法实现尾递归优化,这是我们在第八章中解释的一种做法。但也意味着二分搜索可以很容易地作为一个不使用递归函数调用的迭代算法来实现。本书的可下载资源位于nostarch.com/recursive-book-recursion,其中包括用于比较递归二分搜索的迭代二分搜索的源代码。

快速排序:将未排序的书堆分成排序的堆

请记住,binarySearch()的速度优势来自于项目中的值是排序的。如果值是无序的,算法将无法工作。输入quicksort,这是由计算机科学家 Tony Hoare 在 1959 年开发的递归排序算法。

快速排序使用一种称为分区的分而治之技术。想象一下分区:想象你有一大堆未按字母顺序排列的书。拿起一本书并把它放在书架上的正确位置意味着当书架变得满时,你将花费大量时间重新排列书架。如果你首先将书堆分成两堆:一个AM的堆和一个NZ的堆会有所帮助。(在这个例子中,M将是我们的枢轴。)

你还没有对这堆进行排序,但你已经分区了。分区很容易:书不必放在两堆中的一个正确的位置,它只需放在正确的堆中。然后你可以进一步将这两堆分成四堆:AGHMNT,和UZ。这在图 5-2 中显示。如果你继续分区,最终会得到每堆包含一本书的情况(基本情况),而且这些堆现在是按顺序排列的。这意味着书现在也是按顺序排列的。这种重复的分区是快速排序的工作原理。

对于AZ的第一次分区,我们选择M作为枢轴值,因为它是在AZ之间的中间字母。然而,如果我们的书集包括一本关于亚伦·伯尔的书和 99 本关于斑马、和风、动物园、合子和其他Z主题的书,我们的两个分区堆将会严重失衡。我们会在AM堆中有一本单独的亚伦·伯尔的书,而在MZ堆中有其他所有的书。当分区均匀平衡时,快速排序算法的工作速度最快,因此在每个分区步骤中选择一个好的枢轴值是很重要的。

树状结构中几堆书的图。显示一堆书,A-Z,分成两堆,A-M 和 N-Z。A-M 进一步分成 A-G 和 H-M。N-Z 进一步分成 N-T 和 U-Z。

图 5-2:快速排序通过反复将项目分成两组来工作。

然而,如果你对你要排序的数据一无所知,那么选择一个理想的枢轴是不可能的。这就是为什么通用的快速排序算法简单地使用范围中的最后一个值作为枢轴值。

在我们的实现中,每次调用quicksort()都会给出一个要排序的项目数组。它还给出了leftright参数,指定了要对该数组中的索引范围进行排序,类似于binarySearch()的 left 和 right 参数。算法选择一个枢轴值与范围中的其他值进行比较,然后将这些值放在范围的左侧(如果它们小于枢轴值)或右侧(如果它们大于枢轴值)。这是分区步骤。接下来,quicksort()函数在这两个更小的范围上递归调用,直到一个范围已经减少到零。随着递归调用的进行,列表变得越来越有序,直到最终整个列表按正确的顺序排列。

请注意,该算法会就地修改数组。有关详细信息,请参见第四章中的“就地修改列表或数组”。因此,quicksort()函数不会返回一个排序好的数组。基本情况只是返回以停止产生更多的递归调用。

让我们对我们的二分搜索实现提出三个递归问题:

  1. 基本情况是什么?给定一个要排序的范围,其中包含零个或一个项目,并且已经按顺序排列。

  2. 递归函数调用传递了什么参数?我们正在排序的列表中范围的左右端的索引。

  3. 这个论点如何接近基本情况?每次递归调用时,范围的大小减半,所以最终变为空。

quicksort.py Python 程序中的以下quicksort()函数将 items 列表中的值按升序排序:

def quicksort(items, left=None, right=None):
    # By default, `left` and `right` span the entire range of `items`:
    if left is None:
        left = 0 # `left` defaults to the 0 index.
    if right is None:
        right = len(items) - 1 # `right` defaults to the last index.

 print('\nquicksort() called on this range:', items[left:right + 1])
    print('................The full list is:', items)

    if right <= left: # ❶
        # With only zero or one item, `items` is already sorted.
        return  # BASE CASE

    # START OF THE PARTITIONING
    i = left # i starts at the left end of the range. ❷
    pivotValue = items[right] # Select the last value for the pivot.

    print('....................The pivot is:', pivotValue)

    # Iterate up to, but not including, the pivot:
    for j in range(left, right):
        # If a value is less than the pivot, swap it so that it's on the
        # left side of `items`:
        if items[j] <= pivotValue:
            # Swap these two values:
            items[i], items[j] = items[j], items[i] ❸
            i += 1

    # Put the pivot on the left side of `items`:
    items[i], items[right] = items[right], items[i]
    # END OF THE PARTITIONING

    print('....After swapping, the range is:', items[left:right + 1])
    print('Recursively calling quicksort on:', items[left:i], 'and', items[i + 1:right + 1])

    # Call quicksort() on the two partitions:
    quicksort(items, left, i - 1)   # RECURSIVE CASE
    quicksort(items, i + 1, right)  # RECURSIVE CASE

myList = [0, 7, 6, 3, 1, 2, 5, 4]
quicksort(myList)
print(myList)

quicksort.html程序包含了 JavaScript 等效程序:

<script type="text/javascript">
function quicksort(items, left, right) {
    // By default, `left` and `right` span the entire range of `items`:
    if (left === undefined) {
        left = 0; // `left` defaults to the 0 index.
    }
    if (right === undefined) {
        right = items.length - 1; // `right` defaults to the last index.
    }

    document.write("<br /><pre>quicksort() called on this range: [" + 
    items.slice(left, right + 1).join(", ") + "]</pre>");
    document.write("<pre>................The full list is: [" + items.join(", ") + "]</pre>");

    if (right <= left) { // ❶
        // With only zero or one item, `items` is already sorted.
 return; // BASE CASE
    }

    // START OF THE PARTITIONING
    let i = left; ❷ // i starts at the left end of the range.
    let pivotValue = items[right]; // Select the last value for the pivot.

    document.write("<pre>....................The pivot is: " + pivotValue.toString() + 
"</pre>");

    // Iterate up to, but not including, the pivot:
    for (let j = left; j < right; j++) {
        // If a value is less than the pivot, swap it so that it's on the
        // left side of `items`:
        if (items[j] <= pivotValue) {
            // Swap these two values:
            [items[i], items[j]] = [items[j], items[i]]; ❸
            i++;
        }
    }

    // Put the pivot on the left side of `items`:
    [items[i], items[right]] = [items[right], items[i]];
    // END OF THE PARTITIONING

    document.write("<pre>....After swapping, the range is: [" + items.slice(left, right + 1).join(", ") + "]</pre>");
    document.write("<pre>Recursively calling quicksort on: [" + items.slice(left, i).join(", ") + "] and [" + items.slice(i + 1, right + 1).join(", ") + "]</pre>");

    // Call quicksort() on the two partitions:
    quicksort(items, left, i - 1); // RECURSIVE CASE
    quicksort(items, i + 1, right); // RECURSIVE CASE
}

let myList = [0, 7, 6, 3, 1, 2, 5, 4];
quicksort(myList);
document.write("<pre>[" + myList.join(", ") + "]</pre>");
</script>

这段代码类似于二分搜索算法中的代码。作为默认值,我们将items数组中范围的leftright端设置为整个数组的开始和结束。如果算法达到了right端在left端之前或在left端之前的基本情况(一个或零个项目的范围),则排序完成❶。

在每次调用quicksort()时,我们对当前范围内的项目进行分区(由leftright中的索引定义),然后交换它们,使得小于枢纽值的项目最终位于范围的左侧,而大于枢纽值的项目最终位于范围的右侧。例如,如果数组[81, 48, 94, 87, 83, 14, 6, 42]中的枢纽值为42,则分区后的数组将是[14, 6, 42, 81, 48, 94, 87, 83]。请注意,分区后的数组与排序后的数组不同:尽管42左侧的两个项目小于4242右侧的五个项目大于42,但项目并不按顺序排列。

quicksort()函数的主要部分是分区步骤。要了解分区的工作原理,想象一个索引j,它从范围的左端开始并向右端移动❷。我们将索引j处的项目与枢纽值进行比较,然后向右移动以比较下一个项目。枢纽值可以任意选择范围内的任何值,但我们将始终使用范围的右端的值。

想象一下第二个索引i,它也从左端开始。如果索引j处的项目小于或等于枢纽值,则交换索引ij处的项目❸,并将i增加到下一个索引。因此,j在与枢纽值进行比较后总是增加(即向右移动),而i只有在索引j处的项目小于或等于枢纽值时才会增加。

ij这两个名称通常用于保存数组索引的变量。其他人的quicksort()实现可能会使用ji,甚至完全不同的变量。重要的是要记住,这两个变量存储索引并且表现如此。

例如,让我们来看看数组[0, 7, 6, 3, 1, 2, 5, 4]的第一次分区,范围由left0right7定义,覆盖数组的整个大小。pivot将是右端的值4ij索引从索引0开始,即范围的左端。在每一步中,索引j总是向右移动。只有当索引j处的值小于或等于枢纽值时,索引i才会移动。items数组,i索引和j索引的初始状态如下:

items:   [0, 7, 6, 3, 1, 2, 5, 4]
indices:  0  1  2  3  4  5  6  7
          ^
i = 0     i
j = 0     j

索引j处的值(为0)小于或等于枢纽值(为4),因此交换ij处的值。这实际上没有产生任何变化,因为ij是相同的索引。此外,增加i,使其向右移动。j索引在与枢纽值进行比较时每次都会增加。变量的状态现在如下所示:

items:   [0, 7, 6, 3, 1, 2, 5, 4]
indices:  0  1  2  3  4  5  6  7
             ^
i = 1        i
j = 1        j

索引j处的值(为7)不小于或等于枢纽值(为4),因此不交换值。请记住,j始终增加,但只有在执行交换后i才会增加——因此i始终在j处或左侧。变量的状态现在如下所示:

items:   [0, 7, 6, 3, 1, 2, 5, 4]
indices:  0  1  2  3  4  5  6  7
             ^
i = 1        i  ^
j = 2           j

索引j处的值(为6)不小于或等于枢纽值(为4),因此不交换值。变量的状态现在如下所示:

items:   [0, 7, 6, 3, 1, 2, 5, 4]
indices:  0  1  2  3  4  5  6  7
             ^
i = 1        i     ^
j = 3              j

索引j处的值(为3)小于或等于枢纽值(为4),因此交换ij处的值。73交换位置。此外,增加i,使其向右移动。变量的状态现在如下所示:

items:   [0, 3, 6, 7, 1, 2, 5, 4]
indices:  0  1  2  3  4  5  6  7
                ^
i = 2           i     ^
j = 4                 j

索引j处的值(为1)小于或等于枢纽值(为4),因此交换ij处的值。61交换位置。此外,增加i,使其向右移动。变量的状态现在如下所示:

items:   [0, 3, 1, 7, 6, 2, 5, 4]
indices:  0  1  2  3  4  5  6  7
                   ^
i = 3              i     ^
j = 5                    j

索引j处的值(为2)小于或等于枢纽值(为4),因此交换ij处的值。72交换位置。此外,增加i,使其向右移动。变量的状态现在如下所示:

items:   [0, 3, 1, 2, 6, 7, 5, 4]
indices:  0  1  2  3  4  5  6  7
                      ^
i = 4                 i     ^
j = 6                       j

索引j处的值(为6)不小于或等于枢轴值(为4),因此不交换值。现在变量的状态如下:

items:   [0, 3, 1, 2, 6, 7, 5, 4]
indices:  0  1  2  3  4  5  6  7
                      ^
i = 4                 i        ^
j = 7                          j

我们已经到达了分区的末尾。索引j位于枢轴值(始终是范围中最右边的值)处,因此让我们最后一次交换ij,以确保枢轴不在分区的右半部分。64交换位置。现在变量的状态如下:

items:   [0, 3, 1, 2, 4, 7, 5, 6]
indices:  0  1  2  3  4  5  6  7
                      ^
i = 4                 i        ^
j = 7                          j

注意i索引的变化:由于交换的结果,该索引始终会接收小于枢轴值的值;然后i索引向右移动以接收未来小于枢轴值的值。因此,i索引左侧的所有值都小于或等于枢轴,而i索引右侧的所有值都大于枢轴。

整个过程会重复进行,我们会在左右分区上递归调用quicksort()。当我们对这两半进行分区(然后对这两半的四半进行分区,再进行更多递归的quicksort()调用,依此类推),整个数组最终会被排序。

当我们运行这些程序时,输出显示了对[0, 7, 6, 3, 1, 2, 5, 4]列表进行排序的过程。点的行用于在编写代码时对齐输出:

quicksort() called on this range: [0, 7, 6, 3, 1, 2, 5, 4]
................The full list is: [0, 7, 6, 3, 1, 2, 5, 4]
....................The pivot is: 4
....After swapping, the range is: [0, 3, 1, 2, 4, 7, 5, 6]
Recursively calling quicksort on: [0, 3, 1, 2] and [7, 5, 6]

quicksort() called on this range: [0, 3, 1, 2]
................The full list is: [0, 3, 1, 2, 4, 7, 5, 6]
....................The pivot is: 2
....After swapping, the range is: [0, 1, 2, 3]
Recursively calling quicksort on: [0, 1] and [3]

quicksort() called on this range: [0, 1]
................The full list is: [0, 1, 2, 3, 4, 7, 5, 6]
....................The pivot is: 1
....After swapping, the range is: [0, 1]
Recursively calling quicksort on: [0] and []

quicksort() called on this range: [0]
................The full list is: [0, 1, 2, 3, 4, 7, 5, 6]

quicksort() called on this range: []
................The full list is: [0, 1, 2, 3, 4, 7, 5, 6]

quicksort() called on this range: [3]
................The full list is: [0, 1, 2, 3, 4, 7, 5, 6]

quicksort() called on this range: [7, 5, 6]
................The full list is: [0, 1, 2, 3, 4, 7, 5, 6]
....................The pivot is: 6
....After swapping, the range is: [5, 6, 7]
Recursively calling quicksort on: [5] and [7]

quicksort() called on this range: [5]
................The full list is: [0, 1, 2, 3, 4, 5, 6, 7]

quicksort() called on this range: [7]
................The full list is: [0, 1, 2, 3, 4, 5, 6, 7]

Sorted: [0, 1, 2, 3, 4, 5, 6, 7]

快速排序是一种常用的排序算法,因为它易于实现,而且速度快。另一种常用的排序算法——归并排序也很快,而且使用了递归。我们接下来会介绍它。

归并排序:将小堆纸牌合并成更大的排序好的堆

计算机科学家约翰·冯·诺伊曼于 1945 年开发了归并排序。它采用了分割-合并的方法:每次对mergeSort()的递归调用都将未排序的列表分成两半,直到它们被分割成长度为零或一的列表。然后,随着递归调用的返回,这些较小的列表被合并成排序好的顺序。当最后一个递归调用返回时,整个列表将被排序。

例如,分割步骤将列表[2, 9, 8, 5, 3, 4, 7, 6]分成两个列表,如[2, 9, 8, 5][3, 4, 7, 6],然后传递给两个递归函数调用。在基本情况下,列表已经被分成了零个或一个项目的列表。没有项目或一个项目的列表自然是排序好的。递归调用返回后,代码将这些小的排序好的列表合并成更大的排序好的列表,直到最终整个列表被排序。图 5-3 展示了使用归并排序对纸牌进行排序的示例。

按照一系列步骤排列纸牌的图表。第一组步骤将纸牌分成较小的组,第二组步骤将这些组合并,直到纸牌再次全部在一起。

图 5-3:归并排序的分割和合并阶段

例如,在分割阶段结束时,我们有八个单独的数字列表:[2], [9], [8], [5], [3], [4], [7], [6]。只有一个数字的列表自然是按顺序排列的。将两个排序好的列表合并成一个更大的排序好的列表涉及查看两个较小列表的开头,并将较小的值附加到较大的列表上。图 5-4 显示了合并[2, 9][5, 8]的示例。在合并阶段中重复执行此操作,直到最终结果是原始的mergeSort()调用以排序顺序返回完整列表。

代表对两对纸牌(黑桃 2 和 9,黑桃 5 和 8)应用一系列步骤的图表。在第一步中,因为 2 小于 5,所以选择黑桃 2。在第二步中,因为 5 小于 9,所以将黑桃 5 放在黑桃 2 的上面。在第三步中,因为 8 小于 9,所以将黑桃 8 放在黑桃 5 的上面。在第四步中,将黑桃 9 放在黑桃 8 的上面。

图 5-4:合并步骤比较较小排序列表开头的两个值,并将它们移动到较大的排序列表中。合并四张卡只需要四个步骤。

让我们问一下我们的三个递归算法问题关于归并排序算法:

  1. 什么是基本情况? 给定一个要排序的列表,其中有零个或一个项目,已经按排序顺序排列。

  2. 递归函数调用传递了什么参数? 由原始列表的左半部分和右半部分制成的列表。

  3. 这个参数如何接近基本情况? 传递给递归调用的列表的大小是原始列表的一半,因此最终成为零个或一个项目的列表。

mergeSort.py Python 程序中的以下mergeSort()函数将项目列表中的值按升序排序:

import math

def mergeSort(items):
    print('.....mergeSort() called on:', items)

    # BASE CASE - Zero or one item is naturally sorted:
    if len(items) == 0 or len(items) == 1:
        return items # ❶

    # RECURSIVE CASE - Pass the left and right halves to mergeSort():
    # Round down if items doesn't divide in half evenly:
    iMiddle = math.floor(len(items) / 2) # ❷

    print('................Split into:', items[:iMiddle], 'and', items[iMiddle:])

    left = mergeSort(items[:iMiddle]) # ❸
    right = mergeSort(items[iMiddle:])

    # BASE CASE - Returned merged, sorted data:
    # At this point, left should be sorted and right should be
    # sorted. We can merge them into a single sorted list.
    sortedResult = []
    iLeft = 0
    iRight = 0
    while (len(sortedResult) < len(items)):
        # Append the smaller value to sortedResult.
        if left[iLeft] < right[iRight]: # ❹
            sortedResult.append(left[iLeft])
            iLeft += 1
        else:
            sortedResult.append(right[iRight])
            iRight += 1

        # If one of the pointers has reached the end of its list,
        # put the rest of the other list into sortedResult.
        if iLeft == len(left):
            sortedResult.extend(right[iRight:])
            break
        elif iRight == len(right):
            sortedResult.extend(left[iLeft:])
            break

    print('The two halves merged into:', sortedResult)

    return sortedResult # Returns a sorted version of items.

myList = [2, 9, 8, 5, 3, 4, 7, 6]
myList = mergeSort(myList)
print(myList)

mergeSort.html程序包含等效的 JavaScript 程序:

<script type="text/javascript">
function mergeSort(items) {
    document.write("<pre>" + ".....mergeSort() called on: [" + 
    items.join(", ") + "]</pre>");

    // BASE CASE - Zero or one item is naturally sorted:
    if (items.length === 0 || items.length === 1) { // BASE CASE
        return items; // ❶
    }

    // RECURSIVE CASE - Pass the left and right halves to mergeSort():
    // Round down if items doesn't divide in half evenly:
    let iMiddle = Math.floor(items.length / 2); // ❷

    document.write("<pre>................Split into: [" + items.slice(0, iMiddle).join(", ") + 
    "] and [" + items.slice(iMiddle).join(", ") + "]</pre>");

    let left = mergeSort(items.slice(0, iMiddle)); // ❸
    let right = mergeSort(items.slice(iMiddle));

    // BASE CASE - Returned merged, sorted data:
    // At this point, left should be sorted and right should be
    // sorted. We can merge them into a single sorted list.
    let sortedResult = [];
    let iLeft = 0;
    let iRight = 0;
    while (sortedResult.length < items.length) {
        // Append the smaller value to sortedResult.
        if (left[iLeft] < right[iRight]) { // ❹
            sortedResult.push(left[iLeft]);
            iLeft++;
        } else {
            sortedResult.push(right[iRight]);
            iRight++;
        }

        // If one of the pointers has reached the end of its list,
        // put the rest of the other list into sortedResult.
        if (iLeft == left.length) {
            Array.prototype.push.apply(sortedResult, right.slice(iRight));
            break;
        } else if (iRight == right.length) {
            Array.prototype.push.apply(sortedResult, left.slice(iLeft));
            break;
        }
    }

    document.write("<pre>The two halves merged into: [" + sortedResult.join(", ") + 
    "]</pre>");

    return sortedResult; // Returns a sorted version of items.
}

let myList = [2, 9, 8, 5, 3, 4, 7, 6];
myList = mergeSort(myList);
document.write("<pre>[" + myList.join(", ") + "]</pre>");
</script>

mergeSort()函数(以及对mergeSort()函数的所有递归调用)接受一个未排序的列表并返回一个排序的列表。该函数的第一步是检查列表是否只包含零个或一个项目❶。这个列表已经排序,所以函数原样返回列表。

否则,函数确定列表的中间索引❷,以便我们知道在哪里将其分成左半部分和右半部分列表,然后传递给两个递归函数调用❸。递归函数调用返回排序的列表,我们将其存储在左侧和右侧变量中。

下一步是将这两个排序好的半列表合并成一个排序好的完整列表,命名为sortedResult。我们将为leftright列表维护两个索引,命名为iLeftiRight。在循环内,将两个值中较小的一个❹附加到sortedResult,并递增其相应的索引变量(iLeftiRight)。如果iLeftiRight达到其列表的末尾,则将另一半列表中剩余的项目附加到sortedResult

让我们跟随一个合并步骤的示例,如果递归调用已经返回了[2, 9]作为left[5, 8]作为right。由于这些列表是从mergeSort()调用返回的,我们总是可以假设它们是排序好的。我们必须将它们合并成一个单独的排序好的列表,以便当前的mergeSort()调用将其返回给其调用者。

iLeftiRight索引从0开始。我们比较left[iLeft](为2)和right[iRight](为5)的值,以找到较小的值:

sortedResult = []
      left: [2, 9]    right: [5, 8]
   indices:  0  1             0  1
 iLeft = 0   ^
iRight = 0                    ^

由于left[iLeft]的值2是较小的值,我们将其附加到sortedResult并将iLeft0增加到1。 变量的状态现在如下:

sortedResult = [2]
      left: [2, 9]    right: [5, 8]
   indices:  0  1             0  1
 iLeft = 1      ^
iRight = 0                    ^

再次比较left[iLeft]right[iRight],我们发现95中,right[iRight]5更小。 代码将5附加到sortedResult并将iRight0增加到1。 变量的状态现在如下:

sortedResult = [2, 5]
      left: [2, 9]    right: [5, 8]
 indices:  0  1             0  1
 iLeft = 1      ^
iRight = 1                       ^

再次比较left[iLeft]right[iRight],我们发现98中,right[iRight]8更小。 代码将8附加到sortedResult并将iRight0增加到1。 现在变量的状态如下:

sortedResult = [2, 5, 8]
      left: [2, 9]    right: [5, 8]
   indices:  0  1             0  1
 iLeft = 1      ^
iRight = 2                         ^

因为iRight现在是2,等于right列表的长度,所以从iLeft索引到末尾的left中剩余的项目被附加到sortedResult,因为right中没有更多的项目可以与它们进行比较。这使得sortedResult成为[2, 5, 8, 9],它需要返回的排序列表。这个合并步骤对每次mergeSort()调用都执行,以产生最终排序的列表。

当我们运行mergeSort.pymergeSort.html程序时,输出显示了对[2, 9, 8, 5, 3, 4, 7, 6]列表进行排序的过程。

.....mergeSort() called on: [2, 9, 8, 5, 3, 4, 7, 6]
................Split into: [2, 9, 8, 5] and [3, 4, 7, 6]
.....mergeSort() called on: [2, 9, 8, 5]
................Split into: [2, 9] and [8, 5]
.....mergeSort() called on: [2, 9]
................Split into: [2] and [9]
.....mergeSort() called on: [2]
.....mergeSort() called on: [9]
The two halves merged into: [2, 9]
.....mergeSort() called on: [8, 5]
................Split into: [8] and [5]
.....mergeSort() called on: [8]
.....mergeSort() called on: [5]
The two halves merged into: [5, 8]
The two halves merged into: [2, 5, 8, 9]
.....mergeSort() called on: [3, 4, 7, 6]
................Split into: [3, 4] and [7, 6]
.....mergeSort() called on: [3, 4]
................Split into: [3] and [4]
.....mergeSort() called on: [3]
.....mergeSort() called on: [4]
The two halves merged into: [3, 4]
.....mergeSort() called on: [7, 6]
................Split into: [7] and [6]
.....mergeSort() called on: [7]
.....mergeSort() called on: [6]
The two halves merged into: [6, 7]
The two halves merged into: [3, 4, 6, 7]
The two halves merged into: [2, 3, 4, 5, 6, 7, 8, 9]
[2, 3, 4, 5, 6, 7, 8, 9]

从输出中可以看出,该函数将[2, 9, 8, 5, 3, 4, 7, 6]列表分成[2, 9, 8, 5][3, 4, 7, 6],并将它们传递给递归的mergeSort()调用。第一个列表进一步分成[2, 9][8, 5][2, 9]列表分成[2][9]。这些单值列表不能再分割,所以我们已经达到了基本情况。这些列表合并成排序后的顺序为[2, 9]。函数将[8, 5]列表分成[8][5],达到基本情况,然后合并成[5, 8]

[2, 9][5, 8]列表分别按顺序排序。记住,mergeSort()不只是将列表简单地连接[2, 9, 5, 8],这样不会按顺序排序。相反,该函数将它们合并成排序后的列表[2, 5, 8, 9]。当原始的mergeSort()调用返回时,返回的完整列表已完全排序。

对整数数组求和

我们已经在第三章中使用头尾技术对整数数组求和进行了讨论。在本章中,我们将使用分治策略。由于加法的结合律意味着将 1 + 2 + 3 + 4 相加与将 1 + 2 和 3 + 4 的和相加是相同的,我们可以将要求和的大数组分成两个要求和的小数组。

好处在于,对于更大的数据集,我们可以将子问题分配给不同的计算机,并让它们并行工作。无需等待数组的前半部分被求和,另一台计算机就可以开始对后半部分进行求和。这是分治技术的一个很大的优势,因为 CPU 的速度并没有提高多少,但我们可以让多个 CPU 同时工作。

让我们对求和函数的递归算法提出三个问题:

  1. 基本情况是什么?要么是包含零个数字的数组(返回0),要么是包含一个数字的数组(返回该数字)。

  2. 递归函数调用传递了什么参数?要么是数字数组的左半部分,要么是右半部分。

  3. 这个参数如何变得更接近基本情况?每次数组的大小减半,最终变成一个包含零个或一个数字的数组。

sumDivConq.py Python 程序实现了sumDivConq()函数中的分治策略,用于对数字进行求和。

Python

def sumDivConq(numbers):
    if len(numbers) == 0: # BASE CASE
        return 0 # ❶
    elif len(numbers) == 1: # BASE CASE
        return numbers[0] # ❷
    else: # RECURSIVE CASE
        mid = len(numbers) // 2 # ❸
        leftHalfSum = sumDivConq(numbers[0:mid])
 rightHalfSum = sumDivConq(numbers[mid:len(numbers) + 1])
        return leftHalfSum + rightHalfSum # ❹

nums = [1, 2, 3, 4, 5]
print('The sum of', nums, 'is', sumDivConq(nums))
nums = [5, 2, 4, 8]
print('The sum of', nums, 'is', sumDivConq(nums))
nums = [1, 10, 100, 1000]
print('The sum of', nums, 'is', sumDivConq(nums))

sumDivConq.html程序包含了 JavaScript 的等效内容。

JavaScript

<script type="text/javascript">
function sumDivConq(numbers) {
    if (numbers.length === 0) { // BASE CASE
        return 0; // ❶
    } else if (numbers.length === 1) { // BASE CASE
        return numbers[0]; // ❷
    } else { // RECURSIVE CASE
        let mid = Math.floor(numbers.length / 2); // ❸
        let leftHalfSum = sumDivConq(numbers.slice(0, mid));
        let rightHalfSum = sumDivConq(numbers.slice(mid, numbers.length + 1));
        return leftHalfSum + rightHalfSum; // ❹
    }
}

let nums = [1, 2, 3, 4, 5];
document.write('The sum of ' + nums + ' is ' + sumDivConq(nums) + "<br />");
nums = [5, 2, 4, 8];
document.write('The sum of ' + nums + ' is ' + sumDivConq(nums) + "<br />");
nums = [1, 10, 100, 1000];
document.write('The sum of ' + nums + ' is ' + sumDivConq(nums) + "<br />");
</script>

该程序的输出是:

The sum of [1, 2, 3, 4, 5] is 15
The sum of [5, 2, 4, 8] is 19
The sum of [1, 10, 100, 1000] is 1111

sumDivConq()函数首先检查numbers数组是否包含零个或一个数字。这些微不足道的基本情况很容易求和,因为它们不需要进行加法:返回0或数组中的单个数字。其他情况是递归的;计算数组的中间索引,以便对数字数组的左半部分和右半部分进行单独的递归调用。这两个返回值的和成为当前sumDivConq()调用的返回值。

由于加法的结合性质,没有理由要求一个数字数组必须由单个计算机按顺序相加。我们的程序在同一台计算机上执行所有操作,但对于大数组或比加法更复杂的计算,我们的程序可以将一半数据发送到其他计算机上进行处理。这个问题可以分解成类似的子问题,这是一个递归方法可以被采用的重要提示。

Karatsuba 乘法

*运算符使得在高级编程语言(如 Python 和 JavaScript)中进行乘法变得容易。但是低级硬件需要一种使用更原始操作进行乘法的方法。我们可以使用循环仅使用加法来相乘两个整数,比如下面的 Python 代码来相乘5678 * 1234

>>> x = 5678
>>> y = 1234
>>> product = 0
>>> for i in range(x):
...     product += y
...
>>> product
7006652

然而,这段代码对大整数的扩展效率并不高。Karatsuba 乘法是一种快速的递归算法,由 Anatoly Karatsuba 于 1960 年发现,可以使用加法、减法和预先计算的所有单个数字乘积的乘法表来相乘整数。这个乘法表,如图 5-5 所示,被称为查找表

我们的算法不需要相乘单个数字,因为它可以直接在表中查找。通过使用内存存储预先计算的值,我们增加了内存使用量以减少 CPU 运行时间。

数字 0 到 9 的乘法表。

图 5-5:查找表,比如这个包含所有单个数字乘积的表,可以使我们的程序避免重复计算,因为计算机将预先计算的值存储在内存中以供以后检索。

我们将在高级语言(如 Python 或 JavaScript)中实现 Karatsuba 乘法,就好像*运算符并不存在一样。我们的karatsuba()函数接受两个整数参数xy进行相乘。Karatsuba 算法有五个步骤,前三个步骤涉及对从xy派生的较小的、分解的整数进行递归调用karatsuba()。基本情况发生在xy参数都是单个数字时,此时可以在预先计算的查找表中找到乘积。

我们还定义了四个变量:ab分别是x的数字的一半,cd分别是y的数字的一半,如图 5-6 所示。例如,如果xy分别是56781234,那么a56b78c12d34

图示 x = 5678,箭头指向 a = 56 和 b = 78,以及 y = 1234,箭头指向 c = 12 和 d = 34。

图 5-6:要相乘的整数xy被分成一半abcd

以下是 Karatsuba 算法的五个步骤:

  1. ac相乘,可以从乘法查找表中查找,也可以通过对karatsuba()进行递归调用来实现。

  2. bd相乘,可以从乘法查找表中查找,也可以通过对karatsuba()进行递归调用来实现。

  3. a + cb + d相乘,可以从乘法查找表中查找,也可以通过对karatsuba()进行递归调用来实现。

  4. 计算第 3 步 - 第 2 步 - 第 1 步。

  5. 用零填充第 1 步和第 4 步的结果,然后将它们加到第 2 步。

第 5 步的结果是xy的乘积。如何用零填充第 1 步和第 4 步的结果的具体方法将在本节后面解释。

让我们向karatsuba()函数提出三个递归算法的问题:

  1. 基本情况是什么?相乘单个数字,可以在预先计算的查找表中找到。

  2. 递归函数调用传递了什么参数?从xy参数派生的abcd的值。

  3. 这个参数是如何变得更接近基本情况的呢?因为abcd分别是xy的一半,并且它们自己被用作下一个递归调用的xy参数,递归调用的参数变得越来越接近基本情况所需的单个数字。

我们的 Python 实现 Karatsuba 乘法在karatsubaMultiplication.py程序中:

import math

# Create a lookup table of all single-digit multiplication products:
MULT_TABLE = {} # ❶
for i in range(10):
    for j in range(10):
 MULT_TABLE[(i, j)] = i * j

def padZeros(numberString, numZeros, insertSide):
    """Return a string padded with zeros on the left or right side."""
    if insertSide == 'left':
        return '0' * numZeros + numberString
    elif insertSide == 'right':
        return numberString + '0' * numZeros

def karatsuba(x, y):
    """Multiply two integers with the Karatsuba algorithm. Note that
    the * operator isn't used anywhere in this function."""
    assert isinstance(x, int), 'x must be an integer'
    assert isinstance(y, int), 'y must be an integer'
    x = str(x)
    y = str(y)

    # At single digits, look up the products in the multiplication table:
    if len(x) == 1 and len(y) == 1: # BASE CASE
        print('Lookup', x, '*', y, '=', MULT_TABLE[(int(x), int(y))])
        return MULT_TABLE[(int(x), int(y))]

    # RECURSIVE CASE
    print('Multiplying', x, '*', y)

    # Pad with prepended zeros so that x and y are the same length:
    if len(x) < len(y): # ❷
        # If x is shorter than y, pad x with zeros:
        x = padZeros(x, len(y) - len(x), 'left')
    elif len(y) < len(x):
        # If y is shorter than x, pad y with zeros:
        y = padZeros(y, len(x) - len(y), 'left')
    # At this point, x and y have the same length.

    halfOfDigits = math.floor(len(x) / 2) # ❸

    # Split x into halves a & b, split y into halves c & d:
    a = int(x[:halfOfDigits])
    b = int(x[halfOfDigits:])
    c = int(y[:halfOfDigits])
    d = int(y[halfOfDigits:])

    # Make the recursive calls with these halves:
    step1Result = karatsuba(a, c) # ❹ # Step 1: Multiply a & c.
    step2Result = karatsuba(b, d) # Step 2: Multiply b & d.
    step3Result = karatsuba(a + b, c + d) # Step 3: Multiply a + b & c + d.

    # Step 4: Calculate Step 3 - Step 2 - Step 1:
    step4Result = step3Result - step2Result - step1Result # ❺

    # Step 5: Pad these numbers, then add them for the return value:
    step1Padding = (len(x) - halfOfDigits) + (len(x) - halfOfDigits)
    step1PaddedNum = int(padZeros(str(step1Result), step1Padding, 'right'))

    step4Padding = (len(x) - halfOfDigits)
 step4PaddedNum = int(padZeros(str(step4Result), step4Padding, 'right'))

    print('Solved', x, 'x', y, '=', step1PaddedNum + step2Result + step4PaddedNum)

    return step1PaddedNum + step2Result + step4PaddedNum # ❻

# Example: 1357 x 2468 = 3349076
print('1357 * 2468 =', karatsuba(1357, 2468))

JavaScript 的等价代码在karatsubaMultiplication.html中。

<script type="text/javascript">

// Create a lookup table of all single-digit multiplication products:
let MULT_TABLE = {}; // ❶
for (let i = 0; i < 10; i++) {
    for (let j = 0; j < 10; j++) {
        MULT_TABLE[[i, j]] = i * j;
    }
}

function padZeros(numberString, numZeros, insertSide) {
    // Return a string padded with zeros on the left or right side.
    if (insertSide === "left") {
        return "0".repeat(numZeros) + numberString;
    } else if (insertSide === "right") {
        return numberString + "0".repeat(numZeros);
    }
}

function karatsuba(x, y) {
    // Multiply two integers with the Karatsuba algorithm. Note that
    // the * operator isn't used anywhere in this function.
    console.assert(Number.isInteger(x), "x must be an integer");
    console.assert(Number.isInteger(y), "y must be an integer");
    x = x.toString();
    y = y.toString();

    // At single digits, look up the products in the multiplication table:  
    if ((x.length === 1) && (y.length === 1)) { // BASE CASE
        document.write("Lookup " + x.toString() + " * " + y.toString() + " = " + 
        MULT_TABLE[[parseInt(x), parseInt(y)]] + "<br />");
        return MULT_TABLE[[parseInt(x), parseInt(y)]];
    }

    // RECURSIVE CASE
    document.write("Multiplying " + x.toString() + " * " + y.toString() + 
    "<br />");

    // Pad with prepended zeros so that x and y are the same length:
    if (x.length < y.length) { // ❷
        // If x is shorter than y, pad x with zeros:
        x = padZeros(x, y.length - x.length, "left");
    } else if (y.length < x.length) {
        // If y is shorter than x, pad y with zeros:
 y = padZeros(y, x.length - y.length, "left");
    }
    // At this point, x and y have the same length.

    let halfOfDigits = Math.floor(x.length / 2); // ❸

    // Split x into halves a & b, split y into halves c & d:
    let a = parseInt(x.substring(0, halfOfDigits));
    let b = parseInt(x.substring(halfOfDigits));
    let c = parseInt(y.substring(0, halfOfDigits));
    let d = parseInt(y.substring(halfOfDigits));

    // Make the recursive calls with these halves:
    let step1Result = karatsuba(a, c); // ❹ // Step 1: Multiply a & c.
    let step2Result = karatsuba(b, d); // Step 2: Multiply b & d.
    let step3Result = karatsuba(a + b, c + d); // Step 3: Multiply a + b & c + d.

    // Step 4: Calculate Step 3 - Step 2 - Step 1:
    let step4Result = step3Result - step2Result - step1Result; // ❺

    // Step 5: Pad these numbers, then add them for the return value:
    let step1Padding = (x.length - halfOfDigits) + (x.length - halfOfDigits);
    let step1PaddedNum = parseInt(padZeros(step1Result.toString(), step1Padding, "right"));

    let step4Padding = (x.length - halfOfDigits);
    let step4PaddedNum = parseInt(padZeros((step4Result).toString(), step4Padding, "right"));

    document.write("Solved " + x + " x " + y + " = " + 
    (step1PaddedNum + step2Result + step4PaddedNum).toString() + "<br />");

    return step1PaddedNum + step2Result + step4PaddedNum; // ❻
}

// Example: 1357 x 2468 = 3349076
document.write("1357 * 2468 = " + karatsuba(1357, 2468).toString() + "<br />");
</script>

当你运行这段代码时,输出如下:

Multiplying 1357 * 2468
Multiplying 13 * 24
Lookup 1 * 2 = 2
Lookup 3 * 4 = 12
Lookup 4 * 6 = 24
Solved 13 * 24 = 312
Multiplying 57 * 68
Lookup 5 * 6 = 30
Lookup 7 * 8 = 56
Multiplying 12 * 14
Lookup 1 * 1 = 1
Lookup 2 * 4 = 8
Lookup 3 * 5 = 15
Solved 12 * 14 = 168
Solved 57 * 68 = 3876
Multiplying 70 * 92
Lookup 7 * 9 = 63
Lookup 0 * 2 = 0
Multiplying 7 * 11
Lookup 0 * 1 = 0
Lookup 7 * 1 = 7
Lookup 7 * 2 = 14
Solved 07 * 11 = 77
Solved 70 * 92 = 6440
Solved 1357 * 2468 = 3349076
1357 * 2468 = 3349076

这个程序的第一部分发生在调用karatsuba()之前。我们的程序需要在MULT_TABLE变量中创建乘法查找表。通常,查找表是直接硬编码在源代码中的,从MULT_TABLE[[0, 0]] = 0MULT_TABLE[[9, 9]] = 81。但为了减少输入量,我们将使用嵌套的for循环来生成每个乘积。访问MULT_TABLE[[m, n]]会给我们整数mn的乘积。

我们的karatsuba()函数还依赖于一个名为padZeros()的辅助函数,它在字符串的左侧或右侧填充额外的零。这种填充是在 Karatsuba 算法的第五步中完成的。例如,padZeros("42", 3, "left")返回字符串00042,而padZeros("99", 1, "right")返回字符串990

karatsuba()函数本身首先检查基本情况,即xy是单个数字。这些可以使用查找表相乘,并且它们的乘积会立即返回。其他情况都是递归情况。

我们需要将xy整数转换为字符串,并调整它们,使它们包含相同数量的数字。如果其中一个数字比另一个短,就会在左侧填充 0。例如,如果x13y2468,我们的函数调用padZeros(),这样x就可以被替换为0013。这是因为我们随后创建abcd变量,每个变量包含xy的一半数字。ac变量必须具有相同数量的数字,以使 Karatsuba 算法起作用,bd变量也是如此。

请注意,我们使用除法和向下取整来计算x的数字的一半是多少。这些数学运算和乘法一样复杂,可能在我们为 Karatsuba 算法编程的低级硬件上不可用。在实际实现中,我们可以使用另一个查找表来存储这些值:HALF_TABLE = [0, 0, 1, 1, 2, 2, 3, 3...],以此类推。查找HALF_TABLE[n]将得到n的一半,向下取整。一个仅有 100 个项目的数组就足以满足大多数情况,可以避免我们的程序进行除法和取整。但我们的程序是用来演示的,所以我们将使用/运算符和内置的取整函数。

一旦这些变量被正确设置,我们就可以开始进行递归函数调用。前三个步骤涉及使用参数abcd,最后是a + bc + d进行递归调用。第四步是将前三步的结果相互相减。第五步是将第一步和第四步的结果右侧填充 0,然后加上第二步的结果。

Karatsuba 算法背后的代数

这些步骤可能看起来像魔术,所以让我们深入代数,看看为什么它们有效。我们使用 1,357 作为x和 2,468 作为y,作为我们要相乘的整数。我们还考虑一个新变量n,表示xy的数字位数。由于a是 13,b是 57,我们可以计算原始x为 10^(n)^(/2) × a + b,即 10² × 13 + 57 或 1,300 + 57,即 1,357。同样,y也是 10^(n)^(/2) × c + d

这意味着x × y的乘积=(10^(n)^(/2) × a + b)×(10^(n)^(/2) × c + d)。通过一些代数运算,我们可以将这个方程重写为x × y = 10n × ac + 10^(n)^(/2) × (ad + bc) + bd。对于我们的示例数字,这意味着 1,357 × 2,468 = 10,000 × (13 × 24) + 100 × (13 × 68 + 57 × 24) + (57 × 68)。这个方程的两边都计算为 3,349,076。

我们将xy的乘法分解为acadbcbd的乘法。这构成了我们递归函数的基础:我们通过使用较小数字的乘法(记住,abcdxy的一半的数字)来定义xy的乘法,这接近了将单个数字相乘的基本情况。我们可以使用查找表而不是乘法来执行单个数字的乘法。

因此,我们需要递归计算ac(Karatsuba 算法的第一步)和bd(第二步)。我们还需要计算(a + b)(c + d)作为第三步,我们可以将其重写为ac + ad + bc + bd。我们已经从前两步得到了acbd,所以减去这些值给了我们ad + bc。这意味着我们只需要进行一次乘法(和一次递归调用)来计算(a + b)(c + d),而不是计算ad + bc需要两次。而ad + bc是我们原始方程中 10n^(/2) × (ad + bc)的一部分。

通过填充零位数,可以通过 10n和 10^(n)^(/2)的幂来进行乘法:例如,10,000 × 123 是 1,230,000。因此,对于这些乘法,没有必要进行递归调用。最后,将x × y的乘法分解为三个较小乘积的乘法,需要进行三次递归调用:karatsuba(a, c)karatsuba(b, d)karatsuba((a + b), (c + d))

通过仔细研究本节,您可以理解 Karatsuba 算法背后的代数。我无法理解的是,23 岁的学生 Anatoly Karatsuba 是如何在不到一周的时间里聪明地设计出这个算法的。

总结

将问题分解为更小的、自相似的问题是递归的核心,使得这些分而治之的算法特别适合递归技术。在本章中,我们为数组中数字求和的第三章程序创建了一个分而治之的版本。这个版本的一个好处是,在将问题分解为多个子问题时,可以将子问题分配给其他计算机并行处理。

二分搜索算法通过不断缩小搜索范围的方式在排序数组中搜索。线性搜索从开头开始搜索整个数组,而二分搜索利用数组的排序顺序来定位它正在寻找的项目。性能提升如此之大,以至于值得对未排序的数组进行排序,以便对其项目进行二分搜索。

在本章中,我们介绍了两种流行的排序算法:快速排序和归并排序。快速排序根据一个枢轴值将数组分成两个分区。然后算法递归地对这两个分区进行分割,重复这个过程,直到分区的大小为一个单独的项目。在这一点上,分区和其中的项目都是按排序顺序排列的。归并排序采取相反的方法。算法首先将数组分成较小的数组,然后将这些较小的数组合并成排序顺序。

最后,我们介绍了 Karatsuba 乘法,这是一种递归算法,用于执行整数乘法,当*乘法运算符不可用时。这在低级硬件编程中会出现,因为它没有内置的乘法指令。Karatsuba 算法将两个整数相乘分解为三个较小整数的乘法。为了基本情况下的单个数字相乘,该算法在查找表中存储了从 0 × 0 到 9 × 9 的每个乘积。

本章中的算法是大一计算机科学学生学习的许多数据结构和算法课程的一部分。在下一章中,我们将继续研究计算的核心算法,包括计算排列和组合的算法。

进一步阅读

YouTube 的 Computerphile 频道有关于快速排序的视频,网址为youtu.be/XE4VP_8Y0BU,以及关于归并排序的视频,网址为youtu.be/kgBjXUE_Nwc。如果你想要更全面的教程,免费的“Algorithmic Toolbox”在线课程涵盖了许多大一数据结构和算法课程会涵盖的相同主题,包括二分搜索、快速排序和归并排序。你可以在www.coursera.org/learn/algorithmic-toolbox上报名参加这门 Coursera 课程。

排序算法经常在大 O 算法分析课程中相互比较,你可以在我的书Beyond the Basic Stuff with Python(No Starch Press, 2020)的第十三章中阅读有关此内容。你可以在inventwithpython.com/beyond上在线阅读这一章。Python 开发者 Ned Batchelder 在 2018 年的 PyCon 演讲中描述了大 O 和“随着数据增长,代码的减速”,演讲的名称也是这个名字,网址为youtu.be/duvZ-2UK0fc

分治算法很有用,因为它们经常可以并行在多台计算机上运行。Guy Steele Jr.在 Google TechTalk 上发表了题为“Four Solutions to a Trivial Problem”的演讲,网址为youtu.be/ftcIcn8AmSY

Tim Roughgarden 教授为斯坦福大学制作了一段关于 Karatsuba 乘法的视频讲座,网址为youtu.be/JCbZayFr9RE

为了帮助你理解快速排序和归并排序,获取一副扑克牌或者简单地在索引卡上写上数字,并按照这两种算法的规则手动排序它们。这种离线方法可以帮助你记住快速排序的中轴和分区以及归并排序的分治。

练习问题

通过回答以下问题来测试你的理解:

  1. 与第三章中的头尾求和算法相比,本章中的分治求和算法有什么好处?

  2. 如果在书架上搜索 50 本书的二分搜索需要六步,那么搜索两倍的书需要多少步?

  3. 二分搜索算法能搜索未排序的数组吗?

  4. 分区和排序是一样的吗?

  5. 快速排序的分区步骤发生了什么?

  6. 快速排序中的中轴值是多少?

  7. 快速排序的基本情况是什么?

  8. quicksort()函数有多少递归调用?

  9. 数组[0, 3, 1, 2, 5, 4, 7, 6]在以4为中轴值时没有正确分区?

  10. 归并排序的基本情况是什么?

  11. mergeSort()函数有多少递归调用?

  12. 当归并排序算法对数组[12, 37, 38, 41, 99][2, 4, 14, 42]进行排序时,结果数组是什么?

  13. 查找表是什么?

  14. 在乘法整数xy的 Karatsuba 算法中,变量abcd分别存储什么?

  15. 回答关于本章中每个递归算法的三个问题:

  16. 基本情况是什么?

  17. 递归函数调用传递了什么参数?

  18. 这个参数如何变得更接近基本情况?

然后,重新创建本章中的递归算法,而不看原始代码。

练习项目

为了练习,为以下每个任务编写一个函数:

  1. 创建一个具有从 0×0 到 999×999 的乘法查找表的karatsuba()函数的版本,而不是从 0×0 到 9×9。粗略估计使用这个更大的查找表在循环中计算karatsuba(12345678, 87654321) 10,000 次所需的时间,与原始查找表相比。如果这仍然运行得太快以至于无法测量,增加迭代次数到 100,000 或 1,000,000 或更多。(提示:您应该删除或注释掉karatsuba()函数内的print()document.write()调用,以进行此定时测试。)

  2. 创建一个函数,在一个包含 10,000 个整数的大数组上执行线性搜索 10,000 次。粗略估计这需要多长时间,如果程序执行得太快,增加迭代次数到 100,000 或 1,000,000。将此与第二个函数在执行相同数量的二进制搜索之前对数组进行排序所需的时间进行比较。

六、排列和组合

原文:Chapter 6 - Permutations and Combinations

译者:飞龙

协议:CC BY-NC-SA 4.0

涉及排列和组合的问题特别适合递归。这在集合论中很常见,集合论是处理对象集合的选择、排列和操作的数学逻辑分支。

处理我们短期记忆中的小集合很简单。我们可以轻松地想出一组三个或四个对象的每种可能顺序(即排列)或组合。对更大集合中的项目进行排序和组合需要相同的过程,但很快就变成了我们人类大脑无法完成的任务。在那一点上,引入计算机来处理随着我们向集合中添加更多对象而发生的组合爆炸变得实际。

在其核心,计算大群体的排列和组合涉及计算较小群体的排列和组合。这使得这些计算适合递归。在本章中,我们将看看用于生成字符串中所有可能排列和组合的递归算法。我们将扩展到生成所有可能的平衡括号组合(正确匹配的开括号顺序与闭括号)。最后,我们将计算集合的幂集,即集合的所有可能子集的集合。

本章中的许多递归函数都有一个名为indent的参数。这并不是由实际的递归算法使用的;相反,它是由它们的调试输出使用的,以便您可以看到哪个递归级别产生了输出。每次递归调用时缩进增加一个空格,并在调试输出中呈现为句点,以便轻松计算缩进级别。

集合论术语

本章并没有像数学或计算机科学教科书那样完全涵盖集合论。但它涵盖了足够的内容,以证明从解释该学科的基本术语开始是有道理的,因为这样做将使本章的其余部分更容易理解。集合是一组唯一对象,称为元素成员。例如,字母ABC形成了一个三个字母的集合。在数学(以及 Python 代码语法)中,集合用大括号括起来,对象之间用逗号分隔:{A, B, C}。

集合的顺序并不重要;集合{A,B,C}与集合{C,B,A}是相同的集合。集合具有不同的元素,这意味着没有重复:{A,C,A,B}有重复的A,因此不是一个集合。

如果一个集合只包含另一个集合的成员,则称其为另一个集合的子集。例如,{A,C}和{B,C}都是{A,B,C}的子集,但{A,C,D}不是它的子集。相反,{A,B,C}是{A,C}和{B,C}的超集,因为它包含它们的所有元素。空集{}是一个不包含任何成员的集合。空集被认为是每个可能集合的子集。

一个子集也可以包括另一个集合的所有元素。例如,{A,B,C}是{A,B,C}的一个子集。但是,真子集严格子集是一个不包含所有集合元素的子集。没有集合是其自身的真子集:因此{A,B,C}是一个子集,但不是{A,B,C}的真子集。所有其他子集都是真子集。图 6-1 显示了集合{A,B,C}及其一些子集的图形表示。

嵌套圆圈的图示:一个外部虚线圆圈;一个在虚线圆圈内部的圆圈,其中包含 B;一个在该圆圈内部的圆圈,其中包含 A 和 C;一个在该圆圈内部的圆圈,其中不包含任何字母。

图 6-1:集合{A,B,C}在虚线内以及其一些子集{A,B,C},{A,C}和{ }在实线内的图形表示。圆圈代表集合,字母代表元素。

一个集合的排列是集合中所有元素的特定顺序。例如,集合{A,B,C}有六个排列:ABC,ACB,BAC,BCA,CAB 和 CBA。我们称这些排列无重复排列,或者无替换排列,因为每个元素在排列中不会出现超过一次。*

一个组合*是一个集合的元素选择。更正式地说,k-组合是从一个集合中选择 k 个元素的子集。与排列不同,组合没有顺序。例如,集合{A,B,C}的 2-组合是{A,B},{A,C}和{B,C}。集合{A,B,C}的 3-组合是{A,B,C}。

“n 选 k”一词指的是可以从 n 个元素的集合中选择 k 个元素的可能组合(不重复)。 (一些数学家使用“n 选 r”一词。)这个概念与元素本身无关,只与它们的数量有关。例如,4 选 2 是 6,因为有六种方法可以从四个元素的集合{A,B,C,D}中选择两个元素:{A,B},{A,C},{A,D},{B,C},{B,D}和{C,D}。同时,3 选 3 是 1,因为从三个元素的集合{A,B,C}中只有一种 3 个元素的组合,即{A,B,C}本身。计算 n 选 k 的公式是(n!)/(k!×(n-k)!)。回想一下,n!是阶乘的表示法:5!是 5×4×3×2×1。

术语n multichoose k指的是可以从 n 个元素的集合中选择 k 个元素的可能组合带有重复。因为 k-组合是集合,而集合不包含重复元素,所以 k-组合不会重复。当我们使用带有重复元素的 k-组合时,我们特别称它们为带重复的 k-组合

请记住,无论有无重复,您都可以将排列视为集合中所有元素的特定排列,而组合是集合中某些元素的无序选择。排列有顺序并使用集合中的所有元素,而组合没有顺序并使用集合中的任意数量的元素。为了更好地了解这些术语,表 6-1 显示了集合{A,B,C}的排列和组合之间的区别,有无重复。

表 6-1:集合{A,B,C}的所有可能排列和组合,有无重复。

排列组合
无重复ABC,ACB,BAC,BCA,CAB(无),A,B,C,AB,AC,BC,ABC
有重复AAA,AAB,AAC,ABA,ABB,ABC,ACA,ACB,ACC,BAA,BAB,BAC,BBA,BBB,BBC,BCA,BCB,BCC,CAA,CAB,CAC,CBA,CBB,CBC,CCA,CCB,CCC(无),A,B,C,AA,AB,AC,BB,BC,CC,AAA,AAB,AAC,ABB,ABC,ACC,BBB,BBC,BCC,CCC

当我们向集合添加元素时,排列和组合的数量会迅速增长,这种组合爆炸由表 6-2 中的公式捕捉到。例如,一个包含 10 个元素的集合有 10!,或 3,628,800 个可能的排列,但是一个包含两倍元素的集合有 20!,或 2,432,902,008,176,640,000 个排列。

表 6-2:计算n元素集合的可能排列和组合的数量,有重复和无重复

排列组合
无重复n!2n
有重复n^(n)2n 选择 n,或 (2n)!/(n!)²

请注意,没有重复的排列总是与集合的大小相同。例如,{A,B,C}的排列总是三个字母长:ABC,ACB,BAC 等。然而,有重复的排列可以是任意长度。表 6-1 显示了{A,B,C}的三个字母排列,范围从 AAA 到 CCC,但是你也可以有有重复的五个字母排列,范围从 AAAAA 到 CCCCC。有重复的n元素的排列的长度为k的数量是n^(k)。表 6-2 将其列为n^(n),表示有重复的n元素的排列也是n元素长的排列。

排列的顺序很重要,但组合的顺序不重要。虽然 AAB,ABA 和 BAA 被视为具有重复的相同组合,但它们被视为具有重复的三个不同排列。

寻找没有重复的所有排列:婚礼座位表

想象一下,你必须为一个有着微妙社交要求的婚礼宴会安排座位表。一些客人彼此讨厌,而其他人则要求坐在一个有影响力的客人附近。长方形桌子上的座位形成一排,而不是一个圆圈。对于你的计划来说,看到每个客人的所有可能的排列,也就是每个客人的没有重复的排列,将是有帮助的。没有重复发生,因为每个客人在座位表中只出现一次。

让我们以 Alice,Bob 和 Carol,或{A,B,C}为例。图 6-2 显示了这三位婚礼客人的所有六种可能排列。

我们确定没有重复的排列数量的一种方法是使用头尾递归策略。我们从集合中选择一个元素作为头部。然后我们得到剩余元素的每个排列(构成尾部),对于每个排列,我们将头部放在排列的每个可能位置上。

在我们的 ABC 示例中,我们将从 Alice(A)作为头部开始,Bob 和 Carol(BC)作为尾部。{B,C}的排列是 BC 和 CB。(我们如何得到 BC 和 CB 将在下一段解释,所以现在先把这个问题放在一边。)我们将 A 放在 BC 的每个可能位置上。也就是说,我们将 Alice 放在 Bob 之前(ABC),在 Bob 和 Carol 之间(BAC),和在 Carol 之后(BCA)。这样就创建了 ABC,BAC 和 BCA 的排列。我们还将 A 放在 CB 的每个可能位置上,创建了 ACB,CAB 和 CBA。这样就创建了 Alice,Bob 和 Carol 在宴会桌上的所有六种排列。现在我们可以选择导致最少争吵的安排(或者如果你想要一个难忘的婚礼宴会,也可以选择导致最多争吵的安排)。

六张晚餐桌的图画,上面坐着同样三个人,但是顺序不同。

图 6-2:桌子上三位婚礼客人的六种可能排列

当然,要得到{B,C}的每个排列,我们需要用 B 作为头部,C 作为尾部递归重复这个过程。单个字符的排列是字符本身;这是我们的基本情况。通过将头部 B 放在 C 的每个可能位置上,我们得到了上一段中使用的 BC 和 CB 排列。请记住,虽然集合的顺序无关紧要(如{B,C}与{C,B}相同),但排列的顺序很重要(BC 不是 CB 的重复)。

我们的递归排列函数接受一个字符字符串作为参数,并返回这些字符的每种可能排列的字符串数组。让我们针对这个函数的递归算法提出三个问题:

  1. 什么是基本情况?单个字符字符串或空字符串的参数,返回一个只包含该字符串的数组。

  2. 递归函数调用传递了什么参数?缺少一个字符的字符串参数。为每个缺失的字符进行了单独的递归调用。

  3. 这个参数如何接近基本情况?字符串的大小缩小,最终变成一个单字符字符串。

递归排列算法在permutations.py中实现:

def getPerms(chars, indent=0):
    print('.' * indent + 'Start of getPerms("' + chars + '")')
    if len(chars) == 1: # ❶
        # BASE CASE
        print('.' * indent + 'When chars = "' + chars + '" base case returns', chars)
        return [chars]

    # RECURSIVE CASE
    permutations = []
    head = chars[0] # ❷
    tail = chars[1:]
 tailPermutations = getPerms(tail, indent + 1)
    for tailPerm in tailPermutations: # ❸
        print('.' * indent + 'When chars =', chars, 'putting head', head, 'in all places in', tailPerm)
        for i in range(len(tailPerm) + 1): # ❹
            newPerm = tailPerm[0:i] + head + tailPerm[i:]
            print('.' * indent + 'New permutation:', newPerm)
            permutations.append(newPerm)
    print('.' * indent + 'When chars =', chars, 'results are', permutations)
    return permutations

print('Permutations of "ABCD":')
print('Results:', ','.join(getPerms('ABCD')))

等效的 JavaScript 程序在permutations.html中:

<script type="text/javascript">
function getPerms(chars, indent) {
    if (indent === undefined) {
        indent = 0;
    }
    document.write('.'.repeat(indent) + 'Start of getPerms("' + chars + '")<br />');
    if (chars.length === 1) { // ❶
        // BASE CASE
        document.write('.'.repeat(indent) + "When chars = \"" + chars + 
        "\" base case returns " + chars + "<br />");
        return [chars];
    }
    // RECURSIVE CASE
    let permutations = [];
    let head = chars[0]; // ❷
    let tail = chars.substring(1);
    let tailPermutations = getPerms(tail, indent + 1);
    for (tailPerm of tailPermutations) { // ❸
        document.write('.'.repeat(indent) + "When chars = " + chars + 
        " putting head " + head + " in all places in " + tailPerm + "<br />");
        for (let i = 0; i < tailPerm.length + 1; i++) { // ❹
            let newPerm = tailPerm.slice(0, i) + head + tailPerm.slice(i);
            document.write('.'.repeat(indent) + "New permutation: " + newPerm + "<br />");
            permutations.push(newPerm);
        }
    }
    document.write('.'.repeat(indent) + "When chars = " + chars + 
    " results are " + permutations + "<br />");
    return permutations;
}

document.write("<pre>Permutations of \"ABCD\":<br />");
document.write("Results: " + getPerms("ABCD") + "</pre>");
</script>

这些程序的输出如下:

Permutations of "ABCD":
Start of getPerms("ABCD")
.Start of getPerms("BCD")
..Start of getPerms("CD")
...Start of getPerms("D")
...When chars = "D" base case returns D
..When chars = CD putting head C in all places in D
..New permutation: CD
..New permutation: DC
..When chars = CD results are ['CD', 'DC']
.When chars = BCD putting head B in all places in CD
.New permutation: BCD
.New permutation: CBD
.New permutation: CDB
.When chars = BCD putting head B in all places in DC
.New permutation: BDC
.New permutation: DBC
.New permutation: DCB
.When chars = BCD results are ['BCD', 'CBD', 'CDB', 'BDC', 'DBC', 'DCB']
# --snip--
When chars = ABCD putting head A in all places in DCB
New permutation: ADCB
New permutation: DACB
New permutation: DCAB
New permutation: DCBA
When chars = ABCD results are ['ABCD', 'BACD', 'BCAD', 'BCDA', 'ACBD', 'CABD', 'CBAD', 'CBDA', 'ACDB','CADB', 'CDAB', 'CDBA', 'ABDC', 'BADC', 'BDAC', 'BDCA', 'ADBC', 'DABC', 'DBAC', 'DBCA', 'ADCB', 'DACB', 'DCAB', 'DCBA']
Results: ABCD,BACD,BCAD,BCDA,ACBD,CABD,CBAD,CBDA,ACDB,CADB,CDAB,CDBA,ABDC,
BADC,BDAC,BDCA,ADBC,DABC,DBAC,DBCA,ADCB,DACB,DCAB,DCBA

当调用getPerms()时,首先检查基本情况❶。如果chars字符串只有一个字符长,它只能有一个排列:chars字符串本身。函数将此字符串返回为数组。

否则,在递归情况下,函数将chars参数的第一个字符分为head变量和其余部分为tail变量❷。函数调用getPerms()递归获取tail字符串的所有排列。第一个for循环❸遍历这些排列的每一个,第二个for循环❹通过将head字符放在字符串的每个可能位置来创建一个新的排列。

例如,如果getPerms()chars参数为ABCDheadAtailBCDgetPerms('BCD')调用返回一个尾部排列的数组,['BCD','CBD','CDB','BDC','DBC','DCB']。第一个for循环从BCD排列开始,第二个for循环将A字符串放在head的每个可能位置,产生ABCDBACDBCADBCDA。这将重复进行剩余的尾部排列,并由getPerms()函数返回整个列表。

使用嵌套循环获取排列:一个不太理想的方法

假设我们有一个简单的自行车锁,如图 6-3,有一个四位数字组合。这个组合有 10,000 种数字排列(0000 到 9999),但只有一个可以打开它。(它们被称为组合锁;然而,在这种情况下更准确的称呼应该是重复排列锁,因为顺序很重要。)

现在假设我们有一个更简单的锁,只有五个字母 A 到 E。我们可以计算可能组合的数量为 5⁴,或 5×5×5×5,即 625。一个由n个可能性的集合中选择的每个字符的k字符组合锁是n^(k)。但是获取组合本身的列表要复杂一些。

自行车锁的照片,有四位数字组合。

图 6-3:四位数字组合自行车锁有 10⁴,或 10,000 种可能的重复排列(照片由 Shaun Fisher 提供,CC BY 2.0 许可)。

使用嵌套循环获得重复排列的一种方法是,即一个循环内嵌另一个循环。内部循环遍历集合中的每个元素,而外部循环也做同样的事情,同时重复内部循环。创建所有可能的k字符排列,每个字符从n个可能性的集合中选择,需要k个嵌套循环。

例如,nestedLoopPermutations.py包含生成{A,B,C,D,E}的所有 3 组合的代码:

Python

for a in ['A', 'B', 'C', 'D', 'E']:
    for b in ['A', 'B', 'C', 'D', 'E']:
        for c in ['A', 'B', 'C', 'D', 'E']:
            for d in ['A', 'B', 'C', 'D', 'E']:
                print(a, b, c, d)
  • nestedLoopPermutations.html *包含等效的 JavaScript 程序:

JavaScript

<script>
for (a of ['A', 'B', 'C', 'D', 'E']) {
    for (b of ['A', 'B', 'C', 'D', 'E']) {
        for (c of ['A', 'B', 'C', 'D', 'E']) {
            for (d of ['A', 'B', 'C', 'D', 'E']) {
                document.write(a + b + c + d + "<br />")
            }
        }
    }
}
</script>

这些程序的输出如下:

A A A A
A A A B
A A A C
A A A D
A A A E
A A B A
A A B B
# --snip--
E E E C
E E E D
E E E E

使用四个嵌套循环生成排列的问题在于,它仅适用于恰好为四个字符的排列。嵌套循环无法为任意长度生成排列。相反,我们可以使用递归函数,如下一节所述。

您可以通过本章中的示例来记住有和没有重复的排列之间的区别。没有重复的排列会遍历集合中元素的所有可能的排序,就像我们的婚礼座位表示例一样。有重复的排列会遍历组合锁的所有可能组合;顺序很重要,同一个元素可以出现多次。

重复排列:密码破丨解器

假设您收到了一份来自一位最近去世的记者的敏感加密文件。在他们的最后一条消息中,记者告诉您该文件包含了一个邪恶的亿万富翁的逃税记录。他们没有解密文件的密码,但他们知道密码正好是四个字符长;可能的字符是数字 2、4 和 8 以及字母 J、P 和 B。这些字符可以出现多次。例如,可能的密码是 JPB2、JJJJ 和 2442。

根据这些信息生成所有可能的四位密码列表,您希望获得集合{J,P,B,2,4,8}的所有可能的四元素重复排列。密码中的每个字符都可以是六个可能的字符之一,使得有 6×6×6×6,或 6⁴,即 1,296 种可能的排列。我们要生成{J,P,B,2,4,8}的排列,而不是组合,因为顺序很重要;JPB2 与 B2JP 是不同的密码。

让我们向这三个递归算法问题询问有关我们排列函数的问题。我们将使用更具描述性的名称permLength,而不是k

  1. 什么是基本情况?permLength参数为0,表示排列长度为零,表明prefix参数现在包含完整的排列,因此应该在数组中返回prefix

  2. 递归函数调用传递了什么参数?chars字符串是要获取排列的字符,permLength参数开始为chars的长度,prefix参数开始为空字符串。递归调用会递减permLength参数,同时将chars中的一个字符附加到prefix参数。

  3. 这个参数如何接近基本情况?最终,permLength参数递减为0

递归排列重复的算法在permutationsWithRepetition.py中实现:

def getPermsWithRep(chars, permLength=None, prefix=''):
    indent = '.' * len(prefix)
    print(indent + 'Start, args=("' + chars + '", ' + str(permLength) + ', "' + prefix + '")')
    if permLength is None:
        permLength = len(chars)

    # BASE CASE
    if (permLength == 0): # ❶
        print(indent + 'Base case reached, returning', [prefix])
        return [prefix]

    # RECURSIVE CASE
    # Create a new prefix by adding each character to the current prefix.
    results = []
    print(indent + 'Adding each char to prefix "' + prefix + '".')
    for char in chars:
        newPrefix = prefix + char # ❷

        # Decrease permLength by one because we added one character to the prefix.
        results.extend(getPermsWithRep (chars, permLength - 1, newPrefix)) # ❸
    print(indent + 'Returning', results)
    return results

print('All permutations with repetition of JPB123:')
print(getPermsWithRep('JPB123', 4))

等效的 JavaScript 程序在permutationsWithRepetition.html中:

<script type="text/javascript">
function getPermsWithRep(chars, permLength, prefix) {
    if (permLength === undefined) {
        permLength = chars.length;
    }
    if (prefix === undefined) {
        prefix = "";
    }
    let indent = ".".repeat(prefix.length);
    document.write(indent + "Start, args=(\"" + chars + "\", " + permLength + 
    ", \"" + prefix + "\")<br />");

    // BASE CASE
    if (permLength === 0) { // ❶
        document.write(indent + "Base case reached, returning " + [prefix] + "<br />");
        return [prefix];
    }

    // RECURSIVE CASE
    // Create a new prefix by adding each character to the current prefix.
    let results = [];
    document.write(indent + "Adding each char to prefix \"" + prefix + "\".<br />");
    for (char of chars) {
        let newPrefix = prefix + char; // ❷

        // Decrease permLength by one because we added one character to the prefix.
        results = results.concat(getPermsWithRep(chars, permLength - 1, newPrefix)); // ❸
    }
    document.write(indent + "Returning " + results + "<br />");
    return results;
}

document.write("<pre>All permutations with repetition of JPB123:<br />");
document.write(getPermsWithRep('JPB123', 4) + "</pre>");
</script>

这些程序的输出如下所示:

All permutations with repetition of JPB123:
Start, args=("JPB123", 4, "")
Adding each char to prefix "".
.Start, args=("JPB123", 3, "J")
.Adding each char to prefix "J".
..Start, args=("JPB123", 2, "JJ")
..Adding each char to prefix "JJ".
...Start, args=("JPB123", 1, "JJJ")
...Adding each char to prefix "JJJ".
....Start, args=("JPB123", 0, "JJJJ")
....Base case reached, returning ['JJJJ']
....Start, args=("JPB123", 0, "JJJP")
....Base case reached, returning ['JJJP']
# --snip--
Returning ['JJJJ', 'JJJP', 'JJJB', 'JJJ1', 'JJJ2', 'JJJ3', 
'JJPJ', 'JJPP', 'JJPB', 'JJP1', 'JJP2', 'JJP3', 'JJBJ', 'JJBP', 
'JJBB', 'JJB1', 'JJB2', 'JJB3', 'JJ1J', 'JJ1P', 'JJ1B', 'JJ11', 
'JJ12', 'JJ13', 'JJ2J', 'JJ2P', 'JJ2B', 'JJ21', 'JJ22', 'JJ23', 
'JJ3J', 'JJ3P', 'JJ3B', 'JJ31', 'JJ32', 'JJ33', 'JPJJ', 
# --snip--

getPermsWithRep()函数有一个prefix字符串参数,默认情况下为空字符串。当调用该函数时,首先检查基本情况❶。如果permLength,即排列的长度,为0,则返回一个包含prefix的数组。

否则,在递归情况下,对于chars参数中的每个字符,函数创建一个新的前缀❷传递给递归的getPermsWithRep()调用。这个递归调用将permLength - 1作为permLength参数传递。

permLength参数从排列的长度开始,每次递归调用递减一次❸。prefix参数从空字符串开始,每次递归调用增加一个字符。因此,当达到k == 0的基本情况时,prefix字符串就是k的完整排列长度。

例如,让我们考虑调用getPermsWithRep('ABC', 2)的情况。prefix参数默认为空字符串。该函数对ABC的每个字符进行递归调用,并将其连接到空前缀字符串作为新前缀。调用getPermsWithRep('ABC', 2)会进行三次递归调用:

  • getPermsWithRep('ABC', 1, 'A')

  • getPermsWithRep('ABC', 1, 'B')

  • getPermsWithRep('ABC', 1, 'C')

这三个调用将各自进行三次递归调用,但将permLength的值设为0而不是1。基本情况发生在permLength == 0时,因此它们返回它们的前缀。这就是生成所有九个排列的方式。getPermsWithRep()函数以相同的方式生成更大集合的排列。

使用递归获取 K-组合

回想一下,对于排列而言,顺序并不重要。然而,生成集合的所有k-组合有点棘手,因为你不希望算法生成重复项:如果你从集合{A,B,C}创建 AB 2-组合,你不希望也创建 BA,因为它与 AB 是相同的 2-组合。

要弄清楚如何编写递归代码来解决这个问题,让我们看看树如何以可视化方式描述生成集合的所有k-组合。图 6-4 显示了来自集合{A,B,C,D}的所有组合的树。

树状图,每个深度级别被分类为 0-组合、1-组合、2-组合、3-组合或 4-组合。

图 6-4:显示来自集合{A,B,C,D}的每个可能k-组合(从 0 到 4)的树

例如,要从这棵树中收集 3-组合,从顶部的根节点开始,进行深度优先树遍历到 3-组合级别,同时记住每个节点的字母直到底部。(深度优先搜索在第四章中讨论。)我们的第一个 3-组合将从根到 1-组合级别的 A 开始,然后到 2-组合级别的 B,然后到 3-组合级别的 C,我们停在那里,得到了我们完整的 3-组合:ABC。对于下一个组合,我们从根到 A 到 B 到 D,得到了组合 ABD。我们继续为 ACD 和 BCD 做同样的操作。我们的树在 3-组合级别有四个节点,从{A,B,C,D}中有四个 3-组合:ABC,ABD,ACD 和 BCD。

请注意,我们通过从根节点开始使用空字符串创建了图 6-4 中的树。这是 0-组合级别,并且适用于从集合中选择零个元素的所有组合;它只是一个空字符串。根的子节点都是来自集合的所有元素。在我们的情况下,这是来自{A,B,C,D}的所有四个元素。虽然集合没有顺序,但在生成这棵树时,我们需要一致地使用集合的 ABCD 顺序。这是因为每个节点的子节点都包括在 ABCD 字符串中它之后的字母:所有 A 节点都有 B、C 和 D 子节点;所有 B 节点都有 C 和 D 子节点;所有 C 节点只有一个 D 子节点;所有 D 节点没有子节点。

虽然它与递归组合函数没有直接关系,但也注意每个级别的k-组合数量的模式:

  • 0-组合和 4-组合级别都有一个组合:空字符串和 ABCD,分别。

  • 1-组合和 3-组合级别都有四个组合:A,B,C,D 和 ABC,ABD,ACD,BCD,分别。

  • 中间的 2-组合级别有最多的六个组合:AB,AC,AD,BC,BD 和 CD。

组合数量增加、在中间达到峰值,然后减少的原因是k-组合是彼此的镜像。例如,1-组合是由未被选中的元素构成 3-组合的:

  • 1-组合 A 是 3-组合 BCD 的镜像。

  • 1-组合 B 是 3-组合 ACD 的镜像。

  • 1-组合 C 是 3-组合 ABD 的镜像。

  • 1-组合 D 是 3-组合 ABC 的镜像。

我们将创建一个名为getCombos()的函数,它接受两个参数:一个chars字符串,其中包含要获取组合的字母,以及组合的大小k。返回值是一个字符串数组,其中包含来自字符串chars的组合,每个组合的长度为k

我们将使用chars参数的头部-尾部技术。例如,假设我们调用getCombos('ABC', 2)来从{A, B, C}获取所有 2-组合。该函数将A设置为头部,BC设置为尾部。图 6-5 显示了从{A, B, C}选择 2-组合的树。

树状图将每个深度级别分类为 0-组合、1-组合、2-组合或 3-组合。

图 6-5:树状图显示了从集合{A, B, C}中的每个可能的 2-组合

让我们问一下我们的三个递归算法问题:

  1. 什么是基本情况?第一个基本情况是k参数为0,这意味着请求 0-组合,无论chars是什么,它总是一个空字符串数组。第二种情况是如果chars是空字符串,则是一个空数组,因为从空字符串中无法生成任何可能的组合。

  2. 传递给递归函数调用的参数是什么?对于第一个递归调用,传递了chars的尾部和k - 1。对于第二个递归调用,传递了chars的尾部和k

  3. 这个参数如何接近基本情况?由于递归调用会减少k并从chars参数中删除头部,最终k参数会减少到0,或者chars参数会变成空字符串。

生成组合的 Python 代码在combinations.py中:

Python

def getCombos(chars, k, indent=0):
    debugMsg = '.' * indent + "In getCombos('" + chars + "', " + str(k) + ")"
    print(debugMsg + ', start.')
    if k == 0:
        # BASE CASE
        print(debugMsg + " base case returns ['']")
        # If k asks for 0-combinations, return '' as the selection of
        # zero letters from chars.
        return ['']
    elif chars == '':
        # BASE CASE
        print(debugMsg + ' base case returns []')
        return [] # A blank chars has no combinations, no matter what k is.

    # RECURSIVE CASE
    combinations = []
    # First part, get the combos that include the head: # ❶
    head = chars[:1]
    tail = chars[1:]
    print(debugMsg + " part 1, get combos with head '" + head + "'")
    tailCombos = getCombos(tail, k - 1, indent + 1) # ❷
    print('.' * indent + "Adding head '" + head + "' to tail combos:")
    for tailCombo in tailCombos:
        print('.' * indent + 'New combination', head + tailCombo)
        combinations.append(head + tailCombo)

    # Second part, get the combos that don't include the head: # ❸
    print(debugMsg + " part 2, get combos without head '" + head + "')")
    combinations.extend(getCombos(tail, k, indent + 1)) # ❹

    print(debugMsg + ' results are', combinations)
    return combinations

print('2-combinations of "ABC":')
print('Results:', getCombos('ABC', 2))

等效的 JavaScript 程序在combinations.html中:

<script type="text/javascript">
function getCombos(chars, k, indent) {
    if (indent === undefined) {
        indent = 0;
    }
    let debugMsg = ".".repeat(indent) + "In getCombos('" + chars + "', " + k + ")";
    document.write(debugMsg + ", start.<br />");
    if (k == 0) {
        // BASE CASE
        document.write(debugMsg + " base case returns ['']<br />");
        // If k asks for 0-combinations, return '' as the selection of zero letters from chars.
        return [""];
    } else if (chars == "") {
        // BASE CASE
        document.write(debugMsg + " base case returns []<br />");
 return []; // A blank chars has no combinations, no matter what k is.
    }

    // RECURSIVE CASE
    let combinations = [];
    // First part, get the combos that include the head: // ❶
    let head = chars.slice(0, 1);
    let tail = chars.slice(1, chars.length);
    document.write(debugMsg + " part 1, get combos with head '" + head + "'<br />");
    let tailCombos = getCombos(tail, k - 1, indent + 1); // ❷
    document.write(".".repeat(indent) + "Adding head '" + head + "' to tail combos:<br />");
    for (tailCombo of tailCombos) {
        document.write(".".repeat(indent) + "New combination " + head + tailCombo + "<br />");
        combinations.push(head + tailCombo);
    }
    // Second part, get the combos that don't include the head: // ❸
    document.write(debugMsg + " part 2, get combos without head '" + head + "')<br />");
    combinations = combinations.concat(getCombos(tail, k, indent + 1)); // ❹

    document.write(debugMsg + " results are " + combinations + "<br />");
    return combinations;
}

document.write('<pre>2-combinations of "ABC":<br />');
document.write("Results: " + getCombos("ABC", 2) + "<br /></pre>");
</script>

这些程序的输出如下:

2-combinations of "ABC":
In getCombos('ABC', 2), start.
In getCombos('ABC', 2) part 1, get combos with head 'A'
.In getCombos('BC', 1), start.
.In getCombos('BC', 1) part 1, get combos with head 'B'
..In getCombos('C', 0), start.
..In getCombos('C', 0) base case returns ['']
.Adding head 'B' to tail combos:
.New combination B
.In getCombos('BC', 1) part 2, get combos without head 'B')
..In getCombos('C', 1), start.
..In getCombos('C', 1) part 1, get combos with head 'C'
...In getCombos('', 0), start.
...In getCombos('', 0) base case returns ['']
..Adding head 'C' to tail combos:
..New combination C
..In getCombos('C', 1) part 2, get combos without head 'C')
...In getCombos('', 1), start.
...In getCombos('', 1) base case returns []
..In getCombos('C', 1) results are ['C']
.In getCombos('BC', 1) results are ['B', 'C']
Adding head 'A' to tail combos:
New combination AB
New combination AC
In getCombos('ABC', 2) part 2, get combos without head 'A')
.In getCombos('BC', 2), start.
.In getCombos('BC', 2) part 1, get combos with head 'B'
..In getCombos('C', 1), start.
..In getCombos('C', 1) part 1, get combos with head 'C'
...In getCombos('', 0), start.
...In getCombos('', 0) base case returns ['']
..Adding head 'C' to tail combos:
..New combination C
..In getCombos('C', 1) part 2, get combos without head 'C')
...In getCombos('', 1), start.
...In getCombos('', 1) base case returns []
..In getCombos('C', 1) results are ['C']
.Adding head 'B' to tail combos:
.New combination BC
.In getCombos('BC', 2) part 2, get combos without head 'B')
..In getCombos('C', 2), start.
..In getCombos('C', 2) part 1, get combos with head 'C'
...In getCombos('', 1), start.
...In getCombos('', 1) base case returns []
..Adding head 'C' to tail combos:
..In getCombos('C', 2) part 2, get combos without head 'C')
...In getCombos('', 2), start.
...In getCombos('', 2) base case returns []
..In getCombos('C', 2) results are []
.In getCombos('BC', 2) results are ['BC']
In getCombos('ABC', 2) results are ['AB', 'AC', 'BC']
Results: ['AB', 'AC', 'BC']

每个getCombos()函数调用都有两个递归调用,用于算法的两个部分。对于我们的getCombos('ABC', 2)示例,第一部分❶是获取包含头部A的所有组合。在树中,这会生成 1-组合级别下 A 节点下的所有组合。

我们可以通过将尾部和k - 1传递给第一个递归函数调用getCombos('BC', 1) ❷来实现这一点。我们将A添加到这个递归函数调用返回的每个组合中。让我们使用信任原则,假设我们的getCombos()正确返回了一个k-组合列表['B', 'C'],即使我们还没有完成编写它。现在我们已经有了包含头部A的所有k-组合的数组来保存我们的结果:['AB', 'AC']

第二部分❸获取不包括头部A的所有组合。在树中,这会生成 1-组合级别中 A 节点右侧的所有组合。我们可以通过将尾部和k传递给第二个递归函数调用getCombos('BC', 2)来实现这一点。这将返回['BC'],因为 BC 是 BC 的唯一 2-组合。

getCombos('ABC', 2)的两个递归调用的结果,['AB', 'AC']['BC']被连接在一起并返回:['AB', 'AC', 'BC'] ❹。getCombos()函数以相同的方式生成更大集合的组合。

获取平衡括号的所有组合

如果每个开括号后面都紧跟一个闭括号,则字符串具有平衡括号。例如,′()()′′(())′是两对平衡括号的字符串,但′)(()′′(()′则不是平衡的。这些字符串也被称为Dyck words,以数学家瓦尔特·冯·迪克命名。

一个常见的编码面试问题是编写一个递归函数,给定括号对的数量,生成所有可能的平衡括号的组合。例如,调用getBalancedParens(3)应该返回['((()))', '(()())', '(())()', '()(())', '()()()']。请注意,调用getBalancedParens(``n``)会返回长度为 2n的字符串,因为每个字符串由n对括号组成。

“我们可以尝试通过找到括号字符的所有排列来解决这个问题,但这将导致平衡和不平衡的括号字符串。即使稍后过滤掉无效的字符串,对于n对括号,存在 2n!种排列。该算法速度太慢,不实用。

相反,我们可以实现一个递归函数来生成所有平衡括号的字符串。我们的getBalancedParens()函数接受一个整数,表示括号对的数量,并返回一个平衡括号字符串的列表。该函数通过添加开放或关闭括号来构建这些字符串。只有在剩余要使用的开放括号时才能添加开放括号。只有在迄今为止添加的开放括号比关闭括号多时才能添加关闭括号。

我们将使用名为openRemcloseRem的函数参数跟踪剩余要使用的开放和关闭括号的数量。当前正在构建的字符串是另一个名为current的函数参数,它与permutationsWithRepetition程序中的prefix参数具有类似的作用。第一个基本情况发生在openRemcloseRem都为0且没有更多的括号要添加到current字符串时。第二个基本情况发生在两个递归情况在添加开放和/或关闭括号后接收到平衡括号字符串列表之后。

让我们问一下关于getBalancedParens()函数的三个递归算法问题:

  1. 什么是基本情况?当剩余要添加到正在构建的字符串中的开放和关闭括号的数量达到0时。第二个基本情况总是在递归情况完成后发生。

  2. 递归函数调用传递了什么参数?括号对的总数(pairs),剩余要添加的开放和关闭括号的数量(openRemcloseRem),以及当前正在构建的字符串(current)。

  3. 这个参数如何接近基本情况?当我们向current添加更多的开放和关闭括号时,我们递减openRemcloseRem参数,直到它们变为 0。

balancedParentheses.py文件包含了我们平衡括号递归函数的 Python 代码:

def getBalancedParens(pairs, openRem=None, closeRem=None, current='', indent=0):
    if openRem is None: # ❶
        openRem = pairs
    if closeRem is None:
        closeRem = pairs

    print('.' * indent, end='')
    print('Start of pairs=' + str(pairs) + ', openRem=' + 
    str(openRem) + ', closeRem=' + str(closeRem) + ', current="' + current + '"')
    if openRem == 0 and closeRem == 0: # ❷
        # BASE CASE
        print('.' * indent, end='')
        print('1st base case. Returning ' + str([current]))
        return [current] # ❸

    # RECURSIVE CASE
    results = []
    if openRem > 0: # ❹
        print('.' * indent, end='')
        print('Adding open parenthesis.')
        results.extend(getBalancedParens(pairs, openRem - 1, closeRem, 
        current + '(', indent + 1))
    if closeRem > openRem: # ❺
        print('.' * indent, end='')
        print('Adding close parenthesis.')
        results.extend(getBalancedParens(pairs, openRem, closeRem - 1, 
        current + ')', indent + 1))

    # BASE CASE
    print('.' * indent, end='')
    print('2nd base case. Returning ' + str(results))
    return results # ❻

print('All combinations of 2 balanced parentheses:')
print('Results:', getBalancedParens(2))

balancedParentheses.html文件包含了该程序的 JavaScript 等效代码:

<script type="text/javascript">
function getBalancedParens(pairs, openRem, closeRem, current, indent) {
    if (openRem === undefined) { // ❶
        openRem = pairs;
    }
    if (closeRem === undefined) {
        closeRem = pairs;
    }
    if (current === undefined) {
        current = "";
    }
 if (indent === undefined) {
        indent = 0;
    }

    document.write(".".repeat(indent) + "Start of pairs=" + 
    pairs + ", openRem=" + openRem + ", closeRem=" + 
    closeRem + ", current=\"" + current + "\"<br />");
    if (openRem === 0 && closeRem === 0) { // ❷
        // BASE CASE
        document.write(".".repeat(indent) + 
        "1st base case. Returning " + [current] + "<br />");
        return [current]; // ❸
    }

    // RECURSIVE CASE
    let results = [];
    if (openRem > 0) { // ❹
        document.write(".".repeat(indent) + "Adding open parenthesis.<br />");
        Array.prototype.push.apply(results, getBalancedParens(
        pairs, openRem - 1, closeRem, current + '(', indent + 1));
    }
    if (closeRem > openRem) { // ❺
        document.write(".".repeat(indent) + "Adding close parenthesis.<br />");
        results = results.concat(getBalancedParens(
        pairs, openRem, closeRem - 1, current + ')', indent + 1));
    }

    // BASE CASE
    document.write(".".repeat(indent) + "2nd base case. Returning " + results + "<br />");
    return results; // ❻
}

document.write(<pre>"All combinations of 2 balanced parentheses:<br />");
document.write("Results: ", getBalancedParens(2), "</pre>");
</script>

这些程序的输出如下:

All combinations of 2 balanced parentheses:
Start of pairs=2, openRem=2, closeRem=2, current=""
Adding open parenthesis.
.Start of pairs=2, openRem=1, closeRem=2, current="("
.Adding open parenthesis.
..Start of pairs=2, openRem=0, closeRem=2, current="(("
..Adding close parenthesis.
...Start of pairs=2, openRem=0, closeRem=1, current="(()"
...Adding close parenthesis.
....Start of pairs=2, openRem=0, closeRem=0, current="(())"
....1st base case. Returning ['(())']
...2nd base case. Returning ['(())']
..2nd base case. Returning ['(())']
.Adding close parenthesis.
..Start of pairs=2, openRem=1, closeRem=1, current="()"
..Adding open parenthesis.
...Start of pairs=2, openRem=0, closeRem=1, current="()("
...Adding close parenthesis.
....Start of pairs=2, openRem=0, closeRem=0, current="()()"
....1st base case. Returning ['()()']
...2nd base case. Returning ['()()']
..2nd base case. Returning ['()()']
.2nd base case. Returning ['(())', '()()']
2nd base case. Returning ['(())', '()()']
Results: ['(())', '()()']

getBalancedParens()函数❶在用户调用时需要一个参数,即括号对的数量。但是,它需要在递归调用中传递额外的信息。这些信息包括剩余要添加的开放括号的数量(openRem),剩余要添加的关闭括号的数量(closeRem)和当前正在构建的平衡括号字符串(current)。openRemcloseRem都以与pairs参数相同的值开始,而current则以空字符串开始。indent参数仅用于调试输出,以显示程序的递归函数调用级别。

函数首先检查剩余要添加的开放和关闭括号的数量❷。如果两者都是0,我们已经达到了第一个基本情况,并且current中的字符串已经完成。由于getBalancedParens()函数返回一个字符串列表,我们将current放入列表中并返回它❸。

否则,函数继续进行递归。如果可能的话,剩余的开放括号❹,函数调用getBalancedParens()并向当前参数添加一个开放括号。如果剩余的关闭括号比开放括号多❺,函数调用getBalancedParens()并向当前参数添加一个关闭括号。这个检查确保不会添加不匹配的关闭括号,因为这会使字符串不平衡,比如())中的第二个关闭括号。

在这些递归情况之后是一个无条件的基本情况,它返回从两个递归函数调用返回的所有字符串(当然还有这些递归函数调用所做的递归函数调用,依此类推)❻。

幂集:查找集合的所有子集

一个集合的幂集是该集合的每个可能子集的集合。例如,{A, B, C}的幂集是{{ }, {A}, {B}, {C}, {A, B}, {A, C}, {B, C}, {A, B, C}}。这等同于集合的每个可能k组合的集合。毕竟,{A, B, C}的幂集包含了它的所有 0-组合、1-组合、2-组合和 3-组合。

如果你正在寻找一个现实世界的例子,你需要生成一个集合的幂集,想象一下一个面试官要求你生成一个集合的幂集。你几乎不可能需要为任何其他原因生成一个集合的幂集,包括你正在面试的工作。

要找到集合的每个幂集,我们可以重用我们现有的getCombos()函数,用每个可能的k参数重复调用它。这种方法被powerSetCombinations.pypowerSetCombinations.html程序采用,这些程序可以从nostarch.com/recursive-book-recursion的可下载资源文件中获得。

然而,我们可以使用更有效的方法来生成幂集。让我们考虑{A, B}的幂集,即{{A, B}, {A}, {B}, { }}。现在假设我们再添加一个元素 C 到集合中,并且想要生成{A, B, C}的幂集。我们已经生成了{A, B}的幂集中的四个集合;此外,我们有这些相同的四个集合,但是加上了元素 C:{{A, B, C}, {A, C}, {B, C}, {C}}。表 6-3 显示了向集合添加更多元素如何向其幂集添加更多集合的模式。

表 6-3:随着新元素(加粗)添加到集合中,幂集如何增长

带有新元素的集合幂集的新集合完整的幂集
{ }{ }{{ }}
{A}{A}{{ }, {A}}
{A, B}{B}, {A, B}{{ }, {A}, {B}, {A, B}}
{A, B, C}{C}, {A, C}, {B, C}, {A, B, C}{{ }, {A}, {B}, {C}, {A, B}, {A, C}, {B, C}, {A, B, C}}
{A, B, C, D}{D}, {A, D}, {B, D}, {C, D}, {A, B, D}, {A, C, D}, {B, C, D}, {A, B, C, D}{{ }, {A}, {B}, {C}, {D}, {A, B}, {A, C}, {A, D}, {B, C}, {B, D}, {C, D}, {A, B, C}, {A, B, D}, {A, C, D}, {B, C, D}, {A, B, C, D}}

更大集合的幂集类似于更小集合的幂集,这暗示我们可以创建一个递归函数来生成它们。基本情况是一个空集,它的幂集是一个只有空集的集合。我们可以使用头尾技术来实现这个递归函数。对于我们添加的每个新元素,我们希望得到尾部的幂集以添加到我们的完整幂集中。我们还将头元素添加到尾部幂集中的每个集合中。这些一起形成了chars参数的完整幂集。

让我们问三个递归算法问题关于我们的幂集算法:

  1. 什么是基本情况?如果chars是空字符串(空集),函数返回一个只有空字符串的数组,因为空集是空集的唯一子集。

  2. 递归函数调用传递了什么参数?chars的尾部被传递。

  3. 这个参数如何接近基本情况?由于递归调用从chars参数中删除头部,最终chars参数变为空字符串。

getPowerSet()递归函数在powerSet.py中实现:

Python

def getPowerSet(chars, indent=0):
    debugMsg = '.' * indent + 'In getPowerSet("' + chars + '")'
    print(debugMsg + ', start.')

    if chars == '': # ❶
        # BASE CASE
        print(debugMsg + " base case returns ['']")
        return ['']

    # RECURSIVE CASE
    powerSet = []
    head = chars[0]
    tail = chars[1:]

    # First part, get the sets that don't include the head:
    print(debugMsg, "part 1, get sets without head '" + head + "'")
    tailPowerSet = getPowerSet(tail, indent + 1) # ❷

    # Second part, get the sets that include the head:
    print(debugMsg, "part 2, get sets with head '" + head + "'")
    for tailSet in tailPowerSet:
        print(debugMsg, 'New set', head + tailSet)
        powerSet.append(head + tailSet) # ❸

    powerSet = powerSet + tailPowerSet
    print(debugMsg, 'returning', powerSet)
    return powerSet # ❹

print('The power set of ABC:')
print(getPowerSet('ABC'))

等效的 JavaScript 代码在powerSet.html中:

<script type="text/javascript">
function getPowerSet(chars, indent) {
    if (indent === undefined) {
        indent = 0;
    }
    let debugMsg = ".".repeat(indent) + 'In getPowerSet("' + chars + '")';
    document.write(debugMsg + ", start.<br />");

    if (chars == "") { // ❶
        // BASE CASE
        document.write(debugMsg + " base case returns ['']<br />");
        return [''];
    }

    // RECURSIVE CASE
    let powerSet = [];
    let head = chars[0];
    let tail = chars.slice(1, chars.length);

 // First part, get the sets that don't include the head:
    document.write(debugMsg + 
    " part 1, get sets without head '" + head + "'<br />");
    let tailPowerSet = getPowerSet(tail, indent + 1); // ❷

    // Second part, get the sets that include the head:
    document.write(debugMsg + 
    " part 2, get sets with head '" + head + "'<br />");
    for (tailSet of tailPowerSet) {
        document.write(debugMsg + " New set " + head + tailSet + "<br />");
        powerSet.push(head + tailSet); // ❸
    }

    powerSet = powerSet.concat(tailPowerSet);
    document.write(debugMsg + " returning " + powerSet + "<br />");
    return powerSet; // ❹
}

document.write("<pre>The power set of ABC:<br />")
document.write(getPowerSet("ABC") + "<br /></pre>");
</script>

程序输出如下:

The power set of ABC:
In getPowerSet("ABC"), start.
In getPowerSet("ABC") part 1, get sets without head 'A'
.In getPowerSet("BC"), start.
.In getPowerSet("BC") part 1, get sets without head 'B'
..In getPowerSet("C"), start.
..In getPowerSet("C") part 1, get sets without head 'C'
...In getPowerSet(""), start.
...In getPowerSet("") base case returns ['']
..In getPowerSet("C") part 2, get sets with head 'C'
..In getPowerSet("C") New set C
..In getPowerSet("C") returning ['C', '']
.In getPowerSet("BC") part 2, get sets with head 'B'
.In getPowerSet("BC") New set BC
.In getPowerSet("BC") New set B
.In getPowerSet("BC") returning ['BC', 'B', 'C', '']
In getPowerSet("ABC") part 2, get sets with head 'A'
In getPowerSet("ABC") New set ABC
In getPowerSet("ABC") New set AB
In getPowerSet("ABC") New set AC
In getPowerSet("ABC") New set A
In getPowerSet("ABC") returning ['ABC', 'AB', 'AC', 'A', 'BC', 'B', 'C', '']
['ABC', 'AB', 'AC', 'A', 'BC', 'B', 'C', '']

getPowerSet()函数接受一个参数:包含原始集合字符的字符串chars。基本情况发生在chars是空字符串❶时,表示空集。回想一下,幂集是原始集合的所有子集。因此,空集的幂集只是包含空集的集合,因为空集是空集的唯一子集。这就是为什么基本情况返回['']

递归情况分为两部分。第一部分是获取chars尾部的幂集。我们将使用信任原则,假设调用getPowerSet()返回尾部的幂集正确❷,即使在这一点上我们仍在编写getPowerSet()的代码。

为了形成chars的完整幂集,递归情况的第二部分通过将头部添加到每个尾部幂集来形成新集合❸。与第一部分的集合一起,这形成了chars的幂集,在函数结束时返回❹。

总结

排列和组合是许多程序员不知道如何开始解决的两个问题领域。虽然递归通常是常见编程问题的过于复杂的解决方案,但它非常适合本章任务的复杂性。

本章以简要介绍集合论开始。这为我们的递归算法操作的数据结构奠定了基础。集合是不同元素的集合。子集包括集合中的一些或所有元素。虽然集合的元素没有顺序,排列是集合中元素的特定顺序。而组合没有顺序,是集合中元素的特定选择。集合的k组合是从集合中选择的k个元素的子集。

排列和组合可以包括一个元素,也可以重复元素。我们称这些为无重复排列或组合和有重复排列或组合。这些由不同的算法实现。

本章还解决了在编码面试中常用的平衡括号问题。我们的算法通过从空字符串开始并添加开放和关闭括号来构建平衡括号的字符串。这种方法涉及回溯到较早的字符串,使递归成为一种理想的技术。

最后,本章介绍了一个用于生成幂集的递归函数,即集合中所有可能的k组合的集合。我们创建的递归函数比反复调用组合函数来生成每个可能大小的子集要高效得多。

进一步阅读

生成排列和组合只是揭示了你可以用排列和组合以及数学逻辑领域集合论做什么的冰山一角。以下维基百科文章提供了这些主题的更多细节,每个链接到维基百科文章:

Python 标准库提供了排列、组合和其他算法的实现,位于其itertools模块中。该模块在docs.python.org/3/library/itertools.html中有文档。

排列和组合也在统计和概率数学课程中涵盖。可在 Khan Academy 的计数、排列和组合单元中找到www.khanacademy.org/math/statistics-probability/counting-permutations-and-combinations

练习问题

通过回答以下问题来测试您的理解:

  1. 集合的元素是否有特定顺序?排列呢?组合呢?

  2. 一个包含n个元素的集合有多少排列(不重复)?

  3. 一个包含n个元素的集合有多少组合(不重复)?

  4. {A,B,C}是{A,B,C}的子集吗?

  5. 计算n选择k的公式是什么,即从n个元素的集合中选择k个元素的可能组合数?

  6. 确定以下哪些是排列或组合,有或没有重复:

  7. AAA,AAB,AAC,ABA,ABB,ABC,ACA,ACB,ACC,BAA,BAB,BAC,BBA,BBB,BBC,BCA,BCB,BCC,CAA,CAB,CAC,CBA,CBB,CBC,CCA,CCB,CCC

  8. ABC,ACB,BAC,BCA,CAB

  9. (无),A,B,C,AB,AC,BC,ABC

  10. (无),A,B,C,AA,AB,AC,BB,BC,CC,AAA,AAB,AAC,ABB,ABC,ACC,BBB,BBC,BCC,CCC

  11. 绘制一个树图,可用于生成集合{A,B,C,D}的所有可能组合。

  12. 回答本章中每个递归算法的递归解决方案的三个问题:

  13. 基本情况是什么?

  14. 递归函数调用传递了什么参数?

  15. 这个论点如何接近基本情况?

然后,重新创建本章中的递归算法,而不查看原始代码。

练习项目

为练习,编写一个执行以下任务的函数:

  1. 本章中的排列函数操作字符串值中的字符。修改它,使得集合由列表(在 Python 中)或数组(在 JavaScript 中)表示,元素可以是任何数据类型的值。例如,您的新函数应该能够生成整数值的排列,而不是字符串。

  2. 本章中的组合函数操作字符串值中的字符。修改它,使得集合由列表(在 Python 中)或数组(在 JavaScript 中)表示,元素可以是任何数据类型的值。例如,您的新函数应该能够生成整数值的组合,而不是字符串。