蓝桥杯算法竞赛系列第二章——深入理解重难点之递归(上)

目录

一、递归是什么?

二、如何理解“递归”?

1、递归定义

2、递归需要满足的三个条件

3、递归函数

三、怎么玩转递归

1、大招:递归“三段论式”设计经验

2、练习策略

四、精选练习题讲解

1、求n的阶乘

三段论:

代码执行

2、递归求1+2+...+10

三段论

代码执行

3、返回各位数字之和

三段论

代码执行

4、按顺序打印整数i~j

三段论

代码执行

5、对数组arr所有元素求和

三段论

代码执行

五、思考题

六、蓝桥结语:遇见蓝桥遇见你,不负代码不负卿


欢迎回到:遇见蓝桥遇见你,不负代码不负卿!

【声明】:为了让更多的铁汁理解递归,笔者在前面的引入部分赘述可能过长,请铁汁们多点耐心哦,后面有大招。

【前言】:很多人都认为“递归”是语言学习中最难理解的内容之一,当然笔者也是这么认为的,哈哈,但是既然笔者已经将文章发布出来了,自然是有了充分的准备,所以,铁汁们不用紧张,看到最后你会发现,递归其实是一个很自然的东西。

一、递归是什么?

递归是一种应用非常广泛的算法(或者编程技巧)。之后我们要讲的很多数据结构和算法的编码实现都要用到递归,比如 DFS 深度优先搜索、前中后序二叉树遍历等等。所以,搞懂递归非常重要,否则,后面复杂一些的数据结构和算法学起来就会比较吃力。
不过,别看我说了这么多,递归本身可是一点儿都不“高冷”,咱们生活中就有很多用到递归的例子。说一个笔者小时候经常听到的故事:从前有座山,山上有座庙,庙里有个老和尚在讲故事,讲的什么呢,从前有座山...哈哈,这不就是递归吗,只不过是一个无限递归,原因是没有设置终止条件。

举个栗子:周末你带着女朋友去电影院看电影,女朋友问你,咱们现在坐在第几排啊?电影院里面太黑了,看不清,没法数,现在你怎么办?
别忘了你是程序员,这个可难不倒你,递归就开始派上用场了。于是你就问前面一排的人他是第几排,你想只要在他的数字上加一,就知道自己在哪一排了。但是,前面的人也看不清啊,所以他也问他前面的人。就这样一排一排往前问,直到问到第一排的人,说他在第一排,然后再这样一排一排再把数字传回来。直到你前面的人告诉你他自己在哪一排,于是你就知道答案了。
这就是一个非常标准的递归求解问题的分解过程,去的过程叫“递”,回来的过程叫“归”。基本上,所有的递归问题都可以用递推公式来表示。刚刚这个生活中的例子,我们用递推公式将它表示出来就是这样的:
f(n) = f(n-1) + 1 其中,f(1)=1
f(n) 表示你想知道自己在哪一排,f(n-1) 表示前面一排所在的排数,f(1)=1 表示第一排的人知道自己在第一排。
 

二、如何理解“递归”?

1、递归定义

刘汝佳老师的关于算法竞赛入门经典(紫书)中是这么定义的:

递归:参见“递归”

什么?这个定义什么也没有说啊!好吧,改一下:

递归如果还是没有明白递归是什么意思,参见“递归”。

奥,也许这次你明白了,原来递归就是自己用到自己的意思。这个定义显然比上一个好些,因为当你终于悟出其中到道理后,就不用继续“参见”下去了。事实上,递归的含义比这个要广泛。

A经理:“这事不归我管,去找B经理。” 于是你去找B经理。

B经理:“这事不归我管,去找A经理。”于是你又回到了A经理这儿。

接下来发生的事就不难想到了。只要两个经理的说辞不变,你有始终听话,你将会永远往返于两个经理之间。这叫做“无限递归”。尽管在这里,A经理并没有让你找他自己,但还是回到了他这儿。换句话说,“间接用到了自己” 也算递归。

回忆一下,在小学的时候,正整数是如何定义的?正整数1,2,3......这些数。这样的定义也许对于小学生来说是没有任何问题的,但当你觉得这样定义“不太严密”时,你或许会喜欢这样的定义:

(1)1是正整数。

(2)如果n是正整数,那么n + 1也是正整数。

(3)只有通过(1)、(2)定义出来的才是正整数。(这里牺牲一点严密性,换来的是更通俗易懂的表达方式)

这样的定义为什么说成是递归呢,因为在“正整数“还没有定义完时,就用到了”正整数”的定义。其实这和前面“参见递归”在本质上是相同的,只是没有它那么直接和明显。

2、递归需要满足的三个条件

究竟什么样的问题可以用递归来解决呢?我总结了三个条件,只要同时满足以下三个条件,就可以用递归来解决。
1. 一个问题的解可以分解为几个子问题的解
何为子问题?子问题就是数据规模更小的问题。比如,前面讲的电影院的例子,你要知道,“自己在哪一排”的问题,可以分解为“前一排的人在哪一排”这样一个子问题。
2. 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样
比如电影院那个例子,你求解“自己在哪一排”的思路,和前面一排人求解“自己在哪一排”的思路,是一模一样的。
3. 存在递归终止条件
把问题分解为子问题,把子问题再分解为子子问题,一层一层分解下去,不能存在无限循环,这就需要有终止条件。
 

3、递归函数

数学函数也可以递归定义,例如,阶乘函数f(x) = n! 用递归法编写程序实现如下:

#include<stdio.h>

int factor(int n)
{
	if (n == 1)
	{
		return 1;
	}
	return n * factor(n - 1);
}

int main()
{
	int n = 5;

	int ret = factor(n);

	printf("%d\n", ret);

	return 0;
}

注意:本题就是简单了解一下用递归写程序是什么样,重点讲解放在后面。

提示:C语言支持递归,即函数可以直接或者间接的调用自己。但是要格外注意为递归函数编写终止条件,否则将产生无限循环。

总结一下:写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码。

虽然我讲了这么多方法,但是作为初学者,现在是不是还是有种想不太清楚的感觉呢?这也是文章开头我说递归代码比较难理解的原因所在。那怎么办呢,请继续看...
 

三、怎么玩转递归

在重复中找变化,在变化中找重复!

1、大招:递归“三段论式”设计经验

找重复-->找子问题 找变化-->找重复中的变化量作为参数 找边界-->找参数变化趋势设计出口

注意:

    对于递归这种折磨人的知识,有人两三天就会了,有人三五年也不会。主要看你悟不悟得透,能不能找到对于递归的感觉。 三段论中的找重复,这个步骤是最重要而且还是最需要感觉的,不过你要相信Practice makes prefect!踏踏实实,多做,多练,多敲。 在找边界设计出口时,最好写在函数的开头。

2、练习策略

循环改递归解题 经典递归讲解 大量练习,总结规律,掌握套路 找到感觉,挑战高难度

四、精选练习题讲解

1、求n的阶乘

注意:为了方便大家伙的理解,本题没有考虑n == 0的情况

三段论:

    找重复:n * (n - 1)的阶乘,求(n - 1)的阶乘是原问题的重复,规模更小,是原问题的子问题。 找变化:找重复中的变化的量作为参数。本题中很明显是n在变化,所以函数定义过程中的形参是n. 找边界:也就是出口的判断,什么时候让函数停止。显然本题中当n == 1时结束。

代码执行

#include<stdio.h>

//本题中的变化量是n,所以用作形式参数
int factor(int n)
{
	//出口的判断,最好写在函数的开头,本题中当n == 1时就意味着到边界了,应该终止程序
	if (n == 1)
	{
		return 1;
	}
	//(n - 1)的阶乘是原问题的重复,是其子问题,规模更小
	return n * factor(n - 1);
}

int main()
{
	int n = 5;

	int ret = factor(5);

	printf("%d\n", ret);

	return 0;
}

注意:为了言简意赅,本题中没有考虑n == 0的情况。可能看到这里你还是不能完全理解递归,那么我们应该先理解清楚“上面函数的执行过程”,尤其是,函数执行结束之后,回到调用位置继续往下执行。

注意:建议上面的执行过程,请铁汁们在下面的练习中每一个都画出来,这样你才能清楚地体会到“函数的执行过程”,明白递归的奥秘。

如果仍然无法理解上面的递归,可以作如下比喻。

皇帝(拥有main函数栈帧的男人):宰相,你给我算一下f(5)。

宰相(拥有f(5)栈帧的男人):大臣,你给我算一下f(4)。

大臣(拥有f(4)栈帧的男人):知府,你给我算一下f(3)。

知府(拥有f(3)栈帧的男人):县令,你给我算一下f(2)。

县令(拥有f(2)栈帧的男人):师爷,你给我算一下f(1)。

师爷(拥有f(1)栈帧的男人):回老爷,f(1) = 1。

县令(心算f(2) = 2 * f(1)  = 2)回知府大人,f(2) = 2。

知府(心算f(3) = 3 * f(2) = 6)回大人,f(3) = 6。 

大臣(心算f(4) = 4 * f(3) = 24)回宰相大人,f(4) = 24。

宰相(心算f(5) = 5 * f(4) = 120)回陛下,f(5) = 120。

皇帝满意极了。

虽然这个比喻不甚恰当,但也可以说明一些问题。函数调用时新建了一个栈帧,并且跳转到了函数的开头去执行,就好比皇帝找宰相、宰相找大臣这样的过程。尽管同一时刻可以有多个栈帧(皇帝、宰相、大臣、县令同时处于“等待下级回话”的状态),但是当前代码行只有一个。

铁汁们如果理解了这个比喻,但是不理解调用栈,没关系,它不是本文重点 ,在后面系列的博文中笔者会做详细介绍。你只需要知道递归为什么能正常工作即可。设计递归程序的重点在于上级给下级安排工作。

此时此刻,是补充第一种解题思维——“切蛋糕思维”,最好的时机

如果一时不是很明白,没有关系,后面还有大量的练习让铁汁们体会这个思维的美妙。

2、递归求1+2+...+10

三段论

找重复:求1~(num - 1)是原问题的重复,规模更小,是原题的子问题

找变化:很显然,num是不断变化的,所以函数的形参是num

找边界:num == 1是函数的边界

代码执行

//递归求1+2+3+...+10
#include<stdio.h>
//很显然num是不断变化的,所以用作函数的形参
int sum(int num)
{
	//找边界
	if (num == 1)
	{
		return 1;
	}
	//1~(num - 1)是原问题的重复,规模更小,是原问题的子问题
	return num + sum(num - 1);
}

int main()
{
	int n = 10;

	int ret = sum(n);

	printf("%d\n", ret);

	return 0;
}

3、返回各位数字之和

题目描述:输入一个非负整数,返回组成它的数字之和,如输入1729,应该返回1+7+2+9的值,当然1+7+2+9 == 9+2+7+1,也就是19

三段论

找重复:求(num / 10)组成它的数字之和是原问题的重复,规模更小,是其子问题

找变化:num一直在变化

找边界:num < 10 

代码执行

#include<stdio.h>
int sum(int num)
{
	if (num < 10)
	{
		return num;
	}
	return num % 10 + sum(num / 10);
}

int main()
{
	int num = 1729;
	
	printf("%d\n", sum(num));

	return 0;
}

4、按顺序打印整数i~j

三段论

找重复:(i + 1)是原问题的重复,规模更小,是其子问题

找变化:i 和 j,i在变化不难看出,但为什么要加上j呢,j虽然没有变化,但是i~j这个整体在变,‘i’ 到'j' 的距离不断缩小,所以要加上j来衡量它们二者之间的变化

找边界:当 i > j 时结束

代码执行

#include<stdio.h>

void f2(int i, int j)
{
	if (i > j)
	{
		return;
	}
	printf("%d ", i);
	f2(i + 1, j);
}

int main()
{
	f2(2, 9);

	return 0;
}

5、对数组arr所有元素求和

注意:前面内容为了方便更多的铁汁理解,所以笔者选择用C语言编写,但是讲解了4题之后,相信大家对递归都有了一定的认识,数据结构与算法的讲解主要讲的是思路,跟编程语言无关,所以后面的题目笔者选用正在学习中的JAVA语言编写,大家主要听思路,如果听懂了,自己再用熟练的语言编写程序,那么将会事半功倍!

三段论

找重复:求下标begin + 1到arr.length - 1的元素之和是原问题的重复(将首元素的下边设为begin,JAVA中对数组arr求长度直接用arr.length即可)

找变化:begin和剩下的元素都是在变化的,那么如何衡量begin到数组尾元素之间的变化,所以数组名arr也得用作参数

找边界:当begin == arr.legth - 1,也就是当走到尾时结束。

代码执行

public class Recursion {

    public static int f3(int[] arr, int begin) {
        if(begin == arr.length - 1) {
            return arr[begin];
        }
        return arr[begin] + f3(arr, begin + 1);
    }
    public static void main(String[] args) {

        int[] arr = {1,3,4,6,7,8,9};
        int ret = f3(arr, 0);
        System.out.println(ret);
    }
}

注意:加参数也是设计递归的一个难点,有时候我们就想不明白,为什么“切完“之后不知道怎么去写了,比如这一题,如果形参你只给了begin,那么肯定很难用递归去解本题,所以这个时候你就应该想想,是不是缺参数。当然,实在想不出来,可能就应该换种思维了,可能那题不适用”切蛋糕思维“,后面我们会一一介绍。

五、思考题

看过该系列博文的小伙伴都知道,笔者会在博文的最后布置一道思考题,并且讲解上篇博文的思考题。

今天暂时不布置思考题了,但是有任务哦,就是将斐波那契数列和青蛙跳台阶看一下,下篇博文会详细讲解他们用到的另外一种思维。

在讲解上篇思考题的时候,请大家再浏览一遍上篇博文的大致内容,复习巩固一下。

https://blog.csdn.net/weixin_57544072/article/details/120798996utm_source=app&app_version=4.16.0

题:出现K次与出现1次

数组中只有一个数出现1次,其他的数出现了K次,请输出只出现1次的数。

如2,2,2,8,7,7,7,3,3,3

解这样题目的时候,实在不行,就用暴力求解法--》计数即可

但是既然出现在上篇的博文中,那么一定就是让我们使用位运算解决。

讲思路之前,首先大家需要对一个知识点有所了解,那就是:

两个相同的二进制数做不进位加法,结果为0;

十个相同的十进制数做不进位加法,结果为0;

所以有这么一个结论:K个相同的K进制数做不进位加法,结果为0

明白上面那条结论之后,就可以说解决本题的思路了:先将给定的十进制数转化成K进制,再在每个位上做不进位加法,剩下的那个数就是只出现一次的数,不过暂时是K进制的形式,所以再将它还原成十进制数即可。

笔者在这里只给出思路,代码怎么敲,请铁汁们亲自动手实践,用来检测上篇位运算掌握情况。

六、蓝桥结语:遇见蓝桥遇见你,不负代码不负卿

为了让更多的铁汁们能看到这个系列的文章,笔者在这里请求大家动动小手,给笔者来个一键三连,你的支持就是笔者最大的动力,赠人玫瑰,手留余香哦,蟹蟹大家。