是任意形式的递归,是化解的一般式。
主题所谓的“递归调用化解为栈处理”,意思是,将递归函数调用化解为“一个由stack_push stack_pop stack_top等函数调用组成的循环式子”。这里的 stack_push, stack_pop, stack_top是指,程序员自己实现的一个ADT(Abstract Data Type)中的函数操作接口,这个ADT叫做栈。
要知道,在C语言中,函数调用链本身就是栈处理的,处理C语言中函数调用链的是进程栈/线程栈,进程栈/线程栈是一个C语言程序运行环境的必备部件。
将递归调用化解为栈处理的意义在于,大多数计算机系统,对进程栈/线程栈的大小都是有限制的。譬如在Linux系统上,进程栈的大小限制大约是10MB. 因此,如果程序员直接使用进程栈来进行递归调用, 有可能耗尽进程栈空间,导致 Segment Fault. 虽然进程栈的大小是可调整的(windows用户栈默认为1M,大小通过/stacks设置);然而程序员最好不要做这样的假设;因此,将递归调用化解到一般位于堆(Heap)上的ADT栈上, 在某些场合也许非常有必要。
更为苛刻的是,比如Linux内核,每个进程的内核栈(kernel stack)大小只有1 - 2页,每页只有4 / 8KB, 也就是最多只有16KB的内核栈(windows x86的kernel stack为12KB,x64为24KB);假设程序员在编制一个驱动程序;这个驱动程序如果耗尽了内核栈 ,那么就悲剧了。
读到这里,如果你还是没有明白本帖的主题是指什么,或者你不知道什么是进程/线程栈,或者你不清楚如何实现一个叫做栈的ADT(Abstract Data Type, 抽象数据类型, 数据结构中讲授的内容),那么,你非常非常不适合阅读这篇文章,这篇文章可能会对你造成概念上的混淆什么的。
首先,“任意递归都可以化解为栈处理”是一个一般结论,是有理论依据的,并可能/应该被形式化地证明过。请自行度娘Dijkstra, 我们这个时代最伟大的计算机科学家之一。在此,LZ引用一段,如下:
“最早提出用栈(stack)来编译复杂公式的是德国的Bauer和Samelson,他们的著名论文‘顺序公式的翻译’(Sequential Formula Translation)是编译方面的经典论文。最近有些报道说Dijkstra是栈的发明人,这恐怕不符事实。Dijkstra发展了栈的概念,使之用于整个编译,以及目标代码运行时的动态存储分配,并在此基础上和Jenson完成了世界上第一个ALGOL60编译系统,采用了他首创的优先数编译算法。其中递归调用子程序时的环境维护是Dijkstra的重要贡献,Display这一术语就是当时他发明的,这是用来维护动态环境的一组寄存器(软件),其结构清晰并能适应任何复杂情况。”
========================= Chapter1=========================
递归的定义和阐述
1.1. 广义的递归, 其定义/概念/阐述
“Recursion is the process of repeating items in a self-similar way. ”
递归是指以自相似方式重复事物的过程。(请自行抽出key, 递归是过程)
这里唯一要注意的, 广义的递归是一种过程,这种过程,亦是一种刻画/描述/理解现实世界中许多事物的一种方法/方式。它可以是无限无终止的。
有趣的比如说GNU. GNU的定义是 GNU is not Unix.
GNU -> GNU is not Unix, 如果把GNU当成变量(非终结符), 把is, not, Unix都当做终结符, 把GNU -> GNU is not Unix称之为产生式, 产生式左边的GNU作为这个产生式的头部, 产生式右边的GNU is not Unix称之为产生式体, 那么,GUN -> GNU is not Unix 就是一个上下文无关文法(Context-Free Grammar). 对该文法的推导[文法推导即替换]如下:
GNU ==> GNU is not Unix
==> GNU is not Unix is not Unix
==> GNU is not Unix is not Unix is not Unix
....
这是一个无限无终止的左递归(即,该文法产生式体之最左为非终结符/变元)。相应的,非终结符/变元出现在文法最右的文法称之为右递归。这里的“左递归”,“右递归”中的“递归”,是广义的递归。
1.2. Formal definitions of recursion
递归的形式化定义
In mathematics and computer science, a class of objects or methods exhibit recursive behavior when they can be defined by two properties:
1). A simple base case (or cases), and
2). A set of rules which reduce all other cases toward the base case.
数学/计算机科学中, 如果一类对象/方法的定义具有如下两个特性/属性[properties], 那么它就是递归的。
1). 基准情形/简单情形,
2). 一组能够将所有情形规约/递推[reduce]至基准情形的规则. 注意了. 这里的形式化定义,已经不再是广义的递归了。就是说它要求递归是可以终止的。没错,要讲的 C语言的递归函数调用,应该归结为这样的一类递归。
再扯两句。我们看到了“形式化[Formal]”这个词。请问,什么是形式化的,什么是非形式化[Informal] 的?一般来说, 建立一个系统的方式, 是先有定义和公理, 然后根据定义和公理, 推导各种引理和定理, 然后 -- 一个系统就建立起来了. 比如欧氏几何, 通过有限的公理和公设, 去证明《几何原本》中的所有"真命题". 这估计是人类史上最早的形式化地建立系统的例子, 这个系统的基础是有限的公理和公设, 然后整个系统就是 -- 欧几里得几何, 也即几何原本. 那么欧氏几何就是形式化的.
由于近代符号逻辑的发展, 形式化(formal methods)一般被纳入符号逻辑/命题逻辑/数理逻辑的数学分支中去了. 不过要注意, 形式化只是一种思考方式, 也就是说形式化方法的本质就像前述说欧氏几何的建立一样. 只不过, 由于数学家们认为, 将这种思考方式符号化从某种程度上有助于思考本身(布尔代数奠基人, 其有一本书的名字就是《The Laws of Thought》). 于是, 在数学上看来, 形式化地建立一个系统需要:
Formal systems in mathematics consist of the following elements:
A finite set of symbols (i.e. the alphabet), that can be used for constructing formulas (i.e. finite strings of symbols).
A grammar, which tells how well-formed formulas (abbreviated wff) are constructed out of the symbols in the alphabet. It is usually required that there be a decision procedure for deciding whether a formula is well formed or not.
A set of axioms or axiom schemata: each axiom must be a wff.
A set of inference rules.
即:
1. 一组有限的符号集合, 集合中的符号用来构建公式. [比如, 令命题p表示"3 + 2 = 5", p就是一个符号, 代表一个命题. 比如, 对两个命题的合取一般表示为 p ^ q, 即, p成立而且q也成立, 就像 C语言中, &&对两个表达式取逻辑乘一样. 对两个命题的析取一般表示为p v q, 即p成立, 或者q成立, 或者二者皆成立]
2. 语法规则, 就是根据符号集合(亦称字母表)构建合式公式(原文中的well-formed formulas, 一般数理逻辑中译为合式公式)的规则. [合式公式是由命题常项、命题变项、联结词、括号等组成的符号串, 但不是由这些符号任意组成的符号串都是合式公式]. 语法规则用于判断一个公式是不是合式公式.
3. 一组公理或者公设. 公理和公设必须是合式公式.
4. 一组推理规则.[_三-段-论_神马的]. 即, 形式化方法的本质仍然是: 根据基本的公理和公设, 加上一组推理规则, 然后... 建立整个系统. 就是这个意思. 形式化的方式和别的方式的本质区别在于, 是不是具有有限的基本模型抽象, 然后根据这些基本模型抽象推导出/建立整个系统模型. 对于形式化的方式来说, 由于这种形式化的方式保证了逻辑上的严格性, 在系统日益复杂的情况下, 有可能思考需要回到某个定义本身开始. 因此这种方式被广泛地应用.
***************************************
那么非形式化的方式是什么?就是直观地描述. 如果一上来就给人一套数学形式化描述的东西, 然后让人根据这个东西去推导什么的, 谁都会发疯的. 于是一般在介绍一个形式化的定义/概念之前, 都会先给一大段非形式化的描述, 让人建立起直观的理解, 最后将其形式化地定义下来, 最终就是一个形式化了的系统.
1.3 C语言中的递归
终于来到了这里。
C语言中的递归直接根据1.2得来。
简单地说,如果一个C语言函数调用它自身,那么它就是递归的。由于此处的递归是要求终止的,因此,任何一个C语言递归函数中都包含
1). 终止调用的条件
2). 直接/间接调用自身的函数调用
========================Chapter 2=========================
运行时刻环境
进程栈/线程栈
首先我们要把递归的涵义限制为有终止的递归. 因为没有终止的递归是上帝处理的事情, 有终止的递归才是人类处理的事情.
人类可以理解没有终止的递归的概念;人类也能理解“无限”的概念;但是人类只能处理有终止的递归的情形;人类也只能处理有限的情形;因此,是否能够处理“有限和无限”的“所有情形”是划分上帝和人类的标志之一.
在阐述运行时刻环境之进程栈之前, LZ想要先提下, 什么是栈(Stack).
1. 栈的广义的, 唯一的涵义, 即, 其概念的内涵, 只有一个: LIFO, Last In First Out, 后进先出. 所有的能够称之为"栈"的东西, 都是这个概念内涵的外延, 或者说, 是这个抽象概念的实例.
2. 进程栈/线程栈正是栈的实例. 为什么将它们称之为栈, 是因为它们是栈的实例.
3. 我们要实现的ADT栈也是栈的实例. 为什么将这个数据结构称之为栈, 也是因为这个数据结构是栈的实例.
那么自然, 进程栈能够处理的事情, ADT栈一样可以处理.
所以LZ标题说的
“把递归化解为栈处理”
它的实际涵义是这样的:
1). 递归本来就是栈处理的. 这里的"栈"是广义的栈.
2). 所谓的化解是指, 因为进程栈的空间有限, 为了防止进程栈空间溢出而导致程序dump, 将其转化为ADT栈处理. 因为ADT栈一般在堆(Heap)上管理内存, 而堆相对来说"内存空间是够用的"
2.1 递归就是栈处理的
为了理解"递归本来就是栈处理"这一事实, 让我们回到递归的定义本身.
在递归的形式化定义中已经说过, 所有的(能够终止的)递归都必须:
1). 具有基准情形/简单情形,
2). 一组能够将所有情形规约/递推[reduce]至基准情形的规则.
也即, 一般来说, 需要通过递归来处理的事情, 我们的出发点不是“基础情形”, 而是"想要知道现在的情形", 但是"现在的情形"需要"根据规约规则回到基础情形, 然后再根据基础情形知道现在的情形".
如果我们称"情形"为case, 或者称之为状态state/status, 或者称之为情况situation, 我们称"现在想要知道的情形"为wanted case, 称"基础情形"为base case, 那么我们有:
figure 2.1
===============================================================
wanted case <<<<<<<<<<<<<<<<<<<<<<<<<<<<< stack base
--> middle case i - 1 <<<<<<<<<<<<<<< stack middle 1
--> middle case i - 2 <<<<<<<<<<<<< stack middle 2
--> ...
--> middle case 1 <<<<<<<<<<<<< stack middle i - 1
--> base case <<<<<<<<<<<<<<< stack top
===============================================================
见figure 2.1. 在C语言中, 你想要知道的情形/状态, 要么是一个变量值, 要么是几个变量值的组合[变量值即变量的状态, 很容易理解吧]. 没错, 这是基本的抽象; 至于你想要基于这个状态/变量值输出什么内容/执行什么动**怎么干怎么干对吧.
figure 2.1描述了, 想要知道wanted case[就是说, wanted case里面的变量值/状态现在是未知的, 在等待确定], 只能不断地向base case推进; 直到你知道了base case, 才能得知wanted case[现在, wanted case中的变量值确定下来] -- 请请请注意了, 你不是通过base case一下子就知道了wanted case的!过程是这样的:
1. 根据规约规则/递推规则不断往基础情形推进.
wanted case ==> middle case i -1 ==>...==>base case.
2. 根据base case, 往wanted case回溯.
wanted case <== ... <== middle case 1 <== base case.
好的, 让我们来看行进路径:
先从wanted case开始走, 然后我们走到了base case; 此时我们从base case往回走, 直到退回到wanted case. 最后的base case最先回退.
于是, 这和LIFO[Last In First Out, 后进先出]是完全契合的: 最后到达的最先返回/最后进入的最先出来.
在figure 2.1中, 如果我们把wanted case(就是几个变量是吧)放到一个小箱子中, 这个小箱子称之为stack base; 此时箱子中的变量的值/状态还不确定; 然后我们开始在这个箱子之上再放箱子, 这个箱子称之为stack middle 1, 用来把middle case i - 1的状态装进去... 如是直到我们将base case放到stack top的箱子中; 此时我们知道箱子中的值了; 由于我们的箱子是一个一个叠起来放的; 因此我们只能一次又一次地拿开箱子以确定下面的箱子中的变量的状态; 最后直到最底下的箱子状态确定, 于是我们知道了wanted case. 因此, 最低下的箱子被称之为栈底(stack base), 最顶上的箱子被称为栈顶(stack top).
如是, 我们非形式化地证明了一个结论; 也就是本小节的主题; 这个主题是:
递归就是栈处理的.
2.2 运行时刻环境[Run Time Environment]
2.2.1 前言
豆瓣上, 有一篇是《C语言函数调用的汇编级解释》,它是理解C语言程序如何运行的一些基础。
计算机语言就是人类和计算机沟通的方式(The way to communicate with computer).
首先我们把语言分类. 目前的语言分类一般从两个角度去分, 一是该语言所遵从的范式, 二是该语言的解释/执行方式.
从语言的范式(Paradigm)上来说, 语言一般分为声明式的(Declarative Language), 函数式的(Functional Language)和命令式的(Imperative Language).
声明式的语言如Prolog(Programming in Logic). 只需要给出一个问题的"正确描述", 它就自动找到答案.
函数式的语言如erlang, haskell, clojure, 某种程度上说, 它们的主要宗派是Lisp[List Processing]. Lisp不太像是一门语言, 它没有"官方"的说法, 各种各样的实现都是Lisp.
命令式的语言. C/C++, Java, C#, Objective C等等等等. 所谓命令式, 在于"指定计算机指令执行的过程, 计算机就照此执行"的意思. C++是集多重范式于一身的语言, 命令式范式和纯函数式范式, 乱七八糟的各种范式只要程序员想要C++几乎都提供支持, 以至于LZ常常对C++的复杂性感到不可思议[换句话说C++是怪胎, 但偶尔解决某些问题时又特方便好用]. LZ最终在语言上的选择是回归简单, 由于LZ是C语言出身, 所以最终LZ手上的语言是C语言. 除C语言之外, Makefile, Lisp scheme, LZ都略懂. C++/Java也是略懂。
语言的解释/执行方式分类某种程度上使语言有了一些"本质上的"差别.
从这个角度, 语言分为"编译, 然后执行的", 和"不需要编译, 由解释器解释执行的"语言.Java则是怪胎, 它是"编译, 然后解释执行"的混合物, 好听的说法是混血, 不好听的说法就是杂(^^)种. 另外这种差别也使得, "是否能够在运行期维护类型系统?" -- 这种差别导致了静态类型和动态类型的区分.
============> 分割线 <============
OK... 正题开始...
C语言是静态弱类型的编译型语言.
静态类型是说它的类型系统只在编译期存在. 弱类型是说它允许不一定(运行期)安全的隐式类型转换. 编译型语言是说它必须要先编译然后执行.
静态类型限制程序员太随意*; 弱类型给予程序员*; 编译型就是说它写出来的程序运行时非常快, 非常非常快, 目前没有比它更快的高级语言, C语言就是高级一点的汇编而已.而且, 如果汇编代码写得挫, 未必就比C语言快, 因为编译器的优化偶尔是很恐怖的. 汇编码的优势在于有时有C语言有无法操作到的东西, 比如CPU内部寄存器, 这一点一般通过C语言内嵌汇编码来解决.
============> 分割线 <============
既然是编译型语言, 那么自然需要编译了.
目前来说, 大多C语言实现, 都将编译C语言分为4步.
第一步是预处理. 就是处理掉宏, 还有包含头文件之类的事情.
第二步是编译, 这是真正的编译, 这一步把C语言代码转化为对应平台的汇编码. 这一步是由C语言编译器(也就是通常说的C语言实现)来做的.
第三步是汇编, 这一步将汇编代码转换为目标文件. 目标文件已经不是纯文本文件了; 目标文件是二进制文件. 在目标文件中, 所有的代码段以地址0开始, 所有的数据段以地址0开始.
第四步是链接, 这一步将各个目标文件中的代码段汇总, 将各个目标文件中的数据段汇总, 然后给予汇总的代码段和数据段以运行时起始地址, 并解决和动态库的符号链接问题, 如果是静态库则当作一般目标文件一样处理.
以上这四步通常统称编译期(Compiler Time). 因为这是一个C语言程序从源代码构建出来开始出生的过程, 因此通常就称之为编译或者构建了, 因为其中最主要的步骤是第二步也即编译[实际上每步都很重要].
============> 分割线 <============
在上面的4步需要用到的工具, 即, 预处理器, 编译器, 汇编器, 链接器, 统称为编译工具链[Compile Chain]. Linux系统上的GCC, 即GNU Compiler Collection, 亦可称之为GNU Compile Chain. 不过, 链接器因为目标文件/二进制文件处理的原因, 在其代码维护中被放到binutil[ Binary Utilities , 二进制工具]一类了.
其实...要说吧, 是否理解这四步中的工具到底做了什么, 还有一些二进制工具该怎么用, 其细节如何, 通常已经是区分一个C程序员是否经验老到的标志了. 一个经验老到的C程序员, 清楚他所写下每一个字节. 话题太长, 不便展开. 这些东西虽然基础, 但对C程序员来说实际上极为重要(通常它们也是C程序员理解计算机系统的极其重要的途径). 不过毕竟只是程序员的基本功底, 东西要称得上"计算机科学", 怎么都得和可计算理论和各种算法沾边, LZ本文描述的只是基础, 而不是"计算机科学".
2.2.2 运行时刻环境
当C语言程序被构建为一个可执行程序之后, 它还是一个文件 -- 虽然它是一个二进制的可执行文件. 它静静地躺在硬盘中, 等待程序员, 或者别的什么人去执行它.
假设这个程序叫做app. 当程序员在shell命令行上敲下:
./app
或者, 当某人在windows系统中, 用鼠标双击了某个文件...
于是, 此时程序开始运行.
程序开始运行, 此时, 就是程序运行期[Run Time]的开始.
程序在开始运行之前, 其实还有事情要做的.
这个要做的事情叫做加载(Load).
简单地说, 就是把文件从硬盘上挪到内存中.
现代操作系统一般借助于现代CPU的异常处理来完成加载步骤. 这个异常一般称之为缺页异常. 在Linux Kernel中, 做异常处理的函数名字大约是叫do_page_fault.
细节不用关注太多. 总之一句话, 程序需要加载才能运行. 如果此时有操作系统,那么完成加载的就是操作系统,通常操作系统就是加载器. 如果此时连操作系统都没有,那么程序员需要把这段二进制文件先做成一个磁盘镜像,或者做成一段放到FLASH中的东西,或者...不同的CPU有不同的指定。一般来说,任意CPU都会在起电后跳到某个指定物理地址去执行。把这段二进制代码放到那个指定地址去它就会运行。这些代码一般都被早前的程序员们写得很成熟了,它们一般被叫做BootLoader或者BIOS.
程序在加载之前, 该程序文件中已经有了分段/节(Section). 也即是说, 在二进制文件中, 所有的程序指令被分到一个Section, 所有的程序数据存储空间被分到一个Section, 文件中存储二进制程序指令的Section被称之为.text Section也即代码段, 文件中存储所有全局初始化数据/静态初始化数据的Section被称之为.data Section也即数据段.
当看C语言的基础书籍时, 里面提到的“静态存储区”,就是指二进制文件中的数据段。
很显然,静态存储区在加载后在内存中它也被称之为静态存储区。代码段被加载到内存中之后它仍然被称作代码段。
问题出现了。
文件中的静态存储区大小是有限的[意味著,被加载到内存中之后,它的大小也是固定不变的]。就是说,静态存储区在编译期已经规划好。
可是现在来到了运行期。C语言中有局部变量。函数中的局部变量在函数被调用时开始生存,在函数返回后,它被销毁。
还有。C语言中有指针,指针的基本用处表现在它能动态地获取内存区域,能malloc, 能free.
在程序加载开始运行时,这些问题必须要解决。
============> 分割线 <============
没错。前面提到了栈。栈即为C语言的运行时环境,用来完成存储函数中局部变量的事情。
堆则用来在运行时刻给C语言中的指针分配内存。
2.2.3 进程栈/线程栈
figure 2.2
===============================================================
@@ main <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< stack base
@@@@ --> func1 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< stack frame 1
@@@@@@ --> func2 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< stack frame 2
@@@@@@@@ --> ...
@@@@@@@@@@ --> func_i <<<<<<<<<<<<<<<<<<<<<<<<<< stack frame i
@@@@@@@@@@@@ --> func_return <<<<<<<<<<<<<<<<<<< stack top
===============================================================
见figure 2.2. 注意到这个图和figure2.1并没有本质的区别. LZ通过这个图来解释进程栈和函数调用链.
main, func1, func2...都是函数. 这些函数都有自己的局部变量. stack base, stack frame 1, stack frame 2...都是栈帧. 把函数中的局部变量理解成盒子. 把局部变量中的值/状态理解为盒子中装的东西. 把stack frame[栈帧]理解为箱子, 箱子用来装盒子.
我们假设,程序开始执行时从main函数开始[毫无疑问,这只是个假设]。
于是figure 2.2描述了一个函数调用链,这个函数调用链的执行路径是这样的:
1. 调用
main ==> func1 ==> func2 ==> ... ==> func_i ==> func_return
2. 返回
main <== func1 <== func2 <== ... <== func_i <== func_return
于是,这个路径和 LIFO 又是完全契合的。
如果每个函数都把它自己的所有局部变量(盒子)放到栈帧(箱子)中,
比如, main函数在调用func1之前,把自己所有的盒子先放到叫做stack_base的箱子中, 然后跳转到func1那去执行. func1在调用func2之前, 把自己的盒子先放到叫做stack_frame1的箱子中, 然后跳转到func2那去执行. 如是, 直到函数func_return被函数funci调用并开始执行. func_return也一样把自己的盒子放到叫做stack_top的箱子中.
func_return执行, 并可能改变stack_top箱子中的盒子中的内容. 然后它发现自己没事可干了, 因为它的代码执行完了,它该返回了. 它返回的意思就是说stack_top箱子中的内容没有用了 -- C语言函数不正是这样的吗[先用stack_top箱子, 装传入的参数/变量/盒子, 然后它可能改变传入的参数, 然后它执行完了, 该返回了, 于是它的箱子和盒子都没用了]. 既然箱子没有用了, 那么就可以把它销毁[至于返回值 -- 返回值是属于调用方的盒子, func_return只是负责把返回值计算出来, 至于调用方要不要, 或者拿去干什么, 它是不管的].
同理. func_i等到func_return执行完, func_i接下来执行调用func_return之后的代码. 然后func_i也执行完了. 于是func_i的箱子也没用了, 于是销毁掉.
于是:
1) 建立栈帧并调用
main ==> func1 ==> ...... ==> func_return
stack base ==> stack frame1 ==> ...... ==> stack_top
2) 销毁栈帧并返回
main <== func1 <== ...... <== func_return
stack base <== stack frame1 <== ...... <== stack_top
没错, 这就是函数调用链和栈的关系和总结. 它们的路径都是 LIFO 的.
箱子总要有个地方放对吧。没错。于是在程序开始运行前,操作系统给程序指定一个放箱子的起始地址,诺,你把箱子从那个地方开始叠。这个指定的地址就是栈底。然后函数开始执行,开始调用,开始叠箱子。每次正在执行的函数都只能访问自己的箱子,也就是栈顶的那个箱子。
如果没有操作系统给指定起始地址怎么办?这时候既然操作系统都没有,那就程序自己干。方法是嵌入一段汇编码。
进程栈/线程栈阐述得差不多了。之所以叫进程栈,是因为运行起来的程序一般不再称之为程序,而称之为进程。进程的栈自然就是进程栈了。
线程栈呢? -- 一个进程里面有多个线程怎么办呢?简单,一个函数调用链对应一个栈呗。既然这个函数调用链被称之为线程,那么这个函数调用链所对应的栈就叫线程栈。
2.3 关于进程栈的一些实现细节
在2.2.3 figure 2.2中,LZ给出了:
1) 建立栈帧并调用
main ========> func1 =========> ...... ==> func_return
stack base ==> stack frame1 ==> ...... ==> stack_top
2) 销毁栈帧并返回
main <======== func1 <========= ...... <== func_return
stack base <== stack frame1 <== ...... <== stack_top
这里有个实现细节要考虑。
童鞋们肯定会说:我确实写了一个C语言函数(func1)并让它调用另外一个C语言函数(func2)。可是我没有建立栈帧呀?我也没有销毁栈帧呀?那栈帧是怎么建立的,怎么销毁的?
C语言函数是不是要被编译器编译成汇编码啊?是的。
那编译器能不能在编译C语言函数的时候在函数开头部分和函数结束部分插上汇编代码啊?可以。
所以编译器会在函数开头插入建立栈帧的汇编代码;在函数返回前插入销毁栈帧的汇编代码。请参日志《函数call相关[ASM]》。这里略提。
栈帧,也即箱子,直观地画就像下面这样子:
figure 2.3
|------------------|
|main 函数栈帧,====|
|即, main函数的箱子|
|------------------|
|------------------|
|func1函数栈帧=====|
|------------------|
|------------------|
|======*****=======|
|------------------|
|------------------|
|func_return函数===|
|栈帧==============|
|------------------|
这些箱子其实是连在一起的。下面画出最顶层的箱子。栈顶指针一直指向最顶层的箱子。每个箱子都大同小异,每个箱子中的细节是:
figure 2.4
|-----------------------|
|参数i==================|
|参数i - 1==============|
|参数...================|
|参数0==================|
|-----------------------|
|Program Counter[eip/rip on x86/x86_64]
|-----------------------|
|Stack Base Pointer[ebp/rbp on x86/x86_64]
|-----------------------| <====== Current Stack Base Pointer
|now, contianer,========|
|to save those auto var=|
|or status here=========|
|-----------------------| <====== Stack Top Pointer一直指向这里,
================================== esp/rsp on x86/x86_64
其中, Program Counter即程序计数器,是放的返回到调用方时要用的返回地址。参数和PC都是调用方放进来的,因为调用方才知道该传什么参数给被调用函数,调用方才知道调用完函数后该返回到哪里。Stack Base Pointer放的是调用方的栈基地址,Current Stack Base Pointer即当前栈帧的栈基指针,Stack Top Pointer一直是当前正在执行的函数的栈顶指针。
基本上,
[下面代码中,]
[rbp代表ebp/rbp on x86/x86_64] 栈基指针
[rsp代表esp/rsp on x86/x86_64] 栈顶指针
[pc代表eip/rip on x86/x86_64] 程序计数器,或者正在执行指令的下一条指令的地址
1. 调用方Caller调用函数的汇编代码是:
push 参数i
push 参数i - 1
push 参数...
push 参数0<========>此处设i个参数总共占用4i/8i个字节
call callee_addr [== push pc, jmp callee_addr]
add $4i/8i, %rsp
2. 被调用方Callee被调用函数的汇编代码是:
// 入口代码,entry code:
push %rbp
mov %rsp, %rbp
push %可能破坏的寄存器
sub $num, %rsp [num代表局部变量/盒子至少需要的空间, 或者更多]
===============[sub这一步就是为局部变量分配空间]
// 出口代码, exit/return/out code:
leave [== pop %可能破坏的寄存器, mov %rbp, %rsp, pop %rbp]
ret [== pop pc, jmp caller_addr]
=========================> Chapter 3 ====================
C语言中递归的形式
关于递归的形式,需要一些基本的分析和综合,或者说需要一些洞察。
这正是解决任何一个问题的基本方法,首先分析该问题的表现形式,是否能够穷尽它的表现形式,然后开始综合,抽象出一般的结论。
前述已经提及,C语言中的递归只限于有终止的递归。
这里直接给出结论先,然后开始一些实例,最后重复该结论。
1. 递归的形式分类
1). C语言中的递归首先分为直接的递归和间接的递归。
所谓直接递归,就是函数foo实现中直接调用foo. 所谓间接递归, 就是函数foo实现中不直接调用foo, 而是调用函数bar, 但是函数bar又调用函数foo.
2). 直接的递归分为线形的递归和树形的递归[也称单路递归和多路递归]。
所谓线形的递归,是指函数foo的实现中,只调用它自身一次。
所谓树形的递归,是指函数foo的实现中,调用它自身多次。
3). 根据递归调用是否出现在函数尾部, 将递归分为尾递归和非尾递归。
2. 关于递归的结论
1). 所有间接递归都可以化解为直接递归。
这个洞察是很简单的, 但需要到最后才给出一个实例。
2). 所有尾递归都可以直接化解为迭代处理,不需要用栈。
3). 所有递归都可以用栈处理
这是显然的,前面已经非形式化地证明过,递归就是栈处理的。
4). 并非所有递归都可以化解为迭代处理[这里的迭代处理是指, 不使用栈的迭代]。
这某种程度上显然的, 因为递归的本质是栈处理的。然而形式化或者非形式化地证明它却可能相当困难,所以LZ只是给出这个结论,有兴趣做证明的不妨自己动手。
本节通过一个极致简单的实例介绍关于线形递归的两个基本认知。因为线形递归是除了尾递归之外最简单的递归。
该实例是一个简单的求和实例,即:求 1 + 2 + 3 + ... + n.
这不明明是一个循环可以搞定的事情吗? 嗯... 先别管这个。
如果使用递归定义上述求和序列,那么很显然递推规则如下:
f(n) = f(n - 1) + n
还需要一个基础情形/终止条件. 这个终止条件是
f(1) = 1
或者
f(0) = 0
于是,这个求和的递归是这样的(简单起见, 略过了很多东西, 比如负数会导致...):
/********************************************************************/
#include <stdio.h>
int sum(int n)
{
// addr0, function instructions start address, 函数起始处指令地址
____int tmp;
// stop condition, 终止条件
____if (n == 1)
____{
________printf("这是第 %d 层返回.\n", n);
________return 1;
____}
____printf("这是第 %d 层调用.\n", n);
// addr1, calling point / point of invocation addr, 递归调用点指令地址
____tmp = sum(n - 1) + n;
____printf("这是第 %d 层返回.\n", n);
// addr2, function instructions end addr, 函数结束处指令地址
____return tmp;
}
int main(void)
{
____int n;
____printf("pls input a num:");
____scanf("%d", &n);
____printf("求和的结果是:%d\n", sum(n));
____return 0;
}
/********************************************************************/
这个递归是可以直接写成尾递归的. 即, 不需要定义临时变量tmp, 直接在最后写上
return sum(n - 1) + n;
LZ之所以没有写成尾递归, 是为了能够清楚地观察到递归执行的路径。
比如输入5,
这个程序的输出将会是:
这是第 5 层调用.
这是第 4 层调用.
这是第 3 层调用.
这是第 2 层调用.
这是第 1 层返回.
这是第 2 层返回.
这是第 3 层返回.
这是第 4 层返回.
这是第 5 层返回.
求和的结果是: 15
===============================
1. 第一个简单的结论是,终止条件必须写在递归调用点之前。
这是显然的,否则递归调用永远不会终止。
2. 第二个简单的结论是,如果把递归调用点之前的代码序列称之为递归函数的上半部,把递归调用点之后的代码序列称之为递归函数的下半部,那么递归函数的执行一定要让上半部的所有代码序列全部执行完毕(直到遇到终止条件)才能够执行下半部的代码序列。
注意我在代码中标记了注释。
addr0 -- addr1是该递归函数的上半部。
addr1[之后] -- add2 是该递归函数的下半部。
也即,每次调用,进去之后从函数开始指令执行。执行下去,又遇到函数调用。于是又从函数开始指令执行。又遇到函数调用,... 直到终止条件。此时返回。返回到哪里?由于原调用方的上半部已经执行。所以开始执行原调用方的下半部。原调用方下半部执行完,返回到原调用方的原调用方, 执行其下半部。返回, ...
===============================
如果是树形递归呢?先不管,这两个简单结论先放着。
===============================
首先,可以观察到该函数中只有两个局部变量,一个是参数n, 一个是局部变量tmp。由于调用方[caller]需要将返回地址也传到被调用方[callee],所以实际上还有一个返回地址pc[Program Counter].
于是,该函数的栈帧布局[箱子布局]如图所示:
|#################|
|+++ para, n +++++| ##### 这个盒子用来装参数
|-----------------|
|+++ Caller_PC +++| ##### 这个盒子用来装返回到调用方的地址
|-----------------|
|+ auto var, tmp +| ##### 这个盒子用来装自己的局部变量
|#################|
如果我们把上面这个栈帧[箱子]再画得简单点,并忽略掉返回地址,那么这个栈帧的简约画法如下:
|#################|
|+++++++ n +++++++|
|-----------------|
|++++++ tmp ++++++|
|#################|
再简单点,极致简单:
|#################|
|++++ n, tmp +++++|
|#################|
=======================================
调用时栈帧被相继建立,于是:
********************** ** main ==> sum(5) // main函数调用sum(5), sum(5)栈帧建立
|#################|*** +++此时, n的值/状态确定了[因为它是传进来的参数!],
|++++ n, tmp +++++|*** +++但tmp的状态还不确定
|#################|*** ** sum(5) ==> sum(4) // sum(4) 栈帧建立
|++++ n, tmp +++++|*** +++同理, sum(4)知道n的状态, 但还不确定tmp的状态
|#################|*** ** sum(4) ==> sum(3)
|++++ n, tmp +++++|***
|#################|*** ** sum(3) ==> sum(2)
|++++ n, tmp +++++|***
|#################|*** ** sum(2) ==> sum(1)
|++++ n, tmp +++++|***
|#################|*** ** sum(1)
|++++ n, tmp +++++|*** +++此时, sum(1)直接返回1.
|#################|***
接下来, 返回:
sum(1) ==> sum(2) ==> sum(3) ==> sum(4) ==> sum(5) ==> main
+++++++==> sum(2)中tmp的值/状态确定了.
+++++++++++同时, sum(1)栈帧被销毁.
+++++++++++++++++==> sum(3)中tmp的值/状态确定了.
+++++++++++++++++++++同时, sum(2)栈帧被销毁.
+++++++++++++++++++++++++++++==> sum(4)中tmp的值/状态确定了.
+++++++++++++++++++++++++++++++++同时, sum(3)栈帧被销毁.
++++++++++++++++++++++++++++++++++++++++==> sum(5)中tmp的值/状态确定了.
++++++++++++++++++++++++++++++++++++++++++++同时, sum(4)栈帧被销毁.
+++++++++++++++++++++++++++++++++++++++++++++++++++==> 返回tmp到main函数
+++++++++++++++++++++++++++++++++++++++++++++++++++同时, sum(5)栈帧被销毁.
===============================================================
综上. 本节通过一个简单的例子观察了线形递归的执行步骤,并画出了栈帧建立和
销毁的过程。
所有的一切,都从理解这个例子开始。
===============================================================
Section 3.2 最简单的树形递归 Hanoi Tower
递归树本来就有某种程度上的一些麻烦,所以为了理解递归树,也许抄写并自己画图试一下可能是最快最好的办法。LZ反对过于懒惰,尤其是在学习某个东西时,如果不实践一下的话,就算理解力很高,但由于没有动手,印象始终不够深刻。而且常常在练习的过程中会发现一些新的东西。
数学也是这样的。有的同学对数学的理解力是很高(很有天分)的,但是正由于比较聪明因而缺少练习,最终的结果是数学的基本功底反而不如认真练习了的同学好[LZ对此深有同感, 因为LZ以前就是认为理解是要务,练习是次要,但LZ念书时的微积分老师就一直强调要多练习。可惜LZ念书总是不认真的]。
写代码什么的虽然确实不太要求数学,但若有良好的数学训练和基本功底是好的。写代码做软件是工程。小工程称不上设计,因而和科学无关。但大工程背后的设计和基础理论支撑,则必须是科学。这就是为什么计算机能称之为一门科学的原因。有一句名言大约是这么说的,就是没扯上数学前是扯淡,扯上了数学,就是科学,o(∩_∩)o 。
===============================================================
本来打算直接上汉诺塔的。想了想,还是先看菲波拉契数列好了。
菲波拉契数列用递归写的话是一个尾递归,所以菲波拉契数列是可以不用栈直接用迭代处理的。而且用迭代处理显然是比递归处理效率要高的办法。这里LZ只写递归的,并画出调用递归树。由于涉及到递归树,所以其实比较好的方法是作图了,字符图也不太好看,此后的东西LZ最好用图片上传。
调用递归树是很容易理解的。实际上,某种程度上,像之前证明递归就是栈处理一样,可以形式化或者非形式化地证明:
1. 所有的递归都可以通过递归树表达
2. 所有的递归树都是树形栈处理的
因为之前证明递归就是栈处理这一论题时,并没有考虑树形递归的情况。不过直观地看最简单。
这里最主要的观察结论在于,树形栈的建立/销毁序列和调用递归树的调用/返回序列是完全一致的。因此,调用递归树和树形栈是完全契合的。
===============================================================
Section 3.2.1 fibonacci数列的调用递归树
===============================================================
Fibonacci数列是指这样的数列:
1 1 2 3 5 8 13 21 34 55 89 144...
简而言之,除开始两项之外,任意一项是其前面两项之和。
f(1) = 1, f(2) = 1, f(3) = f(2) + f(1), ... f(n) = f(n - 1) + f(n - 2).
于是,递推关系如下:
1 n <= 2
f(n) = f(n - 1) + f(n - 1) n > 2
根据该递推式,可以很容易地写出代码:
/*****************************************************/
#include <stdio.h>
int fibonacci(int n)
{
if (n <= 2)
{
return 1;
}
return fibonacci(n - 1) + fibonacci(n - 2);
}
int main(void)
{
int n;
printf("pls input n:");
scanf("%d", &n);
printf("The %d's fibonacci num is %d\n", n, fibonacci(n));
return 0;
}
/*****************************************************/
正如看到的那样,因为 fibonacci 函数对自身的调用出现在它的尾部,所以它是一个尾递归(这里的说法可能有误,这不是尾递归,尾递归必须是直接return fib(xxx);这样,不能对返回值做任何运算后再返回 );而因为它的实现中对自身调用不止一次而是两次[fibonacci(n - 1)和fibonacci(n - 1)],因此它不是一个简单的线形递归,而是一个树形递归。
接上. 假设我们输入数字5.
那么fibonacci的递归调用树如下所示:
(哈, 直接从SICP[《Structure and Interpretation of Computer Programs》]拿过来的, 甚好用, LZ画的估计没这么漂亮, 呵呵)
不过这图需要把原来的fibonacci数列递归改一下,改成这样:
数列:
0 1 1 2 3 5 8 13 21...
/****************************************************************/
int fib(int n)
{
if (n == 1)
return 1;
else if (n == 0)
return 0;
return fib(n - 1) + fib(n - 2);
}
/****************************************************************/
figure 3.1
fib(5) 的递归树,
The tree-recursive process generated in computing fib(5):
来源于SICP
|
这张图片太漂亮了.
它非常非常之清晰地展现了fib函数的调用执行过程.
figure 3.1中, 粗线代表调用, 箭头细线代表fib(5)的执行路径[这里要注意一下. C语言的标准并没有规定, fib(4) + fib(3)应该哪一个调用先进行, 即表达式求值顺序其实是没有确定的. 但这完全不影响我们画出上面的递归树].
根据该图, 执行路径也是树形的.
其中, 递归树的左半部分执行路径如下[fib(4)部分]:
figure 3.2
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
栈深:
<=======================================|
|###|----|###|----|###|----|###|----|###|
执行路径:
fib5 ==> fib4 ==> fib3 ==> fib2 ==> fib1
<==
==> fib0
<==
<==
==> fib1
<==
<==
==> fib2
==> fib1
<==
==> fib0
<==
<==
<==
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
==>表示调用, 在调用之前是在执行调用点上半部代码;
<==表示返回/回溯, 在返回后是在执行调用点下半部代码.
比如, fib4调用fib3, 语句如下:
return fib(3) + fib(2);
-- 从fib(3)的角度来看 fib(4) 的上半部和下半部, 则:
1. 调用fib(3)之前执行过条件语句, 则那个条件语句即为上半部[因为从函数开始到调用点就只有这个条件判断语句];
2. 当fib(3)返回fib(4)后, 嗯 -- 下半部还没执行呢, 下半部就是fib(2).
-- 那么从fib(2)的角度来看 fib(4) 的上半部和下半部呢?
1. 条件判断语句和fib(3)是上半部.
2. 没有下半部[或者说下半部就是直接返回].
----------> 这种分析实际上没必要, 一句话, 看调用递归树, 一切明了. <------
同时!
请注意栈深. 栈的最大深度, 就是递归树的深度.
-- 调用函数时, 栈帧建立.
-- 函数返回时, 栈帧销毁.
所以 -- 从递归树来看, 栈帧本也应是这样树形的, 很容易理解吧. 但是我们的栈却是线形的.
-- 由于[函数]调用时栈帧建立, 返回时栈帧销毁, 所以栈帧在调用和返回的过程中, 不断建立, 又不断销毁. -- 进程栈只有一个[不考虑多线程什么的, 这里用不着考虑] -- 进程栈在调用和返回的过程中不断地, 动态地伸缩, 但是最大栈深就是递归树的深度 -- 换句话说, 递归树的深度是5[或者4, 0 -- 4], 所以栈深是5个栈帧.
嗯.
还记得求和序列的递归吧. 求和序列的递归路径是这样的:
|
\/ /\
调 | sum(5) |
用 | sum(4) | 径
路 | sum(3) | 路
径 | sum(2) | 回
$ | sum(1) | 返
===---->----> =======
对比一下.
为什么称之为线形递归和树形递归, 名称就是这样得来的.
实际上, 可以把线形递归看着是树形递归的退化形式[即每个父节点只有一个子节点], 所以实际上递归的本质是树形的.
现在, 请把任意递归想像成递归树的形式. 这个想像有助于对递归的分析: 所有对递归的分析, 都可以用递归树的形式清晰地表达出来. 同时, 这种分析, 让我们直接分析出栈深和栈的建立/销毁过程[也即, 函数调用/返回过程/路径].
================================================================
本节通过对 fibonacci 递归的形式分析,
介绍了表达递归的终极分析利器: 递归树.
通过递归树, 我们表达一切递归, 给出递归的执行路径, 并确立栈深 -- 栈深让我们估算出任意递归所需要占据的内存容量.
Section 3.2.2 又一个树形递归的例子 Hanoi Tower
===============================================================
关于递归有一个非常经典的例子, 就是汉诺塔. 所以如果LZ略过汉诺塔是非常不恰当的.
汉诺塔非常经典的佐证之一就是,
D.E.Knuth 在他的《Concrete Mathematics》的第一章 Recurrent Problems 中, 介绍的第一个实例就是Hanoi Tower.
毫无疑问, 大师们对于递归[递归本身]都是很推崇的. 在可计算理论中, lambda演算, 递归函数[这里的递归函数非C语言中的递归函数, 而是一个形式化了的数学定义]和图灵机这三种计算模型是等价的, 有一个定理大约是说, 如果一个语言是图灵机可识别的, 当且仅当该语言是递归可枚举的.
PS, C语言之父 Dennis M Ritchie 的博士论文据说是《递归函数的层次》(不好意思, LZ半天没度娘/狗狗出该论文的英文原名).
扯淡结束.
============> 分割线 <======================
LZ引用 D.E.Knuth 在 《Concrete Mathematics》中的描述:
Let’s look first at a neat little puzzle called the Tower of Hanoi,
invented by the French mathematician Edouard Lucas in 1883. We are given a tower of eight disks, initially stacked in decreasing size on one of three pegs:
figure 3.3 the Tower of Hanoi
The objective is to transfer the entire tower to one of the other pegs, moving only one disk at a time and never moving a larger one onto a smaller.
Lucas furnished his toy with a romantic legend about a much larger
Tower of Brahma, which supposedly has 64 disks of pure gold resting on three diamond needles. At the beginning of time, he said, God placed these golden disks on the first needle and ordained that a group of priests should transfer them to the third, according to the rules above. The priests reportedly work day and night at their task. When they finish, the Tower will crumble and the world will end.
============> 分割线 <======================
figure 3.3显示了一个有8个碟子的汉诺塔. 我们的目的是把这8个碟子从A桩移动到C桩, 移动的规则是, 每次只能移动一个碟子, 并且绝对不能将一个大的碟子放到一个小的碟子之上.
Knuth:
The best way to tackle a question like this is to generalize it a bit. The Tower of Brahma has 64 disks and the Tower of Hanoi has 8; let’s consider what happens if there are n disks.
处理类似这种问题最好方式是将其稍微一般化/泛化/推广一些. 比如, Brahma的塔有64个碟子; figure3.3中有8个碟子: 让我们考虑 n 个碟子的情况[n 就是对64, 8这样的具体数字的泛化].
Knuth:
One advantage of this generalization is that we can scale the problem
down even more. In fact, we’ll see repeatedly in this book that it’s advanta-geous to LOOK AT SMALL CASES first. It’s easy to see how to transfer a tower that contains only one or two disks. And a small amount of experimentation shows how to transfer a tower of three.
泛化问题的好处是我们可以[经由这种泛化从而]缩小问题的规模. 实际上, 《CM》会多次强调"首先, 看基础情形"是有好处的. 转移1个或者2个碟子[从桩A到桩C]的情形是相当容易处理的, 而稍微实验一下就能发现如何处理转移3个碟子的情形.
knuth:
The next step in solving the problem is to introduce appropriate notation: NAME AND CONQUER. Let’s say that T(n) is the minimum number of moves that will transfer n disks from one peg to another under Lucas’s rules. Then T(1) is obviously 1, and T(2) = 3.
解决问题的下一步就是引入适当的符号/记号, 即, NAME AND CONQUER[以符号命名问题, 然后解决它. 某种程度上, Knuth的意思大约是, 以符号命名问题带给我们思考的便利, 得到便利的原因是因为我们将某个问题抽象为一个符号]. 我们令 T(n) 为转移 n 个碟子[从桩A到桩C]所需要的最小移动次数. 那么显然的, T(1) = 1, T(2) = 3.
knuth:
We can also get another piece of data for free, by considering the smallest case of all: Clearly T(0) = 0, because no moves at all are needed to transfer a tower of n = 0 disks! Smart mathematicians are not ashamed to think small, because general patterns are easier to perceive when the extreme cases are well understood (even when they are trivial).
我们还可以放宽数据范围, 比如让我们考虑最小情形: T(0) = 0, 因为如果碟子数是0的话那么就不需要移动了. 聪明的数学家从来不为考虑最小情形/极端情形感到羞愧, 因为这使得处理问题的一般模式更容易理解.
knuth:
But now let’s change our perspective and try to think big; how can we
transfer a large tower? Experiments with three disks show that the winning idea is to transfer the top two disks to the middle peg, then move the third, then bring the other two onto it. This gives us a clue for transferring n disks in general: We first transfer the n - 1 smallest to a different peg (requiring T(n - 1) moves), then move the largest (requiring one move), and finally transfer the n- 1 smallest back onto the largest (requiring another T(n - 1) moves). Thus we can transfer n disks (for n > 0) in at most 2T(n - 1) + 1 moves:
T(n) <= 2T(n - 1) + 1
现在让我们改变视角, 试着重回问题本身; 我们如何转移一个大塔[有很多碟子的塔]? 转移三个碟子的实验显示, 关键在于转移上面2个碟子到中间桩, 然后移动第三个碟子[到目的桩C桩], 然后再将中间桩上的2个碟子转移到C桩. 这给了我们一些如何转移 n 个碟子的线索: 我们首先转移上面的 n - 1 个碟子到中间桩(需要移动 T(n - 1) 次), 然后移动最底下的碟子到目的桩(需要移动 1 次), 最后再转移中间桩的 n - 1 个碟子到目的桩(又需要移动 T(n - 1) 次). 因此:
T(n) = 2T(n - 1) + 1
LZ: 此后knuth还啰嗦T(n)是否是移动次数的最小值, 但LZ不能再扯了.
======================================
经过上面的分析,
我们可以得到递推关系如下[当然, hanoi视喜好可以改掉其名称/符号]:
1 n == 1
hanoi(n) = 2 * hanoi(n - 1) + 1 n > 1
======================================
现在我们有个问题:
给定碟子数 n, 给定 3 个桩, A, B, C,
请用程序求解出将 n 个碟子从 桩A 转移 到 桩C 的移动步骤.
此即以C语言程序求解 Hanoi Tower 的题目.
如果已经写过汉诺塔, 那么写出来是很容易的.
但是假设从来没有写过汉诺塔,那么应该怎么考虑这个问题呢?
没错, 我们已经得到了递推公式, 但是这个递推公式只是关于移动次数的, 对我们如何写程序没有太多帮助, 因为我们的程序要求将移动的步骤全部输出.
然而, 得到递推公式的思考过程仍然是有用的, 要移动n个碟子[from A to C], 我们需要三步:
1). 移动上面 n - 1 个碟子, from A to B.
2). 移动最底下的 1 个碟子, from A to C.
3). 移动 n - 1 个碟子, from B to C.
我们发现, 移动 n - 1 个碟子和移动 n 个碟子是完全一样的问题[Recurrent Problem]. 于是, 这三个步骤就成为了重复的过程, 于是这重复的过程[Recurrent Procedure/Process]正是将递归函数抽象出来的关键.
/*************************************************************/
#include <stdio.h>
void hanoi(int n, char A, char B, char C)
{
if (n == 1)
{
// move 1 disk.
printf(" %c ===> %c \n", A, C);
}
else
{
// first, move n - 1 disks from A to B
hanoi(n - 1, A, C, B);
// then, move 1 disk from A to C
hanoi(1, A, B, C);
// at last, move n - 1 disks from B to C
hanoi(n - 1, B, A, C);
}
}
int main(void)
{
int n;
printf("pls input disk nums:");
scanf("%d", &n);
printf("now, start move......\n");
hanoi(n, 'A', 'B', 'C');
return 0;
}
/*************************************************************/
汉诺塔的递归树[移动3个碟子]如下, 每个节点有三个孩子, 执行路径像fibonacci数列一样画. 这里没有画出来. 另外由于'A','B','C'占用太宽, 所以就将其表示成ABC了.
hanoi(3,ABC)
hanoi(2,ACB) hanoi(1,ABC) hanoi(2,BAC)
A ==> C
hanoi(1,ABC) hanoi(1,ACB) hanoi(1,CAB) hanoi(1,BCA) hanoi(1,BAC) hanoi(1,ABC)
A ==> C A ==> B C ==> B B ==> A B ==> C A ==> C
移动3个碟子是 2 ^ 3 - 1步, 移动四个碟子需要2 ^ 4 - 1共15步, 四个的画不下.
所以测试的时候 n 不要输入太大, 3或者4就可以了, 验证起来简单. 如果输入64, 由于递归的最大栈深只有64个栈桢, 因此绝不会因栈溢出而导致程序dump, 但毫无疑问是没有机会等待这个程序执行完毕了.
基本上这些步骤做下来, 分析C语言的递归函数时就可以畅行无阻了. 可以直接在头脑中构建较浅的递归树, 这样在直观上和心算能力上都会有一定程度的提升. 递归树使我们可以毫无差错地手工计算任意函数的中间状态而不需要感到清晰性的缺失 -- 当怀疑某个递归是否正确的时候 -- 画递归树.
=========================> Chapter 4 <=====================
一个栈ADT的实现
用动态数组结构还是链表结构来作为栈的基础容器呢?
1) 动态数组如果处理得好[像vector那样]可以不必每次弹栈压栈都涉及内存分配, 因此速度肯定要快得多[基本上速度和系统栈应该是接近的数量级, 但很难快过系统栈... PS, 感谢炮姐光临... 炮姐很认真地在这速度上较劲, 是不是很可耐... 炮姐的"渣实现"绝不会比系统栈慢多少, 比系统栈还快的话, 我肯定做不到的].
2) 链表结构的话每次压栈/弹栈都涉及内存分配和释放, 想想系统栈每次只要加减一下栈顶寄存器[内存访问都不需要的], 而我们却要调malloc/free... 而且我们还会memcpy... 这速度和系统栈根本不是一个数量级的, 像龟兔赛跑.
LZ想偷懒[就算撸个马马虎虎的动态数组, 怕也是渣得很], 所以直接用链表结构了. 郑重声明: 想要个工作起来马马虎虎快的栈, 绝不应该是这种渣实现.
LZ的意图只是提供个接口, 以便后来调用时代码能编译通过, 说明时也好直接说明...
嗯.
那么实现就是一个很简单的单链表了. 每次只在链表头插入[压栈], 取也只从链表头取[弹栈], 这样就是后进先出了. 不判栈满, 反正堆上内存是够用的. 栈空是要判的.
栈上保存用户数据的副本而不是直接把用户数据链入. 弹栈压栈用memcpy, 让用户自己传入分配好内存的数据指针[入参/出参]用于存取数据. 每次都分配/释放节点. top直接用指针, 不搞成计数指示器.
总之速度是没有的, 实现是够渣的.
首先是
list_stack.h
这个声明了ADT栈的接口.
结构非常简单,node_t 是链表节点,stack_t 就是栈 ADT 的基本结构了.
这里也不把操作写在结构体内部了, C实现就是要够简单.
上面的实现中声明了一个 stack_op 的函数指针类型.
这个函数指针用来在销毁栈[假设此时栈未弹空]时,回调用户的销毁函数,防止内存泄漏。
什么意思呢? 因为每个栈帧在这个链栈中表现为一个链表的节点。节点中的 void *data即用来存放用户数据的指针。 该指针指向的内存是 ADT 实现中分配的,就是说用来存放用户数据的副本。 问题在于,实现 ADT 时我们不知道用户会存放什么样的数据。假设用户存放的数据中还有一个指针[即 void *data指向的那片内存中,还有一个指针],这个指针又指向用户自己分配的内存, 则我们在释放 void *data指向的那片内存之前, 还得把用户分配的内存释放掉。
但是我们又不知道该怎么释放,因为毕竟void *data指向的内存内容对于实现 ADT 时的我们来说是未知的。 因此用一个函数指针回调用户的销毁函数来释放。
前面已经说过,这个栈由于链表实现的原因,很慢。LZ没有具体测试,不过比系统栈慢上百倍是正常的~
list_stack.c
首先是stack_create 和 stack_destroy, 创建栈和销毁栈~:
上面的创建栈接口的两个参数说一下~
ele_size 用于指定每次压栈/弹栈时用户数据占用内存大小, 我们让它是固定的.
des 是回调销毁函数的指针. 如果用户数据中没有指针, 那么调用时给成NULL即可.
看销毁栈的接口, 就知道 des 的意思了~
注意, 简单起见, list_stack.h 和 list_stack.c 是放在同一目录的。
因此LZ包含头文件时直接用的 #include ""
如果用 #include <>, 则需要在编译时指定头文件所在目录~ gcc指定头文件搜索目录的选项是 -I
也可以写个简单的 Makefile. 不过简单起见就算了.
销毁栈的接口实际上还有个问题.
正常的实现应该
node_t **cur = &thiz->top;
node_t *tmp;
while (*cur)
{
tmp = *cur;
cur = &(*cur)->next;
if (thiz->des)
thiz->des(tmp->data);
free(tmp->data);
free(tmp);
}
thiz->top = NULL;
free(thiz);
接下来是 stack_push 和判空
接下来是 stack_pop 弹栈 和 stack_top 查看栈顶元素:
=========================> Chapter 5 <======================
将 系统栈 处理转为 ADT栈 处理
Section 5.1 为什么尾递归可以直接化为[不需要使用栈的]迭代处理
尾递归可以直接化解为迭代处理, 不需要使用栈.
下面我们来看看为什么.
本质上, 需要栈处理的原因是因为我们需要保存任意函数计算的中间状态.
让我们把 C语言的函数 这个概念稍微抽象一下先.
让我们把任意 C语言函数记为 foo.
foo的形式自然是
ret foo(paras);
ret代表 foo 函数的返回类型/返回值, paras[parameters]代表 foo 函数的参数序列.
tips:
##############################################
我们知道, 如果不给C语言函数传递指针, 只是调用C语言函数, 那么除了全局变量, 这个函数是没有办法改变这个函数之外的变量的值的.
也即, 只是调用[请注意LZ没有把这个函数的返回值赋值给任意一个变量, 只是, 简单地调用它]:
foo(para1, para2, ...);
如果, 传递给 foo 的参数para1, para2, ... 都不是指针[请注意, C语言中, 当你传递"数组"时, 传递的只是数组首地址, 也即当数组作为形式参数时, 实际上退化为一个指针. 所以LZ这里的"指针", 也包括"数组形式的指针"], 那么foo什么都改变不了 --
除了全局变量 / foo函数内部的静态变量.
[实际上, foo函数内部的静态变量就是只有foo函数能访问的全局变量].
更进一步, 如果我们把foo函数内部的静态变量看成是foo函数自带的东西, 那么除了全局变量之外, foo函数什么都改变不了.
##############################################
我们之前已经说过, 任意变量都可以看作是存储"状态"的东西/盒子.
让我们把 foo函数之外的 所有的变量的状态 称之为 环境状态[Status of Environment],
把 foo函数之内的 foo函数能够操作的变量的状态 称之为 foo局部状态[Status of foo].
那么, 如果只是简单地调用 foo 改变了环境状态[也即, 传递指针给它, foo修改了指针指向的内容, 或者, foo修改了某个全局变量], 我们就称, foo函数具有 副作用[Side Effect].
否则, 我们称 foo函数没有副作用.
tips:
##############################################
函数无副作用不代表它线程安全.
线程安全和有无副作用并不相干,
这种情况主要发生在, foo函数一开始修改了环境状态, 然后返回前又还原环境状态的情形下.
但是, 如果foo函数从不修改环境状态, 并且foo函数不自带静态变量[从而不会修改它], 那么它是线程安全的.
##############################################
printf函数是有副作用的. 因为它改变了输出流. 输入输出流是环境状态.
swap是有副作用的. 因为它交换了两个环境状态.
int add_int(int l, int r)
{
return l + r;
}
add_int本身是没有副作用的, 它不会改变环境状态.
然而, 如果写
int ret = add_int(3, 5);
那么"="操作改变了环境状态 ret. 有副作用的操作不是 add_int, 而是"=" -- 修改动作本身.
我们的整个C语言程序, 可以视为环境状态被改变的过程.
于是, 整个程序可以被视为一个操纵环境状态序列的自动状态机.
同理, foo函数 可以被视为操纵 foo局部状态序列的自动状态机.
C语言是依赖函数副作用编程的语言. 大多数命令式范式的语言都是这样的.
前面有点啰嗦了.
意思只有一个, 就是
foo函数 是一个对 foo局部状态 进行操作的 动作/计算/代码 序列.
[这里的 foo局部状态 不单是指foo函数的所有局部变量, 而是指foo函数能够操作的所有变量 -- 以之前的定义为准. 函数无副作用代表函数结束后不会更改环境状态; 但是函数当然可以有副作用, 也可以没有.]
可以简单地把 foo 看成一个黑盒子; 这个黑盒子的输入是 foo局部状态的 初始状态/函数尚未开始执行时的初始值; 这个黑盒子输出是 foo局部状态的 结束状态/函数结束的时候的值.
晕, 这不废话么...
现在的问题是, 函数的计算不总是"一气呵成"的 -- 因为函数中也可以调用函数.
比如, foo调用bar [pseudo code]:
############################################
ret foo(paras_foo)
{
compute_seq1;
var = bar(paras_bar);
compute_seq2;
return ret;
}
############################################
像Chapter3 的第一个线形递归实例一样, 这个函数的 计算/动作 序列由 bar调用 分割成了两个部分. 调用前的计算序列为 computer_seq1, 调用后的计算序列为 computer_seq2.
于是, computer_seq1 可能将 foo局部状态初始状态更改了, 但foo函数还没有执行完, 也即foo函数对 foo局部状态的更改只做了一部分, 等bar调用完毕, computer_seq2 可能还会更改foo局部状态以达到 foo局部状态结束状态.
那么, 这个调用函数 bar之前[恰好这个时间点]的状态就称为foo函数的 中间状态.
为了能够在 bar函数调用回来之后继续, 解决办法就是转到 bar函数执行之前将该中间状态[foo函数所能操纵的变量] 保存 起来, 然后待 bar函数执行完之后, 在原来 保存 的中间状态上继续[改变该中间状态直至结束状态, 然后return].
这就是"需要栈处理的原因是因为我们需要保存任意函数计算的中间状态"的解释.
还有点东西没有说完. 对于 bar函数来说, 调用 bar之前的所有状态都是环境状态. 也即, foo函数的中间状态对于bar函数来说, 是bar函数的整个环境状态的一部分.
bar函数当然也可能更改它的环境状态.
-- 这个不用理会. 只需要把 foo的中间状态保存起来, 然后调用 bar就可以了. bar要改环境状态[从而在bar函数返回时, foo保存的中间状态可能已经被更改]就让它改去.
-- 就像, 调用 foo函数之前, 所有的状态都是环境状态一样 -- 那些环境状态当然有相应的地方保存. 但是, foo函数如果要更改环境状态, 那就让它改去[或者说, 程序员写foo函数就是为了达到这种效果].
总之, 所需要的只是, 先把中间状态保存[放在某个地方], 调用bar, 等bar返回后, 把中间状态取出来[从保存的那个地方取出. 这时中间状态可能被bar修改过], 然后在其之上继续处理.
============> 分割线 <=============
系统栈正是这样做的.
任意函数[在运行时]从调用时刻开始, 建立栈帧, 栈帧在任意时刻保存中间状态[并且该中间状态被不断更新], 直至该函数返回[到达结束状态], 销毁栈帧.
因此... 这不过是之前阐述"递归的形式"和栈处理的又一种表述而已.
==================================
为什么尾递归可以化解为迭代处理:
设foo函数的局部状态[所有用于保存局部状态的变量]为有限**S,
S = { var_[start], var[1], ..., var_[mid], ... var_[end] };
那么非尾部递归[pseudo code]:
############################################
ret foo(paras)
{
stop_condition;
compute_seq1;
var = foo(new_paras);
compute_seq2;
return ret;
}
############################################
设 compute_seq1 用于确定 var_[start] -- var_[mid] 变量的状态;
设 compute_seq2 用于确定 var_[mid + 1] -- var_[end] 变量的状态.
于是, 调用时有:
s_start ==> s1 ==> s2 ==> s3 ==> ... ==> s_end
由于递归调用结束前总是执行上半部, 因此上述序列中, 每一个**S的状态化实例都只有
var_[start] -- var_[mid]的状态被确定, 剩下的状态在等待确定.
返回时回退:
s_start <== s1 <== s2 <== s3 <== ... <== s_end
由于返回时总是执行下半部, 因此每个**S的实例的剩下的状态被确定.
由于, 任意一个S的状态化实例都是分两步确定的[对于上述线形递归], 这就是和尾递归唯一的差别: 比如, s1一定要先保存起来, 等待s2整个确定并返回了, s1才能被确定. 这正是s1需要先保存的原因.
现在来看尾递归的情况:
尾递归[pseudo code]:
############################################
ret foo(paras)
{
stop_condition;
compute_seq1;
return foo(new_paras);
}
############################################
调用时:
s_start ==> s1 ==> s2 ==> s3 ... ==> s_end
和非尾递归不同的是, 它的所有局部状态[返回值除外]序列在调用序列之后就全部确定了.
回退:
s_start <== s1 <== s2 <== s3 ... <== s_end
回退时, 除返回值外, s2完全不会影响s1. 啊, 返回值. 注意返回值是在 s_end 就确定了, 此后回退时s1返回的值和s2返回的值是一样的. 简单地说, s_end就是我们想要的状态实例, 我们根本不需要回退!
由于s1的确定不依赖s2, 只依赖s_start, 同理s2的确定不依赖s3而是只依赖于之前的s1, 所以, 我们没有必要将每一个状态化实例都保存, 而只是需要 一个 刚好能容纳foo局部状态的容器[一片内存], 然后一个循环不断更新容器中的状态就可以了:
s_start ==> s1 ==> s2 ==> s3 ... ==> s_end.
回退是不需要的 -- s_end就是我们想要的状态. 因此所有尾递归都可以直接化解为循环, 这个循环只需要更新一个保存的实例[一个箱子], 因为没有箱子的叠加[栈帧的叠加], 自然也就称不上栈, 也就不是栈处理的.
====================================================================
感谢炮姐指出, fibonacci数列不是尾递归. 炮姐V5.
return fib(n - 1) + fib(n - 2);
这个返回, 实际上隐含了: 调用fib(n - 1)时, fib(n - 2)是"下半部".
换句话说, 树形递归(子节点数 > 1)都不能算是尾递归.
###############################################################
Section 5.2 模拟
###############################################################
只要是系统栈能处理的东西, 手工栈就能处理[除了速度之外...].
基本的想法就是模拟. 模拟系统栈的保存动作, 模拟函数调用和返回. 递归仍然该怎么写怎么写, 写完了照着模拟就是.
先看一个简单的实例, 这个实例将一个十进制的自然数转换为2进制的形式输出.
十进制正整数转为2进制的算法, 以13为例, 每次除以2取余.
/ 2 13 -- 1 // LSB lowest bit
/ 2 6 -- 0
/ 2 3 -- 1
/ 2 1 -- 1 // HSB highest bit
/ 2 0
即转换的结果是 1101
因为第一次除以2的余数是最低位, 所以应该最后输出.
递归的算法很简单, 每次都除以2, 除以2之前的状态保存在栈帧中, 等待算法结束后输出余数即可. 当商为0时终止.
代码如下所示. dec2bin即转换并输出的函数.
main函数里面有一个值得初学者注意的例子, 当使用scanf时, 如果输入的格式和代码要求的格式不匹配, 那么scanf是不能正确获取输入的. 因此需要清空输入缓冲区和重试的处理. 在循环获取输入的情况下尤其要注意这一点.
在 dec2bin 函数中, LZ加了两个标签, 这两个标签是并不影响执行流, 因为整个函数中根本就没有goto语句. 这两个标签是后面为了说明加的.
上面的递归, 转为手工栈处理如下:
该处理方式适用于对任何线形非尾递归的模拟[根据上面代码可将goto调整成while形式, 很简单].
上图中[首先, 创建手工栈, 最后要销毁手工栈, 这不用说].
start标签 对应于 dec2bin函数 的start; addr_ret 对应于 dec2bin函数 的addr_ret.
dec2bin中, 函数指令/执行流是从start标签开始的, 然后开始递归调用.
1. 递归调用会一直调用进去
%%%%%%%%%%%%%%%%
也就是说, 系统栈默默地
1) 保存当前函数的状态(当前函数的栈帧已经建立, 因为现在就在执行当前函数的指令),
2) 给被调用函数建立栈帧, 传递参数给被调用函数, 然后跳到被调用函数的开始指令处执行
%%%%%%%%%%%%%%%%
-- 在未满足终止条件之前绝不返回. 这期间一直在执行"上半部"代码, 也即, start标签到调用点之间的代码.
2. 一旦遇到终止条件, 则最顶/最后的函数返回. 返回到哪里?
由于调用函数时执行流在调用处被打断, 因此返回到调用点的下一条指令处执行. 下一条指令处就是addr_ret标签那里, 从add_ret到最后的函数出口之间是"下半部"代码, 该下半部代码执行完又返回到原调用函数, 仍是返回到原调用函数的add_ret处. 每次返回后都销毁栈帧.
%%%%%%%%%%%%%%%%
系统栈默默地
1) 在返回时销毁掉该返回函数的栈帧
2) 跳转到原调用函数调用点的下一条指令处执行
%%%%%%%%%%%%%%%%
╮(╯▽╰)╭, 又啰嗦了... 晕, LZ可能是为了让很少看汇编代码的童鞋明白.
其实[C编译器翻译出来的]汇编代码和上面的描述有些许差异, 不过整体来说是这样的.
看汇编码明白调用惯例的童鞋略过即可.
======================================
三个基本模拟语义.
stack_push 的语义是建立栈帧[并用于保存函数状态]
stack_pop 的语义是销毁栈帧[并取出以前保存的状态]
goto 的语义是模拟函数跳转, 跳转前要给函数参数赋值.
当然跳转除了用goto来模拟之外, 还有很多别的方式.
======================================
里面还有个要值得注意的事情, 递归是满足终止条件就返回, 不管手工栈还是系统栈都是这样判别的.
不过系统栈不用判别回退到什么时候结束. 但是手工栈是要判别的, 判别条件就是栈空时回退结束.