在学到数据结构的时候,递归是经常会用到的。
基本介绍
一个函数在函数体内又调用了本身,我们称为递归调用
因为这里只涉及到栈,这里直接画栈就行了。堆区和代码区就不画了。可以看到函数调用的时候是入栈,递归的时候是出栈。
func Test(n int) {
if n > 2 {
n--
Test(n)
}
fmt.Println("n=", n)
}
func main() {
Test(4)
}
n= 2
n= 2
n= 3
再来看看
func Test(n int) {
if n > 2 {
n--
Test(n)
} else {
fmt.Println("n=", n)
}
}
func main() {
Test(4)
}
n= 2
在出栈的时候else语句是不会执行的。只有最上面的栈是可以执行到else。
函数递归需要遵守的重要原则:
(1)执行一个函数时,就创建一个新的受保护的独立空间(新函数栈)
(2)函数的局部变量是独立的,不会相互影响(每个栈空间的变量都是独立的,只不过名字相同而已都叫n)
(3)递归必须向退出递归的条件逼近,否则就是无限递归,死龟了(n--条件是特别重要的,递归必须像退出递归条件逼近,否则就是无限循环调用)
(4)当一个函数执行完毕,或者遇到return,就会返回,遵守谁调用,就将结果返回给谁。当函数返回的时候,执行完毕,那么该函数本身也被销毁的,这样栈的空间就被释放出来了。
从 8086 的原理说起
说完了 x86 的历史,我们再来看 x86 中最经典的一款处理器,8086 处理器。虽然它已经很老了,但是咱们现在操作系统中的很多特性都和它有关,并且一直保持兼容。
我们把 CPU 里面的组件放大之后来看。你可以看我画的这幅图。
我们先来看数据单元
为了暂存数据,8086 处理器内部有 8 个 16 位的通用寄存器,也就是刚才说的 CPU 内部的数据单元,分别是 AX、BX、CX、DX、SP、BP、SI、DI。这些寄存器主要用于在计算过程中暂存数据。
这些寄存器比较灵活,其中 AX、BX、CX、DX 可以分成两个 8 位的寄存器来使用,分别是 AH、AL、BH、BL、CH、CL、DH、DL,其中 H 就是 High(高位),L 就是 Low(低位)的意思。
这样,比较长的数据也能暂存,比较短的数据也能暂存。你可能会说 16 位并不长啊,你可别忘了,那是在计算机刚刚起步的时代。
接着我们来看控制单元
IP 寄存器就是指令指针寄存器(Instruction Pointer Register),指向代码段中下一条指令的位置。CPU 会根据它来不断地将指令从内存的代码段中,加载到 CPU 的指令队列中,然后交给运算单元去执行。
如果需要切换进程呢?每个进程都分代码段和数据段,为了指向不同进程的地址空间,有四个 16 位的段寄存器,分别是 CS、DS、SS、ES。
其中,CS 就是代码段寄存器(Code Segment Register),通过它可以找到代码在内存中的位置;DS 是数据段的寄存器,通过它可以找到数据在内存中的位置。
SS 是栈寄存器(Stack Register)。栈是程序运行中一个特殊的数据结构,数据的存取只能从一端进行,秉承后进先出的原则,push 就是入栈,pop 就是出栈。
凡是与函数调用相关的操作,都与栈紧密相关。例如,A 调用 B,B 调用 C。当 A 调用 B 的时候,要执行 B 函数的逻辑,因而 A 运行的相关信息就会被 push 到栈里面。当 B 调用 C 的时候,同样,B 运行相关信息会被 push 到栈里面,然后才运行 C 函数的逻辑。
当 C 运行完毕的时候,先 pop 出来的是 B,B 就接着调用 C 之后的指令运行下去。B 运行完了,再 pop 出来的就是 A,A 接着运行,直到结束。
递归
递归是指函数直接或间接调用自己,递归常用于解决分治问题, 分治问题就是将大问题分解为相同的小问题进行解决,需要关注终止条件。
递归函数
函数内部调用函数自身的函数称为递归函数
使用递归函数最重要的三点:
- 退出条件: 退出条件基本上都使用退出点来定义,退出点常常也称为递归的基点,是递归函数的最后一次递归点,或者说没有东西可递归时就是退出点。
- 递归函数很可能会产生一大堆的goroutine(其它编程语言则是出现一大堆的线程、进程),也很可能会出现栈空间内存溢出问题。在其它编程语言可能只能设置最大递归深度或改写递归函数来解决这个问题,在Go中可以使用channel+goroutine设计的"lazy evaluation"来解决。
1.阶乘
因为阶乘的公式为n*(n-1)...321,它在参数为1的时候退出函数,也就是说它的递归基点是1,所以对是否为基点进行判断,然后再写递归表达式
package main
import (
"fmt"
"strconv"
)
func fact(n int) int{
if n < 0 {
return -1
}
if n == 0 {
return 1
}
fmt.Println(strconv.Itoa(n)+ "*"+"fact("+strconv.Itoa(n-1)+")")
return n * fact(n - 1)
}
func main() {
/*
n! = 1*2*3*....*n
递归,结束条件
*/
fmt.Println(fact(5))
}
5*fact(4)
4*fact(3)
3*fact(2)
2*fact(1)
1*fact(0)
120
代码区和数据区一般在代码运行起来是固定的,因为堆和栈在运行起来是要分配内存,可以申请内存和释放内存。
栈在函数调用的时候,栈其实就存储了函数和函数之间调用的关系。每个函数在调用的时候都会申请一块存储栈空间,在递归的时候会不断的申请空间,这块空间存储着中间的变量和返回值。
函数在调用另外一个函数的时候,栈空间是不释放的,因为函数没有释放是不会结束的,当申请到堆的上层空间的时候,内存就不够了,就溢出了,这样就是在函数递归的时候需要注意函数结束的条件。如果递归是无限的会一直申请栈空间导致内存被使用完。
栈可以增长也可以缩容的,当函数调用结束的时候栈就会被回收,这样就会将内存释放给操作系统。
- 栈:存放基本类型的数据和对象的引用,但对象本身不存放在栈中,而是存放在堆中(new 出来的对象)
- 堆: 存放用new产生的数据
值类型和引用类型 是什么?
- 值类型: 这段内存里面存储的是基本类型的数据, 比如 "a", 10, 0.01
- 引用类型: 这段内存里面存储的是一个地址, 比如 0xc00011e370 0xc00011e380
2.斐波那契数列
它的计算公式为f(n)=f(n-1)+f(n-2)且f(2)=f(1)=1。它在参数为1和2的时候退出函数,所以它的退出点为1和2
// f(n)=f(n-1)+f(n-2)且f(2)=f(1)=1, 1 2 3 5 8 13
func fib(n int) int {
// 退出点判断
if n == 1 || n == 2 {
return 1
}
// 递归表达式
return fib(n-1) + fib(n-2)
}
3.汉罗塔游戏