【数据结构】递归

来源:互联网 发布:58同城网络兼职日结 编辑:程序博客网 时间:2024/06/01 07:45

1. 递归定义

定义新对象或者新概念的基本规则之一是:定义中只能包含已经定义过得或含义明显的术语。因此,如果对象根据它自身来进行定义,这就严重违反了这一规则,从而导致恶心循环。而另一方面,有许多编程概念是根据自身进行定义的。此时,需要给定义加上形式上的约束,以保证其满足存在性和唯一性,不违反上述规则。这样的定义称为递归定义。主要用于定义无限集合。定义无限集合时,不可能列举出该集合中的所有元素,对于一些大的有限集合也是如此。这样就需要一种更有效的方法来判断对象是否属于某个集合,而递归就是这样一种方法。

递归的定义由两部分组成,第一部分称为锚 ( anchor ) 或者基例 ( ground case ),列出了产生集合中其他元素的基本元素。第二部分给出由基本元素或以有对象产生新对象的构造规则。这些规则被反复使用,从而产生新的对象。例如,如果需要构造自然数集合,取 0 为基本元素,并给出累加1的操作,如下所示:

  1. 0 N;
  2. 如果 n N,那么 (n+1) N
  3. 集合 N 中再没有其他对象。

( 需要更多的公理才能保证这些规则只会构造出我们所认为的自然数集合 )

根据上面这些规则,自然数集合 N 包含以下对象:0、0+1、0+1+1、0+1+1+1 等等。尽管集合 N 包括了我们认为的自然数对象( 并且只包括这些对象 ),但是这样的定义会产生难以处理的元素列表。就像在使用这种方式定义的自然数进行巨大数字之间的算术运算将会变得难以想象的繁琐,所以,使用下面这个定义将更便于处理,它包括了所有自然数。

  1. 0、1、2、3、4、5、6、7、8、9 N
  2. 如果 n N,那么 n0、n1、n2、n3、n4、n5、n6、n7、n8、n9 N
  3. 集合 N 中只包含自然数

这样集合 N 中包括了右 0 到 9 的所有可能的组合。

递归定义的目的是两个:一是产生新元素( 前面已经提到 ),二是测试一个元素是否属于某个集合。在测试的过程中,问题可能简化为更简单的问题,如果简化后的问题还是太复杂,就进一步进行简化,以此类推,直到简化到初始条件可以解决的问题为止。以上面定义的自然数为例,当需要判断 123 是否为自然数时,根据集合 N 定义的第二个条件,如果 12 N,则有 123 N,而第一个条件已经说明了 3 N;如果 1 N,且 2 N,则 12 N,显然 1 和 2 都属于 N ,所以 12 属于 N,123 也属于 N。

有时候将一个问题分解为同类型的几个更为简单的子问题会变得很容易办到。递归定义常常用来定义函数和数列,但是序列的递归定义有一个不好的特性,也就是为了确定序列中的一个元素 s~n~ 的值,首先需要计算这个元素之前所有元素的值或其中一部分。需要说明,对于计算而言,这种特性并不受欢迎,因为这就需要进行 迂回计算 。印尼次,想要寻求一种等价的定义或者等式,在计算一个元素的值时不需要考虑其他元素的值。找到这样的等式一般比较困难,有时根本找不到。等式比递归定义更可取,因为等式简化了计算过程。

大多数计算机上的递归定义最终是使用运行时栈来实现的,但是实现递归的所有工作是由操作系统完成的,源代码并没有指示自身是如何运行的。为了更好的理解递归,了解工作原理,下面首先介绍函数调用的过程和与递归实现的关系。

2. 函数调用与递归实现

在程序中进行函数调用时,如果这个函数有形参,形参就初始化为实参传递来的值。另外,函数阶数后,系统需要知道从哪里继续执行程序。一个函数可以被其他函数调用,也可以被主函数调用。指示函数从何处调用的信息保存在系统中。为了做到这一点,返回地址被存储在主内存中留出的特定区域,不过事先并不知道需要多大的存储空间,单独为了这个目的分配太大的存储空间又是一种浪费。

对于函数调用来说,需要存储的信息不只是返回地址。因此,使用运行时栈进行动态分配效果会更好一些。但是,这就需要确定在调用函数的时候哪些信息需要保存。

  1. 必须保存局部变量

如果函数 f1() (包含局部变量 x 的声明) 调用了函数 f2() ( 也生命了局部变量 x )。那么系统必须要对这两个 x 进行区分。如果函数 f2() 使用了变量 x ,则使用的是它自身的变量 x;如果函数 f2() 对 x 进行赋值,属于函数 f1() 的 x 值应该保持不变。当函数 f2() 执行完毕后,函数 f1() 中的 x 值仍然是在调用 f2() 之前所赋的值。当函数 f1()f2() 相同时,即一个函数递归调用自身的时候,系统如何区分这两个变量 x 呢?

每个函数( 包括主函数 main() )的状态由一下因素决定:函数中所有局部变量的内容,函数参数的值,表明在调用函数的何处重新开始的返回地址。包含所有这些信息的数据区称为 活动记录( activity record) 或者 栈框架( stack frame ),位于 运行时栈。只要函数在执行,其活动记录就一直存在。这个记录是函数的私有信息池,存储了程序正确执行并正确返回到调用它的函数所需要的所有信息。活动记录的寿命一般很短,因为活动记录在函数开始执行时得到动态分配的空间,在函数退出时释放空间。主函数 main() 的活动记录的寿命比其他函数的活动纪录长。

活动记录通常包含以下信息:

  • 函数所有参数的值:如果传递的是数组或按引用传递变量,则活动记录包含该数组第一个单元的地址或者该变量的地址;其他所有数据项的拷贝。
  • 可以存储在其他地方的局部变量,活动记录只包含他们的描述符以及指向其存放位置的指针。
  • 使得调用者重新获得控制权的返回地址,调用者置零的地址紧随这个调用之后。
  • 一个指向调用程序的活动记录的指针,这是一个动态链接。
  • 非 void 类型的函数的返回值。活动记录的空间大小随调用的不同而不同,返回值放在调用程序活动记录的正上方。

无论在什么时候调用函数,都会创建活动记录,这使得系统可以正确处理递归。递归只是被调用函数的名称正好和调用者相同。因此,递归调用不是表面上的函数调用自身,而是一个函数的实例调用同一个函数的另一个实例。这些调用在内部表示为不同的活动机理,并由系统进新区分。

3. 分析递归调用

作为递归函数的一个实例,可以定义一个数 x 的非负整数 n 次幂的函数。这个函数最直接的定义是:

x={1                n=0x xn1      n>0

可以直接根据幂的定义写出计算 xn 的 C++ 的函数:

double power (double x,unsigned int n){  if (n == 0)    return 1.0;  else    return x * power(x,n-1);}

使用这个定义,x4 的值可以按照下面的方式进行计算:

x4=xx3=x(xx2)=x(x(xx1))=x(x(x(xx0)))    =x(x(x(x1)))=x(x(x(x)))=x(x(xx))    =x(xxx)=xxxx

重复应用归纳步骤最终会到达基例,基例hi递归调用链中的最后一步,基例 1 作为 x 的 0 次幂;该结果回传给前一次的递归调用。该次调用执行之前处于悬挂状态,此时返回其执行结果,x1=x。等待这一结果的第三次调用开始计算其结果,也就是xx,并返回该值。然后这个结果由第二次调用接收,并将它乘以 x,然后将结果 xxx 返回给函数 power() 的第一次调用。这次调用接收 xxx ,并返回最终的结果,这样,每次新的调用都增加了递归的级数。

函数在执行时,系统在跟踪运行时栈上的所有调用。系统为每一行代码指定一个数,如果这一行是一个函数调用,这个数就是返回地址。系统使用这个地址来记录函数执行完毕后从哪个位置继续运行程序。

函数 power() 可以使用另一种方法来实现,不适用任何递归,如下所示:

double nonRecPower(double x,unsigned int n){    double result = 1;  for (result = x; n > 1; --n)    result *= x;  return result;}

使用递归与使用循环相比,看上比更为直观一些,因为它类似于幂函数的原始定义。这个定义可以用 C++ 简单的表示出来,而不改变定义的原始结构。递归提高了程序的可读性,简化了编程工作。在这个例子中,非递归版本的代码与递归版本的代码没有明显的差别,但是对于大部分递归的实现形式而言,其代码要比非递归的实现形式更短。

4.尾递归

所有的递归定义都包含对集合或已定义函数的引用。有多种实现方法可以实现该引用,可以直接实现,也可以以复杂的形式实现,可以以此实现也可以多次实现。递归存在许多级别和续断不同量级的复杂度,首先从最简单的类型——尾递归——开始。

尾递归的特点是:

在每个函数实现的末尾只使用一个递归调用。也就是说,当进行调用时,函数中没有其他剩余的语句要执行:递归调用不仅是最后一条语句,而且在这之前也没有其他直接或间接的递归调用。例如,函数 tail() 定义为如下的形式:

void tail(int i){    if (i > 0){        cout << i << '';        tail(i - 1);    }}

这个函数就是一个尾递归的例子,而下面这个函数就不是尾递归:

void nonTail (int n){    if (i > 0){        nonTail(i - 1);        cout << i << '';        nonTail(i - 1);    }}

可以看出,尾递归只是一个变形的循环,很容易用循环来进行代替。在这个例子中,用循环替换 if 语句,并根据递归调用的级别递减变量 i,就可以取代尾递归。这样,tail() 函数可以用一个迭代函数来表示:

void iterativeEquivalentOfTail (int i){    for (; i > 0; i--)      cout << i << '';}

对于 C++ 等语言,尾递归与迭代相比并没有什么明显的有事,但是在 Prolog 等没有明确循环结构的语言中,尾递归的优势十分突出。在含有循环或类似结构(例如 if 加上 goto 语句)的语言中,不推荐使用尾递归。

5. 非尾递归

可以用递归实现的另一个问题是将输入行以相反的顺序输出。下面是一个简单的递归实现:

void reverse() {    char ch;    cin.get(ch);    // 获取一个字符    if (ch != '\n'){        reverse();        cout.put(ch);   // 输出一个字符    }}

直接看这个函数没有做任何事情。但是实际上由于递归的作用,这个函数的确可以完成设计目标。假设由主函数 main() 调用函数 reverse(),其输入是字符串 “ABC” 。首先创建一个活动记录,为变量 ch 和返回地址留出位置。在这个函数中不需要为结果保留位置,因为函数咩有返回值。

函数 get() 读取第一个字符 “A”,接着,该函数读取第二个字符,并判断这个字符是否为行末字符,如果不是,则再一次调用 reverse()。无论是够到达结尾,ch 的值都会痛返回地址一起压入运行时栈。在第三次调用 reverse() (第二次递归) 之前,栈中多了两个数据项。

注意函数的调用次数和输入串的字符数(包括行末字符)相等。在上面这个函数中,reverse() 被调用了 4 次。在第四次调用过程中,get() 得到了行末字符,且 reverse() 没有其他语句要执行。于是系统找活动记录中的返回地址,并将 SP 减小一定的字节数,舍弃这个活动记录。程序从第 6 行继续运行,该行是一个输出语句。因为第三次调用的活动记录是当前有效的,所以 ch 的值 “C” 作为第一个字符进行输出。接着,删除第三次调用 reverse() 的活动记录,SP 指向存放字符 “B” 的单元。第二次调用完成时,字符 “B” 赋给 ch,然后执行第 6 行的语句,在屏幕上的 “C” 之后输出 “B”。最后轮到第一次调用 reverse() 的活动记录。接着输出 “A”,这样屏幕上就可以看到字符串 “CBA”。第一次调用完成之后,程序在主函数 main() 中继续执行。

总之,上面这个程序就相当于将 “ABC” 以此存放在运行时栈中,然后再将其进行输出,根据栈的规则,所以输出的顺序为倒叙,也就是 “CBA”。

下面这个函数使用非递归的实现形式:

void simpleIterativeReverse() {    char stack[80];    register int top = 0;    cin.getline(stack, 80);    for(top = strlen(stack) - 1; top >= 0; cout.put(stack[top--]));}

这个函数很短,似乎比递归实现形式更加模糊。看上去上面这中非递归实现的方式比较简单和间断,但这主要是由于要翻转的是字符串或字符数组,因此可以应用标准 C++ 库中那个的 strlen() 和 getline() 等函数。如果不使用这些函数,则迭代函数必须用不同的方式进行实现。如下所示:

void iterativeReverse() {    char stack[80];    register int top = 0;    cin.get(stack[top]);    while(stack[top] != '\n')      cin.get(stack[++top]);    for(top -= 2;top >= 0; cout.put(stack[top--]));}

可以看出,while 循环取代了函数 getline(),比那两 top 的自增语句取代了 strlen()。for 循环和前面的非递归版本基本相同。

无论采用什么方式,将非尾递归形式转化为迭代形式都需要显示的使用栈。而且,将函数由递归形式转化为迭代形式,程序的清晰度会降低,程序的表述也不再简明

6. 间接递归

前面只讨论了直接地柜,也就是函数 f() 调用自身,函数 f() 还可以通过一系列其他调用来间接地调用自身。例如,函数 f() 调用函数 g() ,函数 g() 调用函数 f()。这是间接递归的最简单的情形。

中间调用链可以为任意长度,如下所示:

f() -> f1() -> f2() -> … -> fn() -> f()

还有一种情况,函数 f() 通过不同的中间调用链间接调用自身。所以,除了刚才给出的调用链,还存在另一种调用链。

f() -> g1() -> g2() -> … -> gm() -> f()

以信息解码的 3 个函数为例来说明这种情况,函数 receive() 将输入的信息存放到缓存中,函数 decode() 将它转化为可识别的形式,函数 store() 将它保存到文件中。receive() 的缓存满了以后调用 decode(),接着函数 decode() 完成任务之后,将具有已解码信息的缓存提交给函数 store()。函数 store() 完成他的任务之后,调用函数 receive(),使用同一个缓存截取更多的编码信息。因此得到如下的调用链:

receive() -> decode() -> store() -> receive() -> decode() ->…

当没有新的信息到达时调用结束。这三个函数的运行方式如下所示:

receive(buffer)  while buffer 未满    if 信息还在继续到来        获取一个字符并将它存放在 buffer 中;    else exit();    decode(buffer);decode (buffer)  对 buffer 中的信息进行解码  store(buffer)store (buffer)  将 buffer 中的信息转移到文件中;  receive (buffer)

7. 不合理递归

使用递归的好处是逻辑上的简单性和可读性,其代价是降低了运行速度,与非递归方法相比,在运行时栈中存储的内容更多。如果递归的次数太多( 例如计算 5.6^100000^),就会用尽占空间并导致程序崩溃。当时递归调用的次数通常比 100 000 小得多,所以栈溢出的情况不大会发生。但是,如果某个递归函数重复计算某些参数,即使是非常简单的问题,运行时间也会非常长。

下面考虑 Fabonacci 数列。Fibonacci数列的定义如下:

Fib(n)={n                                              n<2Fib(n2)+Fib(n+2)       otherwise

上面这个定义表明前两个数为 0 和 1,数列中其他的数都是其两个前驱之和。这些前驱又是其前驱的和,以此类推,直至数列的开始。根据定义,产生的数列是:

0,1,1,2,3,5,8,13,21,34,55,89,...

可以使用递归并用 C++ 语言进行实现:

unsigned int Fib(unsigned int n){    if (n < 2)      return n;    else      return Fib(n - 1) + Fib(n - 2);}

这个函数十分简单易懂,但是效率极低,下面是一种比较简单的迭代算法:

unsigned int iterativeFib (unsigned int n){    if (n < 2)      return n;    else{        register int i = 2, tmp, current = 1, last = 0;        for( ;i <= n; ++i){            tmp = current;            current += last;            last = tmp;        }      return current;    }}

8.回溯

在解决某些问题时,会遇到一种情况:从给定的位置出发有许多不同的路径,但是不知道哪一条路径才能解决问题。尝试一条路径不成功后,我们返回出发的十字路口并尝试另一条路径,希望能够找到解决办法。但是必须保证这样的返回是可以实现的,而且可以尝试所有的路径。这种方法称为回溯。在尝试某些路径不成功后,可以系统地尝试从某一点出发的所有可能路径。使用回溯法可以回到出发的位置,这提供了成功解决问题的其他可能。这种方法用于人工智能,八皇后问题就是可以通过回溯法解决的一个问题。

八皇后问题是把 8 个皇后放在棋盘上,是指不会互相攻击。根据国际象棋的规则,皇后可以吃掉放在与其同行、同列或者统一斜线上的任一个棋子。为了解决这个问题,先把第一个皇后放在棋盘上,再放上第二个皇后,使其不会吃掉第一个皇后,再放第三个皇后,使其不会和前两个发生冲突,以此类推,直到放上所有的皇后。如果第 6 个皇后找不到不与其他皇后冲突的位置,我们就为第 5 个皇后重新安排一个位置,在尝试第 6 个皇后。如果不行,就给第 5 个皇后再换一个位置。如果第 5 个皇后所有可能的位置都尝试过了,就需要移动第 4 个皇后的位置,重新开始这一过程。这个过程的工作量非常大,其中大部分用于回溯到交叉路口,以尝试未试过的路径。然而由于使用了递归,从代码看,这一过程非常简单,用递归实现回溯非常自然。下面是这个回溯算法的伪代码( 最后一行就是关于回溯的 ):

putQueen(row)  for 同一行 row 上的每个位置 col    if 位置 col 可以放皇后        将下一个皇后放在位置 col 处        if (row < 8)          putQueen(row + 1);        else          成功;        取走位置 col 上的皇后;

上面这种算法可以找到所有可能的解,不过其中的某些解是对称的。

这一算法最自然的实现方法是声明一个表示棋盘的 8x8 数组 board,其元素时 0 和 1.这个数组初始化为 1,每当吧一个皇后放在位置 (r,c),board[r][c]就设置为 0 。同事,函数将所有不能放置棋子的位置均设置为 0。当进行回溯时,这些位置( 同一行、同一列以及同一条斜线上的位置) 再设置为 1,一位置又可以将皇后放在该位置了。需要多次才能找到皇后的合适位置,设置和重新设置是整个实现过程中最费时的部分。对于每个皇后,有 22 到 28 个位置需要设置和重新设置,其中的 15 个是同一行或同一列的位置,7 到 13 个是同一斜线上的位置。

上面这种方法是从下棋者的角度来观察棋盘的,可以同时看到整个棋盘以及所有的棋子。但是,如果仅仅考虑这些皇后,就可以从他们的角度来观察棋盘。对于皇后来说,棋盘并不是划分为许多方块,而是划分为行、列以及斜线。如果皇后置于一个方块中,它并不局限于这个方块,这一方块所在的整个行、列和斜线均被视为其私有领地。这一思路可以使用另一种数据结构来表示。

首先,为了简化问题,用 4x4的期盼代替常规的 8x8 的期盼。然后再程序中进行修改,以应用于常规的 8x8 的棋盘。
这里写图片描述

上图给出的是 4x4 的棋盘,我们可以看到一共有七条左斜线,且 r+c 的值得变化范围是 0到6,右斜线上所有的横纵坐标之差相同,即 r-c ,但不同的右斜线的差值不同,右斜线的范围是 -3 到 3。对于右斜线而言,其数据结构也会死一个数组,但是数组的下标不能是复数,因此该数组具有7个元素,但是考虑到表达式 r-c 得到的负值,因此给 r-c 同一加上一个数,从而避免数组越界。

对列来说,也需要一个类似的数组,但行不需要,因为皇后 i 沿着第 i 行进行移动,而所有小于 i 的皇后已经置于小于 i 的各行了,下面的程序实现了这些数组的源代码。程序中使用了递归。

// 八皇后问题的实现代码#include <iostream>using namespace std;class ChessBoard{public:    ChessBoard();       // 8 x 8 chessboard    ChessBoard(int);    // n x n chessboard    void findSolutions();private:    const bool  available;    const int squares, norm;    bool *column, *leftDiagonal, *rightDiagonal;    int *postionInRow, howMany;    void putQueen(int);    void printBoard(ostream&);    void initializeBoard();};ChessBoard::ChessBoard() :available(true),squares(8),norm(squares-1){    initializeBoard();}ChessBoard::ChessBoard(int n) :available(true),squares(n),norm(squares-1){    initializeBoard();}void ChessBoard::initializeBoard() {    register int i;    column = new bool[squares];    postionInRow = new int[squares];    leftDiagonal = new bool[squares*2 - 1];    rightDiagonal = new bool[squares*2 - 1];    for (i = 0; i < squares; i++)        postionInRow[i] = -1;    for (i = 0; i < squares; i++)        column[i] = available;    for (i = 0; i < squares*2 -1; i++)        leftDiagonal[i] = rightDiagonal[i] = available;    howMany = 0;}void ChessBoard::putQueen(int row) {    for (int col = 0; col < squares; col++)        if (column[col] == available &&                leftDiagonal [row+col] == available &&                rightDiagonal [row+col] == available){            postionInRow[row] = col;            column[col] = !available;            leftDiagonal[row+col] = !available;            rightDiagonal[row+col] = !available;            if (row < squares-1)                putQueen(row+1);            else                printBoard(cout);            column[col] = available;            leftDiagonal[row+col] = available;            rightDiagonal [row+col] = available;        }}void ChessBoard::findSolutions() {    putQueen(0);    cout << howMany << " solutions found.\n";}
原创粉丝点击