1. La relation entre récursivité et boucle
1. La définition de la récursivité
L'exécution séquentielle, la boucle et le saut sont les trois structures de contrôle de base du langage de programmation dans le système informatique von Neumann.Ces trois structures de contrôle constituent divers algorithmes, programmes et même le monde logiciel dans son ensemble. La récursivité peut également être considérée comme une sorte de structure de contrôle de programme, mais elle n'est généralement pas considérée comme une structure de contrôle de base, car la structure récursive peut être remplacée par une structure de boucle soigneusement conçue dans des circonstances normales, on peut donc dire que la récursivité est une structure de boucle spéciale. Étant donné que la méthode récursive appelle directement ou indirectement son propre algorithme, il s'agit d'une structure de boucle plus puissante que les boucles itératives.
2. La différence entre la récursivité et l'implémentation en boucle
La structure en boucle (boucle itérative) est généralement utilisée pour résoudre des problèmes linéaires, tels que la sommation polynomiale, l'itération linéaire pour la précision d'un certain résultat, etc. Une structure de boucle typique contient généralement quatre parties: une partie d'initialisation, une partie de condition de boucle, une partie de corps de boucle et une partie d'itération. Le code suivant est un exemple de résolution factorielle à l'aide d'une structure de boucle:
86 / * L'algorithme de boucle calcule la factorielle des petits nombres, 0 <= n <10 * / 87 int CalcFactorial ( int n ) 88 { 89 résultat int = 1 ; 90 91 int i ; 92 pour ( i = 1 ; i <= n ; i ++) 93 { 94 résultat = résultat * i ; 95 } 96 97 résultat de retour ; 98 } |
Les méthodes récursives sont généralement divisées en deux parties: les relations récursives et les conditions de terminaison récursives (la solution du problème minimum). La clé de la méthode récursive est de déterminer la définition récursive et la condition de terminaison récursive. La définition récursive est la décomposition du problème, et c'est la règle qui pointe vers la transformation de la condition de terminaison récursive, et la condition de terminaison récursive est généralement la solution au problème minimum. La structure récursive est similaire à la façon dont les humains résolvent les problèmes, l'algorithme est simple et facile à comprendre, et l'ensemble du processus de résolution du problème peut être décrit en moins d'étapes. Il y a aussi une étape implicite dans la structure de la méthode récursive, qui est le "retour arrière". Pour les opérations qui nécessitent une structure "premier entré-dernier-sorti", la méthode récursive sera plus efficace. Le code suivant est un exemple de résolution factorielle par méthode récursive:
100 / * L'algorithme récursif calcule la factorielle des petits nombres, 0 <= n <10 * / 101 int CalcFactorial ( int n ) 102 { 103 if ( n == 0 ) / * La solution du problème minimum, qui est la condition de terminaison récursive * / 104 retour 1 ; 105 106 return n * CalcFactorial ( n - 1 ); / * définition récursive * / 107 } |
À partir des deux exemples ci-dessus, nous pouvons voir que la structure de code de l'algorithme de structure récursive est concise et claire, et elle est hautement lisible, ce qui est conforme à la philosophie de conception logicielle de «code is document». Mais les défauts de la méthode récursive sont également évidents: l'efficacité de l'opération est faible et l'espace de stockage est plus occupé que la méthode itérative en boucle. La méthode récursive permet de boucler les appels imbriqués. Le coût de l'empilement des paramètres causé par les appels de fonction réduira l'efficacité de l'algorithme. De même, l'occupation de l'espace de stockage se reflète également dans l'espace de pile occupé par les paramètres de pile et les variables locales. En raison de ces deux points, l'application de méthodes récursives et l'ampleur de la résolution des problèmes sont affectées par la taille des tâches système ou de l'espace de pile des threads. Dans certains systèmes embarqués, l'espace de pile des tâches ou des threads n'est que de quelques milliers d'octets. Lors de la conception d'algorithmes L'algorithme de structure récursive doit être utilisé avec précaution, sinon il entraînera facilement un débordement de pile et une panne du système.
3. Un exemple d'abus de récursivité
Il existe de nombreux exemples de dépassement de pile causé par l'utilisation de méthodes récursives. Il y a un exemple de jugement des numéros pairs de produits circulant sur Internet. Je ne me souviens plus du contenu spécifique, je m'en souviens juste à peu près comme ceci:
115 / * Le code écrit par quelqu'un sur Internet pour déterminer le nombre pair du produit utilise un algorithme récursif * / 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点的问题(为了简化问题,我们假设只使用求和计算方法):
从1-9中任选四个数字(数字可以有重复),使四个数字的和刚好是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的值改成9,FULL_NUMBER_VALUE的值改成45即可,算法主体部分不需做任何修改。
2、 单链表逆序
第二个题目是很经典的“单链表逆序”问题。很多公司的面试题库中都有这道题,有的公司明确题目要求不能使用额外的节点存储空间,有的没有明确说明,但是如果面试者使用了额外的节点存储空间做中转,会得到一个比较低的分数。如何在不使用额外存储节点的情况下使一个单链表的所有节点逆序?我们先用迭代循环的思想来分析这个问题,链表的初始状态如图(1)所示:
图(1)初始状态
初始状态,prev是NULL,head指向当前的头节点A,next指向A节点的下一个节点B。首先从A节点开始逆序,将A节点的next指针指向prev,因为prev的当前值是NULL,所以A节点就从链表中脱离出来了,然后移动head和next指针,使它们分别指向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 } |
循环还是递归?这是个问题。当面对一个问题的时候,不能一概认为哪种算法好,哪种不好,而是要根据问题的类型和规模作出选择。对于线性数据结构,比较适合用迭代循环方法,而对于树状数据结构,比如二叉树,递归方法则非常简洁优雅。