Numérique et Sciences Informatiques1ère

Définir un algorithme adapté
Exercices corrigés

Maîtrisez la définition d'algorithmes adaptés : analyse de problèmes, choix d'algorithmes, implémentation et optimisation grâce à ces 5 exercices détaillés.

Concepts & Exercices
\(Algorithme_{adapté} = f(Problème, Complexité, Structures_{données}, Contraintes)\)
Algorithme adapté
Analyse
Problème → Solution
Compréhension du problème
Complexité
O(n), O(n²), O(log n)
Efficacité algorithmique
Structure
Données optimales
Choix des structures
O(1)
Constant
O(log n)
Logarithmique
O(n)
Linéaire
O(n log n)
Quasi-linéaire
O(n²)
Quadratique
Exercice 1
Recherche d'un élément dans un tableau trié
Exercice 2
Tri d'un tableau d'entiers
Exercice 3
Parcours d'un arbre binaire
Exercice 4
Trouver le chemin le plus court dans un graphe
Exercice 5
Calcul de factorielle avec programmation dynamique
Corrigé : Exercices 1 à 3
1 Recherche d'un élément dans un tableau trié
Définition :

Algorithme adapté : Solution optimale pour un problème donné, tenant compte de la complexité, des contraintes et des structures de données appropriées.

Méthodologie de choix :
  1. Analyser le problème et ses contraintes
  2. Identifier les structures de données appropriées
  3. Évaluer les algorithmes possibles
  4. Comparer les complexités
  5. Choisir l'algorithme le plus efficace
Étape 1 : Analyse du problème

"Rechercher un élément dans un tableau trié de n entiers. Le tableau est trié par ordre croissant. Trouver la position de l'élément recherché ou indiquer qu'il n'est pas présent."

  • Données : Tableau trié d'entiers et élément à rechercher
  • Objectif : Trouver l'indice de l'élément ou -1 si absent
  • Contrainte : Tableau trié (information cruciale)
Étape 2 : Algorithmes possibles
Recherche linéaire
Parcours séquentiel de tous les éléments
Complexité: O(n)
Recherche dichotomique
Divise et conquiert en exploitant le tri
Complexité: O(log n)
Étape 3 : Algorithme choisi - Recherche dichotomique
def recherche_dichotomique(tableau, element):
    gauche = 0
    droite = len(tableau) - 1
    
    while gauche <= droite:
        milieu = (gauche + droite) // 2
        
        if tableau[milieu] == element:
            return milieu  # Élément trouvé
        elif tableau[milieu] < element:
            gauche = milieu + 1  # Chercher à droite
        else:
            droite = milieu - 1  # Chercher à gauche
    
    return -1  # Élément non trouvé

# Exemple d'utilisation
tab_trie = [1, 3, 5, 7, 9, 11, 13, 15, 17, 19]
resultat = recherche_dichotomique(tab_trie, 7)
print(f"L'élément 7 est à l'indice: {resultat}")
Étape 4 : Justification du choix
  • Le tableau est trié, ce qui permet d'utiliser la recherche dichotomique
  • Complexité O(log n) vs O(n) pour la recherche linéaire
  • Plus efficace pour de grands tableaux
  • Algorithme optimal pour ce type de problème
Recherche dichotomique: O(log n) - Algorithme adapté
Réponse finale :

L'algorithme de recherche dichotomique est l'algorithme adapté car il exploite le fait que le tableau est trié, offrant une complexité de O(log n) contre O(n) pour la recherche linéaire.

Règles appliquées :

Exploitation des propriétés : Tirer parti du tri du tableau

Complexité optimale : Choisir l'algorithme le plus efficace

Adaptation au problème : L'algorithme doit correspondre aux contraintes

2 Tri d'un tableau d'entiers
Définition :

Tri : Opération consistant à ordonner les éléments d'un ensemble selon un critère particulier. Le choix de l'algorithme de tri dépend de la taille des données, de la mémoire disponible et des contraintes de temps.

Étape 1 : Analyse du problème

"Trier un tableau d'entiers par ordre croissant. La taille du tableau varie de quelques dizaines à plusieurs millions d'éléments. La mémoire est limitée mais suffisante pour un tri en place."

  • Données : Tableau d'entiers de taille variable
  • Objectif : Ordonner les éléments du tableau
  • Contraintes : Mémoire limitée, taille variable
Étape 2 : Comparaison des algorithmes de tri
Algorithme Complexité temporelle Complexité spatiale Stable
Tri à bulles O(n²) O(1) Oui
Tri par insertion O(n²) O(1) Oui
Tri fusion O(n log n) O(n) Oui
Tri rapide O(n log n) en moyenne O(log n) Non
Étape 3 : Algorithme choisi - Tri rapide
def tri_rapide(tableau):
    if len(tableau) <= 1:
        return tableau
    
    pivot = tableau[len(tableau) // 2]
    gauche = [x for x in tableau if x < pivot]
    milieu = [x for x in tableau if x == pivot]
    droite = [x for x in tableau if x > pivot]
    
    return tri_rapide(gauche) + milieu + tri_rapide(droite)

# Version en place pour économiser la mémoire
def tri_rapide_en_place(tableau, debut=0, fin=None):
    if fin is None:
        fin = len(tableau) - 1
    
    if debut < fin:
        pivot_index = partition(tableau, debut, fin)
        tri_rapide_en_place(tableau, debut, pivot_index - 1)
        tri_rapide_en_place(tableau, pivot_index + 1, fin)

def partition(tableau, debut, fin):
    pivot = tableau[fin]
    i = debut - 1
    
    for j in range(debut, fin):
        if tableau[j] <= pivot:
            i += 1
            tableau[i], tableau[j] = tableau[j], tableau[i]
    
    tableau[i + 1], tableau[fin] = tableau[fin], tableau[i + 1]
    return i + 1
Étape 4 : Justification du choix
  • Complexité moyenne O(n log n) - très efficace
  • Tri en place possible - économie de mémoire
  • Bien adapté aux grandes tailles de données
  • Implémentation optimisée disponible dans les bibliothèques
Tri rapide: O(n log n) en moyenne - Algorithme adapté
Réponse finale :

Pour un tableau de taille variable avec contrainte de mémoire, le tri rapide est l'algorithme adapté car il offre une complexité moyenne de O(n log n) et peut être implémenté en place.

Règles appliquées :

Évaluation comparative : Comparer les complexités des algorithmes possibles

Contraintes mémoire : Prendre en compte la complexité spatiale

Scalabilité : Choisir un algorithme qui s'adapte à la taille des données

3 Parcours d'un arbre binaire
Définition :

Parcours d'arbre : Visite systématique de tous les nœuds d'un arbre selon un ordre prédéfini. Les types de parcours sont préfixe, infixé et postfixe pour les arbres binaires.

Étape 1 : Analyse du problème

"Parcourir un arbre binaire pour afficher les valeurs de tous les nœuds. L'ordre d'affichage dépend de l'application : préfixe pour une copie de l'arbre, infixé pour un affichage trié dans un ABR, postfixe pour le calcul d'expressions."

  • Données : Arbre binaire avec nœuds contenant des valeurs
  • Objectif : Visiter tous les nœuds dans un ordre spécifique
  • Contraintes : Différents ordres de parcours selon le besoin
Étape 2 : Types de parcours
Préfixe
Racine → Gauche → Droite
Utilité: Copie d'arbre
Infixé
Gauche → Racine → Droite
Utilité: ABR trié
Postfixe
Gauche → Droite → Racine
Utilité: Calcul expressions
Étape 3 : Structure de données et algorithme
class Noeud:
    def __init__(self, valeur):
        self.valeur = valeur
        self.gauche = None
        self.droite = None

def parcours_prefixe(racine):
    if racine:
        print(racine.valeur)           # Traiter la racine
        parcours_prefixe(racine.gauche)  # Parcourir le sous-arbre gauche
        parcours_prefixe(racine.droite)  # Parcourir le sous-arbre droit

def parcours_infixe(racine):
    if racine:
        parcours_infixe(racine.gauche)   # Parcourir le sous-arbre gauche
        print(racine.valeur)             # Traiter la racine
        parcours_infixe(racine.droite)   # Parcourir le sous-arbre droit

def parcours_postfixe(racine):
    if racine:
        parcours_postfixe(racine.gauche) # Parcourir le sous-arbre gauche
        parcours_postfixe(racine.droite) # Parcourir le sous-arbre droit
        print(racine.valeur)             # Traiter la racine

# Exemple d'utilisation
# Construction d'un arbre binaire
racine = Noeud(1)
racine.gauche = Noeud(2)
racine.droite = Noeud(3)
racine.gauche.gauche = Noeud(4)
racine.gauche.droite = Noeud(5)

print("Parcours préfixe:")
parcours_prefixe(racine)  # Affiche: 1 2 4 5 3
Étape 4 : Choix de l'algorithme adapté
  • Pour un ABR, le parcours infixé donne les éléments triés
  • Pour une copie de l'arbre, le parcours préfixé est approprié
  • Pour le calcul d'expressions arithmétiques, le postfixe est utilisé
  • Complexité: O(n) pour tous les parcours (visite de chaque nœud une fois)
Parcours adapté selon l'objectif: préfixe/infixé/postfixe
Réponse finale :

L'algorithme de parcours adapté dépend de l'objectif : préfixe pour copier l'arbre, infixé pour afficher les valeurs triées dans un ABR, postfixe pour le calcul d'expressions.

Règles appliquées :

Objectif détermine le choix : Le but du parcours influence l'algorithme choisi

Structure adaptée : Utiliser une structure de données récursive pour les arbres

Complexité optimale : Tous les parcours ont la même complexité O(n)

Corrigé : Exercices 4 à 5
4 Trouver le chemin le plus court dans un graphe
Définition :

Plus court chemin : Problème de recherche d'un chemin entre deux sommets d'un graphe qui minimise une certaine mesure (généralement la somme des poids des arêtes).

Étape 1 : Analyse du problème

"Trouver le chemin le plus court entre deux sommets dans un graphe pondéré. Les poids des arêtes représentent des distances, des coûts ou des durées. Le graphe peut contenir des poids négatifs mais pas de cycles négatifs."

  • Données : Graphe pondéré avec sommets et arêtes pondérées
  • Objectif : Trouver le chemin de poids minimal entre deux sommets
  • Contraintes : Poids éventuellement négatifs, pas de cycles négatifs
Étape 2 : Algorithmes de recherche de chemins
Dijkstra
Pour graphes à poids positifs
Complexité: O((V+E)log V)
Bellman-Ford
Pour graphes avec poids négatifs
Complexité: O(VE)
A* (A-star)
Avec heuristique pour la destination
Complexité: O(b^d)
Étape 3 : Algorithme choisi - Dijkstra
import heapq

def dijkstra(graphe, depart, arrivee):
    # Initialisation
    distances = {sommet: float('infinity') for sommet in graphe}
    distances[depart] = 0
    precedent = {}
    pq = [(0, depart)]
    
    while pq:
        distance_actuelle, sommet_actuel = heapq.heappop(pq)
        
        if sommet_actuel == arrivee:
            break
            
        if distance_actuelle > distances[sommet_actuel]:
            continue
            
        for voisin, poids in graphe[sommet_actuel].items():
            distance = distances[sommet_actuel] + poids
            
            if distance < distances[voisin]:
                distances[voisin] = distance
                precedent[voisin] = sommet_actuel
                heapq.heappush(pq, (distance, voisin))
    
    # Reconstruction du chemin
    chemin = []
    sommet = arrivee
    while sommet is not None:
        chemin.append(sommet)
        sommet = precedent.get(sommet)
    chemin.reverse()
    
    return distances[arrivee], chemin

# Exemple d'utilisation
graphe = {
    'A': {'B': 4, 'C': 2},
    'B': {'C': 1, 'D': 5},
    'C': {'D': 8, 'E': 10},
    'D': {'E': 2},
    'E': {}
}

cout, chemin = dijkstra(graphe, 'A', 'E')
print(f"Coût minimum: {cout}")
print(f"Chemin: {' -> '.join(chemin)}")
Étape 4 : Justification du choix
  • Algorithme de Dijkstra optimal pour graphes à poids positifs
  • Complexité O((V+E)log V) - très efficace
  • Garantit le chemin optimal
  • Utilisation d'une file de priorité pour optimiser la sélection
Dijkstra: O((V+E)log V) - Chemin optimal garanti
Réponse finale :

Pour un graphe à poids positifs, l'algorithme de Dijkstra est l'algorithme adapté car il garantit le chemin optimal avec une complexité efficace de O((V+E)log V).

Règles appliquées :

Caractéristiques du graphe : Choisir l'algorithme en fonction des propriétés du graphe

Optimalité : Garantir que l'algorithme trouve la solution optimale

Efficient : Sélectionner l'algorithme avec la meilleure complexité pour le cas d'usage

5 Calcul de factorielle avec programmation dynamique
Définition :

Programmation dynamique : Méthode de résolution de problèmes en décomposant le problème en sous-problèmes et en stockant les résultats intermédiaires pour éviter les recalculs.

Étape 1 : Analyse du problème

"Calculer la factorielle d'un nombre n (n!) avec une approche efficace. La factorielle est le produit de tous les entiers positifs inférieurs ou égaux à n. Des appels successifs avec des valeurs proches sont possibles."

  • Données : Entier n positif
  • Objectif : Calculer n! = n × (n-1) × ... × 2 × 1
  • Contraintes : Plusieurs calculs peuvent être effectués, avec des valeurs proches
Étape 2 : Approches possibles
Récursion naïve
factorielle(n) = n * factorielle(n-1)
Complexité: O(n), O(n) appels
Boucle itérative
Produit itératif de 1 à n
Complexité: O(n), O(1) espace
Programmation dynamique
Stockage des résultats précédents
Complexité: O(n), réutilisable
Étape 3 : Algorithme choisi - Programmation dynamique
class MemoFactorielle:
    def __init__(self):
        self.memo = {0: 1, 1: 1}  # Valeurs de base
    
    def calculer(self, n):
        if n in self.memo:
            return self.memo[n]
        
        # Calcul itératif pour remplir les valeurs manquantes
        for i in range(len(self.memo), n + 1):
            self.memo[i] = i * self.memo[i - 1]
        
        return self.memo[n]
    
    def get_factorielles(self, debut, fin):
        """Retourne une liste de factorielles dans une plage"""
        resultats = []
        for i in range(debut, fin + 1):
            resultats.append(self.calculer(i))
        return resultats

# Version simple avec cache
def factorielle_dynamique(n, memo={}):
    if n in memo:
        return memo[n]
    
    if n <= 1:
        memo[n] = 1
        return 1
    
    memo[n] = n * factorielle_dynamique(n - 1, memo)
    return memo[n]

# Exemple d'utilisation
fact_calculator = MemoFactorielle()

print("Calcul de quelques factorielles:")
for i in [5, 6, 7, 10]:
    resultat = fact_calculator.calculer(i)
    print(f"{i}! = {resultat}")

# Calcul de la plage
factories = fact_calculator.get_factorielles(5, 8)
print(f"Factorielles de 5 à 8: {factories}")
Étape 4 : Analyse comparative
\(n! = \prod_{i=1}^{n} i\)
Définition mathématique
  • Approche naïve: O(n) pour chaque appel, recalcul complet
  • Approche itérative: O(n) pour chaque appel, O(1) espace
  • Programmation dynamique: O(n) pour le premier, O(1) pour les suivants
  • Avantage de la PD: Réutilisation des calculs précédents
Programmation dynamique: O(1) après initialisation - Réutilisation optimale
Réponse finale :

La programmation dynamique est l'algorithme adapté car elle permet de stocker les résultats intermédiaires, évitant ainsi les recalculs lors de multiples appels avec des valeurs proches.

Règles appliquées :

Sous-problèmes superposés : Identifier les calculs répétitifs

Structure optimale : La solution optimale contient des sous-solutions optimales

Stockage intelligent : Conserver les résultats pour réutilisation future

Cours bien détaillé
\(T(n) = O(f(n)) \text{ où } f(n) \text{ représente la complexité}\)
Notation de complexité
🎯
Définition : Définir un algorithme adapté consiste à choisir la solution algorithmique la plus efficace pour résoudre un problème donné, en tenant compte des contraintes et des objectifs.
📏
Complexité : Mesure de l'efficacité d'un algorithme en termes de temps de calcul (complexité temporelle) et d'espace mémoire (complexité spatiale).
📐
Structures de données : Choix approprié des structures (tableaux, listes, arbres, graphes) influençant directement l'efficacité de l'algorithme.
📝
Applications : Algorithmes de tri, recherche, parcours de graphes, programmation dynamique, algorithmes gloutons.
💡
Conseil : Analyser d'abord le problème avant de choisir un algorithme
🔍
Attention : Ne pas choisir un algorithme trop complexe pour un problème simple
Astuce : Comparer plusieurs algorithmes possibles avant de décider
📋
Méthode : Évaluer la complexité dans le pire cas, le meilleur cas et le cas moyen
Vérification : Tester l'algorithme avec des cas limites et typiques
Processus de définition d'un algorithme adapté :
  • Compréhension : Analyser en détail le problème à résoudre
  • Identification : Repérer les contraintes et les objectifs
  • Recherche : Explorer les algorithmes existants pour le problème
  • Évaluation : Comparer les complexités et les propriétés des algorithmes
  • Choix : Sélectionner l'algorithme le plus approprié
  • Implémentation : Coder l'algorithme avec les structures de données adaptées
  • Validation : Tester et vérifier la correction et l'efficacité
Règles importantes :
  • La complexité temporelle et spatiale doivent être compatibles avec les contraintes
  • L'algorithme doit être correct et produire les résultats attendus
  • Le choix doit tenir compte de la taille des données à traiter
  • La lisibilité et la maintenabilité sont des critères supplémentaires
  • L'algorithme doit être adaptable aux variations du problème
\(Efficacité = \frac{Résultats_{corrects}}{Temps_{exécution} \times Espace_{mémoire}}\)
Mesure de l'efficacité d'un algorithme
Définir un algorithme adapté Conception et décomposition de problèmes