Algorithm Series 15: Application of Loop and Recursion in Algorithm

1. The relationship between recursion and loop

 1. The definition of recursion

         Sequential execution, loop and jump are the three basic control structures of the programming language in the von Neumann computer system. These three control structures constitute various algorithms, programs, and even the entire software world. Recursion can also be regarded as a kind of program control structure, but it is generally regarded as not a basic control structure, because the recursive structure can be replaced with a carefully designed loop structure under normal circumstances, so it can be said that recursion is a special loop structure. Because the recursive method directly or indirectly calls its own algorithm, it is a more powerful loop structure than iterative loops.

 

2. The difference between recursion and loop implementation

         Loop (iterative loop) structure is usually used to solve linear problems, such as polynomial summation, linear iteration for the accuracy of a certain result, and so on. A typical loop structure usually contains four parts: initialization part, loop condition part, loop body part and iteration part. The following code is an example of solving factorial using a loop structure:

   86  /*The loop algorithm calculates the factorial of small numbers, 0 <= n <10 */

   87 int CalcFactorial(int n)

   88 {

   89     int result = 1;

   90 

   91     int i;

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

   93     {

   94         result = result * i;

   95     }

   96 

   97     return result;

   98 }

        Recursive methods are usually divided into two parts: recursive relations and recursive termination conditions (the solution of the minimum problem). The key of the recursive method is to determine the recursive definition and the recursive termination condition. The recursive definition is the decomposition of the problem, and it is the rule that points to the transformation of the recursive termination condition, and the recursive termination condition is usually the solution to the minimum problem. The recursive structure is similar to the way humans solve problems, the algorithm is simple and easy to understand, and the whole process of solving the problem can be described with fewer steps. There is also a step implicit in the structure of the recursive method, which is "backtracking". For operations that require a "first-in-last-out" structure, the recursive method will be more efficient. The following code is an example of solving factorial by recursive method:

  100  /*The recursive algorithm calculates the factorial of small numbers, 0 <= n <10 */

  101 int CalcFactorial(int n)

  102 {

  103      if ( n == 0 ) /*The solution of the minimum problem, which is the recursive termination condition*/

  104         return 1;

  105 

  106      return n * CalcFactorial ( n - 1 ); /*recursive definition*/

  107 }

         From the above two examples, we can see that the code structure of the recursive structure algorithm is concise and clear, and it is highly readable, which is in line with the software design philosophy of "code is document". But the shortcomings of the recursive method are also obvious: the operation efficiency is low, and the storage space is more occupied than the iterative loop method. The recursive method achieves the purpose of looping through nested calls. The cost of parameter stacking caused by function calls will reduce the efficiency of the algorithm. Similarly, the storage space occupation is also reflected in the stack space occupied by the stack parameters and local variables. Because of these two points, the application of recursive methods and the scale of problem solving are affected by the size of system tasks or thread stack space. In some embedded systems, the stack space of tasks or threads is only a few thousand bytes. In designing algorithms The recursive structure algorithm should be used with caution, otherwise it will easily lead to stack overflow and system crash.

 3. An example of abuse of recursion

         There are many examples of stack overflow caused by the use of recursive methods. There is an example of judging product even numbers circulating on the Internet. I don't remember the specific content anymore, I just remember it roughly like this:

  115  /*The code written by someone from the Internet to determine the even number of the product uses a recursive algorithm*/

  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 }

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

Guess you like

Origin blog.csdn.net/orbit/article/details/7585756