基础算法 - 递推与递归

一个实际问题的各种可能情况构成的集合通称为“状态空间”

递推与递归就是程序遍历状态空间的两种基本方式。

递推与递归的宏观描述

对于一个待解问题,如果它在某些边界、某个小范围或者特殊情况下时,其答案往往是已知的。如果能将这些解答扩大到原问题,并且这种扩大的过程的每个步骤具有相似性,就可以考虑使用递推或递归了。

递推就是直接算出所有子问题,然后子问题推出父问题,这些解决的父问题又可以推出它们的父问题,以此类推,直至求解原问题。
例如斐波那契数列就是用 f 1 f_{1} f1 f 2 f_{2} f2 来推出 f 3 f_{3} f3 ,直至推出 f n f_n fn。这是一种自下而上的过程

递归就是从原问题出发,看需要那几个子问题,子问题在划分为它的子问题,知道子问题的解很容易解后,使用子问题的解算法原问题的解。这是一种自上而下的过程

递推与递归的简单运用

常见的遍历方式:

枚举形式状态空间规模一般遍历方式
多项式 n k n^k nk k k k 为常数循环、递推
指数 k n k^n kn k k k 为常数递归、位运算
排列 n ! n! n!递归、next_permutation
组合 C n m C_n^{m} Cnm递归 + 剪枝

【例题】递归实现指数型枚举
1 1 1 ~ n n n n   ( n < 20 ) n~(n<20) n (n<20) 个整数中随机选取任意多个,输出所有可能的选择方案。

分析:
对于每个数都有选或不选,所有可能的选择共有 2 n 2^n 2n 种。对于递归我们可以设递归函数 f ( u ) f(u) f(u) 为第 u u u 个数选的状态,那么到达问题 f ( u + 1 ) f(u+1) f(u+1) 就有两种途径,一是选,二是不选。直到 u > n u > n u>n 就问题解决,输出方案。

代码如下:

int n;
bool st[N]; // st[i] := 第 i 数是否选

void dfs(int u) {
    if(u > n) { // 输出问题解
        for(int i = 1; i <= n; ++i)
            if(st[i]) printf("%d ", i);
        puts("");
        return ;
    }
    
    st[u] = true; // 选
    dfs(u + 1);
   
    st[u] = false; // 不选
    dfs(u + 1);
}

【例题】递归实现组合数型枚举

1 1 1 n n n n n n 个整数中随机选出 m ( 0 ≤ m ≤ n ≤ 20 ) m (0\le m\le n\le 20) m(0mn20) 个,输出所有可能的选择方案。
分析:
一样的每个数都是选或不选,不过需要参入一个新的参数 c n t cnt cnt 当前选了 c n t cnt cnt 个数,这样我们就可以将边界条件变得更多了。

  1. 已经选了 m m m 个数了。
  2. 没有到 m m m 个数,但是剩下的数全选也凑不出 m m m 个数。

代码如下:

int n, m;
bool st[N];

void dfs(int u, int cnt) {
	// 两个结束条件
    if(cnt > m || m - cnt > n - u + 1) 
        return ;
        
    if(u > n) {
        for(int i = 1; i <= n; ++i)
            if(st[i]) printf("%d ", i);
        puts("");
        return ;
    }

    st[u] = true;
    dfs(u + 1, cnt + 1);
    
    st[u] = false;
    dfs(u + 1, cnt);
}

【例题】递归实现排列型枚举
1 1 1 n n n n n n 个整数排成一行后随机打乱顺序,输出所有可能的次序。
分析:
全排列问题,设计递归函数 f ( u ) f(u) f(u) 为前 u u u 个数字确定下的状态,那么当前的位置的数字就可以填上前面还未使用的数字,然后进入 f ( u + 1 ) f(u + 1) f(u+1) 问题,最后 u > = n u >= n u>=n ,问题解决输出方案。
代码如下:

int n, a[N];
bool st[N];

void dfs(int u) {
    if(u > n) {
        for(int i = 1; i < n; ++i)
            printf("%d ", a[i]);
        puts("");
        return ;
    }
    
    for(int i = 1; i <= n; ++ i) {
        if(!st[i]) {  // 数字 i 还未使用
            st[i] = true;
            a[u] = i;
            dfs(u + 1);
            st[i] = false; // 恢复现场 
        }
    }
}

【例题】费解的开关
在一个 5 × 5 5\times 5 5×5 01 01 01 矩阵中,点击任意一个位置,该位置以及它上、下、左、右四个相邻的位置中的数字都会变化( 0 0 0 1 1 1 1 1 1 0 0 0),问:最少需要多少次点击可以把一个给定的 01 01 01 矩阵变为全 0 0 0 的矩阵?
分析:
对于这种规则的 01 01 01 矩阵的点击游戏,有三个性质:

  1. 每个位置只有点一次或不点有意义;
  2. 若固定了第一行的点击方案,则满足题意的点击方案就确定了,因为 i i i 行的 0 0 0 只有按 i + 1 i + 1 i+1 行变亮,且不影响前面的状态。
  3. 点击的先后顺序不影响最终结果。

对于第一行的点击方案共有 2 5 2^5 25 种,利用第一行的状态递推下一行的点击方案,直到最后一行。最后检测最后一行是否满足要求。
代码如下:

#include <iostream>
#include <cstring>
#include <cstdio>
using namespace std;
const int INF = 0x3f3f3f3f;
const int N = 6;
char g[N][N], backup[N][N];
int dx[] = {-1, 0, 1, 0, 0}, dy[] = {0, 1, 0, -1, 0};

void turn(int x, int y) {
    for(int i = 0; i < 5; ++i) {
        int nx = x + dx[i], ny = y + dy[i];
        if(0 <= nx && nx < 5 && 0 <= ny && ny < 5) {
            g[nx][ny] ^= 1;
        }
    }
}

int main()
{
    int t;
    cin >> t;
    while (t -- ) {
        int n = 5, ans = INF;
        
        for(int i = 0; i < n; ++i)
            cin >> g[i];
        
        memcpy(backup, g, sizeof g);

        int way;
        
        for(int i = 0; i < 1 << n; ++i) {
            way = 0;
            for(int j = 0; j < n; ++j) if((i >> j) & 1)
                turn(0, j), way ++ ;
            
            for(int j = 0; j < n; ++j) {
                for(int k = 0; k < n; ++k) if(g[j][k] == '0')
                    turn(j + 1, k), way ++ ;
            }
                      
            bool is_successful = true;
            
            for(int j = 0; j < n; ++j)
                if(g[n - 1][j] == '0') {
                    is_successful = false;
                    break;
                }
            
            if(way <= 6 && is_successful) {
                ans = min(way, ans);
            }
            
            memcpy(g, backup, sizeof backup);
        }
        
        if(ans == INF) puts("-1");
        else cout << ans << endl;
    }
    return 0;
}

【例题】奇怪的汉诺塔
解出 n n n 个盘子 4 4 4 座的汉诺塔问题最少需要多少步?
分析:
对于 n n n 个盘子 3 3 3 座塔的经典问题,设 d [ n ] d[n] d[n] 表示要将 n n n 个的盘子移到 C C C 柱子上的最小步数,显然要先将前 n − 1 n - 1 n1 个盘子移到 B B B 柱子上,然后再将第 n n n 个盘子移到 C C C柱子上,再利用 A A A 柱子为中转柱子,将剩下 n − 1 n - 1 n1 个柱子移到 C C C 柱子上。

于是就有了这个递推式, d [ n ] = 2 × d [ n − 1 ] + 1 d[n] = 2\times d[n - 1] + 1 d[n]=2×d[n1]+1

对于 4 4 4 个柱子问题,设 f [ n ] f[n] f[n] 表示将 n n n 个盘子移到 D D D 柱子上的最小步数,显然如果移动了前 i i i 个柱子到一个中转柱后,剩下的柱子数就只有一个中转柱子可以用了,也就变成了 3 3 3 柱子问题了,然后再把前 i i i 个柱子移到到 D D D 柱子上就是原问题了。在选择是有那个 i , i ∈ [ 1 , n ) i, i \in [1, n) i,i[1,n) 时,我们就可以选择其中的最优方案。

于是就有了这个递推式, f [ n ] = min ⁡ 1 ≤ i < n { 2 × f [ i ] + d [ n − i ] } f[n] = \min_{1\le i < n}\{2 \times f[i] + d[n - i]\} f[n]=min1i<n{2×f[i]+d[ni]}

代码如下:

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;
typedef long long LL;
const int N = 13;
// f[i] := 在A、B、C、D四个柱子中,把第i大的盘子从A移到D的最小花费操作
// d[i] := 在A、B、C三个柱子中,把第i大的盘子从A移到C的最小花费操作
LL f[N], d[N];

int n = 12;

int main()
{
    d[1] = 1;
    // 要把第i个盘子移到柱子C上,就得先把第i - 1个盘子移到柱子B上,
    // 再把第i个盘子移到柱子C上,然后把第i - 1个盘子移到柱子A上。
    for(int i = 2; i <= n; ++i) d[i] = 2 * d[i - 1] + 1;
    
    f[1] = 1;
    for(int i = 2; i <= n; ++i) {
        f[i] = 0x3f3f3f3f;
        
        for(int j = 0; j <= i; ++j) {
            f[i] = min(f[i], 2 * f[j] + d[i - j]);
        }
    }
    
    for(int i = 1; i <= n; ++i)
        printf("%lld\n", f[i]);
        
    return 0;
}

分治

分治法把一个问题划分为若干个子问题,对于这些子问题求解。
【例题】约数之和
A B A^B AB 的所有约数之和 m o d mod mod 9901 9901 9901 ( 0 ≤ A , B ≤ 5 × 1 0 7 ) (0 \le A, B \le 5 \times 10^7) (0A,B5×107)
分析:
根据算术基本定理,可以很容易知道整数 A A A 可以表达成若干个质数相乘。
A = p 1 a 1 p 2 a 2 ⋯ p k a k A = p_1^{a_1}p_2^{a_2}\cdots p_k^{a_k} A=p1a1p2a2pkak
那么 A B A^B AB就可以表达成:
A = p 1 a 1 × B p 2 a 2 × B ⋯ p k a k × B A = p_1^{a_1\times B}p_2^{a_2\times B}\cdots p_k^{a_k\times B} A=p1a1×Bp2a2×Bpkak×B
根据约数之和的公式:整数 x x x p 1 a 1 p 2 a 2 ⋯ p k a k p_1^{a_1}p_2^{a_2}\cdots p_k^{a_k} p1a1p2a2pkak ,则 x x x 的约数之和为 ( 1 + p 1 + ⋯ + p 1 a 1 × B ) × ⋯ × ( 1 + p k + ⋯ + p k a k × B ) (1+p_1 + \cdots + p_1^{a_1 \times B}) \times \cdots \times(1+p_k + \cdots + p_k^{a_k \times B}) (1+p1++p1a1×B)××(1+pk++pkak×B)

所以现在只需要解决如果快速计算 ( 1 + p k + ⋯ + p k a k × B ) (1+p_k + \cdots + p_k^{a_k \times B}) (1+pk++pkak×B) 就行了。

这个式子很显然可以变化成 1 − p k a k × B 1 − p k \frac{1 - p_k^{a_k \times B}}{1-p_k} 1pk1pkak×B ,因为模数运算无法对除法做出判断,且模数太小,对于 [ 1 , 3 × 1 0 5 ] [1, 3\times 10^5] [1,3×105] 并不是每一个数都有逆元,所以不能使用这个公式优化。

所以这里才使用分治的思想加速运算,设 s u m ( p , c ) sum(p, c) sum(p,c) 表示为 1 + p + p 2 + ⋯ + p c 1+p + p^2+\cdots+p^c 1+p+p2++pc
如果 c c c 是奇数,显然:
s u m ( p , c ) = 1 + p + p 2 + ⋯ + p c − 1 2 + p c + 1 2 + ⋯ + p c s u m ( p , c ) = ( 1 + p + p 2 + ⋯ + p c − 1 2 ) + p c + 1 2 × ( 1 + p + p 2 + ⋯ + p c − 1 2 ) s u m ( p , c ) = ( 1 + p c + 1 2 ) × s u m ( p , c − 1 2 ) sum(p, c) = 1 + p + p^2 + \cdots + p^{\frac{c- 1}{2}} + p ^ {\frac{c+1}{2}} + \cdots+p^c \\ sum(p, c) = (1 + p + p^2 + \cdots + p^{\frac{c- 1}{2}}) +p ^ {\frac{c+1}{2}} \times (1 + p + p^2 +\\ \cdots + p^{\frac{c- 1}{2}}) \\ sum(p, c) = (1 + p^{\frac{c+ 1}{2}}) \times sum(p, \frac{c-1}{2}) sum(p,c)=1+p+p2++p2c1+p2c+1++pcsum(p,c)=(1+p+p2++p2c1)+p2c+1×(1+p+p2++p2c1)sum(p,c)=(1+p2c+1)×sum(p,2c1)
如果 c c c 是偶数,则:
s u m ( p , c ) = ( 1 + p c 2 ) × s u m ( p , c 2 − 1 ) + p c sum(p, c) = (1 +p^{\frac{c}{2}})\times sum(p,\frac{c}{2} - 1) + p^c sum(p,c)=(1+p2c)×sum(p,2c1)+pc

这样就可以 O ( l o g ( c ) ) O(log(c)) O(log(c)) 的时间复杂度算出 s u m ( p , c ) sum(p, c) sum(p,c) 了。
代码如下:

#include <iostream>

using namespace std;
typedef long long LL;
const int mod = 9901;
const int N = 8001;

int primes[N]; 
bool st[N];
int cnt;

int fact[N], num[N];

LL qpow(LL a, LL b) {
    LL res = 1;
    while(b) {
        if(b & 1) res = (res % mod * a % mod) % mod;
        a = (a % mod * a % mod ) % mod;
        b >>= 1;
    }
    return res % mod;
}


void get_primes(int n) {
    st[0] = st[1] = true;

    for(int i = 2; i <= n; ++i) {
        if(!st[i]) primes[cnt++] = i;
        for(int j = 0; primes[j] <= n / i; ++j) {
            st[i * primes[j]] = true;
            if(i % primes[j] == 0) break;
        }
    }
}

LL cal(LL p, LL c) {
    if(c == 0) return 1;
    if(c & 1) {
        return ((1 + qpow(p, (c + 1) / 2)) % mod * cal(p, (c - 1)/2) % mod)% mod;
    } else {
        return (((1 + qpow(p, c / 2)) % mod * cal(p, c / 2 - 1) % mod) % mod + qpow(p, c) )% mod;
    }
}

int main()
{
    get_primes(N - 1);

    LL a, b;

    cin >> a >> b;

    if(a == 0) {
        puts("0");
        return 0;
    }

    int k = -1;

    for(int i = 0; i < cnt; ++i) {
        if(!a) break;

        if(a % primes[i] == 0) k++, fact[k] = primes[i];

        while(a % primes[i] == 0) {
            a /= primes[i];
            num[k]++;
        }
    }

    if(a > 1) {
        k++;
        fact[k] = a;
        num[k] ++;
    }

    k++;

    int ans = 1;

    for(int i = 0; i < k; ++i) {
        ans = (ans % mod * cal(fact[i], num[i] * b) % mod)%mod;
    }

    cout << ans << endl;

    return 0;
}

分形

【例题】分形之城
在一个笛卡尔坐标系上,以这样的方式排布房子。
在这里插入图片描述
等级 1 1 1 的排布就如图中所示,以房子 1 1 1 为原点,每个房子间距 10 10 10 米。

等级 2 2 2 的排布则是有等级 1 1 1 的排布变换而来的,对于等级 2 2 2 的左上角区域,是将等级 1 1 1 排布顺时针旋转 90 90 90 度变换而来的,左下角区域则是由等级 1 1 1 逆时针旋转 90 90 90 度变换而来的。右上与右下则是和等级 1 1 1 一样的排布,不过位置不同而已。

以此类推,等级 3 3 3 就是这样从 等级 2 2 2 的排布过来的。

图中每个房子有编号,是以上面的路线一次编排的。

现在有 n n n 组测试样例,每个样例给了等级 N N N 和 编号 S S S 与 编号 D D D 的房子,求两个房子的直线距离。
1 ≤ N ≤ 31 , 1 ≤ A , B ≤ 2 2 N , 1 ≤ n ≤ 1000 1\le N \le 31,1\le A,B \le 2^{2N},1\le n\le1000 1N31,1A,B22N,1n1000

分析:
既然等级 i i i 的房子分布是由等级 i − 1 i - 1 i1 的房子变换而来的,我们可以大胆猜测是递归。

对于求编号 S S S 的房子和编号 D D D 的房子的直线距离,很显然需要求出两个房子在坐标系上的坐标,所以我们设计递归函数 f ( N , M ) f(N, M) f(N,M) 为在等级 N N N 的排布上,编号为 M M M 的坐标。

f ( 0 , M ) f(0, M) f(0,M) 肯定计算的结果为 ( 0 , 0 ) (0, 0) (0,0) 。好了,确定了边界条件后,我们来推导 f ( i , M ) f(i, M) f(i,M) f ( i − 1 , M ′ ) f(i - 1, M') f(i1,M) 的过程。 M ′ M' M M M M 映射到等级 i − 1 i - 1 i1的编号。

就如题目所说的变换规则,对于等级 i − 1 i - 1 i1 ( x , y ) (x, y) (x,y) 变换到等级 i i i 的左上角,就是先顺时针变 90 90 90 度,这里可以通过一个公式推出。

坐标 ( x , y ) (x, y) (x,y) 相对于原点顺时针旋转 θ \theta θ 度相当于该坐标乘上矩阵 [ cos ⁡ θ sin ⁡ θ   − cos ⁡ θ − sin ⁡ θ ] \begin{bmatrix} \cos\theta & \sin\theta \\\ -\cos\theta & -\sin\theta \end{bmatrix} [cosθ cosθsinθsinθ]

所以旋转 90 90 90 度就相当于 ( x , y ) (x, y) (x,y) 变换到 ( − y , x ) (-y, x) (y,x),因为是以房子 1 1 1 为原点,所以还要反转一下得 ( y , x ) (y, x) (y,x)

同理左下就是变换为 ( 2 ∗ l e n − y − 1 , l e n − x − 1 ) (2 * len - y - 1, len - x - 1) (2leny1,lenx1) l e n len len 为等级 i − 1 i - 1 i1 分布的长度。
以此类推,右上就是 ( x , y + l e n ) (x, y + len) (x,y+len),右下就是 ( x + l e n , y + l e n ) (x + len, y + len) (x+len,y+len)
代码如下:

#include <iostream>
#include <cstring>
#include <algorithm>
#include <cmath>
using namespace std;
typedef long long LL;
typedef pair<LL, LL> PLL;

PLL cal(LL n, LL m) {
    if(n == 0) {
        return {0, 0};
    }
    // len为等级i - 1的长度,cnt为等级i-1的点个数
    LL len = 1LL << (n - 1), cnt = 1LL << (2 * n - 2);
    
    PLL temp = cal(n - 1, m % cnt);  // m%cnt 既是m在等级i-1下映射的编号
    
    LL x = temp.first;
    LL y = temp.second;
    
    LL z = m / cnt; // m点是在那个部分。
    
    if(z == 0) return {y, x};
    if(z == 1) return {x, y + len};
    if(z == 2) return {x + len, y + len};
    if(z == 3) return {2 * len - y - 1, len - x - 1};
}

int main()
{
    int t;
    cin >> t;
    while(t -- ) {
        LL n, b, a;
        cin >> n >> a >> b;
        PLL p1 = cal(n, a - 1);
        PLL p2 = cal(n, b - 1);
        double x = p1.first - p2.first, y = p1.second - p2.second;
        printf("%.0f\n", sqrt(x * x + y * y) * 10);
    }
    return 0;
}

递归的机器实现

这是因为以前竞赛环境栈空间很小,使用递归会爆栈的风险,因此采用数组模拟栈,从而避免爆栈。但是现在通常栈空间就是题目给的内存限制,所以基本不会爆栈了。

因此我就不做里的笔记了。

  • 6
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值