蓝桥杯十大常见天阶功法——水之呼吸.壹之型.递归

简介: 蓝桥杯十大常见天阶功法——水之呼吸.壹之型.递归

一、由数据范围反推算法复杂度以及算法内容


一般算法题的时间限制是1秒或2秒。

在这种情况下,C++代码中的操作次数控制在 1 0 7 ∼ 1 0 8 10^7 \sim 10^810

7∼10 8大概就是一亿次运算的样子为最佳。


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


接下的内容是一个很宽泛但是也很实用的总结,不用刻意的背诵,很难暴力的记住,建议收藏,假如感觉忘记了,就翻开看一下,更加符合人类的记忆遗忘曲线,用这种方式进行迭代记忆会更轻松 ^ - ^

image.png

二、简述状态空间


一个实际问题的各种可能情况构成的集合通常称为状态空间,程序的运行可以看做对这个集合的处理,也就是对状态空间的遍历。

算法和数据结构的主要目的就是通过划分、归纳、提取、抽象来帮助提高程序遍历状态空间的效率。

递归和递推就是程序遍历状态空间的两种基本方式。


三、递推与递归的宏观描述


对于一个待求解问题,当它局限在某处的边界、某个小范围或者某种特殊情形下时,其答案往往是已知的了。

如果能够将当前这个解答的应用场景扩大到原问题的状态空间,并且拓展过程中每个步骤具有相似性,也就是着重考虑当前步骤的逻辑怎么实现,下一步的逻辑和当前这步的一样。那么此时就可以考虑递归和递推。

用已知的问题边界作为起点,向原问题正向推导的拓展方式是递推。


推导的路线难以确定,这时以原问题作为起点,尝试寻找把每个状态空间缩小到已知的问题边界的路线,再通过该路线反向回溯的遍历方式是递归


四、递归和递推的简单应用


使用递归和递推要求把原问题与问题边界之间的每个变换步骤具有相似性,这样我们才能够设计一段程序实现这个步骤,将其重复作用于问题之中。


换句话说,程序在每个步骤上应该面对相同种类的问题,这些问题都是原问题的一个子问题,可能仅在规模或者某些限制条件上有所区别,因为是子问题,那么,也就理所应当能够用求解原问题的程序进行求解

image.png


递归


递归的火柴人演示

微信图片_20221020220437.jpg

对火柴人演示的具体流程图如下:

微信图片_20221020220507.png

通过这张流程图可以看到,递归程序的基本单位是由缩小、求解、扩展组成的一种变换步骤,只是在求解时候,因为问题的"相似性,不断重复使用这样一种变换步骤,直至在已知的问题边界上直接确定答案。对于其中任意一条从原问题到问题边界的变换路线,例如图中左边我用实线圈出来的部分。

从横向来看,它的每一层是一次递归程序体的执行;


从纵向来看,它的左右两边分别是寻找路线和沿这路线进行推导的流程。


为了保证每层的缩小与扩展能够衔接在同一形式的问题上,求解操作自然要保证在执行前后程序面对同一个问题的状态是相同的,也就是还原现场的必要性。

对还原现场举个栗子吧


我现在面前有一个双叉路口,我可以向左走,也可以向右走,我现在向右走,走呀走呀走呀,发现到达这条路的边界了,我慢慢退回去(也就是代码中的回溯),当我退回原来的双叉路口的时候,这里是空空荡荡的,和我最初进行选择的时候一模一样。我可以选择右边那条路,这个岔口没有被什么大石头堵住。

接下来就通过以下这些常见的题型具体的进入递归的世界吧


例1、试题 算法提高 判断水仙花数


题目描述

微信图片_20221020220736.jpg

解题报告


判水仙花数应该是才接触C语言时候


解法很多,可以直接循环,也可以递归。循环的效率是要更高的,hh~。这里就提供递归的解法吧。让大家对每个递归函数都会有的基线条件和递归条件掌握得更清楚


参考代码(C++版本)

#include <cstdio>
#include <iostream>
#include <cstring>
#include <cmath>
#include <algorithm>
using namespace std;
int n,res,cnt;
void f(int x)
{
  //递归:基线\边界条件+递归条件
  //基线条件
  if(x < 10)
  {
    res += (int)pow(x,cnt);
    return;//记得及时退出递归
  }
  //递归部分
  res += (int)pow((x%10),cnt);
  f(x /= 10);
}
int main()
{
  cin >> n;
  int t = n,r = n;
  //求当前这个数的位数
  while(t)
  {
    cnt ++;
    t /= 10;
  }
    f(r);
  if(res == n) cout << "YES" << endl;
  else cout << "NO" << endl;
    return 0;
}

例2、试题 算法提高 逆向输出各位数字


题目描述

微信图片_20221020220908.jpg

解题报告


题目说意思其实是让我们补全一个递归函数,可以实现从低位向高位输出。假如直接写全部代码放oj上是会 Compile Error 的


获取低位的数字可以用模运算实现。


对于递归,我想再提一下我在分享深搜dfs的时候说的一句话,叫做只考虑当前这步的逻辑,下一步的逻辑和当前的一致。


那么我们就考虑好基线条件也就是递归结束的边界,然后再编写递归条件中的逻辑。


参考代码(C++版本)

  if(n == 0) return;
  printf("%d ",n%10);
  dfs(n /= 10);


例3、试题 算法提高 进制转换


题目描述

微信图片_20221020221114.jpg

解题报告


一、模拟十进制转其他进制——除基数取余法。

用十进制转二进制作为例子,其他都是类似的。

方法:除2取余法,即每次将整数部分除以2,把余数挂在一边,商继续除以2,这个步骤一直持续下去,直到商为0为止,最后读数时候,从最后一个余数读起,倒着读回去

微信图片_20221020221159.png

即(43)D=(101011)B。

对于八进制,就更改被除数为8就好。


需要注意的是十六进制,被除数需要被修改为16,同时,倘若余数是大于等于10 ,此时就需要使用字母代替。A ∼ F A\sim FA∼F分别代替10 ∼ 15 10\sim 1510∼15

微信图片_20221020221234.png

即(796)D=(31C)H。


二、实现的细节


通过上文中的模拟,可以明确,我们的核心是去实现除基数取余的操作。运用 取模运算符% \%% 和除法运算符  / \ / / 能够实现这步。


比较棘手的是对A ∼ F A\sim FA∼F分别代替10 ∼ 15 10\sim 1510∼15的表示上,这里可以玩出很多花的。


最朴实的玩法是用打表的方式,提前开一个数组存放0 ∼ F 0\sim F0∼F的每个数据。


参考代码(C++版本)

#include <iostream>
#include <cstdio>
#include <cmath>
#include <vector>
using namespace std;
char d[16]={'0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F'};
vector<char> ans;
void f(int n,int k){
  if(n == 0 ) return;
  //取模+除
  int r;
  r=n%k;
  n=n/k;
  //记录答案
  ans.push_back(d[r]);
  //递归进去
  f(n,k);
}
int main(){
  int x,m;
  cin>>x>>m;
  f(x,m);
  for(int i =ans.size() -1 ; i >= 0;i--) cout << ans[i];
  return 0;
}

例4、递归实现指数型枚举


题目描述

微信图片_20221020221433.jpg

解题报告


一、看数据范围大致确定算法的时间/空间复杂度

image.png


二、核心——顺序


递归或者说是dfs的时候,最重要的是顺序,需要定一个顺序,可以把所有方案不重复不疏漏的找出来。对于这道题而言,就可用从1~n nn依次考虑每个数,选或者不选。


假如n nn是3,则可以获得下面这棵递归搜索树

微信图片_20221020221548.jpg

观察递归搜素树,可以发现,对于每个位置,我们是需要去记录它的状态。比如当前位置的数字,是选择它了,还是不选择它了,亦或没有考虑到这个位置。

三、细节

恢复现场

微信图片_20221020221621.png

记得恢复现场,要保证当程序从边界回溯回到原本的位置的时候,可以做的选择仍旧当初递归进去时候的状态。


参考代码(C++版本)

#include <cstdio>
#include <cstring>
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 20;
int n;
int st[N];//用于记录状态的数组,默认是0,也就是什么都没有放,假如是1就表示不放,假如是2表示放
void dfs(int u)
{
    //处理递归的边界情况,此时还需要将到达边界的数组中存的信息输出出来
    if(u >n)
    {
        for(int i = 1; i <= n;i++)
            if(st[i] == 2) cout << i << ' ';
        cout << endl;
        return;
    }
    st[u] = 1;
    dfs(u+1);
    st[u] = 2;
    dfs(u+1);
    st[u] = 0;//恢复现场
}
int main()
{
    cin >> n;
    //注意数据范围是1 ~ n ,所以dfs的起点是1
    dfs(1);
    return 0;
}

例5、递归实现排列型枚举


题目描述

微信图片_20221020221736.jpg

解题报告


一、观察数据范围


数据范围

1≤n nn≤9


记住9 ! 9!9! = 326880,因为要输出n nn个方案,那么大致应该是n ∗ n ! n *n!n∗n!,心里估摸着可能是一个d f s dfsdfs


二、字典序


在英文字典中,排列单词的顺序是先按照第一个字母以升序排列(即a 、 b 、 c … … z a、b、c……za、b、c……z 的顺序);如果第一个字母一样,那么比较第二个、第三个乃至后面的字母,字母大的那个就是字典序大。


如果比到最后两个单词不一样长(比如,s i g h sighsigh 和s i g h t sightsight),那么把s i g h sighsigh排在 s i g h t sightsight的前面,那么s i g h sighsigh的字典序就小


三、输出字典序最小的方案


在每次执行的时候,从小到大枚举每个数,也就是让每个分支都是按照字典序的从小到大排列,那么全部分支合起来的时候,就是一个字典序最小的排序方式


四、核心——顺序


在递归的时候,需要一个顺序,保证我们可以把所有方案不重复不疏漏的枚举出来。全排列的常用方式是,依次枚举每个位置放在哪个数。具体怎么实现的了,此时就可以拿出我们可爱的递归搜索树。

微信图片_20221020221826.png

五、细节

一个萝卜一个坑,依旧是要记录当前这个坑有没有放过萝卜


参考代码(C++版本)

#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 10;
int st[N];//记录存放的数据
bool used[N];//表示当前这个数是否用过
int n;
void dfs(int u)
{
    if(u > n)
    {
      //抵达边界,输出存储的方案
        for(int i = 1; i <= n;i++) cout << st[i] << ' ';
        cout << endl;
        return;
    }
    for(int i = 1; i <= n;i++)
    if(!used[i])
    {
        st[u] = i;
        used[i] = true;//标记这个数字i已经用过了
        dfs(u+1);
        st[u] = 0;
        used[i] =false;
    }
}
int main()
{
    cin >> n;
    dfs(1);
    return 0;
}

例6、剑指Offer 数字排列


题目描述

微信图片_20221020221941.png

解题报告


一、数据范围定算法


观察数据范围,大概率是一个暴搜了。需要注意的是,可能包含重复数字让这道题不能直接简简单单的全排列了。

二、算法实现雏形


暴搜的核心是:确定以什么样的顺序可以不重不漏的去枚举所有状态。对于本题的重复数字,通过一些人为设置的"规定"来实现"不重复"。规定:所有相同数字的相对顺序不变。即,再次出现的相同数,只能放到已经排放好的这个树后面。

三、思路模拟

比如有一串数字,12123 1212312123


第一步:通过排序来统计有多少个数字,每个数字有几相同的。因为排序之后,相同的数字是相邻的。排序后:11223

第二步:确定能够不重不漏的暴搜的顺序。从前向后枚举每个数字,放置放好以后,递归处理下一个数,此时需要结合规定处理出现的相同数,继续递归处理下去。

四、细节处理


对于相同数,我们人为定序,就可以避免重复计算:我们在d f s dfsdfs时记录一个额外的状态,记录上一个相同数存放的位置s t a r t startstart,我们在枚举当前数时,只枚举 s t a r t + 1 start+1start+1,s t a r t + 2 start+2start+2,…这些位置。


参考代码(C++版本)


class Solution {
public:
    vector<bool> st;
    vector<int> path;
    vector<vector<int>> ans;
    vector<vector<int>> permutation(vector<int>& nums) {
        sort(nums.begin(), nums.end());
        st = vector<bool>(nums.size(), false);
        path = vector<int>(nums.size());
        dfs(nums, 0, 0);
        return ans;
    }
    void dfs(vector<int>& nums, int u, int start)
    {
        if (u == nums.size())
        {
            ans.push_back(path);
            return;
        }
        for (int i = start; i < nums.size(); i ++ )
            if (!st[i])//当前这个位置没有用过
            {
                st[i] = true;
                path[i] = nums[u];
                if (u + 1 < nums.size() && nums[u + 1] != nums[u])
                    dfs(nums, u + 1, 0);
                else
                    dfs(nums, u + 1, i + 1);//u+1位置的数和u位置的数一样了
                st[i] = false;//恢复现场
            }
    }
};

例7、试题 算法提高 汉诺塔


题目描述

image.png

image.png

解题报告


一、背景阐述

汉诺塔问题:古代有一个梵塔,塔内有三个座A、B、C,A座上有64个盘子,盘子大小不等,大的在下,小的在上。有一个和尚想把这64个盘子从A座移到B座,但每次只能允许移动一个盘子,并且在移动过程中,3个座上的盘子始终保持大盘在下,小盘在上。如下图

image.png

二、样例模拟


谨记递归的核心是先确定好边界来跳出递归,然后对于递归部分,就是当前这步的逻辑和递归进去以后下一步的逻辑一样的。


1、当只有一个盘子的时候,只需要从将A塔上的一个盘子移到C塔上。


2、当A塔上有两个盘子的时候,先将A塔上的1号盘子(编号从上到下)移动到B塔上,再将A塔上的2号盘子移动的C塔上,最后将B塔上的小盘子移动到C塔上。


3、当A塔上有3个盘子的时候,先将A塔上编号1至2的盘子(共2个)移动到B塔上(需借助C塔),然后将A塔上的3号最大的盘子移动到C塔,最后将B塔上的两个盘子借助A塔移动到C塔上。


4、当A塔上有n个盘子的时候,先将A塔上编号1至n-1的盘子(共n-1个)移动到B塔上(借助C塔),然后将A塔上最大的n号盘子移动到C塔上,最后将B塔上的n-1个盘子借助A塔移动到C塔上。


参考代码(C++)

#include <iostream>
#include <algorithm>
#include <cstring>
#include <cstdio>
using namespace std;
int n,m,cnt;
void hannoi(int n, char a, char b, char c)
{
  //递归的边界条件
  if(n == 1)
  {
    cnt ++;
    if(cnt == m)
    {
      printf("#1: %c->%c\n",a,c);
      return;
    }
  }else//递归部分
  {
    //按照我们的模拟,先将a杆中上面n-1个移动到b,需要借助c
    hannoi(n-1,a,c,b);
    cnt++;
    if(cnt == m) printf("#%d: %c->%c\n",n,a,c);
    //依旧是实现我们的模拟,将b杆上的盘,借助a,移动到c
    hannoi(n-1,b,a,c);
  }
}
int main()
{
  cin >> n >>m;
  hannoi(n,'A','B','C');
  cout << cnt << endl;
  return 0;
}

总结


对于递归的算法题,首先要明确递归的两大组成是基线条件+递归条件。分别决定了递归什么时候退出,以及递归函数中如何完成题目要求的逻辑。


其次是谨记,想清楚当前这步的逻辑,递归以后,在未达到边界之前,下一步的逻辑和当前的逻辑一致,不然容易陷入捋不清递归过程的的死胡同。对于排列、组合类的问题,也可以通过画递归搜索树来理解喔


相关文章
|
2月前
|
Java C语言 C++
第十四届蓝桥杯集训——练习解题阶段(无序阶段)-ALGO-449 递归输出数字三角形
第十四届蓝桥杯集训——练习解题阶段(无序阶段)-ALGO-449 递归输出数字三角形
44 0
|
7月前
|
存储 算法
蓝桥杯:递归 与 例题:斐波那契数列及优化与应用
蓝桥杯:递归 与 例题:斐波那契数列及优化与应用
49 0
|
2月前
|
Java C语言 C++
第十四届蓝桥杯集训——练习解题阶段(无序阶段)-ALGO-446 递归输出数字
第十四届蓝桥杯集训——练习解题阶段(无序阶段)-ALGO-446 递归输出数字
38 1
|
2月前
|
Java C语言 C++
第十四届蓝桥杯集训——练习解题阶段(无序阶段)-ALGO-150 6-1 递归求二项式系数值
第十四届蓝桥杯集训——练习解题阶段(无序阶段)-ALGO-150 6-1 递归求二项式系数值
39 1
|
9月前
《蓝桥杯每日一题》递归·AcWing 1497. 树的遍历
《蓝桥杯每日一题》递归·AcWing 1497. 树的遍历
43 0
|
9月前
蓝桥杯AcWing 题目题解 - 递归与递推
蓝桥杯AcWing 题目题解 - 递归与递推
|
机器学习/深度学习 算法
【蓝桥杯Java_C组·从零开始卷】第七节、递归
【蓝桥杯Java_C组·从零开始卷】第七节、递归
51 0
【蓝桥杯Java_C组·从零开始卷】第七节、递归
|
存储 人工智能 算法
蓝桥杯十大常见天阶功法——炎之呼吸.叁之型.动态规划--(上篇)
蓝桥杯十大常见天阶功法——炎之呼吸.叁之型.动态规划--(上篇)
175 0
蓝桥杯十大常见天阶功法——炎之呼吸.叁之型.动态规划--(上篇)
|
机器学习/深度学习 存储 缓存
蓝桥杯十大常见天阶功法——音之呼吸.肆之型.模拟
蓝桥杯十大常见天阶功法——音之呼吸.肆之型.模拟
116 0
蓝桥杯十大常见天阶功法——音之呼吸.肆之型.模拟
|
存储 机器学习/深度学习 算法
蓝桥杯十大常见天阶功法——虫之呼吸.贰之型.二分
蓝桥杯十大常见天阶功法——虫之呼吸.贰之型.二分
220 0
蓝桥杯十大常见天阶功法——虫之呼吸.贰之型.二分