Vous êtes sur la page 1sur 32

Polycopié du cours :

Algorithmique Avancé

Préparé par : A. DARGHAM 1

Version : Octobre 2020

1. Enseignant-chercheur à l’ENSA de Khouribga.


2
Table des matières

1 Récursivité, pointeurs & modules 5


1.1 Récursivité . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
1.1.1 Reconnaı̂tre la récursivité . . . . . . . . . . . . . . . . . . 5
1.1.2 Décomposition récursive . . . . . . . . . . . . . . . . . . . 7
1.1.3 Quelques termes algorithmiques . . . . . . . . . . . . . . . 8
1.1.4 Récursivité vs. itération . . . . . . . . . . . . . . . . . . . 14
1.1.5 Types de récursivité . . . . . . . . . . . . . . . . . . . . . 17
1.1.6 Preuve d’un algorithme . . . . . . . . . . . . . . . . . . . . 19
1.2 Pointeurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
1.2.1 Rappel sur les pointeurs . . . . . . . . . . . . . . . . . . . 22
1.2.2 Pointeur vers une fonction et déclarations complexes . . . 24
1.3 Modules . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
1.4 Atelier du chapitre . . . . . . . . . . . . . . . . . . . . . . . . . . 29

3
4 TABLE DES MATIÈRES
Chapitre 1

Récursivité, pointeurs & modules

1.1 Récursivité
1.1.1 Reconnaı̂tre la récursivité

Figure 1.1 – La plupart des feuilles d’arbres présentent des motifs récursifs. Le
poumon humain présente une structure récusive.

Figure 1.2 – Les poupées russes (matriochka) sont des objets récursifs, où chaque
poupée englobe une autre de taille plus petite.

La récursivité est omniprésente : on la trouve dans la nature, dans l’art, etc.


C’est un concept fondamental dans plusieurs disciplines : les mathématiques,

5
6 CHAPITRE 1. RÉCURSIVITÉ, POINTEURS & MODULES

l’informatique, la linguistique, etc.

Figure 1.3 – Ce verset coranique a une structure récursive.

Figure 1.4 – La plupart des structures de données sont récursives.

La récursivité est fortement présente dans les mathématiques : factorielle, suite


de Fibonacci, somme des n premiers nombres entiers positifs, définition récursive
d’un entier positif n, définition récursive du carrée n2 d’un entier positif n, for-
mule de la dérivée d’une somme, formule de la limite d’une somme, etc.

En algorithmique, la récursivité constitue une stratégie puissante de résolution


de problèmes, car il nous permet de concevoir des algorithmes simples et
naturels, concis et élégants, et parfois efficaces.

Définition 1 Une entité (ou un concept) est dite récursive (ou récursif),
lorsque des instances identiques (ou auto-similaires) plus simples (ou plus
petites) font partie de ses constituants.

Définition 2 Un algorithme (ou programme) est dit récursif, si dans sa


définition, il fait appel à lui-même.
1.1. RÉCURSIVITÉ 7

1.1.2 Décomposition récursive


Ingrédients de la récursivité :
1. Cas de base : correspondent à des scénarios où la sortie de l’algorithme
peut être obtenue de manière triviale et directe. En même temps, ils
jouent le rôle de conditions d’arrêt pour l’algorithme récursif.
2. Cas récursifs (ou cas inductifs) : correspondent à des scénarios où la
sortie de l’algorithme est obtenue par un appel au même algorithme
mais appliqué à des arguments d’entrée de tailles plus petites. Ils ap-
paraı̂ssent souvent sous forme d’expressions (ou de formules) récursives.
Étapes de décomposition récursives :
1. Dans une phase de conception, la tâche principale consistera à fournir
les définitions récursives d’entités, de concepts, de fonctions, de
problèmes, de structures, etc.
2. Dans une phase d’implémentation, la première étape consiste à établir
les cas de base ⇒ tâche généralement facile.
3. La deuxième étape consiste à établir les cas récursifs ⇒ tâche difficile
pour les problèmes complexes.
Deux concepts clés dans une méthodologie récursive, à savoir :

• La décomposition récursive du problème.

• L’induction (la preuve par récurrence).

Exercice corrigé N° 1
1. Considérons les mots binaires (qui s’écrivent en utilisant uniquement les
deux symboles 0 et 1). Un palindrome binaire de longueur impaire
est un mot binaire identique à son image miroir. Par exemple, 10101 est
un palindrome de longueur impaire, mais 001 ne l’est pas. Donner une
définition récursive du concept ”palindrome de longueur impaire”.
2. L’ensemble des ”descendants d’une personne” peut être défini récursivement
comme étant les fils de cette personne, plus les descendants de ces fils. Don-
ner une description mathématique de ce concept en utilisant la notation
ensembliste. En particulier, définir une fonction D(p), où D dénote les des-
cendants, et l’argument p représente une personne spécifique. Considérer
qu’une fonction C(p) est disponible, laquelle retourne l’ensemble des fils
d’une personne p.

Corrigé de l’exercice N° 1
1. Donnons une définition récursive à un palindrome de longueur impaire :
8 CHAPITRE 1. RÉCURSIVITÉ, POINTEURS & MODULES

• Cas de base : les palindromes de longueur impaire les plus


élémentaires à fabriquer sont 0 et 1.

• Cas inductifs : soit b1 b2 ...b2n+1 un palindrome de longueur im-


paire 2n + 1, où n ≥ 1. Remarquons que b1 = b2n+1 et que b2 ...b2n est
également un palindrome de longueur imapire. Par conséquent,
un palindrome de longueur impaire est soit 0x0 ou 1x1, avec x
est un palindrome de longueur impaire.
2. Soient p une personne donnée, et C(p) l’ensemble de ses fils. Il est clair
que :
D(p) = C(p) ∪q∈C(p) D(q)

1.1.3 Quelques termes algorithmiques


Définition 3 Un problème informatique est une question à la quelle un or-
dinateur pourrait éventuellement répondre. La réponse de l’ordinateur est défini
en termes d’instructions décrivant les relations entre une collection de va-
leurs d’entrée ”connues”, et un ensemble de valeurs de sortie ”calculées”.
C’est ce qu’on appelle un programme.

Exemples :

— Calculer une somme ou une moyenne de notes.


— Établir le montant totale d’une facture.
— Conjuguer un verbe au future.
— Trier un ensemble de nombres réels.

Définition 4 Énoncé informel : il est spécifié par le texte de la question


définissant le problème informatique. Par exemple, ”étant donné un entier positif
n, calculer la somme des n premiers entiers positifs”.

Définition 5 Énoncé formel : il est spécifié en termes d’inputs, d’outputs


et de paramètres définissant de façon précise le problème informatique. Il doit
être précis, clair et non ambiguı̈. Par exemple, pour notre problème précédent :

• L’input est : n (un entier positif).

• L’output est : 1 + 2 + ... + n (un entier positif).

Définition 6 Une instance d’un problème est une collection spécifique de


valeurs d’entrée valides qui nous permettra de calculer une solution au problème
informatique.
1.1. RÉCURSIVITÉ 9

Par exemple, si n = 100, on obtient un problème particulier qui est le calcul de


la somme des entiers de 1 à 100. Un problème peut être considéré comme une
classe générale pour ses instances.

Définition 7
Un algorithme est une procédure effective qui décrit les étapes de résolution
d’un problème informatique.

Qualité d’un algorithme :

• Input : un algorithme doit traiter un ensemble de données en entrée.

• Output : un algorithme doit fournir des des résultats en sortie.

• Efectivité : un algorithme ne doit contenir que des opérations qui peuvent


effectivement réalisées par une machine (ordinateur dans notre cas).

• Généricité : un algorithme doit résoudre toutes les instances valides


du problème.

• Correction : un algorithme doit toujours fournir la réponse juste quelque


soient les valeurs des inputs.

• Terminaison : un algorithme doit finir au bout d’un certain temps.

Une classification des algorithmes :

• Itératif vs. récursif.

• Séquentiel vs. parallèle.

• Numérique vs. alphanumérique.

• Déterministe vs. probabiliste.

• Exacte vs. approché.

Modèle de machine pour l’analyse des algorithmes :

• Pour analyser un algorithme, il est important de préciser certaines hy-


pothèses concernant le modèle de machine sur lequel cet algorithme
sera exécuté.
10 CHAPITRE 1. RÉCURSIVITÉ, POINTEURS & MODULES

• Nous considérons une machine RAM simple (machine de Von Neuman)


composée :

1. d’une unité centrale fournissant des opérations arithmétiques et lo-


giques.

2. d’une mémoire stockant à la fois les programmes et les données.

• Pas de parallélisme.

• On suppose que le coût de transfert des données est négligeable devant le


coût de calcul ⇒ machine faiblement cablée.

Technique de la décomposition récursive :

La décomposition est un concept fondamental en informatique et joue un


rôle majeur, non seulement dans la programmation, mais aussi dans la résolution
générale des problèmes. L’idée générale de la décomposition consiste à ”diviser”
un problème complexe en plusieurs sous-problèmes plus petits et plus simples,
plus faciles à exprimer, à calculer, à coder ou à résoudre. Par la suite, les solu-
tions partielles aux sous-problèmes peuvent être combinées afin d’obtenir
la solution globale au problème complexe d’origine.

Dans le contexte de la résolution récursive de problèmes, la décomposition


récursive consiste à diviser un problème en plusieurs sous-problèmes, dont cer-
tains sont auto-similaires (ou identiques) à l’original. Notez que l’obtention
de la solution à un problème peut nécessiter la résolution de différents problèmes
supplémentaires qui ne sont pas auto-similaires à l’original. Dans la suite, nous
examinerons des exemples où les problèmes d’origine ne seront décomposés qu’en
problèmes auto-similaires. La figure 1.5 illustre le concept de la décomposition
récursive d’un problème.

Un exemple classique : ”calculer la somme des n premiers entiers”. Soit le


problème Pde calcul de la somme des n premiers entiers positifs, notée S(n) :
i=n
S(n) = i=1 i = 1+2+...+(n−1)+n. Il existe plusieurs façons de décomposer
le problème en sous-problèmes plus petits identiques pour obtenir une
définition récursive de S(n). Tout d’abord, cela ne dépend que du paramètre
d’entrée n, qui spécifie également la taille du problème. Dans cet exemple,
le cas de base est associé au plus petit entier positif n = 1, où S(1) = 1 est
clairement la plus petite instance du problème.

En outre, nous devons nous rapprocher du scénario de base du problème lorsque


1.1. RÉCURSIVITÉ 11

Figure 1.5 – La philosophie de la décomposition récursive.

nous examinons les sous-problèmes. Par suite, nous devons penser à la façon
dont nous pouvons réduire le paramètre d’entrée n. Une première possibilité
consiste à diminuer n d’une seule unité. Dans ce cas, l’objectif serait de définir
S(n) d’une certaine manière en utilisant le sous-problème S(n − 1). La solution
récursive correspondante est alors : S(n) = S(n − 1) + n.

Nous pouvons également obtenir le cas récursif en analysant une description


graphique du problème. Par exemple, l’objectif pourrait consister à compter
le nombre total de blocs dans une structure triangulaire qui contient n blocs dans
son premier niveau, (n − 1) dans son second, et ainsi de suite (le nème niveau
aurait donc un seul bloc). La figure suivante montre un exemple pour n = 8.
12 CHAPITRE 1. RÉCURSIVITÉ, POINTEURS & MODULES

Figure 1.6 – Description grpahique du problème ”Somme de 1 à n”.

Afin de décomposer le problème récursivement, nous devons trouver des


problèmes identiques. Dans ce cas, il n’est pas difficile de trouver des formes
triangulaires similaires plus petites à l’intérieur de l’original. Par exemple,
nous pouvons choisir la structure triangulaire de hauteur (n − 1) qui contient
tous les blocs du problème original, à l’exception des n blocs du premier niveau.
Puisque cette forme triangulaire plus petite contient exactement S(n − 1) blocs,
il s’ensuit que : S(n) = S(n − 1) + n

Figure 1.7 – Première décomposition récursive possible.

Les figures 1.8 et 1.9 montrent une décomposition récursive possible dans laquelle
on a : S(n) = 3S( n2 ) + S( n2 − 1), si n est pair et S(n) = 3S( n−1 2
) + S( n+1
2
), si
n est impair. Notons que cette deuxième définition récursive a besoin d’un cas
de base supplémentaire pour n = 2, à savoir S(2) = 3. Sans cela, nous aurions :
S(2) = 3S(1) + S(0) en raison du cas récursif lorsque n est pair. Mais, S(0)
n’est pas défini car, selon l’énoncé du problème, l’entrée de S doit être un entier
1.1. RÉCURSIVITÉ 13

Figure 1.8 – Deucième décomposition récursive possible lorsque n est pair.

Figure 1.9 – Deuxième décomposition récursive possible lorsque n est impair.

positif. Le nouveau cas de base est donc nécessaire pour éviter d’utiliser la for-
mule récursive pour n = 2.

Lors de la division de la taille d’un problème, les sous-problèmes résultants


sont considérablement de tailles plus petites que l’original et peuvent donc être
résolus beaucoup plus rapidement. En gros, si le nombre de sous-problèmes à
résoudre est petit et qu’il est possible de combiner efficacement leurs solutions,
cette stratégie peut conduire à des algorithmes sensiblement plus rapides pour
résoudre le problème d’origine. C’est pourquoi nous appelons cette stratégie une
stratégie de diviser pour régner. Cependant, dans cet exemple particulier,
le code n’est pas nécessairement plus efficace que celui implémentant la première
solution simple. Intuitivement, cela est dû au fait que la décomposition de la
deuxième solution nécessite de résoudre deux sous-problèmes (avec des argu-
14 CHAPITRE 1. RÉCURSIVITÉ, POINTEURS & MODULES

ments différents), tandis que la décomposition dans la première solution n’im-


plique qu’un seul sous-problème.

Critères pour utiliser la récursivité :

• Afin d’utiliser la récursivité lors de la conception d’algorithmes, il est cru-


cial d’apprendre à diviser un problème en sous-problèmes identiques
mais de tailles plus petites et à définir des méthodes récursives en ap-
pliquant l’induction.

• La décomposition récursive ne doit pas être infinie, mais elle doit se ter-
miner à un certain moment et converger vers l’un des cas de base.
Exercice corrigé N° 2
1. Exprimer n2 en fonction de (n − 1)2 , puis donner une définition récursive
pour calculer n2 .
2. Exprimer n2 en fonction de (n − 2)2 , puis donner une définition récursive
pour calculer n2 .
3. Implémenter les deux fonctions précédentes en langage C.
Corrigé de l’exercice N° 2
1. Nous avons : n2 = ((n−1)+1)2 = (n−1)2 +2(n−1)+12 = (n−1)2 +2n−1.
Cas de base : 02 = 0.
Cas inductifs : n2 = (n − 1)2 + 2n − 1, pour tout n ≥ 1.
2. De même : n2 = ((n−2)+2)2 = (n−2)2 +4(n−2)+22 = (n−2)2 +4(n−1).
Cas de base : 02 = 0 et 12 = 1.
Cas inductifs : n2 = (n − 2)2 + 4(n − 1), pour tout n ≥ 2.
3. Implémentation en C :
int square1(int n)
{
if(n == 0) return 0 ;
else return square1(n - 1) + 2 * n - 1 ;
}
int square2(int n)
{
if(n <= 1) return n ;
else return square2(n - 2) + 4 * (n - 1) ;
}

1.1.4 Récursivité vs. itération


La puissance de calcul des ordinateurs est principalement due à leur capa-
cité à effectuer des tâches de manière répétitive. Dans le contexte de la
1.1. RÉCURSIVITÉ 15

programmation, il existe deux formes de traitement répétitif : l’itération et


la récursivité. La première forme utilise des constructions appelées boucles
(comme while et for) pour implémenter les répétitions. La deuxième forme uti-
lisent des fonctions récursives qui s’invoquent successivement, effectuant ainsi
des tâches à plusieurs reprises dans chaque appel récursif, jusqu’à atteindre un
cas de base.

L’itération et la récursivité sont équivalentes en ce sens qu’elles peuvent


résoudre les mêmes types de problèmes et produire les mêmes résultats. Chaque
programme itératif peut être converti en un programme récursif équivalent,
et vice-versa. Récursivation : transformer un programme itératif en un pro-
gramme récursif équivalent. Dérécursivation : transformer un programme récursif
en un programme itératif équivalent. Le choix de celui à utiliser peut dépendre
de plusieurs facteurs, tels que :

• La nature du problème à résoudre.

• L’efficacité.

• Le langage ou le style de programmation.

L’itération est préférée dans les langages de programmation impératifs, comme


C, P ascal, et F ortran. La récursivité est largement utilisée dans les langages
de programmation déclaratifs, comme Lisp et P rolog.

Avantages de l’approche récursive :

• Plus simple et plus naturel au niveau conception et au niveau com-


prhénsion.
• Intuitive : ressemble à l’approche logique que nous adoptons pour résoudre
un problème.
• Concise et élégante : les programmes récursives sont généralement moins
longs que les programmes itératifs.
• Efficace dans certains cas.

Inconvénients de l’approche récursive :

• Plus coûteux en temps et en espace : utilise implicitement la pile de


programmation à chaque appel récursif =⇒ Problème de débordement
de pile (Stack overflow ).
• Plus difficile à débouger.
• Parfois inefficace : par exemple l’algorithme récursif de la suite de Fibo-
nacci est exponentiel, donc non utilisable en pratique.
16 CHAPITRE 1. RÉCURSIVITÉ, POINTEURS & MODULES

Exercice corrigé N° 3
1. Écrire une version récursive de la fonction fibonacci.
2. Écrire une version itérative de cette fonction :
• En utilisant un tableau.
• Sans utiliser aucun tableau.
3. Comparer les différentes versions de la fonction fibonacci sur les deux
critères de temps et d’espace. Pour le temps, considérer uniquement le
nombre d’additions de chaque algorithme. Pour l’espace, considérer qu’un
entier est codé sur 4 octets. Dresser un tableau qui montre les valeurs des
critères pour n = 5, 10, 15, 20, 30, 40, 50.

Corrigé de l’exercice N° 3
1. Une version récursive de la fonction fibonacci :
int fibo(int n)
{
if(n <= 2) return 1 ;
else return fibo(n - 1) + fibo(n - 2) ;
}
2. Deux versions itératives de la fonction fibonacci :
• Une version utilisant un tableau :
int fibo(int n)
{
int i ;
int * t ;
if(n <= 2) return 1 ;
t = (int *) malloc(sizeof(int) * n) ;
t[0] = t[1] = 1 ;
for(i = 2 ; i < n ; i++)
t[i] = t[i - 1] + t[i - 2] ;
return t[n - 1] ;
}
• Une version sans aucun tableau :
int fibo(int n)
{
int cur, prev, bprev ;
if(n <= 2) return 1 ;
bprev = prev = 1 ;
for(i = 3 ; i <= n ; i++)
{
cur = bprev + prev ;
bprev = prev ; prev = cur ;
}
1.1. RÉCURSIVITÉ 17

return cur ;
}
3. Analyse de l’efficacité des trois algorithmes :
Notons par A(n) le nombre d’additions effectuées par chaque algorithme
et M (n) le nombre minimum d’octets nécessaires pour exécuter chaque
algorithme, lorsque l’entrée est n.
— Pour la version récursive, nous avons : A(1) = A(2) = 0 et A(n) =
A(n - 1) + A(n - 2) + 1, pour tout n ≥ 3. Pour l’espace, nous avons :
M (n) = 4, pour tout n ≥ 1 (sans compter la mémoire pour la pile
d’exécution).
— Pour la première version itérative , nous avons : A(1) = A(2) = 0 et
A(n) = n - 2, pour tout n ≥ 3. Pour l’espace, nous avons : M (2) =
M (3) = 4 et M (n) = 4n + 12, pour tout n ≥ 3.
— Pour la deuxième version itérative , nous avons : A(1) = A(2) = 0 et
A(n) = n - 2, pour tout n ≥ 3. Pour l’espace, nous avons : M (n) = 16,
pour tout n ≥ 3.

Figure 1.10 – Tableau des résultats d’analyse des trois algorithmes de la fonction
fibonacci.

1.1.5 Types de récursivité


Définition 8 La récursivité linéaire se produit lorsque la fonction ne s’ap-
pelle qu’une seule fois, mais traite le résultat de l’appel récursif d’une manière
ou d’une autre avant de produire ou de renvoyer sa propre sortie.

Exemple 1 La factorielle :
int fact(int n)
{
if(n == 0)
return 1 ;
else
return n * fact(n − 1) ;
}
18 CHAPITRE 1. RÉCURSIVITÉ, POINTEURS & MODULES

Définition 9 La récursivité terminale se produit lorsque la fonction ne


s’appelle qu’une seule fois, mais l’appel récursif est la dernière opération
effectuée dans le cas récursif =⇒ la fonction ne manipule pas le résultat de
l’appel récursif.

Exemple 2 Tester si un entier est une puissance de 2 :


int isPower2(int n)
{
if(n == 0) return 0 ;
if(n == 1) return 1 ;
if(n%2 == 1) return 0 ;
return isPower2(n/2) ;
}

Définition 10 La récursivité multiple se produit lorsque la fonction s’appelle


plusieurs fois.

Exemple 3 Suite de Fibonacci :


int fibo(int n)
{
if(n == 1 || n == 2) return 1 ;
return fibo(n − 1) + fibo(n − 2) ;
}

Définition 11 La récursivité croisée (ou mutuelle) se produit lorsque deux


fonctions s’appellent mutuellement =⇒ une fonction f appelle une fonction g, et
la fonction g appelle à son tour la fonction f .

Exemple 4 Tester si un entier est pair ou impair :


int isEven(int n)
{
if(n <= 1) return 1 − n ;
return isOdd(n − 1) ;
}
int isOdd(int n)
{
if(n <= 1) return n ;
return isEven(n − 1) ;
}

Définition 12 La récursivité imbriquée se produit lorsqu’un argument d’une


fonction récursive est défini via un autre appel récursif.
1.1. RÉCURSIVITÉ 19

Exemple 5
int f(int n, int s)
{
if(n == 1 || n == 2) return 1 + s ;
return f(n − 1, s + f(n − 2, 0)) ;
}

1.1.6 Preuve d’un algorithme


Définition 13 Prouver un algorithme c’est :

• Vérifier qu’il se termine toujours : l’algorithme effectue un traitement


qui nécessite un nombre fini d’opérations élémentaires =⇒ c’est la preuve
de terminaison.

• Vérifier qu’il est correct : l’algorithme fait bien ce qu’il est supposé faire
dans sa spécification =⇒ c’est la preuve de correction.

La preuve de terminaison et de correction d’un algorithme récursif se font


généralement par une démonstration par récurrence. La preuve de correc-
tion d’un algorithme itératif se fait généralement par une démonstration
par récurrence en utilisant un invariant de boucle.

Exemple de preuve d’un algorithme récursif :


int f(int n)
{
if (n == 0) return 2 ;
return f(n − 1) * f(n − 1) ;
}
n
Théorème 1 L’algorithme précédent calcul la valeur 22 , pour tout entier n ≥ 0.

Preuve de terminaison :

• Cas de base : si n = 0, l’appel de l’algorithme f se termine immédiatement


(instruction return).

• Cas inductif : soit n ≥ 0, et supposons que l’appel de l’algorithme f se


termine pour l’argument n. Montrons alors que l’appel de l’algorithme f
avec l’argument (n+1) se termine aussi. Puisque n+1 ≥ 1 > 0, l’appel de f
avec l’argument (n+1) provoque deux appels récursifs de f respectivement
avec l’argument n. Par hypothèse, chacun de ces deux appels se termine.
Par suite, l’algorithme f se termine, car il va tout simplement effectuer la
20 CHAPITRE 1. RÉCURSIVITÉ, POINTEURS & MODULES

multiplication des deux résultats obtenus.

Preuve de correction : notons fn la valeur retournée par l’appel de l’algorithme


f avec le paramètre n. D’après le code de la fonction f, nous avons : f0 = 2 et
2
fn = fn−1 × fn−1 = fn−1 , pour tout entier n ≥ 1. Il suffit alors de montrer par
n
récurrence que : fn = 22 , ∀n ≥ 0.

• Cas de base : si n = 0, l’appel de l’algorithme f retourne la valeur


0
f0 = 2 = 22 . La propriété est vérifiée pour n = 0.

• Cas inductif : soit n ≥ 0, et supposons que l’appel de l’algorithme f


n
retourne la valeur 22 pour l’argument n. Montrons que l’appel de l’algo-
n+1
rithme f avec l’argument (n + 1) retourne la valeur 22 . Puisque n + 1 ≥
1 > 0, l’appel de f avec l’argument (n + 1) renvoie la valeur fn2 . Or, par
n
hypothèse, fn = 22 . Par conséquent, la valeur retournée par l’appel de
n n n n n+1
f avec le paramètre (n + 1) est : fn+1 = 22 × 22 = 22 +2 = 22 . La
propriété est alors vraie pour (n + 1).

Remarque 1 On ne sait pas toujours conclure quant à la terminaison d’un al-


gorithme récursif.

Exemple 6 (Algorithme de Syracuse)


int Syracuse(int n)
{
if(n <= 1) return 1 ;
if(n%2 == 0) return Syracuse(n/2) ;
else return Syracuse(3 * n + 1) ;
}

Une conjecture : la fonction Syracuse finit par atteindre la valeur 1 pour n ≥ 1.

Exemple de preuve d’un algorithme itératif :


int myster(int n)
{
int x, y ;
y = x = n;
while(y ! = 0) {
x = x + 2;
y − −;
}
return x ;
}

Théorème 2 L’algorithme ”myster” calcul la valeur 3n, pour tout entier n ≥ 0.


1.1. RÉCURSIVITÉ 21

Preuve de terminaison : Si n = 0, l’appel de l’algorithme ”myster” se termine


immédiatement, car la valeur de y étant égale à 0, la boucle while ne sera pas
exécutée et l’algorithme se branche automatiquement à une instruction return,
qui provoque son arrêt. Si n ≥ 1, la boucle while sera exécutée au moins une
seule fois, puisque y > 0. Comme à chaque itération, y est décrémentée, alors
sa valeur décroit strictement jusqu’à 0, et à ce moment l’algorithme ”myster”
s’arrête après l’exécution d’une instruction return.

Preuve d’un algorithme itératif : La méthode consiste à utiliser une propriété,


dite ”invariant de boucle”.
Définition 14 Un invariant de boucle est une relation mathématique qui
lie les valeurs de certaines variables d’une boucle et qui est toujours vraie à chaque
passage de cette boucle.
Remarque 2 Un invariant de boucle se démontre généralement en utilisant une
preuve par récurrence.
Les étapes de la méthode d’invariant de boucle sont :

1. Identifier un invariant de boucle.

2. Démontrer l’invariant de boucle en utilisant une preuve par récurrence.

3. Exploiter l’invariant de boucle pour prouver la correction de l’algo-


rithme.

Preuve de l’algorithme itératif : déterminiation d’un invaraiant de boucle


pour l’algorithme ”myster”. Notons par ai la valeur d’une variable a après i
itérations. a0 sera alors la valeur de la variable a avant l’entrée de la boucle. Nous
avons pour l’algorithme ”myster” : x0 = y0 = n.

Un invariant de boucle pour ”myster” :


xi + 2yi = 3n
Voici la preuve de l’invariant de boucle de l’algorithme ”myster” :

• Cas de base : Pour i = 0 : n = 0, nous avons x0 + 2y0 = n + 2n = 3n.

• Cas inductif : soit i ≥ 0, et supposons que l’on a xi +2yi = 3n. Montrons


alors que xi+1 + 2yi+1 = 3n. Nous avons : xi+1 = xi + 2 et yi+1 = yi − 1.
Par suite, xi+1 + yi+1 = (xi + 2) + 2(yi − 1) = xi + 2yi = 3n (d’après
l’hypothèse de récurrence).
22 CHAPITRE 1. RÉCURSIVITÉ, POINTEURS & MODULES

Exploitation de l’invariant de boucle pour l’algorithme ”myster”. Tout


d’abord, notons que la boucle while sera exécutée n fois, vu que la variable y
se décrémente de n à 0. À la sortie de la boucle, la valeur de x est xn et celle
de y est yn . Comme yn = 0, alors xn = 3n − 2yn = 3n. La valeur retournée par
l’algorithme ”myster” est donc x = xn = 3n.

1.2 Pointeurs
1.2.1 Rappel sur les pointeurs
Définition 15 Un pointeur est une variable qui stocke l’adresse d’un objet
(une autre variable).

Déclaration et initialisation d’un pointeur en C :

T * ptr ; // ptr est un poniteur sur une variable de type T.


T var ; // var est une variable de type T.
ptr = &var ; // la valeur de ptr est l’adresse de la variable var.

Exemples :

int x = 7 ;
float y = 1.28 ;
int * p = &x ;
float * q = &y ;

Différence et comparaison de deux pointeurs :

Soient p et q deux poniteurs sur deux objets x et y de même type T.

• p − q indique le nombre d’objets de type T compris p et q.


• La valeur de p − q est : (&x − &y) / sizeof (T).
• p == q : vrai si p et q pointent sur le même objet.
• p! = q : vrai si p et q pointent sur des objets différents.
• p > q : vrai si p pointe sur un objet après l’objet sur lequel pointe q.
• p < q : vrai si p pointe sur un objet avant l’objet sur lequel pointe q.
• p >= q : vrai si p > q ou p == q.
• p <= q : vrai si p < q ou p == q.

Opérations additives :

Soient p un poniteur sur un objet x de type T et k un entier positif.


1.2. POINTEURS 23

• p + k pointe sur le k eme objet de type T après x.


• La valeur de p + k est : &x + k· sizeof (T).
• p − k pointe sur le k eme objet de type T avant x.
• La valeur de p − k est : &x − k· sizeof (T).
• p + 1 pointe sur l’objet de type T qui suit immédiatement x.
• p − 1 pointe sur l’objet de type T qui précède immédiatement x.
• p + + (ou + + p) incrémente le pointeur p =⇒ p pointe maintenant sur
l’objet de type T qui suit immédiatement x.
• p − − (ou − − p) décrémente le pointeur p =⇒ p pointe maintenant sur
l’objet de type T qui suit immédiatement x.

Gestion dynamique de la mémoire :

Soient p un poniteur sur un objet de type T et n un entier positif.

• T * p = (T*) malloc(sizeof (T)) ;


• La fonction ”malloc” alloue une zone mémoire pour stocker l’adresse d’un
objet de type T. p pointe sur cette zone allouée. Si l’opération a échoué,
p contient la valeur NULL (qui indique l’absence de pointeur).
• T * p = (T*) calloc(sizeof (T), n) ;
• La fonction ”calloc” alloue une zone mémoire pour stocker un tableau de
n objets de type T. p pointe sur l’emplacement du premier objet de ce
tableau. Les éléments du tableau alloué sont tous initialisés à 0.
• T * p = (T*) realloc(sizeof (T), n) ;
• La fonction ”realloc” permet de réallouer une zone mémoire. Cela signi-
fie que si l’espace mémoire libre qui suit le bloc à réallouer est suffisament
grand, le bloc de mémoire est simplement agrandi. Par contre si l’espace
libre n’est pas suffisant, un nouveau bloc de mémoire sera alloué, le contenu
de la zone d’origine recopié dans la nouvelle zone et le bloc mémoire d’ori-
gine sera libéré automatiquement. p pointe sur le premier élément de la
zone allouée.
• free(p) : libère la zone mémoire occupée par l’objet pointé par p et allouée
par ”malloc”, ”calloc” ou ”realloc”.

Opérations d’affectation et accès à un champ d’une structure :

Soient p et q deux poniteurs sur deux objets de même type T et c est le nom
d’un champ d’une structure S.

• p = q : p et q pointeront sur le même objet.


• ∗p = ∗q : la valeur pointée par q sera recopiée à la place de l’ancienne
valeur pointée par p.
• Si ptr est un pointeur sur la structure S, la valeur de l’expression ptr → c
24 CHAPITRE 1. RÉCURSIVITÉ, POINTEURS & MODULES

désigne la valeur du champ c de la structure pointée par ptr. Cette expres-


sion est l’écriture abrégée de (∗ptr).c.

La mémoire de programmation :

Figure 1.11 – Structure de la mémoire de programmation.

La mémoire de programmation se compose de 4 segments :

• Le segment de code (Code Segment) : contient les codes binaires de


toutes les fonctions du programme, y compris la fonction main.

• Le segment de données (Data Segment) : mémoire pour les variables


globales et statiques du programme. Elles sont allouées et initialisées
avant même l’exécution de la fonction main.

• Le segment de pile (Stack Segment) : mémoire pour exécuter une fonc-


tion. Toutes les variables locales et les paramètres des fonctions sont
allouées dans la pile. Lorsque l’on entre dans un bloc, toutes les variables
du bloc sont allouées dans la pile. Lorsqu’une fonction retorune sa valeur
ou lorsque l’on sort d’un bloc, les données de la pile sont détruites.

• Le segment de tas (Heap Segment) : mémoire pour stocker les données


de taille dynamique.

1.2.2 Pointeur vers une fonction et déclarations complexes


En C, il est possible de pointer vers une fonction. La valeur d’un pointeur
vers une fonction f est l’adresse du 1er octet de la zone mémoire allouée pour
le code machine de f . Un avantage d’avoir un pointeur vers une fonctions est la
1.2. POINTEURS 25

possibilité de passer une fonction comme paramètre.

Règles pour déchiffrér une déclaration complexe :

1. Repérér l’identificateur.

2. Aller vers la droite de l’identificateur, puis continuer à partir de la


gauche de l’identificateur.

3. Appliquer les priorités des opérateurs de déclaration : d’abord les


parenthèses ( ), ensuite les crochets [ ], et finalement les étoiles *.

Exemples de déclarations :
1. int p ;
p est un entier /* déclaration simple */.
2. int * p ;
p est un pointeur vers un entier.
3. int p[7] ;
p est un tableau de 7 entiers.
4. int * p[7] ;
p est un tableau de 7 pointeurs vers entiers.
5. int (* p)[7] ;
p est un pointeur vers un tableau de 7 entiers.
6. int * (* p)[7] ;
p est un pointeur vers un tableau de 7 pointeurs vers entiers.
7. int p(int x) ;
p est une fonction ayant un argument de type entier et retournant
un entier.
8. int * p(int x) ;
p est une fonction ayant un argument de type entier et retournant
un pointeur vers un entier.
9. int (* p)(int x) ;
p est un pointeur vers une fonction ayant un argument de type entier
et retournant un entier.

Exemple d’utilisation d’un pointeur vers une fonction :

Écrivons une fonction qui calcule le taux d’accroissement d’une fonction f


sur un intervalle [a, b]. Le taux d’accroissement d’une fonction f sur un in-
tervalle [a, b] est la quantité : f (b)−f
b−a
(a)
. Notre fonction appelée ”taux” aura trois
26 CHAPITRE 1. RÉCURSIVITÉ, POINTEURS & MODULES

arguments : une fonction f , et deux réels a et b. L’argument f représente une


fonction numérique. Il doit donc être un pointeur vers une fonction ayant
un argument de type réel et retournant un réel. Sa déclaration est alors :
double (* f )(double) ;
Voici le code C de cette fonction :

double taux (double (* f )(double), double a, double b)


{
return (f (b) − f (a)) / (b − a) ;
}
main()
{
double a = 0.0, b = 3.14159 ;
printf (”taux d’acroissement du sinus : %lf\n”, taux(sin, a, b)) ;
printf (”taux d’acroissement du cosinus : %lf\n”, taux(cos, a, b)) ;
printf (”taux d’acroissement de la racine carree : %lf\n”, taux(sqrt, a, b)) ;
system(”pause”) ;
}

Pointeurs et tableaux :

Le nom d’un tableau est un pointeur (constant) vers son premier élément.
Si t est un tableau, alors :

• &t[0] ≡ t.
• t[0] ≡ *t.
• &t[i] ≡ (t + i), pour tout entier i.
• t[i] ≡ *(t + i), pour tout entier i.

De même, si p est un pointeur, alors p est considéré comme un tableau (dynamique)


de sorte que :

• p ≡ &p[0].
• *p ≡ p[0].
• (p + i) ≡ &p[i], pour tout entier i.
• *(p + i) ≡ p[i], pour tout entier i.

Pointeurs et fonctions :

Le nom d’une fonction est un pointeur (constant) vers cette fonction. Si f


est une fonction, alors :
1.2. POINTEURS 27

• &f ≡ f .
• f (x) ≡ (*f )(x).

Exemple 7
#include <stdio.h>
#include <math.h>
main()
{
printf(”adresse de la fonction taux : %p\n”, taux) ;
printf(”adresse de la fonction taux : %p\n”, &taux) ;
printf(”taux du cosinus entre 0 et 1 : %lf \n”, taux(cos, 0.0, 1.0)) ;
printf(”taux du cosinus entre 0 et 1 : %lf \n”, (*taux)(cos, 0.0, 1.0)) ;
system(”pause”) ;
}

Cas des structures récursives : par exemple, dans le cadre d’une application
traitant des arbres généalogiques, nous désirons définir une structure personne
pour enregistrer les informations suivantes : (i) le nom de la personne, (ii) le
prénom de la personne (qui sont deux chaı̂nes de caractères), (iii) l’âge de la
personne (qui est un entier), et (iv) ses parents, le père et la mère (qui sont
deux personnes).

En pensant à la récursivité, nous pouvons imaginer la solution suivante :

typedef struct person


{
char f irstN ame[50] ;
char lasttN ame[50] ;
int age ;
struct person mother ;
struct person f ather ;
} person ;

Théoriquement, cette solution est imaginable. Mais, elle n’est pas implémentable
en C. En fait, le compilateur ne va pas savoir calculer la taille de cette structure.
En effet, sa taille doit respecter l’équation suivante : sizeof (person) = 2 × 50 +
4 + 2× sizeof(person). Cette équation étant identique à : 2x + 104 = x ⇔ x =
-104, qui est impossible à réaliser ! ! !

L’erreur vient du fait que le compilateur doit toujours savoir calculer la taille de
chaque champ d’une structure avant de calculer la taille totale de cette structure.
28 CHAPITRE 1. RÉCURSIVITÉ, POINTEURS & MODULES

La solution de ce problème consiste à utiliser les pointeurs. Au lieu de considérer


le champ mother (f ather) comme une structure person, on va le déclarer comme
pointeur vers une structure person. Il faut noter que la taille d’un pointeur
est égale à celle d’un entier, quelque soit le type de l’objet pointé. Maintenant,
le compilateur a toutes les informations pour calculer la taille de la structure
person : sizeof (person) = 2 × 50 + 4 + 2 × 4 = 112 octets.

Voici alors la déclaration correcte pour la structure person :

typedef struct person


{
char f irstN ame[50] ;
char lasttN ame[50] ;
int age ;
struct person * mother ;
struct person * f ather ;
} person ;

Par exemple, pour savoir le nom de famille de la mère d’une personne p, il suffit
d’écrire :
p → mother → f irstN ame

1.3 Modules
D’une manière générale, un module est une unité constitutive d’un ensemble.
En algorithmique, un module est un ensemble de fonctions traitant des
données communes. Les objets (constantes, variables, types, fonctions) déclarés
dans la partie interface sont accessibles de l’extérieur du module, et sont utili-
sables dans un autre programme (un autre module ou un programme principal).
Il suffit de référencer le module pour avoir accès aux objets de sa partie in-
terface. Celle-ci doit être la plus réduite possible, tout en donnant au futur uti-
lisateur un large éventail de possibilités d’utilisation du module. Les déclarations
de variables doivent être évitées au maximum.

On peut toujours définir une variable locale au module à laquelle on accède ou


que l’on modifie par des appels de fonctions de l’interface. On parle alors
d’encapsulation des données qui sont invisibles pour l’utilisateur du module
et seulement accessibles à travers un jeu de fonctions. L’utilisateur du module n’a
pas besoin de savoir comment sont mémorisées les données. Le module est pour
lui un type abstrait de données (TAD). Du reste, cette mémorisation locale
peut évoluer, elle n’affectera pas les programmes des utilisateurs du module dès
lors que les prototypes des fonctions d’interface restent inchangés.
1.4. ATELIER DU CHAPITRE 29

L’implémentation de la notion de module varie d’un langage de programmation


à l’autre. En Turbo Pascal, le module est mémorisé dans un fichier. Les mots-
clés interface et implementation délimitent les deux parties visible et invisible
du module. L’utilisateur référence le module en donnant son nom : uses module.
En C, la partie interface est décrite dans un fichier à part ”module.h” appelé
fichier d’en-tête. La partie données locales est mémorisée dans un autre fi-
chier ”module.c” qui référence la partie interface en incluant ”module.h”.
De même, le programme utilisateur référence l’interface en faisant une inclu-
sion de ”module.h”, ce qui définit pour lui, la partie interface. Cette notion
de module est aussi référencée sous le terme d’unité de compilation ou de
compilation séparée. Chaque unité de compilation connaı̂t grâce aux fichiers
d’en-tête, le type et les prototypes des fonctions définies dans une autre unité
de compilation.

Figure 1.12 – La notion de module.

1.4 Atelier du chapitre


On souhaite réaliser un module en C pour les nombres complexes. Un nombre
complexe est une somme a + bi, avec a et b deux nombres réels (partie réelle
et partie imaginaire), et i vérifiant i2 = −1. Les fonctions que l’on peut réaliser
sur les complexes sont données ci-dessous :
30 CHAPITRE 1. RÉCURSIVITÉ, POINTEURS & MODULES

1. La fonction ”create alg” permettant la création d’un nombre complexe à


partir de 2 réels.
2. La fonction ”create geom” permettant la création d’un nombre complexe
à partir de son module, et de son argument en radians entre -π et π.
3. Les fonctions ”real”, ”imag”, ”modulus” et ”argument” délivrant res-
pectivement la partie réelle, la partie imaginaire, le module et l’argument
d’un nombre complexe.
4. Les fonctions ”display alg” et ”display geom” faisant l’affichage des 2 com-
posantes d’un nombre complexe sous la forme algébrique a + bi, ou sous
la forme géométrique ρ(cosθ + isinθ).
5. Les fonctions ”oppos”, ”conjug”, ”inverse” et ”power” délivrant le
complexe opposé, le conjugué, l’inverse ou une puissance entière d’un
nombre complexe.
6. Les fonctions ”add”, ”sub”, ”mul” et ”div” faisant l’addition, la sous-
traction, la multiplication ou la division de deux nombres complexes.

Travail à faire :

1. Écrire dans le fichier ”complexNumber.c”, les définitions des fonctions du


module déclarées dans l’interface ”complexNumber.h”.
2. Écrire un programme principal de test des différentes fonctions du mo-
dule. Votre programme doit contenir un menu pour le choix des différentes
opérations.
Étapes de création d’un module en langage C :

Figure 1.13 – Boı̂te de dialogue pour la création d’un nouveau projet C.


1.4. ATELIER DU CHAPITRE 31

Figure 1.14 – Boı̂te de dialogue pour l’enregistrement d’un projet C.

Figure 1.15 – Ajout de fichiers sources ”.h” et ”.c” à un projet C.


32 CHAPITRE 1. RÉCURSIVITÉ, POINTEURS & MODULES

Figure 1.16 – Extrait de l’interface du dmodule à réaliser.

Vous aimerez peut-être aussi