1.序
今晚cf连WA五发,一时兴起就写下这篇博客
在笔者和身边同学初学编程时,总有些许晕递归。当我们晕递归我们究竟在困惑什么呢?或者说,我们的编程学习中,是缺乏了哪些认知导致我们初来时对递归结构感到诧异而无法接受呢?
笔者在文中放置了大量的超链接和黑体标注的关键字,是笔者想表达的对“算法” ”语法“的理解方式,有些词我贴了超链接便以读者参阅,有些词一目了然但真的是个人觉得impressive的perspective,无论如何总希望这篇谬误诸多组织混乱的博客能为各位看官带来些许帮助。
就权当衣陈算法从0到0.01的记录
目录:
文章目录
2.Fibonacci:递推,回归
Fibonacci是一个优美的数列**{1 1 2 3 5 8 ……},它具有如下递推公式**:
f
(
n
)
=
f
(
n
−
1
)
+
f
(
n
−
2
)
∣
f
(
1
)
=
1
,
f
(
2
)
=
1
f(n)=f(n-1) +f(n-2) \ \ \ \ |f(1)=1,f(2)=1
f(n)=f(n−1)+f(n−2) ∣f(1)=1,f(2)=1
数学上可推导它具有通项公式
F
(
n
)
=
1
5
[
(
1
+
5
2
)
n
−
1
−
5
2
)
n
]
F(n)=\frac{1}{\sqrt{5}}[(\frac{1+\sqrt5}{2})^n \ -\ \frac{1-\sqrt5}{2})^n]
F(n)=51[(21+5)n − 21−5)n],水平有限,不证,建议移步:斐波那契数列的通项公式
本文更想探讨的是其递推公式 f ( n ) = f ( n − 1 ) + f ( n − 2 ) f(n)=f(n-1)+f(n-2) f(n)=f(n−1)+f(n−2),从编程,算法的角度。
注意到,欲求解数列第n项f(n),其值为f(n-1)+f(n-2),需先求解f(n-1)+f(n-2),如此递推直至 f ( 1 ) = 1 , f ( 2 ) = 1 f(1)=1,f(2)=1 f(1)=1,f(2)=1
1.我们将此分析过程称为自顶向下分析/递推,对本公式称递推/状态转移方程,对 f ( 1 ) = 1 , f ( 2 ) = 1 f(1)=1,f(2)=1 f(1)=1,f(2)=1的推导终点称递归基
2.在编程语言中,可用递归的编程技巧来实现该递推公式的求解
int fbac(int n)
{
//递归基
if(n==1 ||n==2)
{
return 1;
}
else
{
return fbac(n-1) + fbac(n-2);
}
}
像笔者初识算法时一样,很多同学能理解其自顶向下递推过程,但对其自底向上回归过程感到诧异和难以接受(像和那位同学探讨时一样,笔者特意重读了自xx向xx,这也正是笔者想要传达的 算法的观点:自顶向下)
哪位beginer没有过晕递归的经历呢,笔者想探讨对哪些认知的缺乏让我们难以理解递归过程
3.递归:函数 嵌套 调用 表达式 递归树
3.1 晕函数
什么是函数咧,我们亦有称之为法则,映射,方法,数学与编程意义的函数或许已由些许区别,**但总之**,怎么定义和理解函数各有各的方法,可其**过程**应该是比较清晰的:函数接受某些**参数**(可以为空),进行一定的**运算**(可以很复杂/发生嵌套),返回一定的**结果**(可以无返回值)
笔者以为以上足以解释递归过程了:在求解问题规模为n的函数运算过程中,嵌套调用一个函数(就是它自身),用以解决某些问题规模有异同而性质不变、算法框架不变的子问题,从而最终解决母问题。
母问题与子问题相似在:函数形参相同(而调用的实参有异),返回类型相同(而结果有异),求解流程相同(而有递归基)。
但还是有点晕递归:
3.2 晕return
- 晕递归的一个可能原因也许是对函数内的return的表达式感到困惑,语法题-分析语句语义
return fbac(n-1) + fbac(n-2);
为方便理解,代码这样写
//其实是一行代码 int fbac(int n) {return n<3? 1 : fbac(n-1) + fbac(n-2);} //混淆递归基和一般情况,拙劣而无意义的三目炫技,并不优雅
int fbac(int n)
{
if(n==1 || n==2) return 1;
else
{
/*
*原语句的效果与执行流程等同于如下,但少了这些不必要的临时变量。
*/
int res,a,b;
a=fbac(n-1);
b=fbac(n-2);
res=a+b;
return res; //繁琐而无意义的临时变量,额外的代码量和内存开销,并不优雅
}
}
3.3 晕嵌套调用与多路递归: 递归树
-
晕递归的一个可能原因也许是对递推的嵌套调用与结果的回归返回感到困惑
return f(n-1) + f(n-2);
我最终理清楚这晕的兴许是表达式与递归树先作递归树分析:
我想晕递归因为晕回归return,晕return因为不够理解表达式。
4.return :->表达式
表达式是有意义的数据与运算符组合成的式子,可计算求解,其解具有特定的数据类型。
1.如 1 , 1+1-1*1/1 , f(n-1) + f(n-2) 都是表达式
2.表达式是一种右值,其可读,其数据类型由参与运算的数据的数据类型决定,亦受上下文影响(可能存在隐式类型转换)
3.函数返回值是右值
子递归的运算的结果返回在母递归中调用它的那条表达式,此表达式最终被解算为一个(int)类型的数据/值,作为函数的结果/返回值返回。此返回值返回给调用此函数的表达式:上层递归//main中的递归入口,用以彼表达式的运算求解。
至此,我想Fibonacci问题已经完全理清楚了吧。但还有好多可以吹
下面是对表达式两个优秀的例子:
4.1char 字节流 整形提升
char a=3,b=127,c=a+b;
printf("%d",c);//结果为-127
首先要摒弃"char是按ascii码映射其值的字符"的观点。“ascii字符”与char无关,与pritnf(”%c“)
有关,char本身仅是内存里长度为一个字节即八位的变量,char在内存中是一段0和1构成的字节流,在代码中,可以用整形3/127赋值,未指定其 signed or unsigned
时(C语言标准未规定其默认情况),其signed or unsigned
,暨,由内存中的字节流的解释方式(原码,补码)决定的程序中变量的值的情况。
在x86架构中,char默认是有符号的,在鲲鹏架构中,char默认是无符号的。(林湾村男子铁道技术学院华为智能基座实验3)
扯远辽,在本例子中 signed char a,b
的字节流为0000 0011
和0111 1111
,其在bus(总线)中被 整形提升为int-(32位),计算结果再被截断低8位,最终这个“容器”中储存了字节流1111 1110
,在printf(%d)
中它被按补码形式读为有符号整形(signed int) -126
4.2 while(*s++=*t++):你在盲目无知地学习编程吗?
大名鼎鼎的代码,出自《7 suggestions for Computer Majors(给计算机专业的7条建议)》,大概是说,“无论你在学习的是c,java,python,如果你不能解释为什么“while(*s++=*t++);”的作用是复制字符串,那你就是在盲目无知地学习编程。(白学捏)
4.2.1.逻辑表达式:while(expr){}
的循环结构等价于while(1){if(!expr)break;}
,括号内填入一个逻辑表达式,逻辑表达式的值仅有真与假两种结果,在cpp或c的 stdbool.h 库中,有专门的布尔类型来描述真和假。很多表达式可以被隐式转化为逻辑表达式,只要表达式的值可以归类为两种情况:零为假,非零为真,
比如判a是否为0:if(a)
,判a是否为偶 :if(a%2)
或if(a&1)
(&:按位与操作符),
4.2.2赋值表达式:赋值表达式a=b
由赋值运算符=
描述,=
是一个二元操作符,其语义为将·=·右边的操作对象的值 右值(可读) 赋予给’='左边的操作对象 左值(可寻址),其运算与结合方向为自右向左,例如,赋值表达式a=b=3
是合法的,(左值b可寻址必可读,可退化为右值),a=b+1=3
是非法的(b+1可读不可寻址,无法赋值) (左右值精彩之处不止赋值语句的,不妨参阅 cherno cpp)(笔者心中最好的cpp教程)
当赋值表达式出现在if()中时,它当然被隐式转化为逻辑表达式了,其值为左值的值,左值的值为真为假由右值决定。
4.2.3运算符优先级 后置自增运算符的运算符优先级是较高的,至少高于解引用。但后置自增运算符的结合时机与自增时机确是分开的,其自增时机为整个表达式运算完成后,结合了自增运算符的变量依次自增,其顺序可参阅C语言中多个自增自减的运算规律
很喜欢此文的一句话,表达式 (i++) + (i++) + (i++)的值是多少
总述,也许我们可以没在白学编程了:while(*s++=*t++);
将t所指向地址的内容写入给s所指向地址,随后判断s所指向地址的内容是否为零,满足迭代条件的,指针st自增继续循环。
代码如下:
while(*t!='0')
{
*s=*t;
s++;
t++;
}
所以它的作用是复制字符串,尽管丢失了待复制体的首地址。事实上这就是strcpy的原型,不过为一些内存安全考量引入了r,断言,return。(函数return右值,形如表达式strcpy(lhs,rhs);
不同于赋值表达式a=b
,我们可以轻易察觉到它有一个显式的明确的右值,尽管never use)
char* strcpy(char* des,const char* source)
{
char*r=des;
assert((des != NULL) && (source != NULL));//断言语句:当逻辑表达式不成立时,中止程序
while((*r++ = *source++)!='\0');
return des;
}
5.Fibonacci算法优化
5.1时间/空间复杂度
要谈算法与优化算法,要追求高质量的算法,必然要谈及复杂度。
复杂度的概念不难理解,由基本操作数(加减乘除,访问变量,计算个表达式),对基本操作的计数或是估测可以作为评判算法用时的指标。
衡量一个算法的快慢,一定要考虑数据规模的大小n。在Fibonacci的递归算法int fbac(int n) {return n<3? 1 : fbac(n-1) + fbac(n-2);}
中,(递归树见上文),共有n层递归,每一层递归调用两层子递归,忽略那些乏善可陈的常数因子,我们可称这个算法的时间复杂度是
O
(
2
n
)
O(2^n)
O(2n),因为这种一调二的多路递归,其函数调用次数在每层是{1,2,4,8,…}的等比数列,我们并不关心最后一层不太规则的递归树形状,而是将其二路递归的性质抽象出来,作为评判这个算法快慢的指标:
O
(
2
n
)
O(2^n)
O(2n)。
<img src="https://s2.loli.net/2023/01/04/KYRT15EJBPWaAw9.png" alt="image-20230104074845563" style="zoom:50%;" />
总所周知指数函数是爆炸式增长的,随着问题规模n的增长这个算法的表现是灾难性的,
5.2记忆化递归优化
观察递归树,发现算法性能低下的原因在于,进行了大量重复的运算,相当部分的递归展开是不必要的,因为在先前的递推中已经顺路求解玩这一项了
优化思路:记忆化递归。每当通过递归在求解完一项时,将该值打表记录起来,后续再此遇到这个问题时,直接return先前记录的值
代码如下:
int mey[N]; //memory
int fbac(int n)
{
if(n==1 ||n==2) return 1;
else if(!mey[n]) return mey[n];
else
{
mey[n]=fbac(n-1) + fbac(n-2);
return mey[n];
}
}
时间复杂度分析:
调用O(n)会一路递推到f(3)=f(2)+f(1),数列所有项均有记录,此后左路回归后求解右路的f(3:n-2)只需查表,无需递推。即共有(n+n-3)次函数调用,时间复杂度为O(n)。
5.3迭代结构优化
考查递归结构:一个性能杀手是,函数的调用返回,栈的开栈销毁,数据的入栈出栈,都需要时间和内存,所以,去递归结构能在常数级别上优化算法。
思路:观察f(n)=f(n-1)+f(n-2), f(1)=1,f(2)=1,从自顶向下分析的角度易写出递归函数;而从自底向上构建的角度,由归纳法,知f(1) f(2)可推f(2),……如此可推f(n)
int f[N]={0,1,1};//列表初始化的缺省形式,前三项被初始化为011,其余为0
int fbac(int n)
{
for(int i=3;i<=n;i++)
{
f[i]=f[i-1] + f[i-2];
}
return f[n];
}
时间复杂度分析:
如上自底向上构建好了Fibonacci序列,时间复杂度O(n),比记忆化递归结构由常数优化,聊胜于无
空间复杂度分析:
欲求解fbac(n),引入了数列数组f[N],空间复杂度O(n)
观察到 f ( n ) = f ( n − 1 ) + f ( n − 2 ) f(n)=f(n-1)+f(n-2) f(n)=f(n−1)+f(n−2),每一项仅欲前两项有关,还可以继续优化
5.4滚动数组优化
思路:每一项仅欲前两项有关,而只需要求解第n项,无需维护整个序列,而仅需维护ans前两项les,less.
int fbac(int n)
{
int less=1,les=1,ans=0;
if(n<3) return 1;//特判
else for(int i=3;i<=n;i++)
{
ans=less+les;
less=les;
les=ans;
}
return ans;
}
滚动数组:
空间复杂度:
用常数个变量求解出fbac(n),空间复杂度O(1)
优雅多了……
6.求解fbac(5000):高精加法
由通项公式 F ( n ) = 1 5 [ ( 1 + 5 2 ) n − ( 1 − 5 2 ) n ] F(n)=\frac{1}{\sqrt{5}}[(\frac{1+\sqrt5}{2})^n \ -\ (\frac{1-\sqrt5}{2})^n] F(n)=51[(21+5)n − (21−5)n]可分析之ans近于1.6185000,这是灾难性的数字
不论 unsigned long long
还是double
都难以装下。
只有一种也许能装下的容器:字符串。:1.6185000 << 105000,也就不到5000位而已
需要设计一条基于字符串的高精度加法,问题规模不大,1<=n<=5000;
思路:将加法式子中的两个字符串,模拟末端对齐,相加进位即可
更具体地,用字符串存储,用数组运算。构造两个整形数组,逆序地维护超长整数字符串的每一位,加法的各位对齐,向前进位变为数组的首位对齐,向后进位。运算结果再写入字符串维护
题面
楼梯有 N N N 阶,上楼可以一步上一阶,也可以一步上二阶。
编一个程序,计算共有多少种不同的走法。
-
对于 60 % 60\% 60% 的数据, N ≤ 50 N \leq 50 N≤50;
-
对于 100 % 100\% 100% 的数据, 1 ≤ N ≤ 5000 1 \le N \leq 5000 1≤N≤5000。
Code
int fbac(int n)
{
string less="1",les="2",ans;
if(n==1) return 1;//特判
else if(n==2) return 2;
else for(int i=3;i<=n;i++)
{
ans=highadd(less,les);//高精加法函数
less=les;
les=ans;
}
return ans;
}
完整代码见yceachan
7.End
very cheap的分享。
后续计划(挖坑向):
-
新秀杯,寒假赛,cf题解
-
我的算法从1到2 排序:冒泡,选择,插入,希尔,快排,归并,堆排,桶排,topK。
-
我的嵌入式从0到1:Arduino 51 32 :定时 中断 外设 HAL库 u8g2 dmp库移植