笔记 递推递归分治

本文探讨了递归和递推在解决问题中的应用,以洛谷平台上的几道算法题目为例,详细解释了如何利用递归和递推策略解决覆盖墙壁、栈的出栈序列计数、平面上的最接近点对等问题。通过实例展示了递归分治、记忆化搜索和动态规划等方法,并分析了复杂度。此外,还强调了在面对问题时,如何将问题分解并利用数学模型来简化问题,如卡特兰数的应用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

递归和递推

递归和递推解决问题的思考方向相反:递归关心怎样把问题分解成规模更小的相似子问题,递推关心怎么从一个规模更小的状态得到一个规模更大的状态。

洛谷P1990 覆盖墙壁

在我们已经铺好了 2 * N 块砖后,我们可以:

  • 通过铺一块 I 型砖头到达 2 * (N + 1) 块砖头的状态
  • 通过铺两块 I 型砖头到达 2 * (N + 2) 块砖头的状态
  • 通过铺两块 L 型砖头以及若干块 I 型砖头到达 2 * (N + k) (k \geq 3) 块砖头的状态,每一种结果都对应着两种方案(上下颠倒一下)

因此,我们有递推式:f_{n}=f_{n-1}+f_{n-2}+2*(f_{n-3}+...+f_{1}),我们可以继而求出另一个通项公式:f_{n}=2*f_{n-1}+f_{n-3},或者我们也可以用前缀和来简化计算。

洛谷P1044 栈

首先我们想用搜索的方法来暴力求解。容易看出虽然我们要求的是“出栈序列的总数”,但是我们并不用关心具体的数字顺序是什么。这是因为在任意一个时刻,我们只能有入栈、出栈两种选择,并且这两种选择对应的结果必然不相同。

证明:考虑任意一个状态,设栈顶的元素为 top,出栈序列中已经有了 k 个元素。如果我们此时选择出栈,那么出栈序列的第 k + 1 个元素为 top。如果我们选择入栈,那么入栈的那个元素在出栈序列中必然排在 top 前面,也就是说 top 不可能成为出栈序列的第 k + 1 个元素。

根据以上的结论,我们发现真正有意义的信息其实就是“还没入栈的元素个数”和“栈中的元素个数”,因此不难写出以下的搜索代码:(初始执行search(n, 0))

long long search(int num1, int num2) {
	if (num1 == 0) return 1;
	else if (num2 == 0) return search(num1 - 1, 1);
	else return search(num1, num2 - 1) + search(num1 - 1, num2 + 1);
}

但是这样会超时,因此我们使用记忆化搜索的策略,使用 f_{i, j} 来表示“有 i 个元素还没入栈”并且“有 j 个元素在栈中”的方案数,初始为 f_{0, j} = 1

long long search(int num1, int num2) {
	if (f[num1][num2] != 0);
	else if (num2 == 0) f[num1][num2] = search(num1 - 1, 1);
	else f[num1][num2] = search(num1, num2 - 1) + search(num1 - 1, num2 + 1);
	return f[num1][num2];
}

//for (int i = 1; i <= n; ++i) f[0][i] = 1;

我们使用递归 + 优化的方式 AC 了这一题。

我们也可以使用递推的方式,我们有递推式:f_{i,j}=\begin{cases} 1& \text{i = 0} \\ f_{i-1, j+1}& \text{j = 0} \\ f_{i, j-1} + f_{i-1, j+1} \end{cases}\

因此:

for (int i = 0; i <= n; ++i) f[0][i] = 1;
for (int i = 1; i <= n; ++i) {
	for (int j = 0; j <= n; ++j) {
		if (j == 0) f[i][j] = f[i - 1][1];
		else f[i][j] = f[i][j - 1] + f[i - 1][j + 1];
	}
}

最后,让我们从数学上分析这个问题:这其实就是卡特兰数。

f_{n} 为 n 个数字出栈序列的方案数,我们第一步进行的操作无疑是将 1 入栈,此时待入栈的序列为 2 3 ... n - 1。

我们考虑数字 1 在出栈序列中的位置:若 1 排在第 k 位,那么在 1 出栈时,前 k - 1 个元素都已经历了由出栈到出栈的过程,方案数为 f_{k-1},在此之后还有 n - k - 1 个元素需要经历由出栈到出栈的过程,方案数为 f_{n - k-1}。由乘法原理,我们有公式:

                                                                       f_{n}=\sum_{i=0}^{n-1} f_{i}*f_{n - i - 1}

f_{n} 其实就是卡特兰数,我们也有其他的表达式:

                                                                       f_{n}=\frac{\binom{2n}{n}}{n+1}

                                                                      f_{n}=\frac{4n-2}{n+1}f_{n-1}

                                                                       f_{n}=\binom{2n}{n}-\binom{2n}{n-1}

实质上,卡特兰数的模型为:执行相等数量的任意两种操作,要求在执行第 k 次操作 2 时必须先执行 k 次操作 1。这个模型有很多实际例子,比如 n 对括号的合法序列个数,n 个元素的进出栈方案数,n + 1 个叶子的无标号二叉树个数,n + 2 边形的三角剖分方案数,从原点向右或向上不穿过 y = x 走到 (n, n) 的方案数等等。

对于最后一个例子,我们可以用折线法和减法原理来解答:

我们将 y = x 看作新的 x 轴,那么我们是要从 (0, 0) 走到 (n, 0),每一步能够往右上方或者右下方走一个对角线的方向。假如说没有不穿过坐标轴的限制,那么我们的方案数其实就是要在 2n 步中选择 n 步向右上方走,为 \binom{2n}{n}。现在我们思考不合法的方案数,对于任意跨越坐标轴的方案,它必然与 y = -1 相交,那么我们将在交点之前的部分沿 y = -1 翻转,转化成为从 (0, -2) 走到 (n, 0) 的方案数,为 \binom{2n}{n-1}。因此最后我们有 f_{n}=\binom{2n}{n}-\binom{2n}{n-1}

在别人的博客上看到几个练习题,先放在这

洛谷P1928 外星密码

这道题代码的写法稍微有些惊艳到了我,因为我是写不出来这个。思路很易懂:对于每一个左方括号都意味着进入下一层递归,待所有的都处理完后(以遇到右括号为中止条件)就把字符串返回。这是经典的递归分治思想:不管方括号内部的运算细节,只当 s 就是方括号内最终的结果。这段代码也涉及到了很多 C++ 实用的东西:比如 string 的加号表示字符串连接,scanf 返回的是成功输入的元素个数(为 EOF 就表示输入终止),整数的读入会自动省略掉后面的非法字符。

string work() {
	string temp;
	char c;
	while (scanf("%c", &c) != EOF) {
		if (c == ']') break;
		else if (c == '[') {
			int d;
			scanf("%d", &d);
			string s = work();
			while (d--) temp += s;
		}
		else {
			temp += c;
		}
	}
	return temp;
}

分治

当我们解决一些较大的问题时,我们可以将这个问题分成几个较小的子问题,分别解决之后,使用得到的信息求解较大问题的答案。我们可以不断递归下去,直到问题的规模足够小后直接解决。假设原问题的规模为 n,我们每次将问题的规模削减一半,那么会分成两个规模为 \frac{n}{2} 的子问题,继而分成四个规模为 \frac{n}{4} 的子问题...... 在 logn 次后问题的规模会变成常数级别。因此,假如说处理子问题并合并的复杂度为 O(1),那么分治的复杂度为 O(logn)。假如说处理子问题并合并的复杂度为 O(n),那么分治的复杂度为 O(nlogn)

洛谷P1226 【模板】快速幂

如果我们可以知道 b^{\lfloor \frac{p}{2} \rfloor},那么我们就可以轻而易举地合并出 b^{p},代码如下:

long long pow(long long now) {
	if (now == 0) return 1;
	if (now == 1) return b;
	if (now == 2) return b * b % k;
	if (now == 3) return (b * b % k) * b % k;
	if (now == 4) return ((b * b % k) * b % k) * b % k;
	long long tmp = pow(now / 2) % k;
	tmp = (tmp * tmp) % k;
	if (now & 1) return (tmp * b) % k;
	else return tmp;
}

多加几个 now 的判断可以缩短运行时间。

洛谷P1115 最大子段和

我们在序列中任意选一个元素,那么最大的子段要么在这个元素左面,要么在这个元素右面,要么包括这个元素。我们的递归分治即可以此为基础:对于前两种情况,它们是规模更小的子问题;而对于最后一种情况,我们只需要求出以该点为一个端点的最大子段和即可得到答案,最后我们对三个答案取最大值。该算法的时间复杂度为 O(nlogn)

int solve(int left, int right) {
    if (right - left == 1) return num[left];
    int mid = (left + right) / 2, sum = 0, tmp1 = INT_MIN, tmp2 = INT_MIN, d = max(solve(left, mid), solve(mid, right));
    for (int i = mid; i < right; ++i) {
        sum += num[i];
        tmp1 = max(sum, tmp1);
    }
    sum = 0;
    for (int i = mid - 1; i >= left; --i) {
        sum += num[i];
        tmp2 = max(sum, tmp2);
    }
    return max(d, tmp1 + tmp2);
}

这个问题也有 O(n) 的算法,仅需扫描一遍数组即可。f_{i} 为“以第 i 个元素结尾的最大子段和”,最终 max(f_{i}) 即为答案。

f[0] = num[0];
for (int i = 0; i < n; ++i) {
    f[i] = num[i] + max(0, f[i - 1]);
}
int maxn = f[0];
for (int i = 1; i < n; ++i) maxn = max(maxn, f[i]);

我们甚至可以用滚动数组优化使它仅使用常数空间。在这里我们换了一种写法,大体思路是:使用 sum 记录前缀和并实时维护最大值,如果 sum 变成了负数,那么此时对于下一个元素,我们只应该选择它本身(选择前面的会使结果变小),于是我们将 sum 设置为 0 重新开始。

int n, maxn, sum, d;
scanf("%d%d", &n, &maxn);
sum = maxn;
while (--n){
    scanf("%d", &d);
    sum = sum > 0 ? sum : 0;
    sum += d;
    maxn = maxn > sum ? maxn : sum;
}

洛谷P1257 平面上的最接近点对洛谷P1429 平面最近点对(加强版)

fabs 是对浮点数类型的绝对值运算,因为 abs 函数的参数类型是 int。定义 cmp 函数时里面写的是小于号。

#include<bits/stdc++.h>
using namespace std;

double INF;

struct Point{
    double x, y;
};
Point point[200005];

double dist(Point a, Point b) {
    return sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y));
}

bool cmpx(const Point &a, const Point &b) {
    return a.x < b.x;
}

bool cmpy(const Point &a, const Point &b) {
    return a.y < b.y;
}

double solve(int left, int right) {
    if (right - left == 1) return INF;
    if (right - left == 2) return dist(point[left], point[left + 1]);
    int mid = (left + right) / 2, k = 0;
    static Point temp[200005];
    double minn = min(solve(left, mid), solve(mid, right));
    for (int i = left; i < right; ++i) if (fabs(point[i].x - point[mid].x) <= minn) temp[k++] = point[i];
    sort(temp, temp + k, cmpy);
    for (int i = 0; i < k; ++i) {
        for (int j = i + 1; j < k; ++j) {
            if (temp[j].y - temp[i].y > minn) break;
            minn = min(minn, dist(temp[i], temp[j]));
        }
    }
    return minn;
}

int main() {
    int n;
    scanf("%d", &n);
    for (int i = 0; i < n; ++i) {
        scanf("%lf%lf", &point[i].x, &point[i].y);
    }
    INF = dist(point[0], point[1]);
    sort(point, point + n, cmpx);
    printf("%.4lf\n", solve(0, n));
    return 0;
}

CodeForces343C Painting Fence

这题我一开始想复杂了,试图把“横着涂”和“竖着涂”两种情况交替纳入考虑,但是没法通过所有的点,至今不懂为什么。

这也提醒我不要把一开始的想法就作为算法的一部分,而是将他们作为题目的性质纳入考虑,最终结合所有的性质得出一个步骤详尽的算法。

回到这个题目上来,对于一个高度为 h 的竖列,要么我们直接从上往下涂一道将这个竖列涂色,要么我们横向涂抹 h 次。

#include<bits/stdc++.h>
using namespace std;

int n, num[5005];
bool is_painted[5005];

int solve(int left, int right, int level) {
    if (right - left == 1) return 1;
    int minn = num[left], ans = 0;
    for (int i = left + 1; i < right; ++i) minn = min(minn, num[i]);
    ans += minn - level;
    level = minn;
    for (int i = left; i < right; ++i) {
        if (num[i] == level) is_painted[i] = true;
    }
    for (int pos = left; pos < right; ++pos){
        if (is_painted[pos]) continue;
        int tmp = pos + 1;
        for (; num[tmp] > level && tmp < right; ++tmp);
        while (num[tmp - 1] == level) --tmp;
        ans += solve(pos, tmp, level);
        pos = tmp;
    }
    return min(right - left, ans);
}

int main() {
    scanf("%d", &n);
    for (int i = 0; i < n; ++i) {
        scanf("%d", &num[i]);
    }
    printf("%d\n", solve(0, n, 0));
    return 0;
}

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值