递归与分治

递归

image

递归:直接或间接(A:这件事找 B;B:这件事找 A)地用到自己。

如何定义正整数?正整数是 \(1,2,3,\cdots\) 这些数。这个定义不是那么的“严密”,换一种方式:

  1. \(1\) 是正整数
  2. 如果 \(n\) 是正整数,\(n+1\) 也是正整数

这种定义就是递归式的:在“正整数”还没有定义时,就用到了“正整数”的定义。递归式定义能让定义简洁而严密。

例题:P5739 [深基7.例7] 计算阶乘

解题思路

原来学过循环求解,用一个 for 循环,就可以完成递推了。

参考代码
#include <cstdio>
int main()
{
    int n; scanf("%d", &n);
    int ans = 1;
    for (int i = 1; i <= n; i++) ans *= i;
    printf("%d\n", ans);
    return 0;
}

数学函数也可以递归定义,阶乘函数 \(f(n)=n!\) 可以定义为:

\( \ f(n) = \begin{cases} 1 & \quad n=0 \\ f(n-1) \times n, &\quad n \ge 1\\ \end{cases} \ \)

参考代码
#include <cstdio>
int f(int n) {
    return n == 0 ? 1 : f(n - 1) * n;
}
int main()
{
    int n; scanf("%d", &n);
    printf("%d\n", f(n));
    return 0;
}

要注意:一定要有个递归边界,要不然就无限递归了,另外递归过程中每一层的变量(包括参数)是独立的,互不影响。

例题:P5743 【深基7.习8】猴子吃桃

解题思路

可以循环递推,设 \(f_i\) 表示第 \(i\) 天时还剩多少桃子,则有 \(f_n = 1\)\(f_i = (f_{i+1} + 1) \times 2\),倒序循环即可,答案为 \(f_1\)

也可以正着递推,设 \(f_i\) 表示在倒数第几天(或者说还剩几天)的时候还剩多少桃子,则有 \(f_1 = 1\)\(f_i = (f_{i-1} + 1) \times 2\),最后答案就是 \(f_n\)

当然,这两种递推方式也可以写成递归形式,类似于上一题。

参考代码1
#include <cstdio>
int n;
int calc(int x) {
    if (x == n) return 1;
    return 2 * (calc(x + 1) + 1);
}
int main()
{
    scanf("%d", &n);
    printf("%d\n", calc(1));
    return 0;
}
参考代码2
#include <cstdio>
int calc(int x) {
    if (x == 1) return 1;
    return 2 * (calc(x - 1) + 1);
}
int main()
{
    int n;
    scanf("%d", &n);
    printf("%d\n", calc(n));
    return 0;
}

习题:B2142 求 1+2+3+...+N 的值

解题思路

与阶乘类似,\(1+2+ \cdots + n = (1 + 2 + \cdots + (n-1)) + n\),因而若 \(f(n)\) 表示累加函数,则对于 \(n>0\) 时有 \(f(n)=f(n-1)+n\)

#include <cstdio>
int f(int n) {
    return n == 0 ? 0 : f(n - 1) + n;
}
int main()
{
    int n; scanf("%d", &n);
    printf("%d\n", f(n));
    return 0;
}

选择题:ack 函数在输入参数 m = 2, n = 2 时的返回值为?

unsigned ack(unsigned m, unsigned n) {
    if (m == 0) return n + 1;
    if (n == 0) return ack(m - 1, 1);
    return ack(m - 1, ack(m, n - 1));
}
  • A. 5
  • B. 7
  • C. 9
  • D. 13
答案

这是一个经典的递归函数——阿克曼函数。

ack(2, 2) = ack(1, ack(2, 1))

现在,需要计算内层的 ack(2, 1) = ack(1, ack(2, 0))

接下来,计算更内层的 ack(2, 0) = ack(1, 1)

继续计算 ack(1, 1) = ack(0, ack(1, 0))

继续计算 ack(1, 0) = ack(0, 1) = 1 + 1 = 2

开始将结果代回,ack(1, 1) = ack(0, ack(1, 0)) = ack(0, 2) = 2 + 1 = 3

所以有 ack(2, 0) = ack(1, 1) = 3

接下来计算 ack(1, 3) = ack(0, ack(1, 2))ack(1, 2) = ack(0, ack(1, 1)) = ack(0, 3) = 3 + 1 = 4,所以 ack(1, 3) = ack(0, 4) = 4 + 1 = 5

所以有 ack(2, 1) = ack(1, 3) = 5

最后计算 ack(1, 5) = ack(0, ack(1, 4))ack(1, 4) 等于 ack(0, ack(1, 3)),已经计算出 ack(1, 3) = 5,所以 ack(1, 4) = ack(0, 5) = 5 + 1 = 6

ack(1, 4) = 6 代回,ack(1, 5) 等于 ack(0, 6) = 6 + 1 = 7

B


递归与递推

对于前面的问题,用递归和递推都解决了,那么两者有什么区别?

运行下面两份代码,输入 \(40\),比较运行效率:

#include <cstdio>
int f[45];
int main()
{
	int n; scanf("%d", &n);
	f[1] = 1; f[2] = 1;
	for (int i = 3; i <= n; i++) f[i] = f[i - 1] + f[i - 2];
	printf("%d\n", f[n]);
	return 0;
}

循环递推,时间复杂度 \(O(n)\)

#include <cstdio>
int f(int x) {
	if (x <= 2) return 1;
	return f(x - 1) + f(x - 2);
}
int main()
{
	int n; scanf("%d", &n);
	printf("%d\n", f(n));
	return 0;
}

递归,时间复杂度 \(O(fib(n))\),同一项会多次计算,每次都递归到 f(1)f(2) 返回。

要提升递归的效率,可以使用记忆化(用数组标记每一项是否已经算出,已经算出就直接返回)的方法,不过一定要确定真的不需要重复计算才行。

#include <cstdio>
int ans[45];
bool vis[45];
int f(int x) {
	if (x <= 2) return 1;
	if (vis[x]) return ans[x];
	vis[x] = true;
	return ans[x] = f(x - 1) + f(x - 2);
}
int main()
{
	int n; scanf("%d", &n);
	printf("%d\n", f(n));
	return 0;
}

时间复杂度 \(O(n)\),每项只算一次,算每项时是 \(O(1)\) 的。

通常建议简单递推式使用循环递推实现。

使用递归往往适用于如下情况:

  1. 难以找到循环的合适顺序时(递推式中出现的下标可能变大也可能变小)
  2. 从要求的第 \(n\) 项出发,有用的项很少(比如 \(f_n = f_{n/2} + f_{n/3}\)

选择题:斐波那契数列的定义为:\(F_1 = 1\)\(F_2 = 1\)\(F_n = F_{n-1} + F_{n-2} (n \ge 3)\)。现在用如下程序来计算斐波那契数列的第 \(n\) 项,其时间复杂度为?

F(n):
    if n<=2 return 1
    else return F(n-1) + F(n-2)
  • A. \(O(n)\)
  • B. \(O(n^2)\)
  • C. \(O(2^n)\)
  • D. \(O(n \log n)\)
答案

C

\(T(n)\) 表示计算 \(F(n)\) 所需的时间。根据代码 return F(n-1) + F(n-2),为了计算 F(n),程序需要先计算 F(n-1)F(n-2),然后将它们相加。因此,\(T(n)\) 可以表示为 \(T(n) = T(n-1) + T(n-2) + O(1)\),其中 \(O(1)\) 代表加法操作的常数时间。这个递推关系与斐波那契数列本身的定义非常相似。

对于 \(T(n) = T(n-1) + T(n-2)\) 而言,可以进行估算:

  • 上限:\(T(n) \lt 2 T(n-1) \lt 4 T(n-2) \lt \cdots \lt 2^n T(0)\),所以 \(T(n)\) 的一个上限是 \(O(2^n)\)
  • 下限:\(T(n) \gt 2 T(n-2) \gt 4 T(n-4) \gt \cdots \gt 2^{n/2} T(0)\),所以 \(T(n)\) 的一个下限是 \(O(2^{n/2})\)

实际上,这个更紧的界大约是 \(O(1.618^n)\),其中 1.618 是黄金分割数。

在实际编程时,如果使用递推计算或是记忆化的方法,其时间复杂度可以优化到 \(O(n)\)


例题:P1464 Function

解题思路

使用记忆化的方法就可以避免超时了,有效状态数是 \(20 \times 20 \times 20\) 级别,计算加法的时间复杂度是 \(O(1)\),算下来只需要做 \(8000\) 这个量级的计算量,完全不用担心超时。

不过需要注意当 \(a \le 0\)\(b \le 0\)\(c \le 0\)\(a>20\)\(b>20\)\(c>20\) 时可以不把答案记进数组,避免数组访问越界。

参考代码
#include <cstdio>
using ll = long long;
const int N = 25;
bool vis[N][N][N]; // 记录某状态是否被计算过 
ll ans[N][N][N]; // 记录某状态下的计算结果
ll w(ll a, ll b, ll c) {
    if (a <= 0 || b <= 0 || c <= 0) return 1;
    if (a > 20 || b > 20 || c > 20) return w(20, 20, 20);
    if (vis[a][b][c]) return ans[a][b][c]; // 如果曾经计算过可以直接返回存下来的结果
    // 需要展开计算则在计算完成后更新相应状态及计算结果
    if (a < b && b < c) {
        ans[a][b][c] = w(a, b, c-1) + w(a, b-1, c-1) - w(a, b-1, c);
        vis[a][b][c] = true;
        return ans[a][b][c];
    }
    ans[a][b][c] = w(a-1, b, c) + w(a-1, b-1, c) + w(a-1, b, c-1) - w(a-1, b-1, c-1);
    vis[a][b][c] = true;
    return ans[a][b][c];
}
int main()
{
    while (true) {
        ll a, b, c; scanf("%lld%lld%lld", &a, &b, &c);
        if (a == -1 && b == -1 && c == -1) break;
        printf("w(%lld, %lld, %lld) = %lld\n", a, b, c, w(a, b, c));
    }
    return 0;
}

例题:P1028 [NOIP 2001 普及组] 数的计算

解题思路

可以定义一个函数 int f(int n) 表示当最后一个拼上的数是 \(n\) 的时候,最终能生成多少个数列。

在计算时,首先设答案(返回值)是 \(1\)(表示第一种情况),然后循环枚举拼接的数 \(i\)(需满足 \(2i \le x\)),把 f(i) 的结果加进当前返回值,循环结束后返回结果。

为了保证不重复计算,需要加上记忆化。

一共 \(n\) 项要算,算一项的时候有 \(n/2\) 次循环,所以时间复杂度为 \(O(n^2)\)

参考代码
#include <cstdio>
const int N = 1005;
int f[N];
int solve(int n) {
    if (n == 1) return 1;
    if (f[n] != 0) return f[n];
    int res = 1;
    for (int i = 1; i * 2 <= n; i++) res += solve(i);
    return f[n] = res;
}
int main()
{
    int n; scanf("%d", &n);
    printf("%d\n", solve(n));
    return 0;
}

习题:P1010 [NOIP 1998 普及组] 幂次方

解题思路

任何正整数都可以唯一地表示为二进制形式,即若干个 \(2\) 的幂次之和。可以通过检查 \(n\) 的二进制位,从高位到低位,若第 \(i\) 位为 \(1\),则说明分解式中包含 \(2^i\) 这一项。

题目要求的格式具有明显的递归性质:

  • 对于 \(2^i\) 这一项,如果 \(i=0\)\(i=1\),有特定的表示法。
  • 如果 \(i \gt 1\),则 \(i\) 需要再次被分解并按照同样的规则输出。

这意味着可以定义一个递归函数 solve(n) 来处理。

参考代码
#include <cstdio>

// 递归函数 solve 用于将整数 n 分解为 2 的幂次表示形式
void solve(int n) {
    bool first = true; // 标记当前层级是否是第一个输出的项,用于控制 '+' 号的打印
    
    // 从高位向低位遍历二进制位。n 最大为 2*10^4,2^14 = 16384,2^15 = 32768,所以从 14 开始即可。
    for (int i = 14; i >= 0; i--) {
        // 检查第 i 位是否为 1 (即 2^i 是否存在于分解中)
        if ((n >> i) & 1) {
            // 如果不是第一个项,则在前边加一个 '+'
            if (!first) printf("+");
            first = false;
            
            // 处理不同的指数 i 的情况
            if (i == 0) {
                // 根据约定,2^0 表示为 2(0)
                printf("2(0)");
            } else if (i == 1) {
                // 根据约定,2^1 表示为 2
                printf("2");
            } else {
                // 对于 i > 1 的情况,表示为 2(i的分解形式)
                printf("2(");
                solve(i); // 递归分解指数 i
                printf(")");
            }
        }
    }
}

int main()
{
    int n;
    // 读取输入的正整数
    scanf("%d", &n);
    solve(n);
    return 0;
}

习题:P10458 Fractal

解题思路

度为 \(n\) 的分形边长为 \(3^{n-1}\),其图形可以看作是在一个 \(3 \times 3\) 的大网格中,在左上、右上、中间、左下、右下这 5 个位置分别填充一个度为 \(n-1\) 的图形,其余位置为空白。

这非常适合使用递归来解决,可以定义一个函数 draw(n, x, y),表示在画布的 \((x,y)\) 坐标处开始绘制一个度为 \(n\) 的分形。

参考代码
#include <cstdio>

// 当 n=7 时,边长为 3^6 = 729
const int S = 735;

int p[7];       // 存储 3 的幂次,p[i] = 3^i
char g[S][S];   // 二维字符数组作为画布

// 递归绘图函数
// n: 当前分形的度数
// x, y: 当前分形在画布上的左上角坐标
void draw(int n, int x, int y) {
    // 递归基:度为 1 时,画一个 'X'
    if (n == 1) {
        g[x][y] = 'X';
        return;
    }
    
    // 计算下一级分形的边长
    // 度为 n 的分形由 5 个 度为 n-1 的分形组成
    // 度为 n-1 的分形边长为 3^(n-2),即 p[n-2]
    int s = p[n - 2];
    
    // 根据盒子分形的定义,在 3x3 的网格位置中绘制 5 个子分形:
    // 网格布局示意:
    // [B] [ ] [B]
    // [ ] [B] [ ]
    // [B] [ ] [B]
    
    // 1. 左上角
    draw(n - 1, x, y);
    // 2. 右上角 (列坐标偏移 2*s)
    draw(n - 1, x, y + 2 * s);
    // 3. 中间 (行、列坐标各偏移 s)
    draw(n - 1, x + s, y + s);
    // 4. 左下角 (行坐标偏移 2*s)
    draw(n - 1, x + 2 * s, y);
    // 5. 右下角 (行、列坐标各偏移 2*s)
    draw(n - 1, x + 2 * s, y + 2 * s);
}

int main()
{
    // 预处理 3 的幂次
    p[0] = 1;
    for (int i = 1; i <= 6; i++) p[i] = p[i - 1] * 3;
    
    while (true) {
        int n; 
        scanf("%d", &n);
        if (n == -1) break; // 输入 -1 表示结束
        
        // 计算当前度数 n 对应的图形总边长 len = 3^(n-1)
        int len = p[n - 1];
        
        // 初始化画布,全部填充为空格
        for (int i = 0; i < len; i++) {
            for (int j = 0; j < len; j++) {
                g[i][j] = ' ';
            }
        }
        
        // 从坐标 (0, 0) 开始递归绘制
        draw(n, 0, 0);
        
        // 输出生成的图形
        for (int i = 0; i < len; i++) {
            for (int j = 0; j < len; j++) {
                printf("%c", g[i][j]);
            }
            printf("\n");
        }
        // 每个测试用例后输出一个分隔行
        printf("-\n");
    }
    return 0;
}

例题:P1928 外星密码

分析:如果只有一层方括号,那么只需要找到方括号,就可以提取出重复次数,然后将重复部分按次数复制若干份拼接起来即可。如果方括号的“重复部分”里还有方括号呢?用同样的方式展开即可。可以发现,这个机制和递归非常吻合,因此本题适合用递归的方式来实现。

image

参考代码
#include <cstdio>
#include <iostream>
#include <string>
#include <stack>
using namespace std;
const int N = 20005;
int match[N]; // match[i]代表code[i]为左括号时对应的右括号的下标
/**
 * @brief 递归解压缩函数
 * @param code 完整的原始密码字符串
 * @param l 当前要解压的子串的左边界(包含)
 * @param r 当前要解压的子串的右边界(不包含)
 * @return 解压后的字符串
 */
string decompress(string &code, int l, int r) {
    // 解压缩 code[l]~code[r-1]
    int i = l;
    int num = 0; // 用于累积当前层级的重复次数
    string res; // 用于存储当前层级解压出的基本字符串(重复前)
    // 遍历当前需要处理的子串 code[l...r-1]
    while (i < r) {
        if (code[i] == '[') {
            // 遇到左括号,说明有一个嵌套的压缩块
            // 递归调用 decompress 来处理这个内部块
            // 内部块的范围是 [i+1, match[i]-1]
            res += decompress(code, i + 1, match[i]);
            // 处理完内部块后,直接跳到右括号的下一个位置
            i = match[i] + 1;
        } else {
            // 处理非括号的字符
            if (code[i] >= '0' && code[i] <= '9') {
                // 如果是数字,累加到重复次数 num 中
                num = num * 10 + (code[i] - '0');
            } else {
                // 如果是字母,直接追加到结果字符串中
                res += code[i];
            } 
            i++; // 继续处理下一个字符
        }
    }
    // 如果整个块内没有数字,则默认重复1次
    if (num == 0) num = 1;
    string ret; // 最终要返回的、重复后的字符串
    // 将解压出的基本字符串 res 重复 num 次
    for (int i = 1; i <= num; i++) ret += res;
    return ret;
}
int main()
{
    string code;
    cin >> code; // 读取完整的密码字符串
    // 预处理:使用栈来匹配所有的括号
    stack<int> s;
    for (int i = 0; i < code.size(); i++) {
        if (code[i] == '[') {
            // 遇到左括号,将其下标入栈
            s.push(i);
        } else if (code[i] == ']') {
            // 遇到右括号,栈顶的元素就是与之匹配的左括号的下标
            match[s.top()] = i;
            s.pop(); // 匹配完成,出栈
        }
    }
    // 调用递归函数,从整个字符串的范围 [0, code.size()) 开始解压
    cout << decompress(code, 0, code.size()) << "\n";
    return 0;
}

如果能将一个大的任务分解成若干规模较小的任务,而且这些任务的形式与结构和原问题一致,就可以考虑使用递归。当问题规模足够小或者达到了边界条件就要停止递归。分解完问题后还要将这些规模小的任务的处理结果合并,最后逐级上报,解决最大规模的问题。


递归与栈的关系

栈是一种“后进先出”的数据结构,而函数调用自身,会形成一个调用链。当递归到达最深层(基本情况)后,会开始逐层返回。这个“逐层返回”的顺序,正好与“逐层调用”的顺序相反。最后调用的函数最先返回,最先调用的函数最后返回。这种行为模式与栈的“后进先出”特性完全吻合。

函数调用栈

当在代码中调用一个函数时,操作系统会为这个函数在内存中创建一个“栈帧”(Stack Frame),这个栈帧被推入一个叫作“函数调用栈”(Call Stack)的特殊栈中。

这个栈帧里存储了关于这次函数调用的所有重要信息,包括:

  • 函数的参数(Parameters)
  • 函数的局部变量(Local Variables)
  • 返回地址(Return Address):即函数执行完毕后,程序应该回到哪里继续执行。

联系与引申

栈溢出(Stack Overflow):如果递归没有正确的“基本情况”来终止,或者递归的深度太深,调用栈会持续增长,直到耗尽所有分配给它的内存空间,从而导致程序崩溃,这就是著名的“栈溢出”错误。

递归转迭代:任何一个递归算法,都可以被转换成一个非递归的、使用循环和显式栈(即自己创建和管理的栈数据结构)的等价算法。在某些情况下,这样做可以避免函数调用的开销,提高效率,或者避免栈溢出。


选择题:在程序运行过程中,如果递归调用的层数过多,可能会由于什么引发错误?

  • A. 系统分配的栈空间溢出
  • B. 系统分配的队列空间溢出
  • C. 系统分配的链表空间溢出
  • D. 系统分配的堆空间溢出
答案

A

每次函数调用(包括递归调用)时,系统都会在内存的区域中为该次调用分配一块空间(称为“栈帧”),用于存储函数的参数、局部变量、返回地址等信息。当函数执行完毕返回时,这块空间会被释放。

如果递归调用的层次非常深,就会连续不断地在栈上分配空间,而没有机会释放。由于系统分配给程序的栈空间是有限的,当占用的栈空间超出了这个限制时,就会发生“栈溢出”错误,导致程序异常终止。

2024 CSP-S1 T3

在 C++ 中,以下哪个函数调用会造成栈溢出?

  • A. int foo() { return 0; }
  • B. int bar() { int x = 1; return x; }
  • C. void baz() { int a[1000]; baz(); }
  • D. void qux() { return; }
答案

C。这个函数会导致栈溢出,原因有两个关键点:

  1. 无限递归:函数 baz() 在没有设置任何终止条件的情况下直接调用了自身。
  2. 栈上分配:每次调用 baz() 时,它都会在栈上创建一个包含 1000 个整数的数组 a

每次 baz() 调用自身,一个新的“栈帧”就会被推入调用栈。这个栈帧里包含了数组 a 所需的内存(在大多数系统中大约是 4KB 左右)以及函数的返回地址等信息。由于递归永不停止,调用栈会持续增长,直到耗尽所有分配给它的有限内存空间,最终导致程序因“栈溢出”错误而崩溃。


分治

如果想知道我国的人口数量,就需要进行人口普查。让每一个省份都去统计本省有多少人,然后将各省人口累加起来,就可以获得全国的人口数量。而要想知道某一个省的人口数量,可以让省里的每一个城市统计本市有多少人,然后将各市人口累加起来,就可以获得这个省的人口数量……以此类推,层层细分,最后统计一个村子或者一个小区有多少人,这个任务就足够简单了。把一个复杂的问题细分成若干结构相同但规模更小的子问题,然后将每个子问题的解合并起来,就得到了复杂问题的解,这就是分治策略。

2025 CSP-S1 T11

递归关系式 \(T(n)=2T(n/2)+O(n^2)\) 描述了某个分治算法的时间复杂度。请问该算法的时间复杂度是多少?

答案

为了便于推导,设本层合并开销为 \(f(n) = c \cdot n^2\),其中 \(c\) 为某个正常数,递归关系写为 \(T(n) = 2T(n/2) + c \cdot n^2\)

  • \(0\):规模为 \(n\),本层的处理开销为 \(W_0 = c \cdot n^2\)
  • \(1\):产生 \(2\) 个规模为 \(n/2\) 的子问题,每个子问题的开销为 \(c \cdot (n/2)^2 = \frac{1}{4} c \cdot n^2\),第 \(1\) 层的总开销为 \(W_1 = 2 \times \left(\frac{1}{4} c \cdot n^2\right) = \frac{1}{2} c \cdot n^2\)
  • \(2\):产生 \(4\) 个规模为 \(n/4\) 的子问题,每个子问题的开销为 \(c \cdot (n/4)^2 = \frac{1}{16} c \cdot n^2\),第 \(2\) 层的总开销为 \(W_2 = 4 \times \left(\frac{1}{16} c \cdot n^2\right) = \frac{1}{4} c \cdot n^2\)
  • \(i\) 层(一般规律):共有 \(2^i\) 个子问题,问题规模为 \(n/2^i\),第 \(i\) 层的总开销为 \(W_i = 2^i \times c \cdot \left(\frac{n}{2^i}\right)^2 = \left(\frac{1}{2}\right)^i c \cdot n^2\)

问题规模从 \(n\) 每次减半,直到缩小到 \(1\) 停止递归。设总深度为 \(k\),则有 \(\frac{n}{2^k} = 1 \implies k = \log_2 n\)最后一层共有 \(2^k = 2^{\log_2 n} = n\) 个子问题,若每个子问题的常数开销为 \(O(1)\),则最后一层的总开销为 \(W_k = O(n)\)

算法的总时间开销 \(T(n)\) 等于每一层开销总和 \(T(n) = W_k + \sum\limits_{i=0}^{\log_2 n - 1} W_i = O(n) + c \cdot n^2 \sum\limits_{i=0}^{\log_2 n - 1} \left(\frac{1}{2}\right)^i\),展开求和项,则有 \(T(n) = O(n) + c \cdot n^2 \left( 1 + \frac{1}{2} + \frac{1}{4} + \frac{1}{8} + \cdots + \left(\frac{1}{2}\right)^{\log_2 n - 1} \right)\)。括号内是一个首项为 \(1\)、公比 \(q = \frac{1}{2}\) 的有限等比数列,根据无穷递缩等比数列求和公式,当 \(q \lt 1\) 时,即使项数趋于无穷,其累加和也不会无限增长,而是收敛于一个有限常数 \(\sum\limits_{i=0}^{\infty} \left(\frac{1}{2}\right)^i = \frac{1}{1 - 1/2} = 2\)。这意味着括号内的求和结果恒小于 \(2\)\(T(n) \lt 2c \cdot n^2 + O(n)\)。随着 \(n\) 的增大,\(n^2\) 项增长速度远快于低阶项 \(O(n)\),因此整个算法的时间复杂度由最顶层的 \(O(n^2)\) 主导,即 \(T(n) = O(n^2)\)

P5461 赦免战俘

#include <cstdio>
const int N = 1050;
int a[N][N];
// 左上角坐标(x,y),边长为len的正方形
void solve(int x, int y, int len) {
	// 先考虑边界条件
	if (len==1) {
		a[x][y]=1;
		return;
	} 
	// 拆分问题
	// 左上角全为0(相当于不用处理)
	// 继续用同样的方式处理右上,左下,右下
	solve(x,y+len/2,len/2);
	solve(x+len/2,y,len/2);
	solve(x+len/2,y+len/2,len/2);
}
int main()
{
	int n; scanf("%d",&n);
	// n = (1<<n);
	int len=1;
	for (int i=1;i<=n;i++) len*=2;
	solve(1,1,len);
	for (int i=1;i<=len;i++) {
		for (int j=1;j<=len;j++) {
			printf("%d ",a[i][j]);
		}
		printf("\n");
	}
    return 0;
}

P1228 地毯填补问题

#include <cstdio>
int px, py;
int judge(int xx, int yy, int x, int y, int n) { // 判断残缺的块在哪个分区
    if (xx < x + n / 2) return yy < y + n / 2 ? 1 : 2; // 左上/右上
    return yy < y + n / 2 ? 3 : 4; // 左下/右下
}
void solve(int n, int x, int y, int miss, int xx, int yy) {
    if (n == 1) return;
    n = n / 2;
    if (miss == 1) {
        printf("%d %d %d\n", x + n, y + n, 1);
        solve(n, x, y, judge(xx, yy, x, y, n), xx, yy);
        solve(n, x, y + n, 3, x + n - 1, y + n);
        solve(n, x + n, y, 2, x + n, y + n - 1);
        solve(n, x + n, y + n, 1, x + n, y + n);
    } else if (miss == 2) {
        printf("%d %d %d\n", x + n, y + n - 1, 2);
        solve(n, x, y, 4, x + n - 1, y + n - 1);
        solve(n, x, y + n, judge(xx, yy, x, y + n, n), xx, yy);
        solve(n, x + n, y, 2, x + n, y + n - 1);
        solve(n, x + n, y + n, 1, x + n, y + n);
    } else if (miss == 3) {
        printf("%d %d %d\n", x + n - 1, y + n, 3);
        solve(n, x, y, 4, x + n - 1, y + n - 1);
        solve(n, x, y + n, 3, x + n - 1, y + n);
        solve(n, x + n, y, judge(xx, yy, x + n, y, n), xx, yy);
        solve(n, x + n, y + n, 1, x + n, y + n);
    } else { 
        printf("%d %d %d\n", x + n - 1, y + n - 1, 4);
        solve(n, x, y, 4, x + n - 1, y + n - 1);
        solve(n, x, y + n, 3, x + n - 1, y + n);
        solve(n, x + n, y, 2, x + n, y + n - 1);
        solve(n, x + n, y + n, judge(xx, yy, x + n, y + n, n), xx, yy);
    }
}
int main()
{
    int k;
    scanf("%d%d%d", &k, &px, &py);
    int len = 1;
    for (int i = 1; i <= k; i++) len *= 2;
    solve(len, 1, 1, judge(px, py, 1, 1, len), px, py);
    return 0;
}

例题:P1115 最大子段和

要找到整个序列的最大子段和,可以将其一分为二,从中点 \(mid\) 分成左右两个子序列:\(a_{left \dots mid}\)\(a_{mid+1 \dots right}\)

此时,最大子段和可能存在于三个位置:

  • 完全在左半部分:即 \(a_{left \dots mid}\) 内部。
  • 完全在右半部分:即 \(a_{mid+1 \dots right}\) 内部。
  • 跨越中点:这个子段既包含了左半部分的元素,也包含了右半部分的元素,并且一定跨过 \(mid\)\(mid+1\)

对于前两种情况,可以递归地求解来得到答案。递归的终止条件是当子序列只有一个元素时,最大子段和就是该元素自身的值。

对于第三种情况,即跨越中点的情况,无法直接通过递归得到。此时需要专门解决:

  • \(mid\) 开始向左遍历,找到一个以 \(mid\) 结尾的最大子段和。
  • \(mid+1\) 开始向右遍历,找到一个以 \(mid+1\) 开头的最大子段和。
  • 将这两个和相加,就得到了跨越中点的最大子段和。

比较上述三种情况得出的三个值,取其中最大者,即为当前问题的最终解。

这个分治算法的递推关系式是 \(T(n) = 2T(n/2) + O(n) = O(n \log n)\)

参考代码
#include <cstdio>
#include <algorithm> 

using namespace std;

const int N = 2e5 + 5;

int a[N];

/**
 * @brief 计算跨越中点的最大子段和
 * @param left 区间左边界
 * @param mid 区间中点
 * @param right 区间右边界
 * @return 返回跨越中点的最大子段和
 * @note 这个函数的线性扫描是整个分治算法时间复杂度为 O(n log n) 的原因
 */
int maxCrossingSum(int left, int mid, int right) {
    int sum = 0;
    // 初始化左半部分的最大和为一个极小值,这里直接用 a[mid] 作为初始值
    int left_sum = a[mid]; 
    
    // 从中点 `mid` 开始向左遍历,寻找以 a[mid] 结尾的最大连续和
    for (int i = mid; i >= left; i--) {
        sum += a[i];
        if (sum > left_sum) {
            left_sum = sum;
        }
    }

    // 重置 sum,准备计算右半部分
    sum = 0;
    // 初始化右半部分的最大和
    int right_sum = a[mid + 1];

    // 从 `mid + 1` 开始向右遍历,寻找以 a[mid+1] 开头的最大连续和
    for (int i = mid + 1; i <= right; i++) {
        sum += a[i];
        if (sum > right_sum) {
            right_sum = sum;
        }
    }
    
    // 跨越中点的最大子段和等于左、右两部分最大和之和
    return left_sum + right_sum;
}

/**
 * @brief 使用分治法递归求解最大子段和
 * @param left 区间左边界
 * @param right 区间右边界
 * @return 返回 a[left...right] 区间的最大子段和
 */
int maxSubArraySum(int left, int right) {
    // 递归的终止条件:如果区间只有一个元素,最大子段和就是它本身
    if (left == right) {
        return a[left];
    }

    // --- 1. 分解 (Divide) ---
    int mid = left + (right - left) / 2;

    // --- 2. 解决 (Conquer) ---
    // 递归求解左半部分的最大子段和
    int left_max = maxSubArraySum(left, mid);
    // 递归求解右半部分的最大子段和
    int right_max = maxSubArraySum(mid + 1, right);
    // 求解跨越中点的最大子段和
    int cross_max = maxCrossingSum(left, mid, right);

    // --- 3. 合并 (Combine) ---
    // 返回三种情况中的最大值
    return std::max({left_max, right_max, cross_max});
}

int main() {
    int n;
    scanf("%d", &n);

    for (int i = 1; i <= n; i++) {
        scanf("%d", &a[i]);
    }

    printf("%d\n", maxSubArraySum(1, n));
    
    return 0;
}

这个算法可以进一步改进,使其时间复杂度达到 \(O(n)\)。算法思想仍然是“分治”,但通过在“合并”步骤中进行优化,避免了 \(O(n \log n)\) 解法中重复的线性扫描,从而达到了 \(O(n)\) 的效率。

之前的分治法在“合并”步骤需要 \(O(n)\) 时间来计算跨越中点的最大和,为了优化这一点,必须让“合并”操作在 \(O(1)\) 时间内完成。

如何做到呢?诀窍在于递归函数返回更多的信息,而不仅仅是“最大子段和”。

对于任意一个区间 \(a_{left \dots right}\),维护以下四个值:

  1. max_sum:该区间的最大子段和(这是最终的答案)。
  2. prefix_sum:包含该区间最左侧元素 \(a_{left}\) 的最大子段和。
  3. suffix_sum:包含该区间最右侧元素 \(a_{right}\) 的最大子段和。
  4. total_sum:该区间所有元素的总和。

将这四个值封装在一个结构体里。

和之前一样,将区间 \(a_{left \dots right}\) 分为 \(a_{left \dots mid}\)\(a_{mid+1 \dots right}\)。递归地调用函数,获取左右两个子区间的信息包(称之为 lr)。

现在,利用 lr\(O(1)\) 的时间内计算出整个区间 \(a_{left \dots right}\) 的信息包 res

  • 显然,整体的总和是左右两边的总和相加。res.total_sum = l.total_sum + r.total_sum
  • 整个区间的“最大前缀和”有两种可能,左半部分的“最大前缀和”,或者它跨越了中点,等于左半部分的“总和”加上右半部分的“最大前缀和”。取这两者的较大值:res.prefix_sum = max(l.prefix_sum, l.total_sum + r.prefix_sum)
  • 与前缀和对称,整个区间的“最大后缀和”也有两种可能,右半部分的“最大后缀和”,或者它跨越了中点,等于右半部分的“总和”加上左半部分的“最大后缀和”。取这两者的较大值:res.suffix_sum = max(r.suffix_sum, r.total_sum + l.suffix_sum)
  • 整个区间的“最大子段和”有三种可能:左半部分的“最大子段和”,右半部分的“最大子段和”,或者它跨越了中点,这个跨越中点的最大和,恰好等于左半部分的“最大后缀和”与右半部分的“最大前缀和”之和。取这三者的最大值:res.max_sum = max({l.max_sum, r.max_sum, l.suffix_sum + r.prefix_sum})

通过这种方式,每次合并都只需要几次简单的加法和比较,是 \(O(1)\) 操作。

递推关系式变为 \(T(n) = 2T(n/2) + O(1) = O(n)\)

参考代码
#include <cstdio>
#include <algorithm> 

using namespace std;

const int N = 2e5 + 5;

int a[N];

// 定义一个结构体来存储区间信息 
struct SubArrayInfo {
    int max_sum;    // 区间最大子段和
    int prefix_sum; // 包含左边界的最大子段和   
    int suffix_sum; // 包含右边界的最大子段和
    int total_sum;  // 区间总和 
};

// 合并两个子区间信息的函数
SubArrayInfo pushUp(const SubArrayInfo& l, const SubArrayInfo& r) {
    SubArrayInfo res;
    // 计算总和
    res.total_sum = l.total_sum + r.total_sum;
    // 计算最大前缀和
    res.prefix_sum = max(l.prefix_sum, l.total_sum + r.prefix_sum);
    // 计算最大后缀和   
    res.suffix_sum = max(r.suffix_sum, r.total_sum + l.suffix_sum);
    // 计算最大子段和
    int cross_max = l.suffix_sum + r.prefix_sum;
    res.max_sum = max({l.max_sum, r.max_sum, cross_max});
    return res;  
}

// O(n) 分治法求解主函数
SubArrayInfo solve(int left, int right) {
    // 基本情况:区间只有一个元素
    if (left == right) { 
        int val = a[left];      
        return {val, val, val, val};
    }
    // 1. 分解     
    int mid = left + (right - left) / 2;   
    // 2. 解决
    SubArrayInfo left_info = solve(left, mid); 
    SubArrayInfo right_info = solve(mid + 1, right);
    // 3. 合并
    return pushUp(left_info, right_info); 
}

int main() {
    int n;
    scanf("%d", &n);

    for (int i = 1; i <= n; i++) {
        scanf("%d", &a[i]);
    }

    printf("%d\n", solve(1, n).max_sum);
    
    return 0;
}

阅读程序题

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

int n, a[1005];

struct Node 
{
	int h, j, m, w;

	Node(const int _h, const int _j, const int _m, const int _w) : 
		h(_h), j(_j), m(_m), w(_w)
	{ }

	Node operator+(const Node &o) const 
	{
		return Node(
			max(h, w + o.h),
			max(max(j, o.j), m + o.h),
			max(m + o.w, o.m),
			w + o.w);
	}
};

Node solve1(int h, int m)
{
	if (h > m)
		return Node(-1, -1, -1, -1);
	if (h == m)
		return Node(max(a[h], 0), max(a[h], 0), max(a[h], 0), a[h]);
	int j = (h + m) >> 1;
	return solve1(h, j) + solve1(j + 1, m);
}

Node solve2(int h, int m) 
{
	if (h > m)
		return -1;
	if (h == m)
		return max(a[h], 0);
	int j = (h + m) >> 1;
	int wh = 0, wm = 0;
	int wht = 0, wmt = 0;
	for (int i = j; i >= h; i--) {
		wht += a[i];
		wh = max(wh, wht);
	}
	for (int i = j + 1; i <= m; i++) {
		wmt += a[i];
		wm = max(wm, wmt);
	}
	return max(max(solve2(h, j), solve2(j + 1, m)), wh + wm);
}

int main()
{
	cin >> n;
	for (int i = 1; i <= n; i++) cin >> a[i];
	cout << solve1(1, n).j << endl;
	cout << solve2(1, n) << endl;
	return 0;
}

假设输入的所有数的绝对值都不超过 1000,完成下面的判断题和单选题:

判断题

  1. 程序总是会正常执行并输出两行两个相等的数。

  2. 第 28 行与第 38 行分别有可能执行两次及以上。

  3. 当输入为 5 -10 11 -9 5 -7 时,输出的第二行为 7

单选题

  1. solve1(1, n) 的时间复杂度为?
  • A. \(O(\log n)\)
  • B. \(O(n)\)
  • C. \(O(n \log n)\)
  • D. \(O(n^2)\)
  1. solve2(1, n) 的时间复杂度为?
  • A. \(O(\log n)\)
  • B. \(O(n)\)
  • C. \(O(n \log n)\)
  • D. \(O(n^2)\)
  1. 当输入为 10 -3 2 10 0 -8 9 -4 -5 9 4 时,输出的第一行为?
  • A. 13
  • B. 17
  • C. 24
  • D. 12
答案

代码分析

这段代码实现了两种分治算法(solve1solve2)来求解同一个问题:最大子段和

判断题

  1. 正确

  2. 错误。当输入的 \(n\) 小于等于 0 时,第 28 行和第 38 行会被执行到,但最多执行一次。当 \(n\) 大于 0 时,这两行不会被执行到。

  3. 错误。该数据下最大子段和应该是 11。

单选题

  1. B

  2. C

  3. B\(2+10+0+(-8)+9+(-4)+(-5)+9+4=17\)

posted @ 2023-08-07 06:10  RonChen  阅读(276)  评论(0)    收藏  举报