Série de algoritmos 15: Aplicação de Loop e Recursão em Algoritmo

1. A relação entre recursão e loop

 1. A definição de recursão

         A execução sequencial, o loop e o salto são as três estruturas básicas de controle da linguagem de programação no sistema de computador de von Neumann.Estas três estruturas de controle constituem vários algoritmos, programas e até mesmo todo o mundo do software. A recursão também pode ser considerada como um tipo de estrutura de controle de programa, mas geralmente não é considerada uma estrutura de controle básica, porque a estrutura recursiva pode ser substituída por uma estrutura de loop cuidadosamente projetada em circunstâncias normais, então pode-se dizer que a recursão é uma estrutura de loop especial. Como o método recursivo chama direta ou indiretamente seu próprio algoritmo, é uma estrutura de loop mais poderosa do que os loops iterativos.

 

2. A diferença entre recursão e implementação de loop

         A estrutura de loop (loop iterativo) geralmente é usada para resolver problemas lineares, como soma polinomial, iteração linear para a precisão de um determinado resultado e assim por diante. Uma estrutura de loop típica geralmente contém quatro partes: parte de inicialização, parte de condição de loop, parte de corpo de loop e parte de iteração. O código a seguir é um exemplo de solução fatorial usando uma estrutura de loop:

   86  / * O algoritmo de loop calcula o fatorial de pequenos números, 0 <= n <10 * /

   87  int CalcFactorial ( int n )

   88  {

   89     resultado interno = 1 ;

   90 

   91      int i ;

   92      para ( i = 1 ; i <= n ; i ++)

   93      {

   94          resultado = resultado * i ;

   95      }

   96 

   97     resultado de retorno ;

   98  }

        Os métodos recursivos são geralmente divididos em duas partes: relações recursivas e condições de terminação recursivas (a solução do problema mínimo). A chave do método recursivo é determinar a definição recursiva e a condição de término recursiva. A definição recursiva é a decomposição do problema e é a regra que aponta para a transformação da condição de término recursiva, e a condição de término recursiva é geralmente a solução para o problema mínimo. A estrutura recursiva é semelhante à maneira como os humanos resolvem problemas, o algoritmo é simples e fácil de entender e todo o processo de resolução do problema pode ser descrito em menos etapas. Há também uma etapa implícita na estrutura do método recursivo, que é o "retrocesso". Para operações que exigem uma estrutura "primeiro a entrar, último a sair", o método recursivo será mais eficiente. O código a seguir é um exemplo de solução fatorial por método recursivo:

  100  / * O algoritmo recursivo calcula o fatorial de pequenos números, 0 <= n <10 * /

  101  int CalcFactorial ( int n )

  102  {

  103      if ( n == 0 ) / * A solução do problema mínimo, que é a condição de terminação recursiva * /

  104          return 1 ;

  105 

  106      retornar n * CalcFactorial ( n - 1 ); / * definição recursiva * /

  107  }

         A partir dos dois exemplos acima, podemos ver que a estrutura do código do algoritmo de estrutura recursiva é concisa e clara e é altamente legível, o que está de acordo com a filosofia de design de software de "código é documento". Mas as deficiências do método recursivo também são óbvias: a eficiência da operação é baixa e o espaço de armazenamento é mais ocupado do que o método de loop iterativo. O método recursivo atinge o objetivo de fazer um loop através de chamadas aninhadas. O custo de empilhamento de parâmetros causado por chamadas de função reduzirá a eficiência do algoritmo. Da mesma forma, a ocupação do espaço de armazenamento também se reflete no espaço de pilha ocupado pelos parâmetros da pilha e variáveis ​​locais. Por causa desses dois pontos, a aplicação de métodos recursivos e a escala de resolução de problemas são afetados pelo tamanho das tarefas do sistema ou pelo espaço de pilha de encadeamentos. Em alguns sistemas embarcados, o espaço de pilha de tarefas ou encadeamentos é de apenas alguns milhares de bytes. Na criação de algoritmos O algoritmo de estrutura recursiva deve ser usado com cuidado, caso contrário, levará facilmente ao estouro da pilha e ao travamento do sistema.

 3. Um exemplo de abuso de recursão

         Existem muitos exemplos de estouro de pilha causado pelo uso de métodos recursivos. Há um exemplo de julgamento de números pares de produtos que circulam na Internet. Não me lembro mais do conteúdo específico, apenas lembro-me mais ou menos assim:

  115  / * O código escrito por alguém da Internet para determinar o número par do produto usa um algoritmo recursivo * /

  116 bool IsEvenNumber(int n)

  117 {

  118     if(n >= 2)

  119         return IsEvenNumber(n - 2);

  120     else

  121     {

  122         if(n == 0)

  123             return true;

  124         else

  125             return false;

  126     }

  127 }

 据说这个例子是某个系统中真是存在的代码,它经受住了最初的测试并被发布出去,当用户的数据大到一定的规模时崩溃了。本人在Windows系统上做过测试,当n超过12000的时候就会导致栈溢出,本系列的下一篇文章,会有一个有关Windows系统上栈空间的有趣话题,这里不再赘述。下面就是一个合理的、中规中矩的实现:

  109 bool IsEvenNumber(int n)

  110 {

  111     return ((n % 2) == 0);

  112 }

递归还是循环?这是个问题

1、 一个简单的24点程序

         下面本文将通过两个题目实例,分别给出用递归方法和循环方法的解决方案以及解题思路,便于读者更好地掌握两种方法。首先是一个简单的计算24点的问题(为了简化问题,我们假设只使用求和计算方法):

 

19中任选四个数字(数字可以有重复),使四个数字的和刚好是24

 

题目很简单,数字都是个位数,可以重复且之用加法,循环算法的核心就是使用四重循环穷举所有的数字组合,对每一个数字组合进行求和,判断是否是24。使用循环的版本可能是这个样子:

    8 const unsigned int NUMBER_COUNT = 4; //9

    9 const int NUM_MIN_VALUE = 1;

   10 const int NUM_MAX_VALUE = 9;

   11 const unsigned int FULL_NUMBER_VALUE = 24;//45;

   40 void PrintAllSResult(void)

   41 {

   42     int i,j,k,l;

   43     int numbers[NUMBER_COUNT] = { 0 };

   44 

   45     for(i = NUM_MIN_VALUE; i <= NUM_MAX_VALUE; i++)

   46     {

   47         numbers[0] = i; /*确定第一个数字*/

   48         for(j = NUM_MIN_VALUE; j <= NUM_MAX_VALUE; j++)

   49         {

   50             numbers[1] = j;  /*确定第二个数字*/

   51             for(k = NUM_MIN_VALUE; k <= NUM_MAX_VALUE; k++)

   52             {

   53                 numbers[2] = k; /*确定第三个数字*/

   54                 for(l = NUM_MIN_VALUE; l <= NUM_MAX_VALUE; l++)

   55                 {

   56                     numbers[3] = l; /*确定第四个数字*/

   57                     if(CalcNumbersSum(numbers, NUMBER_COUNT) == FULL_NUMBER_VALUE)

   58                     {

   59                         PrintNumbers(numbers, NUMBER_COUNT);

   60                     }

   61                 }

   62             }

   63         }

   64     }

   65 }

这个PrintAllSResult()函数看起来中规中矩,但是本人的编码习惯很少在一个函数中使用超过两重的循环,更何况,如果题目修改一下,改成9个数字求和是45的组合序列,就要使用9重循环,这将使PrintAllSResult()函数变成臭不可闻的垃圾代码。

         现在看看如何用递归方法解决这个问题。递归方法的解题思路就是对题目规模进行分解,将四个数字的求和变成三个数字的求和,两个数字的求和,当最终变成一个数字时,就达到了递归终止条件。这个题目的递归解法非常优雅:

   67 void EnumNumbers(int *numbers, int level, int total)

   68 {

   69     int i;

   70 

   71     for(i = NUM_MIN_VALUE; i <= NUM_MAX_VALUE; i++)

   72     {

   73         numbers[level] = i;

   74         if(level == (NUMBER_COUNT - 1))

   75         {

   76             if(i == total)

   77             {

   78                 PrintNumbers(numbers, NUMBER_COUNT);

   79             }

   80         }

   81         else

   82         {

   83             EnumNumbers(numbers, level + 1, total - i);

   84         }

   85     }

   86 }

   87 

   88 void PrintAllSResult2(void)

   89 {

   90     int numbers[NUMBER_COUNT] = { 0 };

   91 

   92     EnumNumbers(numbers, 0, FULL_NUMBER_VALUE);

   93 }

如果题目改成“9个数字求和是45的组合序列”,只需将NUMBER_COUNT的值改成9FULL_NUMBER_VALUE的值改成45即可,算法主体部分不需做任何修改。

 

2、 单链表逆序

         第二个题目是很经典的“单链表逆序”问题。很多公司的面试题库中都有这道题,有的公司明确题目要求不能使用额外的节点存储空间,有的没有明确说明,但是如果面试者使用了额外的节点存储空间做中转,会得到一个比较低的分数。如何在不使用额外存储节点的情况下使一个单链表的所有节点逆序?我们先用迭代循环的思想来分析这个问题,链表的初始状态如图(1)所示:

图(1)初始状态

 初始状态,prevNULLhead指向当前的头节点Anext指向A节点的下一个节点B。首先从A节点开始逆序,将A节点的next指针指向prev,因为prev的当前值是NULL,所以A节点就从链表中脱离出来了,然后移动headnext指针,使它们分别指向B节点和B的下一个节点C(因为当前的next已经指向B节点了,因此修改A节点的next指针不会导致链表丢失)。逆向节点A之后,链表的状态如图(2)所示:

图(2)经过第一次迭代后的状态

 从图(1)的初始状态到图(2)状态共做了四个操作,这四个操作的伪代码如下:

 

head->next = prev;

prev = head;

head = next;

next = head->next;

 

这四行伪代码就是循环算法的迭代体了,现在用这个迭代体对图(2)的状态再进行一轮迭代,就得到了图(3)的状态:

图(3)经过第二次迭代后的状态

         那么循环终止条件呢?现在对图(3)的状态再迭代一次得到图(4)的状态:

图(4)经过第三次迭代后的状态

 此时可以看出,在图(4)的基础上再进行一次迭代就可以完成链表的逆序,因此循环迭代的终止条件就是当前的head指针是NULL

        现在来总结一下,循环的初始条件是:

prev = NULL;

 

循环迭代体是:

next = head->next;

head->next = prev;

prev = head;

head = next;

 

循环终止条件是:

head == NULL

 

根据以上分析结果,逆序单链表的循环算法如下所示:

   61 LINK_NODE *ReverseLink(LINK_NODE *head)

   62 {

   63     LINK_NODE *next;

   64     LINK_NODE *prev = NULL;

   65 

   66     while(head != NULL)

   67     {

   68         next = head->next;

   69         head->next = prev;

   70         prev = head;

   71         head = next;

   72     }

   73 

   74     return prev;

   75 }

        现在,我们用递归的思想来分析这个问题。先假设有这样一个函数,可以将以head为头节点的单链表逆序,并返回新的头节点指针,应该是这个样子:

   77 LINK_NODE *ReverseLink2(LINK_NODE *head)

现在利用ReverseLink2()对问题进行求解,将链表分为当前表头节点和其余节点,递归的思想就是,先将当前的表头节点从链表中拆出来,然后对剩余的节点进行逆序,最后将当前的表头节点连接到新链表的尾部。第一次递归调用ReverseLink2(head->next)函数时的状态如图(5)所示:

图(5)第一次递归状态图

 这里边的关键点是头节点head的下一个节点head->next将是逆序后的新链表的尾节点,也就是说,被摘除的头接点head需要被连接到head->next才能完成整个链表的逆序,递归算法的核心就是一下几行代码:

   84     newHead = ReverseLink2(head->next); /*递归部分*/

   85     head->next->next = head; /*回朔部分*/

   86     head->next = NULL;

现在顺着这个思路再进行一次递归,就得到第二次递归的状态图:

图(6)第二次递归状态图

 再进行一次递归分析,就能清楚地看到递归终止条件了:

图(7)第三次递归状态图

 递归终止条件就是链表只剩一个节点时直接返回这个节点的指针。可以看出这个算法的核心其实是在回朔部分,递归的目的是遍历到链表的尾节点,然后通过逐级回朔将节点的next指针翻转过来。递归算法的完整代码如下:

   77 LINK_NODE *ReverseLink2(LINK_NODE *head)

   78 {

   79     LINK_NODE *newHead;

   80 

   81     if((head == NULL) || (head->next == NULL))

   82         return head;

   83 

   84     newHead = ReverseLink2(head->next); /*递归部分*/

   85     head->next->next = head; /*回朔部分*/

   86     head->next = NULL;

   87 

   88     return newHead;

   89 }

        循环还是递归?这是个问题。当面对一个问题的时候,不能一概认为哪种算法好,哪种不好,而是要根据问题的类型和规模作出选择。对于线性数据结构,比较适合用迭代循环方法,而对于树状数据结构,比如二叉树,递归方法则非常简洁优雅。

Acho que você gosta

Origin blog.csdn.net/orbit/article/details/7585756
Recomendado
Clasificación