Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

设计递归

复习

  • 栈:后进先出
  • 队列与双端队列:先进先出
  • 读懂递归:沿调用栈追踪递归的执行

TL;DR

  • 设计递归的关键:缩小问题规模,并确保最终收敛
  • 相信更小的同类问题已经被解决,只关心怎么组合
  • 别在脑子里展开全部调用,先写出递推关系
  • 树、图、排序都大量用到递归

正文

  会读递归之后,更实际的问题是:怎样从零设计一个递归?这里有一套很好用的思路。

三步走

  面对一个问题,可以按这个顺序想:

  1. 找最小规模:什么情况下问题简单到可以直接回答?这就是基本情况
  2. 假设更小的问题已解决:不要去想它具体怎么算的,就当它已经返回了正确答案
  3. 想清楚怎么组合:用“更小问题的答案”,拼出“当前问题的答案”

  最后还要确认一件事:每次递归,问题规模确实在变小,否则永远到不了基本情况。

“信仰之跃”

  第二步是很多人卡住的地方:怎么能假设它已经解决了呢?这不是循环论证吗?

  不是。因为递归的结构是:当前问题依赖一个严格更小的同类问题,而那个更小的问题,最终会落到基本情况上。 只要这个链条一定收敛,假设它就是合理的。这种“相信递归调用会正确返回”的心态,常被形象地叫作“信仰之跃”。别在脑子里把每一层都展开——那只会越想越乱。

一个例子

  以“求一个数组所有元素之和”为例:

求和(数组):
    如果数组为空,返回 0
    否则,返回 第一个元素 + 求和(剩下的数组)

  最小规模是空数组,答案是 0;更小的问题是把第一个元素去掉后的数组;组合方式就是“第一个元素 + 小问题的答案”。干净利落。

  递归特别适合那些结构本身就是递归的问题:树的遍历、图的搜索、分治排序……这些后面都会遇到。当然,递归也有代价——每层调用都要占用栈空间。当深度很大时,可能会栈溢出;有些场景改用循环反而更省。能优雅地递归,也要知道什么时候不该硬递归。

思考题 1

  设计递归时,为什么要“假设更小的问题已经被解决了”?

思考题 2

  什么情况下,递归可能不如循环合适?

小结

知识点

  • 设计递归:找基本情况、假设子问题已解、想清组合
  • 必须保证每次递归问题规模变小
  • “信仰之跃”指相信递归调用会正确返回
  • 深度过大或开销敏感时,循环可能更合适

参考资料

  1. Wikipedia(zh):递归 (计算机科学):设计递归解法的思路
  2. Wikipedia(zh):分治法:把问题拆分求解的思路

思考题答案(仅供参考)

思考题 1

  因为当前问题本来就依赖一个严格更小的同类问题,而后者最终会落到基本情况并返回正确结果。只要规模一定变小、链条必然收敛,假设子问题已解决就是成立的,无需在脑中展开每一层。

思考题 2

  当递归深度很大时,每层都要占用调用栈空间,容易栈溢出;或者当递归带来的函数调用开销明显、而问题本身用循环也能清晰表达时,改用循环更省资源也更直接。

协议

  本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

封面图

设计师 | 南国微雪