Boris Eng

R&D Engineer at OCamlPro, PhD in computer science [first name][last name]@proton.me
« Return to Teaching

Programmation 2

Comptage du nombre d’itérations

Quand vous avez une boucle for :

1
2
3
for (i=0; i<n; i++) {
	...
}

entre 0 et n (exclus), alors on a n étapes (car on commence à 0). Plus généralement, si on avait i=k alors on aurait n-k étapes. Par contre si on a deux boucles imbriquées :

1
2
3
4
5
for (i=0; i<n; i++) {
	for (j=0; j<m; j++) {
		...
	}
}

alors on a n*m étapes. Attention, si on a <= à la place de < alors on a une étape de plus (par exemple pour i<=n on fait n+1 étapes). La mise à jour du compteur (paramètre du for) influence aussi le nombre d’étapes. Si on avait i+=2 ou i = i + 2 alors on avancerait deux par deux et on aurait n/2 étapes pour le compteur i.

Comptage du nombre d’appels récursifs

Pour les fonctions récursives c’est un peu plus subtil. Je rappelle que l’idée est d’avoir une définition d’une fonction par récurrence puis de la transformer en C avec des conditions et un rappel récursif. Par exemple pour la puissance, on aurait :

puiss(n, k) = { 1 si k=0
              { n*puiss(n, k-1) sinon

Qui devient

1
2
3
4
5
6
double puiss(double n, unsigned k) {
	if (k==0)
		return 1;
	else
		return n*puiss(n, k-1);
}

Si on veut compter le nombre d’étapes, on remarque que seul k décrémente à chaque appel récursif. Donc le nombre d’appels sera k. Plus généralement, l’idée est de définir le nombre d’appels par récurrences, par exemple :

Cpuiss(n, k) = { 0 si k=0
               { 1+Cpuiss(n, k-1) sinon

On peut ensuite calculer le nombre d’appels et le prouver par récurrence. Par exemple on pourrait montrer que Cpuiss(n, k) = k. Si on veut compter le nombre d’appels pour puiss(2, 5) alors on aura Cpuiss(2, 5) = 5.

Si on a une fonction dont le nombre d’appels se divise successivement par n alors on aura un logarithme de base n. Prenons un exemple simple :

f(n) = { 0 si n=1
       { 1+f((n-1)/2)) si n est impair
       { 1+f(n/2) si n est pair

On a une somme de 1 un nombre de k fois pour chaque appels récursifs. Ce nombre de fois correspond au nombre de division par 2 qu’on peut faire sur n. On a donc l’expression n*(1/2^k) = n/(2^k). On s’arrête quand n=1 donc on veut résoudre l’équation n/(2^k) = 1 pour k. On a :

n/(2^k) = 1
<=> log2(n/(2^k)) = log2(1)
<=> log2(n/(2^k)) = 0
<=> log2(n) - log2(2^k) = 0
<=> log2(n) - k = 0
<=> log2(n) = k

Donc le nombre d’étapes est log2(n) = ln(n)/ln(2) (tronqué si on est sur des entiers).

Maintenant, prenons l’exemple de la fonction :

1
2
3
4
5
6
7
8
double puiss_rap(double x, unsigned n) {
  if (n == 0)
	return 1;
  if (n%2 == 0)
    return puiss_rap (x*x, n/2);
  else
    return x * puiss_rap (x*x, (n-1)/2);
}

La relation de récurrence du nombre d’appels est :

Cpuiss_rap(x, n) = { 0 si n=0
				   { 1+Cpuiss(x*x), (n-1)/2) si n impair
                   { 1+Cpuiss(x*x, n/2) si n pair

On se retrouve avec la même chose que la fonction f sauf que l’on s’arrête à n=0 et non n=1. Donc on se retrouve avec un appel en plus. On a donc un nombre d’appel de log(n)+1. Pour en être sûr on peut le prouver par récurrence (je ne le fais pas ici).

Pour les coefficients binomiaux, on a cette définition par récurrence :

bin(n, p) = { 0 si p > n
            { 1 si p=0 ou p=n
			{ bin(n-1, p) + bin(n-1, p-1) si 0<p<n

et la définition par récurrence suivante pour le nombre d’appels :

Cbin(n, p) = { 0 si p > n
             { 0 si p=0 ou p=n
			 { 2 + Cbin(n-1, p) + Cbin(n-1, p-1) si 0<p<n

Remarquez qu’on a deux appels récursifs plus ceux obtenus par les sous-appels dans la troisième ligne. On peut calculer le nombre d’appels pour des petites valeurs mais la formule qui permet de calculer directement est plus compliquée à obtenir. Une fois qu’on l’a obtenue, on la prouve par récurrence pour s’assurer que tout marche.

Récursion (non-)terminale

La récursion est terminale lorsqu’il ne reste plus d’opérations à faire sur la pile d’appel en dehors de l’appel récursif (qui est la dernière chose à faire). Par exemple pour la puissance on a l’exécution suivante :

puiss(2, 3)
= 2*puiss(2, 2)
= 2*2*puiss(2, 1)
= 2*2*2*puiss(2, 0)
= 2*2*2*1
= 8

On remarque qu’il est impossible de calculer 2*puiss(2, 2) car puiss(2, 2) doit être calculé pour fournir un résultat mais il demande lui-même de rappeler la fonction. Il faut imaginer des parenthèses vers la droite par exemple 2*(2*(2*puiss(2, 0))) donc on ne peut pas juste multiplier les 2 entre eux. On accumule donc des informations supplémentaires dans la mémoire.

On peut cependant programmer la fonction différemment. Pour cela on utilise un troisième paramètre appelé accumulateur qui permet d’accumuler le résultat et de faire des calculs directement sans attendre le résultat d’une fonction. On a donc :

1
2
3
4
5
6
7
8
9
10
11
12
// C'est la fonction auxiliaire avec accumulateur

double puiss_ter2(double n, unsigned k, double acc) {
	if (k==0)
		return acc; // on renvoie ce qu'on a accumulé à la fin

	else
		return puiss(n, k-1, n*acc); // on accumule dans acc

}

// C'est la vraie fonction puissance qui cache l'accumulateur en interne

double puiss_ter(double n, unsigned k) {
	return puiss_ter2(n, k, 1); // on accumule avec des multiplications sur 1

}

On obtient l’exécution suivante où les opérations peuvent être évaluées directement et donc la taille de la pile d’appel n’évolue plus en fonction du second paramètre.

puiss_ter(2, 3)
= puiss_ter2(2, 3, 1)
= puiss_ter2(2, 2, 2)
= puiss_ter2(2, 1, 4)
= puiss_ter2(2, 0, 8)
= 8

Mémoïsation

Quand on fait des appels récursifs, il se peut que l’on calcule plusieurs fois la même chose inutilement. Par exemple avec les coefficient binomiaux, on a :

bin(n, p) = { 0 si p > n
            { 1 si p=0 ou p=n
			{ bin(n-1, p) + bin(n-1, p-1) si 0<p<n
			
bin(3, 4)
= bin(2, 4) + bin(2, 3)
= bin(1, 4) + bin(1, 3) + bin(1, 3) + bin(1, 2)
= ...

On remarque que bin(1, 3) est calculé deux fois. Si on continue on aura encore plus de redondances. Au lieu de recalculer pour rien, on peut calculer une seule fois puis les autres fois on accèdera à la valeur qu’on a sauvegardé. On obtient cette fonction (que je commente) :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* Le principe est d'utiliser un tableau pour sauvegarder les valeurs, on a deux
   dimensions pour les deux paramètres de la fonction */
unsigned long long bin_mem_2(unsigned long long n, unsigned long long p, unsigned long long bin_tab[][N]) {
  // Cette partie est la même qu'avant

  if (n < p) return 0;
  if (p == 0 || p == n) return (bin_tab[n][p] = 1);
  // Si on a jamais calculé la valeur de bin(n, p)

  // Remarquez qu'on passe le tableau en paramètre pour le propager

  if (bin_tab[n][p] == 0)
    bin_tab[n][p] = bin_mem_2(n-1, p, bin_tab) + bin_mem_2(n-1, p-1, bin_tab);
  // A la fin on renvoie toujours la valeur qu'on a déjà sauvegardée

  return bin_tab[n][p];
}

unsigned long long bin_mem(unsigned long long n, unsigned long long p) {
  /* Toutes les valeurs sont mises à 0 par défaut, donc si on a jamais calculé
     la valeur, la case sera à 0 */
  unsigned long long bin_tab[N][N] = {0};
  return bin_mem_2 (n, p, bin_tab);
}

Pointeurs

Ce sont des variables qui contiennent des adresses dans la mémoire. Les opérations de base sont les suivantes :

1
2
3
4
5
6
7
8
&x; // renvoie l'adresse de x

*p; // renvoie la valeur à l'adresse dans p

p+k; // renvoie l'adresse à p + k cases dans la mémoire (selon le type de p)

*(p+k); // renvoie la valeur à l'adresse p+k

p[k]; // même chose que *(p+k)


// On peut aussi faire d'autres opérations arithmétiques (-, *, ...) comme avec des entiers

// Si le pointeur est de type int* alors on se déplace de 4 octets (taille d'un int)

On peut réserver un nouvel espace mémoire pour un pointeur en demandant un certain nombre de blocs pour un certain type. On utilise la fonction malloc qui renvoie une adresse dans la mémoire qui nous est offert. On peut se déplacer dans la zone réservée et cela nous permet en particulier de simuler un tableau. On utilise ensuite free pour dire qu’on ne se sert plus de cette portion de mémoire.

1
2
3
4
5
6
7
int* p = malloc(sizeof(int)); // on demande 1 bloc de type entier

*p = 3; // on met la valeur 3 dans la case d'adresse p


int* tab = malloc(5*sizeof(int)); // on demande 5 blocs de type entier

*(tab+2) = 3; // on met la valeur 3 à la 2 cases après tab

tab[2] = 4; // on met la valeur 4 à la même case

*tab = 1; // par défaut, tab pointe sur la première case, on aurait pu écrire tab[0] = 1

Attention, si on utilise p+1 ou tab+10, on sort de la zone réservée et on risque d’avoir un Segmentation fault.

Si on veut comparer les pointeurs et les tableaux alors voici deux codes équivalents :

1
2
3
4
5
6
7
8
9
10
11
12
// Version tableau

int tab[5];
tab[0] = 2;
scanf("%d", &tab[1]);
printf("%d\n", tab[2]);

// Version pointeur

int* tab = malloc(5*sizeof(int));
*tab = 2; // on aurait pu écrire tab[0]

scanf("%d", tab+1);
printf("%d\n", *(tab+2)); // on aurait pu écrire tab[2]

free(p);

On a pas forcément besoin de faire un malloc, on aurait très bien pu travailler directement sur un tableau qui a déjà de la mémoire allouée :

1
2
3
4
5
// Tableau + pointeurs

int tab[5];
*tab = 2; // on aurait pu écrire tab[0]

scanf("%d", tab+1);
printf("%d\n", *(tab+2)); // on aurait pu écrire tab[2]

Avec des tableaux à deux dimensions, ça reste le même principe. Il ne faut pas oublier qu’un tableau à deux dimensions est un tableau qui contient des tableaux. Cela correspond donc à des pointeurs de pointeurs.

1
2
3
4
5
6
7
int tab[2][2];
// tab est un pointeur int**

// *tab est un pointeur int*

// **tab est un entier int correspondant à tab[0][0]

*(*(tab+1)+1) = 2; // on aurait pu écrire tab[0][2]

scanf("%d", (*tab+1)+1); // on aurait pu écrire &tab[1][1]

printf("%d\n", *(*(tab+1)+1)); // on aurait pu écrire tab[1][1]

Si on travaille avec des structures, on utilise . pour accéder à un champs. Par exemple pers.prenom. Cependant si on a un pointeur on doit écrire (*p).prenom ou utiliser la syntaxe plus pratique p->prenom. Voici un exemple :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdlib.h>

#include <stdio.h>

#include <string.h>


struct personne {
	char* nom;
	char* prenom;
	int age;
};

int main()
{
    // on aurait pu faire sans malloc en déclarant une structure directement

    struct personne* p = malloc(sizeof(struct personne)); 
    p->age = 5;
    printf("%d\n", p->age);
    
    p->nom = malloc(3*sizeof(char));
    strcpy(p->nom, "Eng");
    
    p->prenom = "Boris"; // const char*

    printf("%s %s\n", p->prenom, p->nom);

    return EXIT_SUCCESS;
}

La différence entre définir un pointeur ou une structure (ou une variable d’un type de donnée classique) est que la structure existe localement dans le bloc d’une fonction et qu’elle disparaît à la fin de la fonction. Si on veut vraiment modifier des données depuis l’extérieur alors on doit utiliser des pointeurs. Voici un exemple simple utilisant des variables entières :

1
2
3
4
5
6
7
void f(int x) {
	x = 3; // on modifie x seulement dans cette fonction mais pas à l'extérieur (on travaille donc avec une copie de x)

}

void g(int* x) {
	*x = 3; // on modifie la valeur de x (on travaille avec l'adresse d'une variable du programme entier)

}

Tri par insertion

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void tri_insertion(int* t, int taille) {
	int i, j;
	// On parcourt chaque case à partir de la première

	for (i=1; i<taille; i++) {
		/* Pour chaque case du tableau, on cherche où bien placer sa valeur derrière
		selon l'ordre du tableau */
		j = i;
		// Tant qu'on ne trouve pas une valeur plus grande que la précédente, on continue à chercher

		while (j > 0 && t[j] < t[j-1]) {
			echanger(t+j, t+j-1); // on échange chaque paire côte-à-côte qui est désordonnée

			j--;
		}
	}
}

// Remarquez qu'on modifie réellement les entiers à l'extérieur, donc on veut les adresses

void echanger(int* a, int* b) {
	int tmp = *a;
	*a = *b;
	*b = tmp;
}

Exemple d’exécution du tri par insertion :

Indice 0 1 2 3 4 5
Valeurs 1 3 4 2 0 5


Indice 0 <1> 2 3 4 5
Valeurs 1 3 4 2 0 5

On commence sur la case 1. On cherche une valeur supérieure à gauche. Il n’y en a pas. On passe à la prochaine valeur de i.


Indice 0 1 <2> 3 4 5
Valeurs 1 3 4 2 0 5

Pareil.


Indice 0 1 2 <3> 4 5
Valeurs 1 3 4 2 0 5

La valeur à la case 3 est 2. Il y a plus grand juste à gauche (4). On échange.


Indice 0 1 2 <3> 4 5
Valeurs 1 3 <2> 4 0 5

On est sur j=2. On voit qu’il y a plus grand juste à gauche. On échange.


Indice 0 1 2 <3> 4 5
Valeurs 1 <2> 3 4 0 5

Il n’y a plus rien de plus grand. On continue avec le i suivant.


Indice 0 1 2 3 <4> 5
Valeurs 1 2 3 4 0 5

On a un 0 et c’est le plus petit. Donc on va le décaler successivement par échanges successifs jusqu’à qu’à arriver au début.


Indice 0 1 2 3 <4> 5
Valeurs 0 1 2 3 4 5

Il nous reste plus qu’à regarder la case 5 qui ne trouvera rien de plus grand à gauche et donc le programme s’arrête. Le tableau est bien trié.

Reorganisation de tableau

Supposons que nous avons un tableau d’entiers et que l’on souhaite placer toutes les occurrences d’un entier à l’avant du tableau. On peut parcourir à partir de la gauche jusqu’à trouver ce qu’on ne veut pas devant et parcourir à partir de la droite jusqu’à trouver ce qu’on veut mettre devant puis faire un échange. On utilise cette fonction :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void echanger(int* a, int* b)
{
	int tmp = *a;
	int *a = *b;
	int *b = tmp;
}

int reorganiser(int* tab, int taille)
{
	int g = 0; // compteur gauche ->

	int d = taille-1; // compteur droit <-

	
	// on continue jusqu'à que les compteurs se croisent

	while (g < d)
	{
		/* on continue tant qu'on a pas trouvé
		   et qu'on est pas arrivé à la fin */
		while (tab[g]==tab[d] && g < taille)
			g++;
		/* on continue tant qu'on a pas trouvé
		   et qu'on est pas arrivé au début */
		while (tab[g]!=tab[d] && d >= 0)
			d--;
		echanger(tab+i, tab+j);
	}
}

Recherche dans un tableau trié

Commençons par le cas des tableaux d’entiers pour faire plus simple. On suppose que le tableau est trié dans l’ordre croissant. On renvoie 1 lorsque l’élément x est trouvé. Si on renvoie 0.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int recherche(int* tab, int taille, int x) {
	if (taille > 0) {
		// On découpe le tableau en deux

		int milieu = taille/2;
		// Si la valeur qu'on cherche est plus grande, on cherche à gauche

		if (x > tab[milieu]) 
			recherche(tab+milieu+1, taille-milieu-1, x);
		// Sinon on cherche à droite

		else if (x < tab[milieu])
			recherche(tab, milieu, an);
		// x est exactement le milieu donc on l'a trouvé

		else
			return 1;
	}
	else
		return 0; // x n'a pas été trouvé

}

Maintenant, comme dans le TD9, on applique la recherche dichotomique avec les opéras. Nous chercons un opéra avec une certaine date de création. Attention, la difficulté est qu’on ne manipule pas un tableau d’opéras mais un tableau d’adresses d’opéras. Chaque élément de notre tableau est donc une adresse vers un opéra dans la mémoire.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void rechercher_tab_annee_creation(struct opera** tab, int taille, unsigned an)
{
	if (taille > 0)
	{
		int milieu = taille/2;
		/* Remarques :
		- tab et tab+milieu sont de type struct opera**
		- tab[milieu] est de type struct opera*
		- *tab[milieu] est de type opera */
		if (an > tab[milieu]->date_creation->annee)
			rechercher_tab_annee_creation(tab+milieu+1, taille-milieu-1, an);
		else if (an < tab[milieu]->date_creation->annee)
			rechercher_tab_annee_creation(tab, milieu, an);
		else
			afficher_opera(tab[milieu]);
	}
	else
	{
		printf("Aucun opéra trouvé.");
	}
}

Pointeurs et structures

On reprend l’exemple du TD9 avec des opéras. On rappelle que l’on a un tableau qui contient des adresses d’opéra (et un tableau est lui-même défini avec un pointeur).

1
2
3
4
5
6
7
8
9
10
11
12
/* on a deux pointeurs qui pointent vers des adresses d'opéra
   utiliser des pointeurs nous permet de modifier les valeurs
   à une certaine adresse */
void echange(struct opera** op1, struct opera** op2)
{
	// on sauvegarde l'adresse d'opéra pointée par op1

	struct opera* tmp = *op1;
	// on remplace la valeur à l'adresse op1 par celle d'op2

	*op1 = *op2;
	// on place l'adresse contenue par tmp dans la case d'adresse op2

	*op2 = tmp;
}
1
2
3
4
5
6
7
8
9
void echange(struct opera** op1, struct opera** op2)
{
	// on sauvegarde l'adresse d'opéra pointée par op1

	struct opera* tmp = *op1;
	// on remplace la valeur à l'adresse op1 par celle d'op2

	*op1 = *op2;
	// on place l'adresse contenue par tmp dans la case d'adresse op2

	*op2 = tmp;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
// on renvoie une adresse qui contiendra un espace pour une adresse d'opéra

struct opera* allouer_opera()
{
	// on demande un espace mémoire pour contenir une structure opera

	struct opera* op = malloc(sizeof(struct opera));
	// on vérifie si l'allocation a bien fonctionné

	if (op == NULL) {
		perror("Échec allocation opéra");
		exit(EXIT_FAILURE); // on quitte le programme en signalant un échec

	}
	return op;
}

struct opera** allouer_tab_opera (int taille) {
	int i;
	// on demande un espace mémoire pour taille * la taille d'un pointeur d'opéra

	struct opera** res = malloc (sizeof(struct opera*) * taille);
	for (i=0; i < taille; i++)
		res[i] = allouer_opera();
	return res;
}

// on libère la mémoire utilisée par un opéra

// Remarque : on utilise une variable struct opera** afin de rediriger l'adresse d'opéra vers le vide (NULL)

void detruire_opera (struct opera ** op) {
	// on libère chaque champs

	// Remarque : on accède à l'adresse d'opéra avec *op car **op est un pointeur vers une adresse d'opéra

	free((*op)->titre);
	free((*op)->ville_creation);
	/* les libérateurs de mémoire utilisent le type struct date** et struct individu**
	donc il faut passer l'adresse d'un pointeur de date et d'individu */
	detruire_date(&(*op)->date_creation);
	detruire_individu(&(*op)->compositeur);
	/* op est un pointeur vers une adresse d'opéra, on libère la mémoire
	de ce pointeur */
	free(*op);
	/* on redirige le pointeur vers NULL */
	*op = NULL;
}

void detruire_tab_opera (struct opera **tab, int taille) {
	int i;
	for (i = 0; i < taille; i ++) {
		detruire_opera(tab+i);
	}
	free(tab);
}

/** Fonction qui échange les adresses d'opéra pointées par les deux arguments */
void echanger_opera (struct opera **op1, struct opera **op2) {
	struct opera *temp = *op1;
	*op1 = *op2;
	*op2 = temp;
}

/** Fonction qui trie le tableau d'opéras tab, de taille n, */
/* selon l'ordre chronologique de création (tri à bulles avec A-R) */
void trier_tab_date_creation (struct opera **tab, int n) {
	int j, deb = 0, fin = n-1, der;
	while (deb < fin) {
		der = 0;
		for (j = deb; j < fin; ++j) {
      if (comparer_date(tab[j]->date_creation, tab[j+1]->date_creation) > 0) {
        echanger_opera(tab+j, tab+j+1);
        der = j;
      }
    }
    fin = der;
		for (j = fin; j > deb; --j) {
      if (comparer_date(tab[j-1]->date_creation, tab[j]->date_creation) > 0) {
        echanger_opera(tab+j, tab+j-1);
        der = j;
      }
    }
    deb = der;
	}
}

/** Fonction qui recherche dans le tableau d'opéras tab, de taille taille, */
/* SUPPOSÉ TRIÉ DANS L'ORDRE CHRONOLOGIQUE DE CRÉATION, un opéra créé au cours de l'année an */
/** Cette fonction affiche un tel opéra s'il s'en trouve un dans le tableau */
/* et affiche un message d'absence sinon */
void recherche_tab_annee_creation (struct opera *tab[], int taille, unsigned an) {
	if (taille > 0) {
		int milieu = taille/2;
		if (an > tab[milieu]->date_creation->annee)
			recherche_tab_annee_creation (tab+milieu+1, taille-milieu-1, an);
		else
			if (an < tab[milieu]->date_creation->annee)
				recherche_tab_annee_creation (tab, milieu, an);
			else
				afficher_opera(tab[milieu]);
	}
	else
		printf("Aucun opéra créé en %d dans le tableau \n", an);
}