极客时间已完结课程限时免费阅读

05 | 递归(上):泛化数学归纳,如何将复杂问题简单化?

05 | 递归(上):泛化数学归纳,如何将复杂问题简单化?-极客时间

05 | 递归(上):泛化数学归纳,如何将复杂问题简单化?

讲述:黄申

时长09:12大小8.41M

你好,我是黄申。上一节的结尾,我们用递归模拟了数学归纳法的证明。同时,我也留下了一个问题:既然递归的函数值返回过程和基于循环的迭代法一致,我们直接用迭代法不就好了,为什么还要用递归的数学思想和编程方法呢?这是因为,在某些场景下,递归的解法比基于循环的迭代法更容易实现。这是为什么呢?我们继续来看舍罕王赏麦的故事。

如何在限定总和的情况下,求所有可能的加和方式?

舍罕王和他的宰相西萨·班·达依尔现在来到了当代。这次国王学乖了,他对宰相说:“这次我不用麦子奖赏你了,我直接给你货币。另外,我也不用棋盘了,我直接给你一个固定数额的奖赏。”
宰相思考了一下,回答道:“没问题,陛下,就按照您的意愿。不过,我有个小小的要求。那就是您能否列出所有可能的奖赏方式,让我自己来选呢?假设有四种面额的钱币,1 元、2 元、5 元和 10 元,而您一共给我 10 元,那您可以奖赏我 1 张 10 元,或者 10 张 1 元,或者 5 张 1 元外加 1 张 5 元等等。如果考虑每次奖赏的金额和先后顺序,那么最终一共有多少种不同的奖赏方式呢?”
让我们再次帮国王想想,如何解决这个难题吧。这个问题和之前的棋盘上放麦粒有所不同,它并不是要求你给出最终的总数,而是在限定总和的情况下,求所有可能的加和方式。你可能会想,虽然问题不一样,但是求和的重复性操作仍然是一样的,因此是否可以使用迭代法?好,让我们用迭代法来试一下。
我还是使用迭代法中的术语,考虑 k=1,2,3,…,n 的情况。在第一步,也就是当 n=1 的时候,我们可以取四种面额中的任何一种,那么当前的奖赏就是 1 元、2 元、5 元和 10 元。当 n=2 的时候,奖赏的总和就有很多可能性了。如果第一次奖赏了 1 元,那么第二次有可能取 1、2、5 元三种面额(如果取 10,总数超过了 10 元,因此不可能)。
所以,在第一次奖赏 1 元,第二次奖赏 1 元后,总和为 2 元;第一次奖赏 1 元,第二次奖赏 2 元后,总和为 3 元;第一次奖赏 1 元,第二次奖赏 5 元后,总和为 6 元。好吧,这还没有考虑第一次奖赏 2 元和 5 元的情况。我来画个图,从图中你就能发现这种可能的情况在快速地“膨胀”。
你应该能看到,虽然迭代法的思想是可行的,但是如果用循环来实现,恐怕要保存好多中间状态及其对应的变量。说到这里,你是不是很容易就想到计算编程常用的函数递归
在递归中,每次嵌套调用都会让函数体生成自己的局部变量,正好可以用来保存不同状态下的数值,为我们省去了大量中间变量的操作,极大地方便了设计和编程。
不过,这里又有新的问题了。之前用递归模拟数学归纳法还是非常直观的。可是,这里不是要计算一个最终的数值,而是要列举出所有的可能性。那应该如何使用递归来解决呢?上一节,我只是用递归编程体现了数学归纳法的思想,但是如果我们把这个思想泛化一下,那么递归就会有更多、更广阔的应用场景。

如何把复杂的问题简单化?

首先,我们来看,如何将数学归纳法的思想泛化成更一般的情况?数学归纳法考虑了两种情况:
初始状态,也就是 n=1 的时候,命题是否成立;
如果 n=k-1 的时候,命题成立。那么只要证明 n=k 的时候,命题也成立。其中 k 为大于 1 的自然数。
将上述两点顺序更换一下,再抽象化一下,我写出了这样的递推关系:
假设 n=k-1 的时候,问题已经解决(或者已经找到解)。那么只要求解 n=k 的时候,问题如何解决(或者解是多少);
初始状态,就是 n=1 的时候,问题如何解决(或者解是多少)。
我认为这种思想就是将复杂的问题,每次都解决一点点,并将剩下的任务转化成为更简单的问题等待下次求解,如此反复,直到最简单的形式。回到开头的例子,我们再将这种思想具体化。
假设 n=k-1 的时候,我们已经知道如何去求所有奖赏的组合。那么只要求解 n=k 的时候,会有哪些金额的选择,以及每种选择后还剩下多少奖金需要支付就可以了。
初始状态,就是 n=1 的时候,会有多少种奖赏。
有了这个思路,就不难写出这个问题的递归实现。我这里列一个基本的实现。
import java.util.ArrayList;
public class Lesson5_1 {
public static long[] rewards = {1, 2, 5, 10}; // 四种面额的纸币
/**
* @Description: 使用函数的递归(嵌套)调用,找出所有可能的奖赏组合
* @param totalReward-奖赏总金额,result-保存当前的解
* @return void
*/
public static void get(long totalReward, ArrayList<Long> result) {
// 当totalReward = 0时,证明它是满足条件的解,结束嵌套调用,输出解
if (totalReward == 0) {
System.out.println(result);
return;
}
// 当totalReward < 0时,证明它不是满足条件的解,不输出
else if (totalReward < 0) {
return;
} else {
for (int i = 0; i < rewards.length; i++) {
ArrayList<Long> newResult = (ArrayList<Long>)(result.clone()); // 由于有4种情况,需要clone当前的解并传入被调用的函数
newResult.add(rewards[i]); // 记录当前的选择,解决一点问题
get(totalReward - rewards[i], newResult); // 剩下的问题,留给嵌套调用去解决
}
}
}
}
我们测试一下总金额为 10 元的时候,有多少种解。
public static void main(String[] args) {
int totalReward = 10;
Lesson5_1.get(totalReward, new ArrayList<Long>());
}
最终,程序运行后大致是这种结果:
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
[1, 1, 1, 1, 1, 1, 1, 1, 2]
[1, 1, 1, 1, 1, 1, 1, 2, 1]
[1, 1, 1, 1, 1, 1, 2, 1, 1]
[1, 1, 1, 1, 1, 1, 2, 2]
...
[5, 5]
[10]
这里面每一行都是一种可能。例如第一行表示分 10 次奖赏,每次 1 元;第二行表示分 9 次奖赏,最后一次是 2 元;以此类推。最终结果的数量还是挺多的,一共有 129 种可能。试想一下,如果总金额为 100 万的话,会有多少种可能啊!
这个代码还有几点需要留意的地方,我再来解释一下:
1. 由于一共只有 4 种金额的纸币,所以无论是 n=1 的时候还是 n=k 的时候,我们只需要关心这 4 种金额对组合产生的影响,而中间状态和变量的记录和跟踪这些繁琐的事情都由函数的递归调用负责。
2. 这个案例的限制条件不再是 64 个棋格,而是奖赏的总金额,因此判断嵌套调用是否结束的条件其实不是次数 k,而是总金额。这个金额确保了递归不会陷入死循环。
3. 我这里从奖赏的总金额开始,每次嵌套调用的时候减去一张纸币的金额,直到所剩的金额为 0 或者少于 0,然后结束嵌套调用,开始返回结果值。当然,你也可以反向操作,从金额 0 开始,每次嵌套调用的时候增加一张纸币的金额,直到累计的金额达到或超过总金额。

小结

递归和循环其实都是迭代法的实现,而且在某些场合下,它们的实现是可以相互转化的。但是,对于某些应用场景,递归确实很难被循环取代。我觉得主要有两点原因:
第一,递归的核心思想和数学归纳法类似,并更具有广泛性。这两者的类似之处体现在:将当前的问题化解为两部分:一个当前所采取的步骤和另一个更简单的问题。
1. 一个当前所采取的步骤。这种步骤可能是进行一次运算(例如每个棋格里的麦粒数是前一格的两倍),或者做一个选择(例如选择不同面额的纸币),或者是不同类型操作的结合(例如今天讲的赏金的案例)等等。
2. 另一个更简单的问题。经过上述步骤之后,问题就会变得更加简单一点。这里“简单一点”,指运算的结果离目标值更近(例如赏金的总额),或者是完成了更多的选择(例如纸币的选择)。而“更简单的问题”,又可以通过嵌套调用,进一步简化和求解,直至达到结束条件。
我们只需要保证递归编程能够体现这种将复杂问题逐步简化的思想,那么它就能帮助我们解决很多类似的问题。
第二,递归会使用计算机的函数嵌套调用。而函数的调用本身,就可以保存很多中间状态和变量值,因此极大的方便了编程的处理。
正是如此,递归在计算机编程领域中有着广泛的应用,而不仅仅局限在求和等运算操作上。在下一节中,我将介绍如何使用递归的思想,进行“分而治之”的处理。

思考题

一个整数可以被分解为多个整数的乘积,例如,6 可以分解为 2x3。请使用递归编程的方法,为给定的整数 n,找到所有可能的分解(1 在解中最多只能出现 1 次)。例如,输入 8,输出是可以是 1x8, 8x1, 2x4, 4x2, 1x2x2x2, 1x2x4, ……
欢迎在留言区交作业,并写下你今天的学习笔记。你可以点击“请朋友读”,把今天的内容分享给你的好友,和他一起精进。
分享给需要的人,Ta购买本课程,你将得20
生成海报并分享

赞 30

提建议

上一篇
04 | 数学归纳法:如何用数学归纳提升代码的运行效率?
下一篇
06 | 递归(下):分而治之,从归并排序到MapReduce
unpreview
 写留言

精选留言(172)

  • 黄申
    置顶
    2018-12-20
    由于暂时无法追加回复,我这里再回复一下网友debugtalk 我看了Python的实现,果然很简介👍 奖金的题目没问题。整数的因子分解有点小瑕疵,少了一些可能。比如,输入8,少了 [1, 2, 2, 2] [1, 2, 4] [1, 4, 2] [2, 1, 2, 2]
    展开
    18
  • 杨景胜
    2018-12-19
    从递归的两个原则到代码实现有点跳跃, 想了半天还是没想明白这个代码的逻辑啊~
    共 2 条评论
    26
  • 李尧
    2018-12-19
     思考题:请大神帮忙看下,输出少了个 [1,8] 输出:[2, 2, 2, 1] [2, 4, 1][4, 2, 1][8, 1] import java.util.ArrayList; /** * @Auther: yli * @Date: 2018/12/19 17:19 * @Description: */ public class Iteration { public static void recursion(long total, ArrayList<Long> result){ if (total == 1){ result.add(1L); System.out.println(result); return; }else { for(long i = 2; i <= total; i ++){ ArrayList<Long> newList = (ArrayList<Long>)(result.clone()); newList.add(Long.valueOf(i)); if(total%i !=0){ continue; } recursion(total/i, newList); } } } public static void main(String[] args){ long total = 8; recursion(total, new ArrayList<Long>()); } }
    展开

    作者回复: 循环的时候不能少了1,可以在结果中判断是否已经涵盖1,我稍微修改了一下 public static void recursion(long total, ArrayList<Long> result) { if (total == 1) { if (!result.contains(1L)) result.add(1L); System.out.println(result); return; } else { for (long i = 1; i <= total; i++) { if ((i == 1) && result.contains(1L)) continue; ArrayList<Long> newList = (ArrayList<Long>) (result.clone()); newList.add(Long.valueOf(i)); if (total % i != 0) { continue; } recursion(total / i, newList); } } }

    共 6 条评论
    13
  • debugtalk
    2018-12-19
    Python 实现赏金问题:https://github.com/debugtalk/geektime-notes/blob/master/programmers-mathematics/chapter5.md Python 实现思考题:https://github.com/debugtalk/geektime-notes/blob/master/programmers-mathematics/chapter5.py

    作者回复: 很棒 我稍后sync下来看

    共 2 条评论
    12
  • 文刂 氵共 超
    2018-12-19
    整数分解 - C++代码 #include <vector> #include <iostream> #include <algorithm> using namespace std; // 输出函数 void PrintResult(vector<int> &Result) { for (size_t i = 0; i < Result.size(); ++i) { cout << Result[i] << " "; } cout << endl; } //数组中是否存在某值 bool IsExit(vector<int> &Result, int value) { vector<int>::iterator result = std::find(Result.begin(), Result.end(), value); if (result == Result.end()) { return false; } else { return true; } } //整数分解 void RecursionAlgo(int Num, vector<int> &Result) { if (Num == 1) { PrintResult(Result); return; } for (int i = 1; i <= Num; ++i) { //判断1是否在解中 if (IsExit(Result, 1)) { if (i == 1) { continue; } } if (Num%i == 0) { vector<int> newResult = Result; newResult.push_back(i); RecursionAlgo(Num/i, newResult); } } } int _tmain(int argc, _TCHAR* argv[]) { int Totalmoney = 10; vector<int> Result; RecursionAlgo(Totalmoney, Result); return 0; }
    展开

    作者回复: 回答很棒,下次可以将运行结果也贴出来👍

    9
  • 罗耀龙@坐忘
    2020-03-28
    茶艺师学编程 一般人说到泡茶,大多数会条件反射般的 1、拿出茶叶 2、放进杯子 3、加入开水 4、等一下,喝 这样从前往后按步骤来的思维方法,正是人类“无师自通”的递推思维。 而经过训练的茶艺师会这么做 1、先看要泡什么茶 2、根据要什么茶来选定用什么器皿,假如要泡龙井绿茶,那就拿出直身玻璃杯 3、这时怎么泡也决定了,就是先把玻璃杯温起来,然后放入龙井,先加一点水润一润,再把水加满,其中水温不能太烫。 4、等一下,喝 想这样根据结果倒逼步骤,从上往下的顶层设计,很明显以上面的方法顺序相反。同学们看到这肯定会得意的说,“你们(茶艺师)在不经意间就使用了递归思想。”
    展开
    8
  • 方得始终
    2018-12-29
    参考老师和其他同学的留言, 下面是Pythoni实现的思考题, 应该是个较为简洁的版本. import copy def prod_factors(num, result=[]): if num == 1: print('x'.join([str(_) for _ in result])) if 1 not in result: result.append(1) print('x'.join([str(_) for _ in result])) elif num < 0: return else: for i in range(1, num+1): if (i == 1 and i not in result) or (i !=1 and num % i == 0): newresult = copy.copy(result) newresult.append(i) prod_factors(num/i, newresult) prod_factors(8) 1x2x2x2 1x2x4 1x4x2 1x8 2x1x2x2 2x1x4 2x2x1x2 2x2x2 2x2x2x1 2x4 2x4x1 4x1x2 4x2 4x2x1 8 8x1
    展开

    作者回复: 同时考虑了1出现和不出现的情况 👍

    共 6 条评论
    7
  • 松原
    2018-12-19
    黄老师,这句“递归和循环其实都是迭代法的实现”我不太理解。 如果递归和循环都属于迭代法,那么就是说递归是从属于迭代法的。而我所理解的迭代法的核心是从1到n的递推,而递归是从n到1的逐步求解的过程,两者应该是并列的关系。希望老师能解答我的这个困惑。

    作者回复: 确实两者的递推方向是不一样的,不过递归在计算机的实现中,是使用的函数调用,在满足条件后,函数开始逐级返回,这时候又是正向递推了,所以我觉得这也是从1到n

    6
  • hallon
    2018-12-21
    思考题,用js实现如下: function mul(totalReward, result) { //i从自身开始循环到1,每次递减1 for(var i=totalReward; i>0; i--) { if(totalReward % i == 0) {//能整除i var newResult = result.concat(); newResult.push(i); if(i == totalReward) {//i为自身的情况,结果就是[totalReward,1] newResult.push(1); console.log(newResult); } else if(i == 1) {//i为1的情况,结果就是[1,totalReward] newResult.push(totalReward); console.log(newResult); }else {//除以i之后,继续分解 mul(totalReward/i, newResult); } } } } 测试:数字6和8 var b = []; mul(6, b); mul(8, b); 结果如下: [ 6, 1 ] [ 3, 2, 1 ] [ 3, 1, 2 ] [ 2, 3, 1 ] [ 2, 1, 3 ] [ 1, 6 ] [ 8, 1 ] [ 4, 2, 1 ] [ 4, 1, 2 ] [ 2, 4, 1 ] [ 2, 2, 2, 1 ] [ 2, 2, 1, 2 ] [ 2, 1, 4 ] [ 1, 8 ]
    展开
    5
  • Sawyer
    2019-03-12
    老师好,我实现了一个算法,但是没有打印出来1的情况。 还有个问题就是,如果使用老师的示例输入8,结果既有 2x4,又有 4x2 这不是重复了吗? static void getFactorization(long product, ArrayList<Long> result) { for (int i = 2; i <= product / 2; i++) { if (0 == product % i) { ArrayList<Long> newResult = (ArrayList<Long>) result.clone(); newResult.add((long) i); getFactorization(product / i, newResult); newResult.add(product / i); System.out.println(newResult); } } }
    展开

    作者回复: 这里作为教学案例,可以遍历所有情况,包括4x2和2x4。至于1,需要特殊处理一下,你可以思考看看,或者看看之前读者的留言

    3
  • debugtalk
    2018-12-20
    感谢 Sean 的指正,之前整数因子分解的解答的确存在问题,没有完整考虑整数 1 在各种位置的情况。 现已更正:https://github.com/debugtalk/geektime-notes/blob/master/programmers-mathematics/chapter5.py
    3
  • 杨景胜
    2018-12-19
    //因数分解 public static void getMultiFactors(long multi,ArrayList<Long> result){ if (multi == 1){ result.add(multi); System.out.println(result); }else{ for (long i = 2; i <= multi; i++) { if(multi % i == 0){ ArrayList<Long> newResult = (ArrayList<Long>) result.clone(); newResult.add(i); getMultiFactors(multi / i,newResult); } } } }
    展开

    作者回复: 如果循环从2开始,可能会漏掉一些情况,请参考为我给另一位网友李尧的回复

    3
  • 悬炫
    2019-01-28
    JavaScript的实现: 赏金问题: /** * * @description 赏金问题 * @param {number} totalReward 奖赏总金额 * @param {array} result 保存当前的解 * @param {array} rewards 可供选择的面额 * @returns void */ function get(totalReward, result=[],rewards = [1, 2, 5, 10]) { // 当 totalReward = 0 时,证明它是满足条件的解,结束嵌套调用,输出解 if (totalReward === 0) { console.log(result.toString()); // 当 totalReward < 0 时,证明它不是满足条件的解,不输出 } else if(totalReward<0) { return; } else { for (let index = 0; index < rewards.length; index++) { let newResult =[...result];// 由于有 4 种情况,需要 clone 当前的解并传入被调用的函数 newResult.push(rewards[index]);// 记录当前的选择,解决一点问题 get(totalReward - rewards[index], newResult);// 剩下的问题,留给嵌套调用去解决 } } }
    展开
    2
  • Youngggg
    2018-12-25
    import copy def get(num, result=[]): sum = 1 for index in result: sum = sum * index if sum == num: print(result) if 1 not in result: result.append(1) print(result) return elif sum > num: return else: i=1 while i<=num: if num%i != 0: i = i+1 continue if i == 1 & 1 in result: i = i+1 continue new_result = copy.copy(result) new_result.append(i) i = i+1 get(num, new_result) get(8, []) [1, 2, 2, 2] [1, 2, 4] [1, 4, 2] [1, 8] [2, 1, 2, 2] [2, 1, 4] [2, 2, 1, 2] [2, 2, 2] [2, 2, 2, 1] [2, 4] [2, 4, 1] [4, 1, 2] [4, 2] [4, 2, 1] [8] [8, 1]
    展开
    2
  • icy
    2018-12-24
    # -*- coding: utf-8 -*- """ Created on Mon Dec 24 14:30:38 2018 @author: icytanz """ import copy def get_mul_factor(num,result=[]): if(num==1): total=copy.copy(result) if 1 not in total: total.append(1) print(total) return elif(num<1): return else: n=list(range(num+1)) n.reverse() if 1 in result: m=range(len(n)-2) else: m=range(len(n)-1) for i in m: new_result=copy.copy(result) if num%n[i]==0: new_result.append(n[i]) get_mul_factor(num//n[i],new_result) if __name__=='__main__': get_mul_factor(2) #[2, 1] #[1, 2] get_mul_factor(1) #[1] get_mul_factor(4) #[4, 1] #[2, 2, 1] #[2, 1, 2] #[1, 4] #[1, 2, 2] get_mul_factor(6) #[6, 1] #[3, 2, 1] #[3, 1, 2] #[2, 3, 1] #[2, 1, 3] #[1, 6] #[1, 3, 2] #[1, 2, 3] get_mul_factor(8) #[8, 1] #[4, 2, 1] #[4, 1, 2] #[2, 4, 1] #[2, 2, 2, 1] #[2, 2, 1, 2] #[2, 1, 4] #[2, 1, 2, 2] #[1, 8] #[1, 4, 2] #[1, 2, 4] #[1, 2, 2, 2]
    展开
    2
  • 郑晨Cc
    2018-12-20
    package 二进制; import java.util.ArrayList; public class Lesson5Test { public int counter = 0; public void chosen(int num,ArrayList list){ if(null==list){ list = new ArrayList(); list.add(1); list.add(num); } if(num==1){ //递归终止 return; } for(int i=1;i<=num;i++ ){ if(i==1){ System.out.println(list); counter++; continue; } if(i==num){ list.add(1); list.remove(0); System.out.println(list); counter++; continue; } if(num%i==0){ int a = num/i; ArrayList list2 = new ArrayList(list); list2.remove(list.size()-1); list2.add(i); list2.add(a); chosen(num/i,list2); } } } public static void main(String[] args){ Lesson5Test too = new Lesson5Test(); too.chosen(8, null); System.out.println("总数:"+too.counter); } } 控制台输出: [1, 8] [1, 2, 4] [1, 2, 2, 2] [2, 2, 2, 1] [2, 4, 1] [1, 4, 2] [4, 2, 1] [8, 1] 总数:8
    展开
    2
  • miracle
    2018-12-19
    我来答下吧,案例中的n代表已经迭代的次数,在这个案例中,限制了每轮迭代只有四种选择
    2
  • cocu
    2018-12-19
    这个案例中的n到底是什么,是奖励总金额,还是取的纸币数?

    作者回复: 这里我统一回答一下,是当前迭代的次数,也就是取的纸币数量

    2
  • Jtay-dlz
    2020-04-19
    def get(t, result): r = [5,2,1] if t==0: print(result) return elif t<0: return for i in r: get(t-i, result+str(i)+' ') get(5, '') ----------------5 2 2 1 2 1 2 2 1 1 1 1 2 2 1 2 1 1 1 1 2 1 1 1 1 2 1 1 1 1 1
    展开
    1
  • Noah●^●
    2019-12-22
    #include <iostream> #include <vector> using namespace std; const int N = 4; int coins[N] = {1, 2, 5, 10}; int n; vector<int> closen; int count; void dfs(int u) { if(u > n) return; if(u == n) { for(int i = 0; i < closen.size(); i++) { if(i > 0) printf(" "); printf("%d", closen[i]); } puts(""); count++; return; } for(int i = 0; i < N; i++) { closen.push_back(coins[i]); u += coins[i]; dfs(u); closen.pop_back(); u -= coins[i]; } return; } int main(void) { scanf("%d", &n); dfs(0); printf("一共有: %d 种方案\n",count); return 0; }
    展开
    1