一个实际问题的各种可能情况构成的集合通称为“状态空间”
递推与递归就是程序遍历状态空间的两种基本方式。
递推与递归的宏观描述
对于一个待解问题,如果它在某些边界、某个小范围或者特殊情况下时,其答案往往是已知的。如果能将这些解答扩大到原问题,并且这种扩大的过程的每个步骤具有相似性,就可以考虑使用递推或递归了。
递推就是直接算出所有子问题,然后子问题推出父问题,这些解决的父问题又可以推出它们的父问题,以此类推,直至求解原问题。
例如斐波那契数列就是用
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(0≤m≤n≤20) 个,输出所有可能的选择方案。
分析:
一样的每个数都是选或不选,不过需要参入一个新的参数
c
n
t
cnt
cnt 当前选了
c
n
t
cnt
cnt 个数,这样我们就可以将边界条件变得更多了。
- 已经选了 m m m 个数了。
- 没有到 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 矩阵的点击游戏,有三个性质:
- 每个位置只有点一次或不点有意义;
- 若固定了第一行的点击方案,则满足题意的点击方案就确定了,因为 i i i 行的 0 0 0 只有按 i + 1 i + 1 i+1 行变亮,且不影响前面的状态。
- 点击的先后顺序不影响最终结果。
对于第一行的点击方案共有
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
n−1 个盘子移到
B
B
B 柱子上,然后再将第
n
n
n 个盘子移到
C
C
C柱子上,再利用
A
A
A 柱子为中转柱子,将剩下
n
−
1
n - 1
n−1 个柱子移到
C
C
C 柱子上。
于是就有了这个递推式, d [ n ] = 2 × d [ n − 1 ] + 1 d[n] = 2\times d[n - 1] + 1 d[n]=2×d[n−1]+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]=min1≤i<n{2×f[i]+d[n−i]} 。
代码如下:
#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)
(0≤A,B≤5×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=p1a1p2a2⋯pkak
那么
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×B⋯pkak×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}
p1a1p2a2⋯pkak ,则
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} 1−pk1−pkak×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+⋯+p2c−1+p2c+1+⋯+pcsum(p,c)=(1+p+p2+⋯+p2c−1)+p2c+1×(1+p+p2+⋯+p2c−1)sum(p,c)=(1+p2c+1)×sum(p,2c−1)
如果
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,2c−1)+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
1≤N≤31,1≤A,B≤22N,1≤n≤1000。
分析:
既然等级
i
i
i 的房子分布是由等级
i
−
1
i - 1
i−1 的房子变换而来的,我们可以大胆猜测是递归。
对于求编号 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(i−1,M′) 的过程。 M ′ M' M′ 为 M M M 映射到等级 i − 1 i - 1 i−1的编号。
就如题目所说的变换规则,对于等级 i − 1 i - 1 i−1 的 ( 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)
(2∗len−y−1,len−x−1) ,
l
e
n
len
len 为等级
i
−
1
i - 1
i−1 分布的长度。
以此类推,右上就是
(
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;
}
递归的机器实现
这是因为以前竞赛环境栈空间很小,使用递归会爆栈的风险,因此采用数组模拟栈,从而避免爆栈。但是现在通常栈空间就是题目给的内存限制,所以基本不会爆栈了。
因此我就不做里的笔记了。