递归法与分治思想

基本概念


递归法与分治思想经常一起使用。递归法通常用于实现分治策略,通过将问题分解为更小的子问题并递归地解决它们来解决问题。分治思想则提供了一种将问题分解为更小、更容易解决的子问题的策略,使得递归法能够更有效地应用。

递归法是一种通过调用自身来解决问题的编程技术。递归算法通常包括两个部分:基本情况(终止条件)和递归步骤。基本情况是算法中最简单的情况,它不需要进一步的递归调用就能直接得到答案。递归步骤则是将问题分解为更小的子问题,并递归地调用自身来解决这些子问题。

分治思想是一种将大问题分解为若干个小问题来解决的策略。这些小问题通常与原问题具有相同的性质,但规模更小、更容易解决。通过解决这些小问题,可以将它们的解合并起来,从而得到原问题的解。

递推和回归


假如准备要编写一个计算自然数n阶乘的程序。在编写程序之前,需要了解什么是自然数n的阶乘?自然数n的阶乘是所有小于及等于该数的自然数的积。
例如自然数6的阶乘运算是:1 X 2 X 3 X 4 X 5 X 6 = 720。
程序要求给定一个自然数n,计算自然数n的阶乘,并将计算结果输出到控制台。如何用程序来求自然数n的阶乘呢?

观察上面的阶乘案例发现,n的阶乘等于n乘以(n-1)的阶乘。例如:4的阶乘等于4乘以(4-1)的阶乘;5的阶乘等于5乘以(5-1)的阶乘。(n-1)的阶乘又等于(n-1)乘以(n-2)的阶乘。例如:(4-1)的阶乘等于(4-1)乘以(4-2)的阶乘;(5-1)的阶乘等于(5-1)乘以(5-2)的阶乘。
因此求n的阶乘可以写为:

n!= n * (n-1)!
= n * (n-1) * (n-2)!
= n * (n-1) * (n-2) * (n-3)!
…………
= n * (n-1) * (n-2) * (n-3) *(n-4) …… * (n-(n-2)) * 1!

上面的算式对n的阶乘进行层层分解:第一层分解将n的阶乘分解为n与(n-1)阶乘的积;第二层将(n-1)的阶乘分解为(n-1)与(n-2)阶乘的积;第三层将(n-2)的阶乘分解为(n-2)与(n-3)阶乘的积。以此类推,阶乘规模越来越小,最终就是计算1的阶乘。
例如求4的阶乘:

4! = 4 * (4-1)!
= 4 * (4-1) * (4-2)!
= 4 * (4-1) * (4-2) *1!

求4的阶乘采用上面的计算方法,进行三层分解即可。
第一层:4的阶乘分解为4与(4-1)阶乘的积;
第二层:(4-1)阶乘分解为(4-1)与(4-2)阶乘的积;
第三层:(4-2)阶乘分解为(4-2)与(4-3)阶乘的积,也就是(4-2)与1阶乘的积。
经过三层分解,将计算规模为4的阶乘分解为计算规模为1的阶乘,剩余的就是四则运算了。
有了计算阶乘的算法,现在就可以编写程序了。下面给出程序流程图:

在上面的流程图中,把计算n的阶乘封装到factorial(n)函数中,该方法计算传入参数n的阶乘,在方法内部对n的阶乘进行分解,当n=1时直接返回1,因为1的阶乘是1,反之,将n的阶乘分解为n与(n-1)阶乘的积,求(n-1)的阶乘,还是调用factorial(n-1)方法。
从流程图可以看出,在factorial(n)函数内部又调用了factorial(n-1)函数,这种在方法内部又调用自身函数的结构称为递归结构,该方法也称为递归函数。
递归有递推和回归的意思,递推就是把复杂的任务不断分解,让问题规模越来越小,当问题规模小到能够直接解答时,递推就结束了。递推的结束预示着回归的开始,回归从递推结束时问题的解答开始回归,由最简问题的解答按照递推路径反方向获取整个问题的解答。
其实递归蕴含的思想类似我们学过的数学归纳法:为了求解问题p(n),需要先求解基础问题p(1),然后再求解p(n-1),最后获得p(n)的解。

归并排序


问题描述


归并排序(Merge Sort)采用分治策略将一个待排序的数组分割成若干个子数组,每个子数组都是原数组的一部分。这个过程会一直递归进行,直到每个子数组只包含一个元素(此时认为子数组已经排序完成)。然后,算法开始合并这些子数组,逐步构建更大的有序数组,直到最终合并为一个完整的、有序的原数组。
给定一个无序的整数数组 arr,对其进行升序排序,使用归并排序算法。

解题步骤


定义递归基准情况:
如果数组 arr 的长度为 0 或 1,则它已经被视为排序好的,直接返回。
分解:
找到数组 arr 的中点 mid,将数组分割成两个子数组 leftArr 和 rightArr。
leftArr 包含 arr[0] 到 arr[mid - 1] 的元素。
rightArr 包含 arr[mid] 到 arr[arr.length - 1] 的元素。
递归排序子数组:
递归地对 leftArr 进行归并排序。
递归地对 rightArr 进行归并排序。
合并:
创建一个临时数组 tempArr,用于存放合并后的有序数组。
使用两个指针 i 和 j 分别指向 leftArr 和 rightArr 的起始位置。
使用一个指针 k 指向 tempArr 的起始位置。
依次比较 leftArr[i] 和 rightArr[j] 的大小,将较小的元素放入 tempArr[k],并移动相应的指针 i 或 j。
当 leftArr 或 rightArr 中的一个数组的所有元素都放入 tempArr 后,将另一个数组的剩余元素依次放入 tempArr。
最后,将 tempArr 中的元素复制回原数组 arr 的相应位置,以完成合并操作。
递归结束与返回:
当所有的递归调用都返回后,原数组 arr 已被排序好。

代码实现

#include <stdio.h>
#include <stdlib.h>

// 合并两个已排序的数组
void merge(int arr[], int left, int mid, int right) {
int n1 = mid - left + 1;
int n2 = right - mid;

int *L = (int *)malloc(n1 * sizeof(int));
int *R = (int *)malloc(n2 * sizeof(int));

for (int i = 0; i < n1; i++)
L[i] = arr[left + i];
for (int j = 0; j < n2; j++)
R[j] = arr[mid + 1 + j];

int i = 0, j = 0, k = left;
while (i < n1 && j < n2) {
if (L[i] <= R[j]) {
arr[k] = L[i];
i++;
} else {
arr[k] = R[j];
j++;
}
k++;
}

while (i < n1) {
arr[k] = L[i];
i++;
k++;
}

while (j < n2) {
arr[k] = R[j];
j++;
k++;
}

free(L);
free(R);
}

// 归并排序函数
void mergeSort(int arr[], int left, int right) {
if (left < right) {
int mid = left + (right - left) / 2;

// 对左半部分进行排序
mergeSort(arr, left, mid);

// 对右半部分进行排序
mergeSort(arr, mid + 1, right);

// 合并两个已排序的半部分
merge(arr, left, mid, right);
}
}

// 测试函数
void printArray(int arr[], int size) {
for (int i = 0; i < size; i++)
printf("%d ", arr[i]);
printf("\n");
}

int main() {
int arr[] = {12, 11, 13, 5, 6, 7};
int arr_size = sizeof(arr) / sizeof(arr[0]);

printf("Given array is \n");
printArray(arr, arr_size);

mergeSort(arr, 0, arr_size - 1);

printf("\nSorted array is \n");
printArray(arr, arr_size);
return 0;

}

merge函数动态分配了两个临时数组 `L` 和 `R` 来存储分割后的左右子数组。在合并完成后,使用free函数释放了这些动态分配的内存,以防止内存泄漏。
mergeSort函数首先检查left是否小于right,以确保数组至少有两个元素,然后再进行分割和递归调用。

算法分析


时间复杂度


归并排序是一个分治算法,它将一个数组分成两半,然后对这两半分别进行排序,最后再将这两个已排序的半部分合并成一个完整的排序数组。
在最坏情况下,归并排序的时间复杂性是 O(n log n),其中 n 是数组的大小。这是因为每次递归调用都将数组的大小减半,所以递归的深度是 log n。在每一层递归中,都需要 O(n) 的时间来进行合并操作(merge 函数)。由于递归的深度是 log n,因此总的时间复杂性是 O(n log n)。

空间复杂度


归并排序的空间复杂性主要来自于合并过程中创建的临时数组 L 和 R。在最坏情况下,空间复杂性是 O(n),其中 n 是数组的大小。这是因为在合并过程中,我们需要两个大小为 n/2 的临时数组来存储左右两个子数组的元素。

汉诺塔问题


问题描述


汉诺塔(Tower of Hanoi)问题是一个古老的数学难题和经典的递归问题。问题起源于一个古老的传说:有三根柱子,其中一根柱子上自上而下按大小顺序摞着n个不同大小的圆盘,目标是将这些圆盘移动到另外一根柱子上,并满足以下规则:

规则
一次只能移动一个圆盘。
大的圆盘不能叠放在小的圆盘之上。
可以使用第三根柱子作为辅助。

要求用C语言实现汉诺塔问题的解。

解题步骤


解决汉诺塔问题的关键在于理解和应用递归和分治思想。基本的思路是,为了将n个圆盘从起始柱子移动到目标柱子,我们需要先将n-1个较小的圆盘从起始柱子移动到辅助柱子(这本身就是一个汉诺塔问题,但规模较小),然后将最大的圆盘从起始柱子移动到目标柱子,最后再将n-1个圆盘从辅助柱子移动到目标柱子。

当汉诺塔问题的规模n为3时,意味着有三根柱子(通常命名为A、B、C),并且柱子A上摞着三个按大小顺序排列的圆盘。目标是将这三个圆盘按照规则移动到柱子C上。解题步骤如下:
移动圆盘2和圆盘1到辅助柱子B:
将最小的圆盘(圆盘1)从柱子A移动到柱子C。
将第二小的圆盘(圆盘2)从柱子A移动到柱子B。
将最小的圆盘(圆盘1)从柱子C移动到柱子B,此时辅助柱子B上有两个圆盘,且圆盘1在圆盘2上面。

移动最大的圆盘(圆盘3)到目标柱子C:
将最大的圆盘(圆盘3)从柱子A移动到柱子C。

将圆盘2和圆盘1从辅助柱子B移动到目标柱子C:
将最小的圆盘(圆盘1)从柱子B移动到柱子A。
将第二小的圆盘(圆盘2)从柱子B移动到柱子C。
将最小的圆盘(圆盘1)从柱子A移动到柱子C,此时目标柱子C上有三个圆盘,且按大小顺序排列。

代码实现

#include <stdio.h>

void hanoi(int n, char from, char to, char temp) {
if (n == 1) {
printf("Move disk 1 from %c to %c\n", from, to);
return;
}
hanoi(n - 1, from, temp, to);
printf("Move disk %d from %c to %c\n", n, from, to);
hanoi(n - 1, temp, to, from);
}

int main() {
int n;
printf("Enter the number of disks: ");
scanf("%d", &n);
hanoi(n, 'A', 'C', 'B');
return 0;
}

hanoi 函数是一个递归函数,用于实现汉诺塔问题的核心逻辑。n 表示盘子的数量,from 表示起始柱子,to 表示目标柱子,temp 表示临时柱子。
当 n == 1 时,表示只剩下一个盘子,此时直接将这个盘子从起始柱子移动到目标柱子即可。这是递归的终止条件。
否则,先将 n-1 个盘子从起始柱子移动到临时柱子(以目标柱子作为临时柱子),然后将剩下的一个盘子从起始柱子移动到目标柱子,最后再将 n-1 个盘子从临时柱子移动到目标柱子(以起始柱子作为临时柱子)。这就是汉诺塔问题的递归过程。
在 main 函数中,通过 scanf 函数获取用户输入的盘子数量,然后调用 hanoi 函数开始解决汉诺塔问题。

算法分析


时间复杂度


汉诺塔问题的递归解法的时间复杂度是O(2^n),其中n是盘子的数量。这是因为每次递归调用都会将问题规模减小1(即n-1),并且每个盘子都需要移动一次。因此,递归树的深度是n,而每一层的操作数是随着递归深度的减小而指数级增长的。具体来说,第一层有1次操作,第二层有2次操作,第三层有4次操作,以此类推,直到第n层有2^(n-1)次操作。因此,总的操作次数是这些层数的操作数之和,即1 + 2 + 4 + ... + 2^(n-1) = 2^n - 1。由于我们只关心最高阶项,所以时间复杂度为O(2^n)。

空间复杂度

对于递归函数来说,空间复杂度通常指的是递归调用栈的深度。在这个程序中,递归调用栈的深度也是n,因为每次递归调用都会将问题规模减小1,直到n=1时递归结束。因此,空间复杂度是O(n)。这个空间复杂度并不包括在递归过程中可能创建的其他数据结构(在这个程序中没有创建其他数据结构),只考虑了递归调用栈本身。

对于较大的n值,由于时间复杂度和空间复杂度的指数级增长,可能会导致程序运行缓慢或者栈溢出。在实际应用中,对于大规模的汉诺塔问题,可能需要考虑使用非递归的解法或者优化递归解法来减少时间和空间的消耗。