递归与分治策略

参考摘自:计算机算法设计与分析(第五版)王晓东

一、简介

        分治法的设计思想:将一个难以解决的大问题分割成一些规模较小的相同问题,分而治之。如果原来n个问题可以分成k个子问题,且1<k<n,且这些子问题都可解,并可利用这些子问题的解求出原问题的解,那么这种分治法就是可行的。

       分治与递归经常同时应用在算法设计中,并由此产生许多高效算法。

二、递归的概念

        递归算法:直接或间接地调用自身的算法称为递归算法。

1、阶乘函数

        递归方程:

         改方程的自变量n的定义域是非负整数。递归式的第一式给出了这个函数的初始值,是非递归定义的。每个递归函数都必须有非递归定义的初始值,否则递归函数无法计算。递归式的第二式用较小的自变量的函数值来表示较大自变量的函数值的方式来定义n的阶乘。

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

非递归定义:n! = 1*2*3*...*(n-1)*n

2、斐波那契数列

       无穷数列1,1,2,3,5,8,13...称为斐波那契数列。

        递归方程:

int fibonacci(int n) {
    if(n <= 1)
        return 1;
    return fibonacci(n-1) + fibonacci(n-2);
}

非递归定义:

3、Ackerman函数

        并非一切递归函数都能用非递归方式定义。Ackerman函数。当一个函数及它的一个变量由函数自身定义时,称这个函数时双递归函数。

        Ackerman函数A(n,m)有两个独立的整型变量m>=0和n>=0,其定义如下:

        

        A(n,m)的自变量m的每个值都定义了一个单变量函数。m=0定义了函数“加2”。当m=1时,由于A(1,1) = A(A(0,1),0) = A(1,0) = 2 且 A(n,1) = A(A(n-1,1),0) = A(n-1,1)+2,因此A(n,1)=2n (n >=1),即A(n,1)是函数“乘2”。当m=2时,A(1,2) = A(A(0,2),1) = A(1,1) = A(A(0,1),0) = A(1,0) = 2 且 A(n,2) = A(A(n-1,2),1) = 2*A(n-1,2),故A(n,2) = 2^n。

        类似的,我们也可以推出A(n,3)。A(n,4)的增长速度非常快,以至于没有适当的数学式子来表示这一函数。所以Ackerman函数就没有非递归定义。

4、排列问题

        设R = {r1,r2,...,rn}是要进行排列的n个元素,Ri = R - {ri}。集合X中元素的全排列记为Perm(X)。(ri)Perm(X)表示在全排列Perm(X)的每个排列前加上前缀ri得到的排列。R的全排列可归纳定义如下:

        当n=1时,Perm(R) = (r),其中r是集合R中唯一的元素;

        当n>1时,Perm(R)由(r1)Perm(R1),(r2)Perm(R2),...,(rn)Perm(Rn)构成。

       依此递归定义,可设计产生Perm(R)的递归算法如下:

template<class Type>
void Perm(Type list[], int k, int m)
//产生list[k:m]的所有排列
{
    if(k==m) // 只剩下1个元素
    {
        for(int i=0; i<m; i++)
        {
            cout << list[i];
        }
        cout << endl;
    }
    else // 还有多个元素待排列,递归产生排列
    {
        for(int i=k; i<=m; i++)
        {
            Swap(list[k], list[i]); // 所有元素依此被放到第一个位置
            Perm(list, k+1, m);
            Swap(list[k], list[i]); // 换回来
        }
    }
}
template<class Type>
inline void Swap(Type & a, Type & b) {
    Type temp = a;
    a = b;
    b = temp;
}

        在一般情况下,k<m。算法将list[k:m]中的每个元素分别于list[k]中的元素交换,然后递归地计算list[k+1:m]的全排列,并将计算结果作为list[0:k]的后缀。算法中Swap()适用于交换两个变量值的内联函数。

5、整数划分问题

        将正整数n表示成一系列正整数之和,n=n1+n2+...+nk(n1>=n2>=...>=nk>=1,k>=1)。正整数n的不同的划分个数成为正整数n的划分数记为p(n)。

        例如,p(6) = 11。           

        在正整数n的所有划分中,将最大加数不大于m的划分个数记作q(n,m)。可以建立
g(n,m)的如下递归关系。 
        ① q(n,1)=1,n≥1。当最大加数不大于1时,任何正整数n只有一种划分形式,即n个1相加。
        ② q(n,m)=q(n,n),m≥n。最大加数n实际上不能大于n,因此q(1,m)=1。
        ③ q(n,n)=1+q(n,n-1)。正整数n的划分由最大加数为n的划分和m≤n-1的划分组成。
        ④ q(n,m)=q(n,m-1)+q(n-m,m),n>m>1。正整数n的最大加数不大于m的划分由最大加数为m
的划分和最大加数≤m-1的划分组成。
        

        递归式: 

int q(int n, int m) {
    if((n<1) || (m<1))
        return 0;
    if((n==1) || (m==1))
        return 1;
    if(n<m)
        return q(n,n);
    if(n==m)
        return q(n,m-1)+1;
    return q(n,m-1) + q(n-m,m);
}

        正整数n的划分数p(n) = q(n,n)。

6、Hanoi塔问题

        

        设a、b、c是三个塔座。开始时,在塔座a上有一叠共n个圆盘,这些圆盘自下而上,由大到小地叠放在一起,各圆盘从小到大编号为1,2,…,n,如图所示。现要求将塔座a上的这一叠圆盘移到塔座b上,并仍按同样顺序叠置。在移动圆盘时应遵守以下移动规则:

        (1)每次只能移动一个圆盘;

        (2)任何时刻都不允许将较大的圆盘压在较小的圆盘之上;

        (3)在满足移动规则I和Ⅱ的前提下,可将圆盘移至a、b、c中任一塔座上。

        当n=1时,问题比较简单。此时,只要将编号为1的圆盘从塔座a直接移至塔座b上即可。当n>1时,需要利用塔座c作为辅助塔座。此时要设法将n-1个较小的圆盘依照移动规则从塔座a移至塔座c上,然后将剩下的最大圆盘从塔座a移至塔座b上,最后设法将n-1个较小的圆盘依照移动规则从塔座c移至塔座b上。由此可见,n个圆盘的移动问题就可分解为两次n-1个圆盘的移动问题,这又可以递归地用上述方法来做。由此可以设计出解Hanoi塔问题的递归算法如下:

void hanoi(int n, int a, int b, int c)
{
    if(n > 0)
    {
        hanoi(n-1,a,c,b);// 先把n-1个盘移动到c上
        move(a,b);// 把最大的盘移到b上
        hanoi(n-1,c,b,a);//再把c上的n-1个盘移到b上
    }
}

三、分治策略设计范例

1、二分搜索技术

        给定已排好序的n个元素a[0:n-1],现要在这n个元素中找出一特定元素x。首先较容易想到的是用顺序搜索方法,逐个比较a[0:n-1]中元素,直至找出元素x或搜索遍整个数组后确定x不在其中。这个方法没有很好地利用n个元素已排好序这个条件,因此在最坏情况下,顺序搜索方法需要O(n)次比较。二分搜索方法采用分治策略,可在最坏情况下用O(logn)时间完成搜索任务。二分搜索算法的基本思想是,将n个元素分成个数大致相同的两半,取a[n/2]与x作比较。如果x=a[n/2],则找到x,算法终止;如果x<a[n/2],则只在数组a的左半部继续搜索x;如果x>afn/2],则只在数组a的右半部继续搜索x。具体算法可描述如下:
template<class Type>
int BinarySearch(Type a[], const Type& x, int n) // a是从小到大已经排好序的数列,搜索x
{
    //找到x时返回其在数组中的位置,否则返回-1
    int left = 0;
    int right = n-1;
    while(left<=right)
    {
        int middle = (left + right)/2;
        if(x == a[middle]) // 找到了
            return middle;
        if(x > a[middle]) // 在右半部
            left = middle+1;
        else // 在左半部
            right = middle-1;
    }
    return -1; // 未找到
}

2、棋盘覆盖

        在一个2^k×2^k个方格组成的棋盘中,若恰有一个方格与其他方格不同,则称该方格为特殊方格,且称该棋盘为一特殊棋盘。显然,特殊方格在棋盘上出现的位置有4^k种情形。因而对任何k≥0,有4^k种特殊棋盘。图中的特殊棋盘是k=2时16个特殊棋盘中的一个。

        在棋盘覆盖问题中,要用下图所示的4种不同形态的L型骨牌覆盖一个给定的特殊棋盘上除特殊方格以外的所有方格,且任何2个L型骨牌不得重叠覆盖。易知,在任何一个2^k×2^k的棋盘覆盖中,用到的L型骨牌个数恰为(4^k-1)3。
 
        用分治策略的算法:当k>0时,将2^k×2^k棋盘分割为4个2^(k-1)×2^(k-1)子棋盘,如图所示。特殊方格必位于4个较小子棋盘之一中,其余3个子棋盘中无特殊方格。为了将这3个无特殊方格的子棋盘转化为特殊棋盘,可以用一个L型骨牌覆盖这三个较小棋盘的汇合处,如图所示,这三个子棋盘上被L型骨牌覆盖的方格就成为该棋盘上的特殊方格,从而将原问题转化为4个较小规模的棋盘覆盖问题。递归地使用这种分割,直至棋盘简化为1*1的棋盘。
void ChessBoard(int tr, int tc, int dr, int dc, int size)
// tr:左上角格子行号,tc:左上角格子列号
// dr:特殊方格行号,dc:特殊方格列号
// size:2^k
// Board:二位整型数组,表示棋盘
// tile:全局变量,表示L型骨牌编号,起初始值是0
{
    if(size == 1)    // 直至棋盘简化为1*1的棋盘
        return;
    int t = tile++ , s = size/2; // 分割棋盘
    
    // 覆盖左上角子棋盘
    if(dr < tr+s && dc < tc+s)    // 特殊方格在此棋盘中
        ChessBoard(tr, tc, dr, dc, s);    // 递归
    else {                        // 特殊方格不在此棋盘中
        Board[tr+s-1][tc+s-1] = t;        // 用t号L型骨牌覆盖右下角
        ChessBoard(tr, tc, tr+s-1, tc+s-1, s);    // 递归
    }
    // 覆盖右上角子棋盘
    if(dr < tr+s && dc >= tc+s)    // 特殊方格在此棋盘中
        ChessBoard(tr, tc+s, dr, dc, s);    //递归
    else {
        Board[tr+s-1][tc+s] = t;         // 用t号L型骨牌覆盖左下角
        ChessBoard(tr, tr+s, tr+s-1, tc+s, s);    // 递归
    }
    // 覆盖左下角棋盘
    if(dr >= tr+s && dc < tc+s)    // 特殊方格在此棋盘中
        ChessBoard(tr+s, tc, dr, dc, s);    // 递归
    else {
        Board[tr+s][tc+s-1] = t;    // 用t号L型骨牌覆盖右上角
        ChessBoard(tr+s, tc, tr+s, tc+s-1, s);    // 递归
    }
    // 覆盖右下角棋盘
    if(dr >= tr+s && dc >= tc+s)    // 特殊方格在此棋盘中
        ChessBoard(tr+s, tc+s, dr, dc, s);    // 递归
    else {
        Board[tr+s][tc+s] = t;      // 用t号L型骨牌覆盖左上角
        ChessBoard(tr+s, tc+s, tr+s, tc+s, s);    // 递归
    }
}

  解得T(k) = O(4^k)

3、合并排序

        合并排序算法是用分治策略实现对n个元素进行排序的算法,其基本思想是:将待排序
元素分成大小大致相同的两个子集合,分别对两个子集合进行排序,最终将排好序的子集合
合并成要求的排好序的集合。合并排序算法可递归地描述如下:
template<class Type>
void MergeSort(Type a[], int left, int right)
{
    if(left < right) {
        int i = (left+right)/2;    // 取中点
        MergeSort(a, left, i);     // 排左边
        MergeSort(a, i+1, right);  // 排右边
        MergeSort(a, b, left, i, right); // 合并到数组b
        Copy(a, b, left, right);   // 复制回数组a
    }
}
解得T(n) = O(nlogn)
        非递归实现:
template<class Type>
void MergeSort(Type a[], int n)
{
    Type *b = new Type [n];    // 临时存储中间排序结果
    int s = 1;                 // 当前归并的子序列长度
    while(s < n)               // s>=n时,整个数组变成一个有序序列
    {
        //MergePass:归并函数,负责将长度为s的子序列两两合并成更长的有序子序列。
        MergePass(a,b,s,n);    // 将数组a中的有序子序列两两归并到b
        s+=s;
        MergePass(b,a,s,n);    // 将b中的有序子序列两两归并回a
        s+=s;
    }
}

        特别地,如果Type是自定义的,则必须重载运算“<=”。

4、快速排序

        快速排序算法基本思想是,对于输入的子数组a[p:r],按以下三个步骤进行排序。

        ①分解:以a[p]为基准元素将a[p:r]划分成3段a[p:q-1],a[q]和a[q+1:r],使a[p:q-1]中任何一个元素小于等于a[q],而a[q+1:r]中任何一个元素大于等于a[q]。下标q在划分过程中确定。

        ②递归求解:通过递归调用快速排序算法,分别对a[p:q-1]和a[q+1:r]进行排序。 

        ③合并:由于对a[p:q-1]和a[q+1:r]的排序是就地进行的,因此在a[p:q-1]和a[q+1:r]都已排好的序后,不需要执行任何计算,a[p:r]则已排好序。

int Partition(Type a[], int p, int r)
{
    int i = p, j = r+1;
    Type x = a[p];       // 选择数组的第一个元素作为基准值。
    // 将小于x的元素交换到左边区域,将大于x的元素交换到右边区域
    while(true) {
        while(a[++i] < x && i < r);    // 从左向右找到第一个大于等于基准值的元素
        while(a[--j] > x);             // 从右向左找到第一个小于等于基准值的元素
        if(i >= j)
            break;    // 左右两部分已经完全划分,跳出循环。
        Swap(a[i], a[j])
    }
    // 基准值归位
    a[p] = a[j];
    a[j] = x;
    return j;
}

void QuickSort(Type a[], int p, int r)
{
    if(p < r)
    {
        int q = Partition(a, p, r);
        Quicksort(a, p, q-1);    // 对左半段排序
        Quicksort(a, q+1, r);    // 对右半段排序
    }
}

        快速算法在平均情况下的时间复杂度也是O(logn)。

5、循环赛日程表

        设有n=2*个运动员要进行网球循环赛。现要设计一个满足以下要求的比赛日程表:

        ①每个选手必须与其他n-1个选手各赛一次;

        ②每个选手一天只能赛一次;

        ③循环赛一共进行n-1天。

        按此要求可将比赛日程表设计成有n行和n-1列的表。在表中第i行和第j列处填入第i个选手在第j天所遇到的选手。

        按分治策略,可以将所有选手对分为两半,n个选手的比赛日程表就可以通过为n/2个选手设计的比赛日程表来决定。递归地用这种一分为二的策略对选手进行分割,直到只剩下两个选手时,比赛日程表的制定就变得简单了。这时只要让这两个选手进行比赛就可以了。

四、例题(C++,随缘更新)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值