分治与递归

分治是解编程题常用的一种思想,而大多数分治思想都是用递归来实现的。下面来分别介绍这两个概念,并给出它们的应用场景。

分治

分治(divide and conquer)的全称称为“分而治之”,分治即是将大问题划分为若干个规模较小、可以直接解决的子问题,然后解决这些子问题,最后将这些子问题的解合并起来,即是原问题的解。

分治是一种思想,它不涉及到具体的算法,而大多数情况下,分治都是借由递归来实现的。

递归

递归是一个很难阐述的概念。从c语言角度来讲,递归就是这个函数反复调用自身,然后将问题一步步地缩小,直到这个问题已经缩小到可以直接解决的程度,然后再一步一步地返回,最终解决原问题。

递归的逻辑中有两个重要的概念:
1.递归边界。递归边界是分解的尽头。
2.递归式。递归式是将问题规模一步步缩小的手段。

n的阶乘和Fibonacci数列的求解过程都很好地体现了这种思想,下面我们来看一下。

先来看N的阶乘。
我们知道,且0!=1,我们可以把0!当作递归边界,N!的递归式可以用N*(N-1)!来表示。

直接上代码:

int jc(int n)
{
    if(n==0)return 1;
    else return n*jc(n-1);
}

下面拿3!来举例,来深入理解一下递归的流程和步骤。
第一步:第一层,n=3,即返回3 x jc(2),但jc(2)未知,进入下一层;
第二步:第二层,n=2,返回2 x jc(1),但jc(1)未知,进入下一层;
第三步:第三层,n=1,返回1 x jc(0),jc(0)已知,等于1,故jc(1)=1,返回上一层;
第四步,第二层,jc(1)已知,故jc(2)=2 x 1=2;
第五步,第一层,jc(2)已知,故jc(3)=3 x 2=6,至此,递归完毕。

可以看出,递归就是不断用递归式找到递归边界(本题递归边界为0!=1),然后再一层一层返回,直到最终问题的解决。

下面再来看Fibonacci数列:

Fibonacci数列是这样的:1,1,2,3,5,8…
不难看出,Fibonacci的递归边界是:F(0)=1,F(1)=1;递归式是:F(n)=F(n-1)+F(n-2)(n>=2)。
代码如下:

int Fibonacci(int n)
{
     if(n==0||n==1)return 1;
     else return Fibonacci(n-1)+Fibonacci(n-2);
}

我们再来分析一下n=3时,这个递归函数是怎么运行的:
第一步:第一层,Fibonacci(3)未知,于是将其分解成Fibonacci(2)和Fibonacci(1);
第二步:第二层,Fibonacci(2)未知,分解成Fibonacci(1)和Fibonacci(0);Fibonacci(1)=1,返回上一层;
第三步:第三层,Fibonacci(1)和Fibonacci(0)已知,返回上一层;
第四步:第二层,Fibonacci(2)=2,返回上一层;
第五步:第一层,Fibonacci(3)=2+1=3,递归完毕。

由上述可见递归对分治思想的具体实现。上述两个例子都是经典的递归问题,代码简洁而优雅。但实际上,递归的时间复杂度并不理想,这里不做深入讨论。

下面再来看一下求a和b最大公约数的代码,这里需要用到欧几里得算法(辗转相除法),这里只给出代码,请读者自己结合欧几里得算法思考其原理:

int gcd(int a,intb)
{
    if(a%b==0)return a;
    else return gcd(b,a%b);
}

通过以上几个例子,我们对分治思想和递归已经有了基本的了解,下面,我们来聊点儿别的,看看分治和递归能做些什么美妙的事情,或者说,分治和递归能解决什么问题。

全排列问题(Full Permutation)

一般把1~n这n个整数按某个顺序摆放的结果称为这n个整数的一个排列,而全排列指的是这n个整数能形成的所有排列。现在需要实现按字典序从小到大的顺序输出1到n的全排列(字典序:(a1,a2,…,an)的字典序小于(b1,b2,…,bn)的字典序是指存在一个i,使得a1=b1,a2=b2,…,ai<bi成立)。

从递归的角度来分析,这个问题可以分解为“输出以1开头的全排列”,“输出以2开头的全排列”…“输出以n开头的全排列”。于是不妨设定一个数组P,用来存放当前的排列,再来一个hash数组,用来判断当前数组P里是否已经存在要放入的数x,若存在,hash[x]=true。

算法步骤:
设已经填好了p[1]到p[index-1],现在将数x填入p[index],如果hash[x]=false,说明可以填,然后将hash[x]置为true,然后再处理p[index+1](递归),递归完成后,再把hash[x]置为false,以便让p[index]继续填下一个数字。
不难发现,递归边界是当index=n+1,即p的1到n位已经填好了,这时就可以输出了,直接return即可。下面给出n=3的代码。

#include<bits/stdc++.h>
using namespace std;
bool hashtable[11]={false};
int p[11],n;
void qp(int index)//全排函数
{
    int i,x;
    if(index==n+1)//如果index等于n+1,直接输出并中止这一层函数
    {
        for(i=1;i<=n;i++)
        {
            if(i!=n)cout<<p[i]<<' ';
            else cout<<p[i]<<endl;
        }
        return;
    }
    for(x=1;x<=n;x++)//将1~n分别放入p数组中
    {
        if(hashtable[x]==false)
        {
            p[index]=x;
            hashtable[x]=true;
            qp(index+1);//递归
            hashtable[x]=false;//别忘了把数组清空
        }
    }
}
int main(){
n=3;
qp(1);//从1开始全排
return 0;
}

知道了全排列,下面我们在此基础上来看八皇后问题(Eight Queens)

八皇后问题指的是在n*n的棋盘上放置n个棋子,要求每一个棋子不能在同一行,不能在同一列,也不能在同一对角线上,问有多少种放法?

这个问题有一种简单粗暴的解法,即列举出所有可能:在n*n个格子里选n个格子,然后看是否符合条件,如符合,则count++;但这样做的时间复杂度太大,显然不符合我们搞算法的风格。那怎么办?

考虑到每一行只能放一个棋子,每一列也只能放一个棋子,那我们就可以把每一列棋子的行号进行全排列。这样需要n!个排列,然后再判定这些棋子是否在对角线上。于是,这个问题就变成了全排列的升级版。下面来看代码:

#include<bits/stdc++.h>
using namespace std;
bool hashtable[11]={false};
int p[11],n,jishu;//jishu用来存最终结果
void qp(int index)//全排函数
{
    int i,x,flag;//flag用来判断这一组排列是否满足条件
    if(index==n+1)//如果index等于n+1,代表完成一个排列,开始判断是否满足条件
    {
        flag=1;
        for(i=1;i<=n;i++)
        {
            for(int j=i+1;j<=n;j++)
            {
                if(abs(p[i]-p[j])==abs(i-j))//判断对角线条件是否成立
                {
                    flag=0;
                }
            }
        }
        if(flag==1)jishu++;
        return;
    }
    for(x=1;x<=n;x++)//将1~n分别放入p数组中
    {
        if(hashtable[x]==false)
        {
            p[index]=x;
            hashtable[x]=true;
            qp(index+1);//递归
            hashtable[x]=false;//别忘了把数组清空
        }
    }
}
int main(){
n=8,jishu=0;
qp(1);//从1开始全排
cout<<jishu;
return 0;
}

以上的方法虽然进行了一定的优化,但依然做了很多无用的工作:有一些情况,在摆完一部分棋子后,之后的棋子无论如何放置,都会违反对角线的约束。而上述代码会在明知之后放置棋子后违反对角线约束后继续递归,进而多做了一些无用的工作。所以,上面解决n皇后问题的方法是暴力法。有没有更优解呢?有的,那就是我们下面将要提到的回溯法

回溯法

在递归过程中,当我们在递归到一定程度时,我们发现不用之后的递归就能得出问题的答案时(比如n皇后问题,在我们递归到一定程度后我们就知道这个排列一定无法满足对角线约定时),我们就可以中断这个递归,从而减少时间复杂度,这就是回溯法。

拿这个n皇后问题来说,当我们放置x个棋子后(x<n),如果再放x+1枚棋子就一定会破坏对角线约束的话,那么我们就可以直接break,来减少无用的工作。下面我们就n皇后问题给出回溯法的代码:

#include<bits/stdc++.h>
using namespace std;
bool hashtable[11]={false};
int p[11],n,jishu;//jishu用来存最终结果
void qp(int index)//全排函数
{
    int i,x;
    if(index==n+1)
    {
        jishu++;//当到这一步时,这个排列很显然满足所有条件
        return;
    }
    for(x=1;x<=n;x++)//将1~n分别放入p数组中
    {
        if(hashtable[x]==false)
        {
            bool flag=true;//用来判断放入x棋子后,是否满足对角线条件
            for(int pre=1;pre<index;pre++)
            {
                if(abs(x-p[pre])==abs(index-pre))
                {
                    flag=false;
                    break;
                }
            }
            if(flag==true)//如满足,继续递归,反之停止
            {
                 p[index]=x;
                 hashtable[x]=true;
                 qp(index+1);//递归
                 hashtable[x]=false;//别忘了把数组清空
            }
        }
    }
}
int main(){
n=8,jishu=0;
qp(1);//从1开始全排
cout<<jishu;
return 0;
}

  • 12
    点赞
  • 32
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值