递推、递归:将大目标分解为小目标的处理方法

一、总述

递推、递归是计算机算法设计中常用的集中基础算法。
递推是从第一步开始,根据一个特定的规律或转移方程层层推进,直到推出答案所在状态位置为止。
递归是从第一个可能性开始,枚举所有的可能性,并且根据这些可能性来找出最后的答案。

递推经常被用作 DP 中实现状态转移,递归会常常被用作 DFS 枚举所有的可能性来找到正确答案,而分治的用途十分广泛,可以使用分治来获得更高的效率。

关于时间复杂度,递推的时间复杂度通常为线型 O ( n ) O(n) O(n) ,递归的时间复杂度通常为多项式级 O ( n k ) O(n^k) O(nk) 或者指数级 O ( k n ) O(k^n) O(kn) 或者阶乘级 O ( n ! ) O(n!) O(n!)

二、递推:一步一步靠近正确答案的状态

递推算法要根据一个特定的情况和规律,来通过一个转移其状态的状态转移方程来靠近答案所在的状态,或者通过一个特定的规律,得出下一次要进行的变换。
这里,状态是指一个使用递推算法求值的数组,状态中的值就是我们递推过程中对于每一个状态通过一个特定的等式求得的值,这个等式就是状态转移方程,表示由一个状态转移到另一个状态时的变化规律,一般由题目中给出的已知条件通过数学计算或者逻辑推理得出。

比如,我们有一个斐波那契数列:
1 , 1 , 2 , 3 , 5 , 8 , 13 , 21 , … 1,1,2,3,5,8,13,21,\dots 1,1,2,3,5,8,13,21,
其中,我们设立一个状态:
f i f_i fi 表示斐波那契数列的第 i i i 项的值。由我们看到的数列规律,即第一和第二项都是 1 1 1 ,后面的每一项都是前两项的和,则我们可以得出以下的状态转移方程:
f i = { 1 ( i = 1 , 2 ) f i − 1 + f i − 2 ( i ∈ N , i ≥ 2 ) f_i= \begin{cases} 1&(i=1,2) \\ f_{i-1}+f_{i-2}&(i\in\mathbb{N},i\ge2) \end{cases} fi={1fi1+fi2(i=1,2)(iN,i2)
这样,我们就完成了对于斐波那契数列第 k k k ( 1 ≤ k ) (1\le k) (1k) 的计算。 C++ 语言实现的代码如下:

f[1] = 1; 						// 数组的初始化
for (int i = 2; i <= 1000; ++i) // 这里我们假设枚举到第 1000 项
{
	f[i] = f[i - 1] + f[i - 2];	// 状态转移方程
	// 我们这里做了一个简单的优化,将原来的 f[2] = 1 初始化条件取消了
	// 其原因是 C++ 中数组的初始化值就是 0 ,而 f[2] = f[0] + f[1] = 0 + 1 = 1 ,不必再次重新初始化
}

如上是一个简单的示例,下面我们看一道比较需要观察能力的题目(来自《算法竞赛进阶指南》),本题不需要有一个特定的状态转移方程:

费解的开关
—— 时间限制: 1 , 000  ms 1,000 \text{ ms} 1,000 ms ,空间限制: 256  MiB 256\text{ MiB} 256 MiB
题目描述
我们现在有一个 5 × 5 5\times5 5×5 0 / 1 0/1 0/1 矩阵,矩阵中的每个元素代表一盏灯, 0 0 0 表示亮, 1 1 1 表示灭。我们现在要在这个矩阵中进行一系列操作,使得这个矩阵所有的元素的值都变成 1 1 1 ,或者说所有灯都变为亮。定义以下的两种操作:

  • 什么都不改变,不操作任何一个数。
  • 操作一个数,改变一盏灯的亮灭状态,即 0 0 0 变成 1 1 1 1 1 1 变成 0 0 0 ,同时改变周围的所有灯的亮灭状态。

比如,我们定义以下的一个序列:
[ 0 1 1 1 1 0 1 0 1 1 1 0 0 0 1 1 1 0 1 0 1 1 1 1 0 ] \begin{bmatrix} 0&1&1&1&1 \\ 0&1&0&1&1 \\ 1&0&0&0&1 \\ 1&1&0&1&0 \\ 1&1&1&1&0 \\ \end{bmatrix} 0011111011100011101111100
改变坐标为 ( 1 , 1 ) (1,1) (1,1) 的灯的亮灭状态后,矩阵将变为:
[ 1 1 1 1 1 1 1 0 1 1 1 0 0 0 1 1 1 0 1 0 1 1 1 1 0 ] \begin{bmatrix} 1&1&1&1&1 \\ 1&1&0&1&1 \\ 1&0&0&0&1 \\ 1&1&0&1&0 \\ 1&1&1&1&0 \\ \end{bmatrix} 1111111011100011101111100
改变坐标为 ( 3 , 3 ) (3,3) (3,3) 的灯的亮灭状态后,矩阵将变为:
[ 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 1 1 0 ] \begin{bmatrix} 1&1&1&1&1 \\ 1&1&1&1&1 \\ 1&1&1&1&1 \\ 1&1&1&1&0 \\ 1&1&1&1&0 \\ \end{bmatrix} 1111111111111111111111100
改变编号为 ( 5 , 5 ) (5,5) (5,5) 的灯的亮灭状态后,矩阵将变为:
[ 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] \begin{bmatrix} 1&1&1&1&1 \\ 1&1&1&1&1 \\ 1&1&1&1&1 \\ 1&1&1&1&1 \\ 1&1&1&1&1 \\ \end{bmatrix} 1111111111111111111111111
因此,最少的变换次数为 3 3 3 次。
如果操作的数量大于 6 6 6 ,在本题中视为无解。
判断是否有一种操作数小于或等于 6 6 6 的方案,满足题意,输出这个方案的操作数量,如果没有,请输出 − 1 -1 1
本题有多组测试数据。
输入格式
1 1 1 行是一个整数 N N N ,表示数据的组数。
接下来 N N N 组数据,每组都是一个 5 × 5 5\times5 5×5 的矩阵,同一行的数中没有空格,行末有换行符。
输出格式
对于每组数据,输出一个整数。
如果有 ≤ 6 \le6 6 的解,输出当前数据的解。
如果没有,输出 − 1 -1 1 表示无解。

观察题面,我们可以找到两个共同点:
1  无论我们以什么方式进行变换,结果总是一样的。证明如下:
假设我们的操作数的坐标序列为 A = { ( x 0 , y 0 ) , ( x 1 , y 1 ) , ( x 2 , y 2 ) , … , ( x t , y t ) } A=\{(x_0,y_0),(x_1,y_1),(x_2,y_2),\dots,(x_t,y_t)\} A={(x0,y0),(x1,y1),(x2,y2),,(xt,yt)}
如果我们选择调换其中第 i i i 个坐标和第 j j j 个坐标的先后顺序,则这两个点周围的点份三种情况讨论:
a  如果这些点只被这两个点中的任意一个点修改了 1 1 1 次,则它会进行变换,即从 0 0 0 变成 1 1 1 ,从 1 1 1 变成 0 0 0
b 如果这些点被这两个点都修改了,则它不会进行变换。
显然,上面的性质与 i , j i,j i,j 的顺序无关,即无论如何变换 i , j i,j i,j 的顺序,最后的总次数仍旧不变。因此,结论的证。
上面的探究说明,进行操作的顺序与最终结果无关,因此我们可以随意(对程序实现有意义)地安排操作的顺序。
2  每个位置只会被点击 0 0 0 次或 1 1 1 次。证明如下:
用反证法。假设点击次数大于或等于 2 2 2 次,则相当于点击了 0 0 0 次,即没有发生任何改变。类似的,如果一个点被点击了 k ( k ∈ N , k ≥ 2 ) k(k\in\mathbb{N},k\ge2) k(kN,k2) ,则相当于被点击了 k m o d    2 k\mod2 kmod2 次。证毕。
因此,我们可以确定,在最优方案下,每个点只会被点击 0 , 1 0,1 0,1 次,否则将不是最优方案。

关于以下文段中涉及到的位运算知识,可以参考 这篇文章
利用上面的性质,我们可以确定一种解决方案:
先定义一个方位偏移数组和一个操作函数:

const int fx[5] = {0, 1, 0, -1, 0}, fy[5] = {0, 0, 1, 0, -1}; //方位偏移数组,可参考 DFS

void turn(int x, int y) 									// 操作函数
{
	for (int i = 0; i < 5; ++i)
	{
		a[x + dx[i]][y + dy[i]] ^= 1; 						// 表示将 (x+dx[i],y+dy[i])取反
	}
}

利用位运算的思想,枚举第一行的点击方案, 0 0 0 表示不点击, 1 1 1 表示点击。因为第一行一共有 5 5 5 个数,所以我们需要枚举 0 ∼ 2 5 − 1 0\sim 2^5-1 0251 的所有数,共需进行 2 5 = 32 2^5=32 25=32 次枚举。
然后,根据每一位的值,变换第一行的数。

for (int j = 0; j < 5; ++j)
{
	if (i >> j & 1) // 这个位是不是 1
	{
		turn(i, j + 1); // 注意,这里一定是 j + 1 ,因为 a 数组从 1 开始下标
	}
}

根据第一行的变换,我们可以检查第 1 ∼ 4 1\sim 4 14 行每一行是否为 1 1 1 ,如果不是 1 1 1 ,则变换下一行相同列位置的数,从而变换这个位置的值。举例如下:
假设我们需要变换位置坐标为 ( 3 , 3 ) (3,3) (3,3) 位置的灯,则我们可以通过变换坐标位置为 ( 3 , 4 ) (3,4) (3,4) 位置的灯,就可以变换这个位置的灯而不影响同一行中其他位置的灯。
[ 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 1 0 0 0 1 1 1 0 1 1 ]    ⟹    [ 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] \begin{bmatrix} 1&1&1&1&1 \\ 1&1&1&1&1\\ 1&1&0&1&1\\ 1&0&0&0&1\\ 1&1& 0&1&1 \end{bmatrix} \implies \begin{bmatrix} 1&1&1&1&1 \\ 1&1&1&1&1\\ 1&1&1&1&1\\ 1&1&1&1&1\\ 1&1& 1&1&1 \end{bmatrix} 1111111101110001110111111 1111111111111111111111111
这里对一个关键的问题做一个说明:为什么开始的时候不把第一行全部初始化为 1 1 1
比如我们有如下的一个矩阵,如果我们将第一行初始化为 1 1 1 ,则下一行将会被第一行改变,且使用算法实现该策略难度较大,不能单纯地通过查找 0 0 0 并将其转换为 1 1 1 实现全 1 1 1 初始化。但如果我们把第一行的操作序列定为 ( 00100 ) 2 (00100)_2 (00100)2 ,则可以实现最优解。
方案 1 1 1 :直接初始化为 1 , 1 , 1 , 1 , 1 1,1,1,1,1 1,1,1,1,1
[ 1 0 0 0 1 1 1 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ]    ⟹    [ 0 1 1 1 0 1 0 1 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] \begin{bmatrix} 1&0&0&0&1 \\ 1&1&0&1&1\\ 1&1&1&1&1\\ 1&1&1&1&1\\ 1&1&1&1&1 \end{bmatrix} \implies \begin{bmatrix} 0&1&1&1&0 \\ 1&0&1&0&1\\ 1&1&1&1&1\\ 1&1&1&1&1\\ 1&1& 1&1&1 \end{bmatrix} 1111101111001110111111111 0111110111111111011101111
方案 2 2 2 :设置 ( 00100 ) 2 (00100)_2 (00100)2 的操作序列:
[ 1 0 0 0 1 1 1 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ]    ⟹    [ 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] \begin{bmatrix} 1&0&0&0&1 \\ 1&1&0&1&1\\ 1&1&1&1&1\\ 1&1&1&1&1\\ 1&1&1&1&1 \end{bmatrix} \implies \begin{bmatrix} 1&1&1&1&1 \\ 1&1&1&1&1\\ 1&1&1&1&1\\ 1&1&1&1&1\\ 1&1& 1&1&1 \end{bmatrix} 1111101111001110111111111 1111111111111111111111111
因此,该部分的程序实现如下:

for (int j = 1; j <= 4; ++j) // 枚举行
{
	for (int k = 1; k <= 5; ++k) // 枚举列
	{
		if (!a[j][k]) // 运用上述策略
		{
			turn(j + 1, k);
		}
	}
}

最后,我们只需检查所有的操作次数和最后一行是否都为 1 1 1 即可。如果是,则这是当前的一个最优解。如果不是,则说明这不是一个最优解。
除此之外,就是细节问题。

#include <bits/stdc++.h>

using namespace std;

const int dx[5] = {0, 1, 0, -1, 0}, dy[5] = {0, 0, 1, 0, -1};

int n, a[10][10], ans, b[10][10];

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

int main()
{
    cin >> n;

    while (n--)
    {
        ans = 1e9;

        for (int i = 1; i <= 5; ++i)
        {
            for (int j = 1; j <= 5; ++j)
            {
                char c;
                cin >> c;

                a[i][j] = c - '0';
            }
        }

        memcpy(b, a, sizeof(a));

        for (int i = 0; i < 1 << 5; ++i)
        {
            memcpy(a, b, sizeof(b));

            int cnt = 0;

            for (int j = 0; j < 5; ++j)
            {
                if (i >> j & 1)
                {
                    turn(1, j + 1);
                    cnt++;
                }
            }

            for (int j = 1; j <= 4; ++j)
            {
                for (int k = 1; k <= 5; ++k)
                {
                    if (!a[j][k])
                    {
                        turn(j + 1, k);
                        cnt++;
                    }
                }
            }

            if (cnt > 6) continue;

            int f = 1;
            for (int j = 1; j <= 5; ++j)
            {
                if (!a[5][j])
                {
                    f = 0;
                    break;
                }
            }

            if (f)
            {
                ans = min(ans, cnt);
            }
        }

        if (ans > 1e8) cout << -1 << endl;
        else cout << ans << endl;
    }

    return 0;
}

三、递归:将大问题分解成若干相同的子问题求解

递归算法一般封装在一个递归函数中,递归即自己调用自己的一种函数写法,通过每次不断改变自身的参数,直到到达递归出口并得到相关信息,再返回层层带回相关答案。
递归的一个常见应用是 DFS 算法,即深度优先搜索算法。DFS 算法是基于递归实现的一种寻找最优解的 “暴力” 算法,其主要思想是每一次在不同状态下找出当前问题的解,再将最终的解与当前已发现的最优答案进行对比,最后找出最终的答案。
递归时,我们可以根据实际情况减去一些不必要的分支,比如超出给定范围,判断无法达到最优解等,从而实现对效率的最大优化。

递归的一个入门应用是用递归的写法取代递推的写法。
比如,我现在要计算以下表达式的值:
Σ i = 1 N 2 i 2 \Sigma^{N}_{i=1}2i^2 Σi=1N2i2
我们可以考虑如下的一个递归函数:

int solve(int k)
{
	if (k > n) // 到达递归边界
	{
		return 0; // 到达出口,返回 0 ,层层带回答案
	}
	
	return 2 * k * k + solve(k + 1); // 累加最后答案
}

/*
也可以这样写:
int sum;
void solve(int k)
{
	if (k > n)
	{
		return;
	}
		
	sum += 2 * k * k;
	solve(k + 1);
}
*/

这样,我们就实现了一个从 1 1 1 累加到 N N N 的递归函数。
这个函数通过分解子任务,即到达每个地方的时候累加当前的和,来计算最后的答案。

下面,我们来看一道递归的例题:
Strange Towers of Hanoi
—— 时间限制: 1 , 000  ms 1,000 \text{ ms} 1,000 ms ,空间限制: 256  MiB 256 \text{ MiB} 256 MiB
题目描述
这里有 A , B , C , D A,B,C,D A,B,C,D 四座塔,我们的目标是将所有的圆盘(共 N N N 个)从塔 A A A 移动到塔 D D D 上。
规则如下:

  • 每次只能移动 1 1 1 个圆盘。
  • 不能将大的圆盘叠加在小的圆盘上面。
  • 可以使用 B , C B,C B,C 两座塔作为辅助将 N N N 个圆盘移动到塔 D D D 上。

问最少要经过多少个步骤,才能将所有的圆盘移动到目标。
输入格式
一行,一个数 N N N ,表示圆盘的总数。保证 N N N 一定满足:
1 ≤ N ≤ 12 1\le N\le12 1N12
输出格式
一行,一个整数,表示最少的操作数。

我们先来看一道这个问题的子问题:三个圆盘数据规模的问题,将所有圆盘从 A A A 塔移动到 C C C 塔上。
通过观察,我们可以发现,假设有 N N N 个圆盘,则最优方案是先将 N − 1 N-1 N1 个圆盘在 3 3 3 塔模式下从 A A A 塔移动到 B B B 塔上,再将 1 1 1 个圆盘在 2 2 2 塔模式下从 A A A 塔移动到 C C C 塔上,最后将 N − 1 N-1 N1 个圆盘在 3 3 3 塔模式下从 B B B 塔移动到 C C C 塔上。
递归函数如下:

int hanoi(char A, char B, char C, int k)
{
	if (k == 2) 
	{
		d[k] = 3;
		return d[k];
	}
	
	d[k] = hanoi(A, C, B, k - 1) + 1 + hanoi(B, A, C, k - 1);
	return d[k];
}

我们可以通过这个函数,求出 3 3 3 塔模式下最优方案的步数。

对于 4 4 4 塔模式的问题,可以采取类似的方案: 1
初始化: f 1 = 1 f_1 = 1 f1=1 (一个盘子在4塔模式下移动到D柱需要1步)
先把i个盘子在 4 4 4 塔模式下移动到 B B B 柱,
然后把 n − i n-i ni 个盘子在3塔模式下移动到 D D D 柱(因为不能覆盖到B柱上,就等于只剩下A、C、D柱可以用)
最后把i个盘子在 4 4 4 塔模式下移动到 D D D
考虑所有可能的 i i i 取最小值,即得到递推公式:
f n = m i n i ≤ n { 2 f i + d n − i } f_n=min_{i\le n}\{2f_i+d_{n-i}\} fn=minin{2fi+dni}

代码如下:

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;

int d[20], f[20];

int hanoi(char A, char B, char C, int k)
{
	if (k == 1) 
	{
		return d[k];
	}
	
	d[k] = hanoi(A, C, B, k - 1) + 1 + hanoi(B, A, C, k - 1);
	return d[k];
}

int main() {
	d[1] = 1;
	solve(12);
	
	memset(f, 0x3f, sizeof(f));
	
    f[1] = 1;
    for (int i = 2; i <= 12; i++)
        for (int j = 1; j < i; j++)
            f[i] = min(f[i], 2 * f[j] + d[i - j]);
    
    for (int i = 1; i <= 12; i++)
        cout << f[i] << endl;
    return 0;
}

  1. 该部分引用自以下文章:
    原文链接 ↩︎

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值