第一讲 递归与递推

准备:做题的套路

做题步骤

  1. 根据题目描述,抽象出模型
  2. 不断思考尝试,能不能用dfs?图论?dp?贪心?
  3. 检查
    1. 正确性
    2. 时间复杂度

注意:多用草稿纸,多模拟,少空想。

C++,一秒钟大概能执行1亿次,10^8次,如果时间复杂度小于此值,那么就能在1秒钟之内算出来。

给出不同数据范围下,代码的时间复杂度和算法该如何选择。

一般ACM或者笔试题的时间限制是1秒或2秒。
在这种情况下,C++代码中的操作次数控制在 107∼108107∼108 为最佳。 下面给出在不同数据范围下,代码的时间复杂度和算法该如何选择: 1递归与递推 - 图1

例如:如果数据范围比较小(n<30),那么时间复杂度要求比较低,为指数级别,可以使用dfs+减枝算法。

如果n<=100,O(n^3)

如果n<=1000,O(n^2)

这样可以做一个粗略的判断,这道题可能是什么时间复杂度,应该用什么算法。

第一节 递归

scanf、printf和cin、cout的使用区别

1递归与递推 - 图2

引子:斐波那契数列

1递归与递推 - 图3

递归搜索树,帮助理解递归

1递归与递推 - 图4

例题:递归实现指数型枚举

https://www.acwing.com/problem/content/94/

题目描述
从 1~n 这 n 个整数中随机选取任意多个,输出所有可能的选择方案。 输入格式
输入一个整数n。 输出格式
每行输出一种方案。 同一行内的数必须升序排列,相邻两个数用恰好1个空格隔开。 对于没有选任何数的方案,输出空行。 本题有自定义校验器(SPJ),各行(不同方案)之间的顺序任意。 数据范围
1≤n≤15 输入样例:
3
输出样例: 3
2
2 3
1
1 3
1 2
1 2 3

思路:

指数型枚举,给他n个坑,然后就是每个坑选与不选的问题。

1递归与递推 - 图5

代码:

一般先无脑打上这四个头文件

1递归与递推 - 图6

mycode:

  1. #include <cstdio>
  2. #include <cstring>
  3. #include <iostream>
  4. #include <algorithm>
  5. using namespace std;
  6. const int N = 16; //use index 1-15
  7. int st[N]; //状态:记录每个位置的状态:0表示还没考虑,1表示选它,2表示不选它。
  8. int n; //1-n个数
  9. void dfs(int c) {
  10. if(c > n) {
  11. //输出方案
  12. for(int i = 1; i <= n; i++) {
  13. if(st[i] == 1){
  14. cout << i << " ";
  15. }
  16. }
  17. cout << "\n";
  18. return;
  19. }
  20. //不选它
  21. st[c] = 2;
  22. dfs(c + 1);
  23. st[c] = 0;
  24. //选它
  25. st[c] = 1;
  26. dfs(c + 1);
  27. st[c] = 0;
  28. }
  29. int main() {
  30. cin >> n;
  31. dfs(1);
  32. return 0;
  33. }

例题:递归实现排列型枚举

https://www.acwing.com/problem/content/96/

题目描述
把 1~n 这 n 个整数排成一行后随机打乱顺序,输出所有可能的次序。 输入格式
一个整数n。 输出格式
按照从小到大的顺序输出所有方案,每行1个。 首先,同一行相邻两个数用一个空格隔开。 其次,对于两个不同的行,对应下标的数一一比较,字典序较小的排在前面。 数据范围
1≤n≤9 输入样例
3
输出样例
1 2 3
1 3 2
2 1 3
2 3 1
3 1 2
3 2 1

字典序概念:比较简单,不多说。

ai不存在则认为它是最小的,所以一定小于存在的bi。

想一个递归枚举全排列,就要先想一个顺序。一般有两种方式。

  1. 依次枚举每个数,放到哪个位置。
  2. 依次枚举每个位置放哪个数。

该题的递归搜索树。

1递归与递推 - 图7

这个就是枚举每个坑应该放什么数。

观察这个递归搜索树,已经按照字典序排列了。

想一下我们枚举的次序,都是从小到大枚举,能够直接按照字典序排列也就不足为奇了。

my code

  1. #include <cstdio>
  2. #include <cstring>
  3. #include <iostream>
  4. #include <algorithm>
  5. using namespace std;
  6. const int maxn = 10; //use index 1-9
  7. int n;
  8. int st[maxn]; //状态,0表示没放,1~n表示放了的数
  9. bool used[maxn]; //false表示没有用,true表示用了
  10. void dfs(int step) {
  11. //数填满了
  12. if(step > n) {
  13. //输出方案
  14. for(int i = 1; i <= n; i++ ) {
  15. cout << st[i] << " ";
  16. }
  17. cout << "\n";
  18. }
  19. //枚举当前位置能够填的数
  20. for(int i = 1; i <= n; i++ ) {
  21. if(!used[i]){
  22. st[step] = i;
  23. used[i] = true;
  24. dfs(step + 1);
  25. used[i] = false;
  26. }
  27. }
  28. }
  29. int main() {
  30. cin >> n;
  31. dfs(1);
  32. return 0;
  33. }

时间复杂度的思考,暂不关注,反正是指数级别。

作业:递归实现组合型枚举

https://www.acwing.com/problem/content/95/

题目描述
从 1~n 这 n 个整数中随机选出 m 个,输出所有可能的选择方案。 输入格式
两个整数 n,m ,在同一行用空格隔开。 输出格式
按照从小到大的顺序输出所有方案,每行1个。 首先,同一行内的数升序排列,相邻两个数用一个空格隔开。 其次,对于两个不同的行,对应下标的数一一比较,字典序较小的排在前面(例如1 3 5 7排在1 3 6 8前面)。 数据范围
n>0,
0≤m≤n ,
n+(n−m)≤25 输入样例:
5 3
输出样例:
1 2 3
1 2 4
1 2 5
1 3 4
1 3 5
1 4 5
2 3 4
2 3 5
2 4 5
3 4 5

思路:

组合型枚举,也就是排列组合中的组合,排列需要输出每种顺序,组合则每种顺序都算一种。

此题要求按字典序输出组合,那组合中的后一个数总是比前一个数大,由此特性,添加了一个变量start,使得循环总从start开始执行。

my code way:

  1. #include <cstdio>
  2. #include <cstring>
  3. #include <iostream>
  4. #include <algorithm>
  5. using namespace std;
  6. const int maxn = 51;
  7. int a[maxn];
  8. int n,m;
  9. void dfs(int step, int start) {
  10. //如果位数满了,那么输出
  11. if(step > m){
  12. for(int i = 1; i <= m; i++) {
  13. cout << a[i] << " ";
  14. }
  15. cout << "\n";
  16. return;
  17. }
  18. for(int i = start; i <= n; i++) {
  19. a[step] = i;
  20. dfs(step + 1, i + 1);
  21. //a[step] = 0;//恢复现场,这里可以省略
  22. }
  23. }
  24. int main() {
  25. cin >> n >> m;
  26. dfs(1,1);
  27. return 0;
  28. }

my code 剪枝优化:

  1. #include <cstdio>
  2. #include <cstring>
  3. #include <iostream>
  4. #include <algorithm>
  5. using namespace std;
  6. const int maxn = 51;
  7. int a[maxn];
  8. int n,m;
  9. void dfs(int step, int start) {
  10. //剪枝:如果已经选了的位数+能选的剩余位数仍然小于m,那么直接返回
  11. if(step-1 + (n-start+1) < m) return;
  12. //如果位数满了,那么输出
  13. if(step > m){
  14. for(int i = 1; i <= m; i++) {
  15. cout << a[i] << " ";
  16. }
  17. cout << "\n";
  18. return;
  19. }
  20. for(int i = start; i <= n; i++) {
  21. a[step] = i;
  22. dfs(step + 1, i + 1);
  23. //a[step] = 0;//恢复现场,这里可以省略
  24. }
  25. }
  26. int main() {
  27. cin >> n >> m;
  28. dfs(1,1);
  29. return 0;
  30. }

作业:带分数

https://www.acwing.com/problem/content/1211/

100 可以表示为带分数的形式:100=3 + 69258 / 714
还可以表示为:100=82 + 3546 / 197
注意特征:带分数中,数字 1∼9 分别出现且只出现一次(不包含 0)。 类似这样的带分数,100 有 11 种表示法。 输入格式
一个正整数。 输出格式
输出输入数字用数码 1∼9 不重复不遗漏地组成带分数表示的全部种数。 数据范围
1≤N<106 样例
输入样例1:
100
输出样例1:
11
输入样例2:
105
输出样例2:
6

代码:

  1. #include <cstdio>
  2. #include <cstring>
  3. #include <iostream>
  4. #include <algorithm>
  5. using namespace std;
  6. const int maxn = 100;
  7. int st[10];//保存全排列的结果
  8. bool book[10];//标记使用过,用1-9
  9. int calc(int l,int r) {
  10. int res = 0;
  11. for(int i = l; i <= r; i++) {
  12. res = res * 10 + st[i];
  13. }
  14. return res;
  15. }
  16. int dfs(int step, int target, int& ans) {
  17. if(step > 9){
  18. //两层循环分三段
  19. for(int i = 1; i <= 7; i++) {
  20. for(int j = i + 1; j <= 8; j++) {
  21. int b = calc(i+1, j);
  22. int c = calc(j+1, 9);
  23. if(b%c != 0)continue;
  24. int a = calc(1, i);
  25. if(a + (b/c) == target)
  26. ans++;
  27. }
  28. }
  29. }
  30. for(int i = 1; i <= 9; i++) {
  31. if(book[i] == false){
  32. st[step] = i;
  33. book[i] = true;
  34. dfs(step + 1, target, ans);
  35. book[i] = false;
  36. }
  37. }
  38. }
  39. int main() {
  40. int n, ans;
  41. cin >> n;
  42. ans = 0;
  43. dfs(1,n,ans);
  44. cout << ans;
  45. return 0;
  46. }

优化算法:

  1. #include <cstdio>
  2. #include <cstring>
  3. #include <iostream>
  4. #include <algorithm>
  5. using namespace std;
  6. const int N = 10;
  7. int n;
  8. bool st[N], backup[N];
  9. int ans;
  10. bool check(int a, int c)
  11. {
  12. int b = n * c - a * c;
  13. if (!a || !b || !c) return false;
  14. memcpy(backup, st, sizeof st);
  15. while (b)
  16. {
  17. //逐个取数
  18. int x = b % 10;
  19. b /= 10;
  20. if (!x || backup[x]) return false;//如果该位是0或该位数字用过了,返回false;
  21. backup[x] = true;//标记用过
  22. }
  23. //如果1-9都用过了,那么正确
  24. for (int i = 1; i <= 9; i ++ )
  25. if (!backup[i])
  26. return false;
  27. return true;
  28. }
  29. void dfs_c(int a, int c)
  30. {
  31. if (check(a, c)) ans ++ ;
  32. for (int i = 1; i <= 9; i ++ )
  33. if (!st[i])
  34. {
  35. st[i] = true;
  36. dfs_c(a, c * 10 + i);
  37. st[i] = false;
  38. }
  39. }
  40. void dfs_a(int a)
  41. {
  42. if (a >= n) return;
  43. if (a) dfs_c(a, 0);
  44. for (int i = 1; i <= 9; i ++ )
  45. if (!st[i])
  46. {
  47. st[i] = true;
  48. dfs_a(a * 10 + i);
  49. st[i] = false;
  50. }
  51. }
  52. int main()
  53. {
  54. cin >> n;
  55. dfs_a(0);
  56. cout << ans << endl;
  57. return 0;
  58. }

比赛中使用第一种方法即可。在比赛中,需要使用更加稳妥的算法来AC。

第二节 递推

例题:斐波那契数列

https://www.acwing.com/problem/content/719/

以下数列0 1 1 2 3 5 8 13 21 …被称为斐波纳契数列。 这个数列从第3项开始,每一项都等于前两项之和。 输入一个整数N,请你输出这个序列的前N项。

输入格式

一个整数N。

输出格式

在一行中输出斐波那契数列的前N项,数字之间用空格隔开。

数据范围

0

输入样例:

5

输出样例:

0 1 1 2 3

way 1 code:

  1. #include <iostream>
  2. using namespace std;
  3. const int maxn = 50;
  4. long long arr[maxn]={0,1};
  5. int main(){
  6. int n;
  7. cin >> n;
  8. for(int i = 0; i < n; i++) {
  9. if(i >= 2) arr[i] = arr[i-1] + arr[i-2];
  10. cout << arr[i] << " ";
  11. }
  12. return 0;
  13. }

way 2 code:

  1. #include <iostream>
  2. using namespace std;
  3. const int maxn = 50;
  4. int main(){
  5. int n;
  6. cin >> n;
  7. int a,b,c;
  8. a = 0; b = 1;
  9. for(int i = 0; i < n; i++) {
  10. cout << a << " ";
  11. c = a + b;
  12. a = b;
  13. b = c;
  14. }
  15. return 0;
  16. }

way2有滚动数组的思想。

例题:费解的开关

https://www.acwing.com/problem/content/97/

你玩过“拉灯”游戏吗?25盏灯排成一个5x5的方形。每一个灯都有一个开关,游戏者可以改变它的状态。每一步,游戏者可以改变某一个灯的状态。游戏者改变一个灯的状态会产生连锁反应:和这个灯上下左右相邻的灯也要相应地改变其状态。 我们用数字“1”表示一盏开着的灯,用数字“0”表示关着的灯。下面这种状态 10111
01101
10111
10000
11011 在改变了最左上角的灯的状态后将变成: 01111
11101
10111
10000
11011 再改变它正中间的灯后状态将变成: 01111
11001
11001
10100
11011 给定一些游戏的初始状态,编写程序判断游戏者是否可能在6步以内使所有的灯都变亮。

输入格式

第一行输入正整数n,代表数据中共有n个待解决的游戏初始状态。 以下若干行数据分为nn组,每组数据有5行,每行5个字符。每组数据描述了一个游戏的初始状态。各组数据间用一个空行分隔。

输出格式

一共输出nn行数据,每行有一个小于等于6的整数,它表示对于输入数据中对应的游戏状态最少需要几步才能使所有灯变亮。 对于某一个游戏初始状态,若6步以内无法使所有灯变亮,则输出“-1”。

数据范围

0

输入样例:

3
00111
01011
10001
11010
11100 11101
11101
11110
11111
11111 01111
11111
11111
11111
11111 输出样例: 3
2
-1

思路:

枚举第一行的所有按法,按下第一行,然后根据第一行的情况,按第二行,使得第一行为全亮,之后根据第二行的情况,按第三行,使得第二行全亮,以此类推。最后,如果按照这种按法,最后一行全亮了,那么这是一种解,比较最小步数。最后得到答案。

mycode:

  1. #include <cstdio>
  2. #include <cstring>
  3. #include <iostream>
  4. #include <algorithm>
  5. using namespace std;
  6. int a[5][5];//存灯表
  7. int backup[5][5];//备份,用于回溯
  8. int xg[5][2] = {
  9. {0,0},{0,1},{0,-1},{1,0},{-1,0},
  10. };//5个相关灯的偏移量
  11. int cstep,mstep = 9999;
  12. //对应坐标取反
  13. void qf(int x, int y) {
  14. a[x][y] = (a[x][y]==0 ? 1 : 0);
  15. }
  16. //改变第x行第y列灯和相关灯的状态
  17. void turn(int x, int y) {
  18. for (int i = 0; i < 5; i++) {
  19. int tx = x + xg[i][0];
  20. int ty = y + xg[i][1];
  21. if(tx < 0 || tx > 4 || ty <0 || ty > 4)continue;
  22. qf(tx,ty);
  23. }
  24. }
  25. void solve() {
  26. memcpy(backup, a, sizeof a);//将a备份
  27. mstep = 9999;//重置最小步
  28. //枚举第一行的所有按法,从二进制00000 ~ 11111,1表示要按,0表示不按
  29. for (int op = 0; op <= 31; op++) {
  30. //尝试这一种按法
  31. cstep = 0;//当前步数重置为0
  32. for (int i = 0; i < 5; i++) {
  33. if((op >> i) & 1) {
  34. turn(0, 4-i);
  35. cstep ++;
  36. }
  37. }
  38. //根据第i行来按第i+1行
  39. for (int i = 0; i < 4; i++) {
  40. for (int j = 0; j < 5; j++) {
  41. if(a[i][j] == 0) {
  42. turn(i + 1, j);
  43. cstep ++;
  44. }
  45. }
  46. }
  47. //如果最后第4行为全1,那么方案成功,更新最小步数
  48. int j;
  49. for (j = 0; j < 5; j++) {
  50. if(a[4][j] == 0)break;
  51. }
  52. if(j == 5) {//没有提前跳出,所以最后一行全为1
  53. mstep = min(cstep,mstep);//更新最小步数
  54. }
  55. //该次尝试结束,回溯恢复a
  56. memcpy(a, backup, sizeof backup);
  57. }
  58. }
  59. int main() {
  60. //一个一个出解
  61. int n;
  62. cin >> n;
  63. for (int i = 0; i < n; i++) {
  64. for (int j = 0; j < 5; j++) {
  65. for (int k = 0; k < 5; k++) {
  66. scanf("%1d", &a[j][k]);
  67. }
  68. }
  69. //输完一个开始处理
  70. solve();
  71. //如果步数超或无解(mstep==9999),则输出-1
  72. if(mstep > 6) mstep = -1;
  73. cout << mstep << endl;
  74. }
  75. return 0;
  76. }

作业:飞行员兄弟

https://www.acwing.com/problem/content/118/

“飞行员兄弟”这个游戏,需要玩家顺利的打开一个拥有16个把手的冰箱。 已知每个把手可以处于以下两种状态之一:打开或关闭。 只有当所有把手都打开时,冰箱才会打开。 把手可以表示为一个4х4的矩阵,您可以改变任何一个位置[i,j]上把手的状态。 但是,这也会使得第i行和第j列上的所有把手的状态也随着改变。 请你求出打开冰箱所需的切换把手的次数最小值是多少。

输入格式

输入一共包含四行,每行包含四个把手的初始状态。 符号“+”表示把手处于闭合状态,而符号“-”表示把手处于打开状态。 至少一个手柄的初始状态是关闭的。

输出格式

第一行输出一个整数N,表示所需的最小切换把手次数。 接下来N行描述切换顺序,每行输入两个整数,代表被切换状态的把手的行号和列号,数字之间用空格隔开。 注意:如果存在多种打开冰箱的方式,则按照优先级整体从上到下,同行从左到右打开。

数据范围

1≤i,j≤4

输入样例:

  1. -+--
  2. ----
  3. ----
  4. -+--

输出样例:

6
1 1
1 3
1 4
4 1
4 3
4 4

思路:

枚举所有按法,使得把手全开,比较最小步数,同时记录最小步数的操作。

mycode:

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

char a[4][5],backup[4][5];
int mop;
int cstep,mstep = 9999;

void qf(int x, int y) {
    a[x][y] = (a[x][y] == '-' ? '+' : '-'); 
}

void turn(int x, int y) {
    for (int i = 0; i < 4; i++) {
        qf(i, y);
        qf(x, i);
    }
    qf(x, y);
}

void solve() {
    //枚举所有操作0~2^16-1
    memcpy(backup, a, sizeof a);//备份 
    for (int op = 0; op < 65536; op++) {
        //执行当前操作
        cstep = 0; // 重置当前步 
        for (int i = 0; i < 16; i++) {
            if (op >> i & 1) {
                turn(i / 4, i % 4);
                cstep ++;
            }
        }
        //如果全是-,那么成功,判最小步
        int i;
        for (i = 0; i < 16; i++) {
            if (a[i/4][i%4] == '+') break;
        }
        if(i == 16) {//全是-,判最小步 
            if(cstep < mstep) {
                mstep = cstep;
                mop = op;//记录最小步的操作 
            }
        }
        //回溯恢复 
        memcpy(a, backup, sizeof backup);
    }
}

int main() {
    for (int i = 0; i < 4; i++) {
        cin >> a[i];//评测系统用不了gets是没想到的 
    }
    solve();
    //输出最小步 
    cout << mstep << endl;
    //输出最小步的操作
    for(int i = 0; i < 16; i++) {
        if(mop >> i & 1) {
            cout << i / 4 + 1 << " ";
            cout << i % 4 + 1 << "\n";
        }
    }
    return 0;
}

作业:翻硬币

https://www.acwing.com/problem/content/1210/

小明正在玩一个“翻硬币”的游戏。 桌上放着排成一排的若干硬币。我们用 表示正面,用 o 表示反面(是小写字母,不是零)。 比如,可能情形是:`oooooo如果同时翻转左边的两个硬币,则变为:oooo*oooo` 现在小明的问题是:如果已知了初始状态和要达到的目标状态,每次只能同时翻转相邻的两个硬币,那么对特定的局面,最少要翻动多少次呢? 我们约定:把翻动相邻的两个硬币叫做一步操作。

输入格式

两行等长的字符串,分别表示初始状态和要达到的目标状态。

输出格式

一个整数,表示最小操作步数

数据范围

输入字符串的长度均不超过100。
数据保证答案一定有解。

输入样例1:

**********
o****o****

输出样例1:

5

输入样例2:

*o**o***o***
*o***o**o***

输出样例2:

1

思路:递推方法,设初始状态为a,目标状态为b,如果ai ≠ bi,则翻转ai 和 ai+1,inc步数,因为题目说明一定有解,所以一定能翻转成功,最后得到结果。

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

char a[110],b[110];

void qf(int i) {
    a[i] = (a[i] == '*'?'o':'*');
}

int main() {
    cin >> a >> b;//空格和换行都会进下个输入
    int res = 0;
    int len = strlen(a);
    for (int i = 0; i < len - 1; i++) {
        if (a[i] != b[i]) {
            qf(i);
            qf(i+1);
            res++;
        }
    }
    cout << res;
    return 0;
}