在任何一个递归函数中,我们一定只有有限的步骤可以操作,这些是可以枚举的
在上一篇的文章中介绍过,递归是时间的综合,它不是发条式思考,我们不能用递归函数的物理运动轨迹(比如分析调用栈的深度,用笔模拟计算、思考何时回栈、光线追踪等)来代表运动本身,用原子论来描述整体论。递归重点是在定义上,是结构性、声明式为主的状态机定义,而不是过程式编程。
我们在递归想象上,强调了分而治之的想法,然而在工程上递归始终是从root一侧开始“绕圈”进行遍历的,本质是仍然一个环形的迭代,在DFS一个node一定只会被遍历一次。这就意味着即使你想出了分而治之的思考与递归的定义,物理运动轨迹仍然需要用迭代方案来实现,这种摇摆带来了理解的困难。
这也意味着,你在工程生产中运用的断点分析、函数式计算等积累的经验均难以使用。个人建议是将思考割裂到底,高阶思考时不需要考虑调用栈、前后序遍历等细节,落地时再分析。
在真实项目管理中,我们通过拆解Milestone
-Storoy
-Task
来制定任务,这是一种典型的Top-down的思考方式,在递归中也一样, 比如计算Tree的深度,等价于先计算Tree的左与右的最大值。然后编码设计状态机,最终执行。
而Bottom-up的方案,本质是求积分,需要做好时刻的割裂。
当我们对Binary Tree的Node进行处理(解释)时,它的属性只有4个,理论上只要逻辑上处理了4个值,就能解决所有的潜在问题。
而所谓的问题,一般是“翻译问题”,和日常拧螺丝的工作并没有本质的区别。值得注意的是,这里一般是用自然语言来描述递归定义(比如 root-to-leaf/inclusive ),你只能用算法/真值表来定义它,难以用亚里士多德的主谓宾的方式来定义。
1 | // dfs伪代码,对上面场景的枚举 |
这里执行步骤是反直觉的,同时与“分而治之”的想象是割裂的,是按照如下栈的形式执行的
1 | pre->pre->...->pre(atomic)->atomic->post(atomic)->...->post->post |
这里是最难的思考是postHook
,可以用给preHook
和postHook
染色来进行理解。
这是可能有人就会疑问,这样岂不是变成了死记硬背----当然是这样,for循环加递归这种写法处理时,本质上就是暴力解法,一般会组合爆炸,比较低效,但是高效的算法又是hard的,考起来都完蛋,所以比较尴尬。
さらに、更高阶思考的《递归与偶然》的作者许煜研究递归耗费10年才写出一本书,刷过leetcode只能证明可以借助模版拧螺丝。在这个前提下,DFS问题就简化为了一个如何在一次迭代中实现某个需求,比如求深度、最大值等。
求Binary Tree的最大高度:
比如计算“Visible Tree Node”
主要用于排列组合问题(比如n!,必须有序或者独立),这类问题一般会组合爆炸,比较低效,但是高效的算法又是hard的,所以定位上比较尴尬。
常见例子有
这些问题主要就是暴力循环解决,并适当加一些缓存。
在进行tree的递归时,我们时常会纠结到底用root==null还是root.childs==null,TBD
从参数中选一个或者多个参数,返回值可以消费,比如or运算。
dp中所用的数组本质上是一种层化的时间维度,用运动轨迹来代替其存在
更高的难度问题,比如node多次遍历,依据上下文左右选择等,需要MCTS等方案。