递归问题——以全排列、青蛙过河问题为例

本文深入浅出地介绍了递归的基本概念,通过阶乘、全排列等实例详细讲解了递归函数的设计方法,并探讨了递归转非递归的两种途径。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一、递归的概念

      递归函数是一种自身调用自身的函数。递归的基本思想是将规模大的问题转化为规模小的相似的子问题来解决。例如,我们可以将一个大洋葱看成一个带着一层洋葱皮的小洋葱,要剥开这个洋葱,就是递归求解的过程。递归包括两种:直接递归和间接递归。直接递归是指一个函数的代码中直接包括了调用自身的代码。间接递归是指类似这样的函数调用:函数A调用函数B,函数B又调用函数A。当然每层递归所给的参数是不同的。

      递归问题的求解过程和数学中的归纳证明是相通的。归纳证明一个与自然数n有关的问题的一般过程是这样的:

  1. 对于n的一个或多个基本的值(如n=0)该问题是成立的;
  2. 假设当n=k时问题是成立的,其中k是一个任意整数;
  3. 利用假设条件,证明对于n的下一个值k+1问题也是成立的。

      递归问题和归纳问题的相似之处就在于它们首先都有一个问题的基本部分,其次再有一个递归部分或者是验证部分。递归问题中每次应用递归部分的结果是更趋近于基本部分,归纳问题中利用比n值小时结论的正确性来证明取值为n时结论的正确性,归纳证明过程的重复应用可以减少基本值验证的应用。

      下面再解释一下递归的两个组成部分:(1)递归出口,即递归的基本部分,也是最小的子问题,递归到这里结束;(2)递归部分,这部分要解决的问题是如何将大问题转换为小问题。

二、递归问题举例

       1.求n的阶乘

#include<iostream>
using namespace std;

int factorial(int n)
{
	if (n <= 1)
		return 1;
	else
		return n * factorial(n-1);
} 

int main()
{
	cout << factorial(5) << endl;
	return 0;
}

     程序输出结果为120。factorial函数的基本部分就是n<=1的情况,结果为1;递归部分中求n的阶乘转化为求n乘以n-1的阶乘,将大问题转化为小问题。

     整个求解过程可以用下图来表示

       2.求n个不同元素的全排列

     首先我们知道n的不同元素的全排列总共有n!种。比如a,b,c的排列方式有abc,acb,bac,bca,cab,cba。当元素较少的时候我们可以列举出来,但是当元素个数较多时就很难列举。这个时候我们可以考虑递归。从三个元素的排列我们可以发现以下求排列的方式:求n个元素的排列,将其划分为n个子问题,每个子问题中首元素分别为n个不同的元素,然后对这n个子问题中的任意一个进一步划分。将除去首元素的n-1个元素再分别放在首元素之后,划分为n-1个子问题,然后再求剩余n-2个元素的全排列,以此类推。直到问题划分为求一个元素的排列方式,那么很显然就是该元素自身了,然后再层层回溯,得到2个元素的排列,3个元素的排列,最后求解出结果。从每层划分的子问题数也就可以得出排列的总数为n * (n-1) * (n-2) * ... * 2 * 1种。

     下面看具体的算法

#include<iostream>
#include<algorithm>

using namespace std;
template<class T>
void permutation(T list[], int k, int m)
{// 生成list[k : m]的所有排列方式 
	int i;
	if (k == m)
	{// 输出排列方式 
		for (i = 0; i <= m; i++)
		cout << list[i] << " ";
		cout << endl;
	}
	else if (k < m)
	{// 递归地产生list[k : m]的排列方式 
		for (i = k; i <= m; i++)
		{
			swap(list[k], list[i]);
			permutation(list, k+1, m);
			swap(list[k], list[i]);
		}
	}
}

int main()
{
	int list[4] = {1, 2, 3, 4};
	permutation(list, 0, 3);
	return 0;
}
我们规定list[0 : k-1]为list[0]到list[k-1]的所有元素。算法输出所有前缀为list[0 : k-1],后缀为list[k : m]的排列方式。当k=m时,仅有一个后缀list[m],因此list[0 : m]就是所要产生的输出;当k<m时,先用list[k]与list[k : m]中的每个元素进行交换,然后产生list[k+1 : m]的所有排列方式,并用它作为list[0 : k]的后缀。swap完成两个变量值的交换,需要包含头文件<algorithm>,当然这个自己写也可以。

       3.青蛙过河问题

     首先问题阐述如下:

     一条小溪尺寸不大,青蛙可以从左岸跳到右岸,在左岸有一石柱L,面积只容得下一只青蛙落脚,同样右岸也有一石柱R,面积也只容得下一只青蛙落脚。有一队青蛙从尺寸上一个比一个小。我们将青蛙从小到大,用1,2,…,n编号。规定初始时这队青蛙只能趴在左岸的石头L上,当然是一个落一个,小的落在大的上面。不允许大的在小的上面。在小溪中有S个石柱,有y片荷叶,规定溪中的柱子上允许一只青蛙落脚,如有多只同样要求一个落一个,大的在下,小的在上。对于荷叶只允许一只青蛙落脚,不允许多只在其上。对于右岸的石柱R,与左岸的石柱L一样允许多个青蛙落脚,但须一个落一个,小的在上,大的在下。当青蛙从左岸的L上跳走后就不允许再跳回来;同样,从左岸L上跳至右岸R,或从溪中荷叶或溪中石柱跳至右岸R 上的青蛙也不允许再离开。问在已知溪中有S根石柱和y片荷叶的情况下,最多能跳过多少只青蛙?

     这里我们先给出分析结果,再给出分析过程。经过分析我们可以得出的结论是:每增加一片荷叶,那么可以跳过的青蛙数加一;每增加一个石柱,可以跳过的青蛙数是原来的两倍。那么为什么是这个结果呢?下面我们一步步分析。

       1.首先考虑没有石柱的情况,即S=0。

          (1)当y=0时,只能跳过一只青蛙,由L直接跳到R。

          (2)当y=1时,可以跳过两只青蛙。过程为:青蛙1从L跳到荷叶上,青蛙2从L直接跳到R,最后青蛙1从荷叶跳到R。

          (3)当y=2时,可以跳过3只青蛙。过程为:青蛙1从L跳到荷叶1,青蛙2从L跳到荷叶2,青蛙3从L跳到R,青蛙2从荷叶2跳到R,青蛙1从荷叶1跳到R。

       由上面的例子我们可以看出,当只考虑荷叶时,每增加一片荷叶,跳过的青蛙数加一,即青蛙数为y+1。

       2.再考虑增加石柱的情况。

          (1)当S=1,y=0时,可以跳过两只青蛙。过程为:青蛙1从L跳到石柱上,青蛙2从L跳到R,青蛙1从石柱跳到R。

          (2)当S=1,y=1时,可以跳过4只青蛙。过称为:青蛙1从L跳到荷叶上,青蛙2从L跳到石柱上,青蛙1从荷叶上跳到石柱上,青蛙3从L跳到荷叶上,青蛙4从L跳到R,青蛙3从荷叶上跳到R,青蛙1从石柱上跳到荷叶上,青蛙2从石柱上跳到R,青蛙1从荷叶跳到R。

          上述过程可以总结为3步:步骤1:青蛙1和青蛙2借助荷叶跳到石柱上;步骤2:青蛙3和青蛙4借助荷叶跳到R;步骤3:青蛙1和青蛙2借助荷叶由石柱跳到R。

          (3)当S=1,y为任意值时,可以跳过2 * (y+1)只青蛙。过程可以理解为3步:步骤1:前y+1只青蛙借助荷叶跳到石柱上;步骤2:后y+1只青蛙借助荷叶跳到R;步骤3:前y+1只青蛙借助荷叶由石柱跳到R。

          (4)当S=2,y为任意值时,可以跳过4 * (y+1)只青蛙。显然当S=1时,y为相同值时可以跳过2 * (y+1)只青蛙。那么这个过程可以理解为:步骤1:前2 * (y+1)只青蛙利用荷叶和其中一个石柱(这里设为S1)从L跳到另外一根石柱(S2)上;步骤2:后2 * (y+1)只青蛙借助荷叶和S1从L跳到R;步骤3:前2 * (y+1)只青蛙从S2借助荷叶和S1跳到R。

     最后,将这个问题总结为3步。步骤1:前2 * (y+1)只青蛙利用y片荷叶和S-1根石柱从L跳到剩余的一根石柱上;步骤2:后2 * (y+1)只青蛙借助y片荷叶和S-1根石柱从L跳到R;步骤3:前2 * (y+1)只青蛙从剩余的那根石柱借助y片荷叶和S-1根石柱跳到R。

     该问题的基本部分为S=0的情况,青蛙数为y+1,递归部分为S不为0的情况。下面给出实现代码:

#include<iostream>

using namespace std;

int cross_river(int S, int y)
{
	if(0 == S)
		return y + 1;
	else
		return 2 * cross_river(S-1, y);
}
int main()
{
	int S, y;
	cout << "Please input the number of pillars and lotus leaves:" << endl;
	cin >> S >> y;
	cout << "Number of frogs: " << cross_river(S, y) << endl;
	return 0;
} 

三、递归转化为非递归

     将递归算法转换为非递归算法有两种方法,一种是直接求值,不需要回溯;另一种是不能直接求值,需要回溯。前者使用一些变量保存中间结果,称为直接转换法;后者使用栈保存中间结果,称为间接转换法,下面简单讨论这两种方法。

       1.直接转换法:

#include<iostream>

using namespace std;

int factorial(int n)
{
	int i, s=1;
	for(i = 1; i <= n; i++)
		s = s * i; // 用s保存中间结果
	return s; 
} 

int main()
{
	int n;
	cin >> n;
	cout << "factorial(" << n << "):" << factorial(n);
	return 0;
}
     上述算法用循环求解阶乘。一般的,直接转换法可以使用变量保存中间结果,将递归结构转换为循环结构。      

       2.间接转换法
     该方法使用栈保存中间结果,一般需根据递归函数在执行过程中栈的变化得到。其一般过程如下:

将初始状态s0进栈
while (栈不为空)
{
    退栈,将栈顶元素赋给s;
    if (s是要找的结果)
        返回;
    else
    {
      寻找到s的相关状态s1;
      将s1进栈
  }
}

      间接转换法在数据结构中有较多实例,如二叉树遍历算法的非递归实现、图的深度优先遍历算法的非递归实现等等。

     递归转化为非递归部分参考博文:http://blog.csdn.net/wangjinyu501/article/details/8248492

<think>好的,我需要回答用户关于如何设计递归程序,以全排列问题。首先,我得回忆一下全排列的基本概念和递归实现的思路。全排列是指对一组元素进行排列,所有可能的顺序都不重复。如,三个元素[1,2,3]的全排列有6种情况。 递归的关键在于分解问题全排列递归思路通常是把问题拆分为更小的子问题。比如,确定第一个位置的元素,然后对剩下的元素进行全排列。这时候,剩下的元素的全排列又是一个同样的问题,但规模更小,这就适合用递归来解决。 接下来,我需要考虑如何具体实现这个递归过程。通常的方法是使用交换法:每次将当前位置的元素与后面的元素交换,然后递归处理剩下的位置。如,处理第i个位置时,依次将i与j(j从i开始)交换,然后递归处理i+1的位置。递归结束后,需要回溯,即再次交换回来,以保证下一次交换的正确性。 这里需要注意递归终止条件,也就是当处理到最后一个元素时,说明已经生成了一个排列,此时可以输出或保存结果。同时,要处理重复元素的情况,比如如果有重复元素,交换可能会导致重复的排列,这时候需要检查是否已经交换过相同的元素,避免重复。 举个子,假设输入数组是[1,2,3]。第一次递归处理第一个位置,i=0。此时j可以是0、1、2。当j=0时,交换后数组不变,递归处理i=1的位置。在i=1的位置,j可以是1、2,依此类推。每次递归到底层时,就会得到一个排列,比如当i=2时,递归终止,输出当前数组。 代码实现方面,需要写一个递归函数,参数包括数组、当前处理的起始位置。在函数内部,循环交换元素,递归调用,然后回溯。同时,在交换前需要判断是否有重复元素,如果有的话就跳过,避免重复排列。 现在,我需要考虑用户可能的疑问,比如如何处理重复元素,递归的终止条件是什么,为什么需要回溯等等。此外,可能需要解释递归调用的具体过程,帮助理解程序的执行流程。 最后,根据用户的要求,需要提出四个相关问题,这些问题应该与递归设计、全排列的实现细节、优化方法等相关,确保问题之间有一定的层次和关联性。</think><< 设计递归程序实现全排列的核心在于通过逐步固定元素位置并处理剩余元素的排列。以下是基于交换法的实现步骤和解释: 1. 基准情形(Base Case): 当处理位置到达最后一个元素时,输出当前排列 2. 递归步骤: - 遍历当前位置到末尾的所有元素 - 每次将当前元素与后续元素交换 - 递归处理下一个位置 - 回溯恢复原始状态(撤销交换) 示代码: ```cpp #include <iostream> #include <vector> using namespace std; void permute(vector<int>& nums, int start) { if (start == nums.size() - 1) { for (int num : nums) cout << num << " "; cout << endl; return; } for (int i = start; i < nums.size(); ++i) { // 处理重复元素的情况 bool duplicate = false; for (int j = start; j < i; ++j) { if (nums[j] == nums[i]) { duplicate = true; break; } } if (duplicate) continue; swap(nums[start], nums[i]); permute(nums, start + 1); swap(nums[start], nums[i]); // 回溯 } } int main() { vector<int> nums = {1, 2, 3}; permute(nums, 0); return 0; } ``` 设计要点解释: 1. 递归终止条件:当start指针到达数组末尾时,表示完成一个完整排列 2. 元素交换:通过交换元素将每个可能的元素放到当前位置 3. 回溯机制:在递归返回后恢复数组原始状态,确保后续交换的正确性 4. 重复处理:当存在重复元素时,跳过已经处理过的相同元素,避免重复排列 时间复杂度:O(n*n!),其中n是元素个数,n!是排列总数,每个排列需要O(n)时间复制 空间复杂度:O(n)(递归栈深度) >>
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值