Deug Mias-2, 2000-2001
Informatique
Institut Galilée

Pour tout problème : Cyril.Banderier@inria.fr

TD-3
Différents aspects de la récursivité

I . Récursivité et décidabilité.

L’une des principales difficultés d’un programme récursif est de prédire s’il va bien faire ce que vous pens(i)ez. Non seulement on peut facilement dépasser les capacités de la machine en mémoire (et notamment dépasser quantité de mémoire allouée à la pile), mais surtout, il est aisé d’écrire des programmes qui ne terminent jamais.

On pourrait rêver d’un langage dont le compilateur testerait si votre programme est “ bien construit ” (c’est-à-dire, s’il termine) et le cas échéant, vous avertisse du problème.
Malheureusement, dès lors que l’on a un programme avec la structure “ while ” (ou de façon équivalente “ repeat until ” ou encore les “ goto ”), ce n’est pas possible, comme nous le montrons dans l’exercice suivant.

Exercice 1. Indécidabilité du problème de l'arrêt.

Pour un programme donné, de deux choses l'une soit il  s'arrête au bout d'un certain temps (en renvoyant la valeur attendue ou encore en renvoyant une erreur)
soit il ne s'arrête jamais (il " boucle ", il " tourne en rond ", fait une infinité de calculs, etc.).

Maintenant, vous construisez le programme que vous voulez,  et vous le montrez à Dieu en lui demandant s'il peut deviner si votre programme va s'arrêter un jour ou non. Dieu a beau avoir tout son temps et être omniscient, nous allons prouver qu'il existe au moins un programme pour lequel il ne saura pas répondre à la question !
Autrement dit, il n'existe pas d'algorithme (appelons-le " VaStopper ")  qui prenne en entrée un programme et qui réponde vrai si “ ce programme va stopper un jour ”  et faux sinon.

Exemples :
VaStopper( var i ; Begin i :=i+1 end.) = vrai
VaStopper( Begin while true do End.) = faux

1. Prouvez que VAStopper n’existe pas.
Indication. Faire une preuve par l'absurde : supposez qu'il existe, qu'en déduisez-vous ?

Solution :
Construisons le programme suivant
MyProg (P) := Si VaStopper(P) alors on boucle sinon on sort.L'idée géniale (ou de fou) consiste à le lancer sur lui-même !
Que renvoie MyProg (MyProg ) ?
De deux choses l'une
soit VaStopper(MyProg)  renvoie vrai et dans ce cas Myprog(Myprog) ne s'arrête pas (contredit ce qu'affirmait VaStopper !)
soit VaStopper(MyProg)  renvoie faux et dans ce cas Myprog(Myprog) s'arrête (contredit également ce qu'affirmait VaStopper !).

Dans tous les cas, on aboutit à une contradiction, c'est donc que le programme VaStopper ne peut pas exister. On a ainsi prouvé que le test de l'arrêt est indécidable.

Remarque (pour les élèves, et pour leur remémorer leur cours de l’an dernier “ principes de calculs ” dont une partie est une initiation à la calculabilité - décidabilité): La “ vraie ” preuve fait appel à la théorie des machines de Turing et comme les fonctions de cette théorie sont toutes à variables et valeurs entières, il faut un moyen de “ tranformer ” un programme en entier, c’est ce que l’on appelle le  “ codage de Gödel ”.

Exercice 2. La fonction d’Ackermann 
Elle est définie par les relations	A(0,m)=2m	A(n,0)=1 	et 	A(n,m)=A(n-1,A(n,m-1)).
Elle a la particularité d’être primitive mais pas primitive récursive (cela se montre en prouvant qu’elle “ croît ” beaucoup plus vite que n’importe quelle fonction primitive récursive), cela signifie qu’on ne peut pas la calculer dans un langage qui n’aurait que des boucles for, des tests booléens, les opérations arithmétiques de bases : on a besoin du  “ while ” ou d’un programme récursif.

1.	Montrer que les relations ci-dessus définissent bien, de façon unique,  une fonction.
(i.e., pour tout couple (m,n), les calculent terminent).
2.	Calculer A(1,2), puis A(2,1), puis A(2,2), puis A(2,3), puis A(3,2), puis A(4,4).

Solution : Posons An(m)= A(n,m) Montrons par récurrence que An est bien partout définie.
1.	initialisation (n=0) : A0  est bien définie (car A0 (m) =2m).
2.	hérédité ( de n à n+1): Supposons An  partout définie, montrons que An+1 est partout définie. 
On fait une récurrence (sur m) à l’intérieur de la récurrence (sur n):
Initialisation (m=0):   A n+1(0)=1 est bien défini. 
Hérédité (de m à m+1) : Supposons An+1(m) défini montrons que An+1(m+1) est défini.
 On a An+1(m+1)= An(An+1(m)), or An+1(m) est défini (hypothèse d’hérédité sur m)
et An (x) est défini pour tout x  (hypothèse d’hérédité sur n)
     Ainsi A n+1 est bien partout définie.
Au final, toutes les fonctions A n sont bien définies et A(n,m) est bien une fonction.

A(2,1)=2
A(1,2)= A(2,2)= A(3,2)=4
A(2,3)=216= 65536

Il fallait bien sûr voir que A(4,4) est inhumain (même pour une machine !) à calculer .

II. Récursivité et complexité

Exercice 3. La suite de Fibonacci 
fn+2 = fn+1+fn  (avec les conditions initiales f 0 = 1 et f1 = 1) a été introduite par Léonard de Pise (alias Bonacci fils, alias Fibonacci) lors de l 'étude d'une évolution d'une population de lapin.

Function fib(n :integer) :integer
Begin
if (n=0) or (n=1) then fib :=1 
else fib :=fib(n-1)+fib(n-2)
End ;

1.	Tracer l’arbre de calcul de Fib(5) pour le programme ci-dessus.
2.	Comment le programme va-t-il “ parcourir ” cet arbre ? 
3.	Comment le programme (avec la modification fib :=fib(n-2)+fib(n-1)) parcourt-il cet arbre ?
4.	Quel est l’ordre de grandeur de la taille de l’arbre associé au calcul de Fib(n) ?
5.	Est-il possible de calculer f999  avec au plus 60 additions et 120 multiplications (au lieu de 999 additions) ?

Solution : l’algorithme parcourt l’arbre dans le sens rétrograde (dessinez l’arbre, puis faire une flèche autour). Le sens est changé. L’arbre a à peu près 2n noeuds  (grosso modo, l’arbre a hauteur n et le nombre de feuille double à chaque fois or 1+2+4+...+2 n-1=2 n-1).
Pour le 5, la reponse est oui, je deconseille de faire chercher les eleves trop longtemps et de donner directement la reponse : 
Il existe une méthode beaucoup plus rapide pour calculer le n-ième nombre de Fibonacci qui fait appel à un calcul matriciel. En effet, en posant
M =    1 1
           1 0
on montre facilement par récurrence que M n =	fn fn-1
fn-1 fn-2
(pour n>1). Cette “ méthode matricielle ” ne prend pas un nombre exponentiel d’opérations (comme ci-dessus), ni même un nombre linéaire (comme une version non récursive) mais simplement un nombre logarithmique d’opérations (on ne peut pas faire mieux !). 
L’idée est d’utiliser l’ “ exponentiation dichotomique ”  
Si on est stupide, on calcule M1024 en faisant 1024 multiplications de matrices mais si on remarque que M1024 =M2 2 2 2 2 2 2 2 2 2, il suffit de faire 10 mises au carré.
Pour f999, on calcule M999 = M512+256+128+64+32+4+2+1 =M M1 M2 M5 M6 M7 M8 M9
avec M1=M2 , M2= M12 , M5=M22 2 2 , M6=M52 , M7=M62 , M8=M92 , M9=M82 .
Soit 9 mises au carré et 6 multiplications de matrices.
Pour calculer fn, on a donc au plus log2(n)  mises au carré et log2(n) multiplications de matrices.
(et une multiplication de matrice = 4 additions et 8 multiplications d’entiers)
Les plus futés, qui auront recours à de l’algèbre linéaire (en considérant une base de l’espace vectoriel des suites récurrentes linéaires) prouveront la formule explicite
fn-1 = (n-’ n )/5 où =(1+5)/2 et ’=(1-5)/2. 
Cette formule permet aussi de calculer fn  en un nombre logarithmique d’opérations, en utilisant ici aussi l’ “ exponentiation dichotomique ”  .

Remarque : on généralise sans mal ces résultats (algorithme logarithmique et formule explicite) à toutes les suites récurrentes linéaires.

Exercice 4. L’algorithme de tri rapide (Quick Sort)

Il s’agit d’un algorithme de tri révolutionnaire, inventé par C.A.R. Hoare en 1962.

1. Expliquez “ grosso modo ” comment il fonctionne.

Program TriRapide;
Uses Crt;

CONST n=10;

VAR
T:array[1..n] of integer;
i:byte;

Procedure QuickSort(g,d:integer);
var p,w,i,j:integer;
Begin
if d-g>0 
 then begin
    p:=T[d];i:=g-1;j:=d;
      repeat
repeat i:=i+1 until T[i]>=p;
repeat j:=j-1 until T[j]<=p;
w:=T[i];T[i]:=T[j];T[j]:=w;
       until j<=i; 
    T[j]:=T[i];T[i]:=T[d];T[d]:=w;
   QuickSort(g,i-1);
   QuickSort(i+1,d); 
 end;
End;

BEGIN
for i:=1 to n do readln(T[i],' ');
Quicksort(1,10);
for i:=1 to n do write(T[i],' ');
END.

Solution :  Cet algorithme tri n nombres de la façon suivante, il prend un élément p (appelé le “ pivot ”), avec un certain nombre d’échanges l’algorithme met tous les éléments plus grands que ce pivot à sa droite et tous les éléments plus petits que lui à sa gauche ; puis il lance récursivement le tri sur les éléments de gauche et sur les éléments de droite. Au final, tous les éléments sont donc bien placés (triés dans l’ordre croissant).

Program TriRapide;
Uses Crt;

CONST n=10;

VAR
T:array[1..n] of integer;
i:byte;

Procedure QuickSort(g,d:integer);
(*on trie les éléments d'un soustableau, d’indices g à d *)
var p,w,i,j:integer;
Begin
if d-g>0 (* il reste des éléments à trier *)
 then begin
    p:=T[d];i:=g-1;j:=d;
      repeat
       repeat i:=i+1 until T[i]>=p;
       (* on tombe "vers la gauche" sur un élément plus grand que le pivot *)
       repeat j:=j-1 until T[j]<=p;
       (* on tombe "vers la droite" sur un élément plus petit que le pivot *)
       w:=T[i];T[i]:=T[j];T[j]:=w;
       (* on les échange ! *)
      until j<=i; (* i est l'indice du pivot p car maintenant
                     tout ce qui est à sa droite est plus grand
                     et tout ce qui est à sa gauche est plus petit *)
    T[j]:=T[i];T[i]:=T[d];T[d]:=w;
    QuickSort(g,i-1); (* on fend le tableau autour du pivot *)
    QuickSort(i+1,d); (* et on trie récursivement les deux parties *)
 end;
End;

BEGIN
for i:=1 to n do readln(T[i],' ');
QuickSort(1,10);
for i:=1 to n do write(T[i],' ');
END.

Remarque (à ignorer) : En toute rigueur il faudrait rajouter une case T[0]=0, dans le cas où le plus petit élément de T serait T[n], je préfère présenter un algo “ faux ” qui a le mérite d’être plus “ lisible ”. 
III. Récursivité et logique

Exercice 5. Le paradoxe de Russell
Un certain nombre de définitions récursives peuvent déboucher sur des cercles vicieux. Ce problème est connu depuis l’Antiquité (Epiménide, un crétois, affirmait “ Tous les crétois sont des menteurs. ” ce qui bien sûr aboutit à un paradoxe : Epiménide dit-il la vérité ou pas ?).
Ce n’est qu’avec le fondement théorique des mathématiques au début du siècle (Hilbert, Russell, axiomatique de Zermelo-Fraenkel ) que l’on a su expliquer divers paradoxes.

La plupart reposent sur le fait suivant, appelé “ paradoxe de Russell ”  :  L’ensemble de tous les ensembles n’est pas un ensemble. 

1. Prouvez que “ L’ensemble de tous les ensembles n’est pas un ensemble. ” 

Solution : 
Sinon on pourrait construire l’ensemble E de tous les ensembles qui ne se contiennent pas eux-mêmes, et alors on ne pourrait répondre de façon cohérente à la question E appartient -il  à E ?
 
2. Il y a à Villetaneuse un barbier qui rase tous les habitants qui ne se rasent pas eux-mêmes (et seulement ceux-ci). Que pouvez-vous dire de ce barbier ?

Solution : Ce n’est pas un habitant de Villetaneuse, sinon on tombe sur un paradoxe (le barbier à la fois se raserait et ne se raserait pas).

Deug Mias-2, 2000-2001
Informatique
Institut Galilée
TP-3
Différents aspects de la récursivité
I . Récursivité et décidabilité.

Nous avons vu dans l’exercice 1 du TD  3 qu’il existait au moins au programme pour lequel on ne sait prédire s’il va s’arrêter à tous les coups ou pas. 

Le programme suivant montre que même pour des programmes très simples, la question de l’arrêt n’est en effet pas évidente.

Exercice 1.  Le problème 3x+1
Soit x un entier positif. Tant que x  est différent de 1, si x est pair, alors on le divise par deux et s’il est impair on le multiplie par trois et on ajoute un.

Implantez ce programme en affichant les transformations successives de x et vérifiez ce qui se passe pour un certain de nombre de valeurs. Que conjecturez-vous ?

Solution :

Program Syracuse ;
uses wincrt;
var x:integer;
Begin
Readln(x);
while x<>1 do begin 
write(x,'->');
if odd(x) then x:=3*x+1 else x:=x div 2;
end;
write(x);
End.

La célèbre “ conjecture de Syracuse ” affirme que l’on finit toujours par retomber sur 1. C’est un problème auquel de nombreux mathématiciens chevronnés se sont attaqués sans succès depuis 40 ans. Durant la guerre froide, les Américains disaient en plaisantant que c’était un problème inventé par les Russes pour que les chercheurs occidentaux perdent leur temps sur des problèmes stériles !

Exercice 2.  La fonction d’Ackermann

Implanter la fonction d’Ackermann et vérifier les valeurs calculées en TD. En calculer d’autres.

Solution :

Program Fonction_Ackermann;
uses wincrt;

Function ackermann(n,m:longint):longint;
  Function puis2(m:longint):longint;
  Begin
    if m=0 then puis2:=1 else puis2:=2*puis2(m-1)
  end;

Begin
 writeln(n,'-',m);
 if n=0 then ackermann:=puis2(m)
  else if m=0 then ackermann:=1
   else ackermann:=ackermann(n-1,ackermann(n,m-1))
End;

BEGIN
 writeln(ackermann(2,2));
END.

Il faut bien sûr remarquer qu’il sera impossible de calculer d’autres valeurs pour m,n>2
e.g.  ackermann(3,3) car on aurait alors plus de 65000 appels récursifs, ce que la pile ne sait supporter. 

II. Récursivité et complexité

Exercice 3. La suite de Fibonacci 

Pour les exercices suivants, Borland pascal sous Windows n’est pas suffisant, car on ne peut y suivre l’évolution de la pile. Allez sous MS-DOS, placez-vous sous votre répertoire personnel (tapez “ cd z:\monNom\ ”)  puis lancez Turbo-Pascal (tapez “ turbo ”).

Faire un programme qui calcule récursivement la suite de Fibonacci.
Tracer l’évolution de la pile lors du calcul de Fib(5)

Appuyez sur contrôle-F3 (pour faire apparaître la pile “ stack ”) puis appuyez sur F7 pour exécuter votre programme pas à pas.
Observez l’évolution de la pile et comparez avec ce que vous avez fait en TD.

Solution : La succession des appels dans la pile correspond tout à fait à l’évolution prédite en TD. 

Lancer Maple et rentrer le programme suivant (besoin d’aucune connaissance si ce n’est que shift-Entrée passe à la ligne suivante et que la touche entrée seule lance le calcul. En ce qui concerne la syntaxe, elle est facile : “ od ” ferme un “ do ”, “ fi ” ferme un “ if ”, etc.)

> fib:=proc(n)
option remember; 
if (n=0) or (n=1) then RETURN(1)
else RETURN(fib(n-1)+fib(n-2))
fi end;

Demandez
> fib(24);
et
> fib(1000);

Enlever le “ option remember ” 
> fib:=proc(n)
if (n=0) or (n=1) then RETURN(1)
else RETURN(fib(n-1)+fib(n-2))
fi end;

et redemandez 
> fib(24);
Que constatez-vous ? Quelle est votre explication ?

Solution : Les deux premiers calculs sont très rapides alors que sans le “ option remember ” fib(24) met “ longtemps ” à être calculé. Dans l’arbre tracé en TD, on voit clairement que certains sous-arbres sont calculés plusieurs fois, l’idée de “ option remember ” est de créer une table qui “ se souvient ” des calculs déjà faits. On passe ainsi d’un programme en temps exponentiel  2^n  à un programme en temps linéaire  n, ce qui est un gain énorme (mais au prix de l’utilisation d’un tableau supplémentaire qui “ coûte ” n cases mémoires).

Morale : subtil équilibre entre mémoire et rapidité. 
La théorie (confer TD) peut aussi beaucoup apporter.

Exercice 4. QuickSort 

Implantez l’algorithme TriRapide vu en TD mais avec la modification suivante :
Procedure QuickSort(g,d:integer, var T:array[1..n] of integer);

Appuyez sur contrôle-F3 (pour faire apparaître la pile “ stack ”) puis appuyez sur F7 pour exécuter votre programme pas à pas pour la suite suivante 3,4,5,1,6,7,10,9,8,2.

Tracez (sur votre cahier !) l’arbre correspondant.

Remarque : un futur TD montrera que QuickSort est bien un tri rapide (en fait, le plus rapide de tous en un certain sens) : pour trier n nombres, QuickSort n’utilise en général que n ln n opérations alors que les autres méthodes en utilisent souvent n2.