Practical session

Duration2h30

Présentation & objectifs

Important

Le but de cette séance est de vous aider à maîtriser des notions importantes en informatique. Un assistant de programmation intelligent tel que GitHub Copilot, que vous avez peut-être déjà installé, pourra vous fournir une solution à ces exercices uniquement à partir d’un nom de fichier judicieusement choisi.

Pour des raisons de formation, nous vous conseillons de désactiver d’abord ces outils.

À la fin de l’activité pratique, nous vous suggérons de retravailler l’exercice avec ces outils activés. Suivre ces deux étapes améliorera vos compétences à la fois de manière fondamentale et pratique.

De plus, nous vous fournissons les solutions aux exercices. Assurez-vous de les consulter uniquement après avoir trouvé une solution aux exercices, à des fins de comparaison ! Même si vous êtes sûr que votre solution est correcte, veuillez les regarder, car elles fournissent parfois des éléments supplémentaires que vous auriez pu manquer.

Contenu de l’activité

1 — Le problème du sac à dos 0-1

Le problème est le suivant (citée de Comer et al.).

Un voleur qui cambriolent un magasin veut prendre la charge la plus précieuse qui peut être portée dans un sac à dos capable de porter au maximum $W$ livres de butin. Le voleur peut choisir de prendre n’importe quel sous-ensemble de $n$ objets dans le magasin. Le $i$-ième objet vaut $v_i$ dollars et pèse $w_i$ livres, où $v_i$ et $w_i$ sont des entiers. Quels objets le voleur doit-il prendre ?

Remarque : Le problème est appelé problème du sac à dos 0-1 car le voleur peut soit prendre un objet, soit le laisser, mais ne peut pas prendre une fraction d’un objet.

Pour cet exercice, nous allons créer un fichier knapsack.py et implémenter diverses solutions au problème du sac à dos 0-1. En parallèle, nous créerons un fichier test_knapsack.py pour tester les fonctions implémentées dans knapsack.py. Nous vérifierons non seulement la justesse des fonctions mais aussi leur efficacité.

Comme paramètre de base, nous considérerons les valeurs suivantes :

  • Le poids maximum que le sac à dos peut porter : $W = 50$.
  • La liste des poids des objets : $weights = [10, 20, 30]$.
  • La liste des valeurs des objets : $values = [60, 100, 120]$.

Deux autres ensembles de paramètres seront considérés pour les tests :

  • $W = 15$, $weights = [12, 7, 11, 8, 9]$, $values = [24, 13, 23, 15, 16]$.
  • $W = 30$, $weights = [2, 3, 5, 7, 1, 4, 6, 8, 10, 9, 2, 3, 5, 7, 1]$, $values = [10, 15, 7, 8, 4, 12, 6, 11, 20, 18, 5, 9, 14, 16, 3]$.

Le premier ensemble de paramètres est suffisamment petit pour vous permettre de calculer les résultats à la main. Les deux autres ensembles sont plus grands et nécessiteront l’implémentation d’algorithmes efficaces et de ressentir la complexité de chaque approche.

1.1 — Solution à la main

Oubliez le programme, prenez un stylo et du papier et considérez le premier exemple de test. Listez toutes les combinaisons possibles d’objets qui peuvent être prises, et pour chacune d’elles, calculez le poids total et la valeur totale. Puis, déterminez la combinaison qui maximise la valeur totale sans dépasser le poids maximum que le sac à dos peut porter. Cette solution vous aidera à comprendre le problème et les résultats attendus.

1.2 — Une solution récursive

La première fonction à implémenter est une solution récursive au problème du sac à dos 0-1. La fonction doit avoir la signature suivante :

def knapsack_recursive(W: int, weights: List[int], values: List[int]) -> int:
    """ Recursive solution to the 0-1 knapsack problem.
        In: 
            - W: the maximum weight the knapsack can carry
            - weights: the list of weights of the items
            - values: the list of values of the items
        Out:
            - the maximum value that can be carried in the knapsack
    """
    pass

La solution récursive est simplement basée sur la propriété de sous-structure optimale du problème : si la charge la plus précieuse inclut l’objet $i$, alors la charge restante doit être la charge la plus précieuse pesant au plus $W - w_i$ que le voleur peut prendre parmi les objets originaux, à l’exclusion de l’objet $i$.

Conseils

Utilisez une fonction auxiliaire récursive qui inclut un paramètre supplémentaire $n$ indiquant l’objet courant à vérifier. Le cas de base de la récursivité est qu’il n’y a plus d’objets à étudier ou plus d’espace dans le sac à dos. Les appels récursifs correspondent au cas où l’objet courant est trop lourd pour être ajouté au sac à dos et aux cas où l’objet est pris ou non. La meilleure solution de ces deux dernières possibilités doit être retournée.

Correction
def knapsack_recursive(W: int, weights: list[int], values: list[int]) -> int:
    """ Recursive solution to the 0-1 knapsack problem.
        In: 
            - W: the maximum weight the knapsack can carry
            - weights: the list of weights of the items
            - values: the list of values of the items
        Out:
            - the maximum value that can be carried in the knapsack
    """

    def knapsack_recursive_helper(W: int, weights: list[int], values: list[int], n: int) -> int:
        """ Helper function to solve the 0-1 knapsack problem recursively.
            In:
                - W: the maximum weight the knapsack can carry
                - weights: the list of weights of the items
                - values: the list of values of the items
                - n: the current item to pick or not (corresponds to weights[n-1] and values[n-1])
            Out:
                - the maximum value that can be carried in the knapsack
        """
        # base case no items to check or no more space left
        if n == 0 or W == 0:
            return 0
        # if the current item is too heavy, skip it
        if weights[n - 1] > W:
            return knapsack_recursive_helper(W, weights, values, n - 1)
        else:
            # return the maximum of two cases:
            # 1. the current item is included
            # 2. the current item is not included
            return max(values[n - 1] + knapsack_recursive_helper(W - weights[n - 1], weights, values, n - 1),
                       knapsack_recursive_helper(W, weights, values, n - 1))
        
    return knapsack_recursive_helper(W, weights, values, len(weights))

#test case 1
W = 15
weights = [12, 7, 11, 8, 9]
values = [24, 13, 23, 15, 16]
print(knapsack_recursive(W, weights, values)) # 28
#test case 2
W = 30
weights = [2, 3, 5, 7, 1, 4, 6, 8, 10, 9, 2, 3, 5, 7, 1]
values = [10, 15, 7, 8, 4, 12, 6, 11, 20, 18, 5, 9, 14, 16, 3]
print(knapsack_recursive(W, weights, values)) # 90
public class Knapsack {

    public static int knapsackRecursive(int W, int[] weights, int[] values) {
        return knapsackRecursiveHelper(W, weights, values, weights.length);
    }

    private static int knapsackRecursiveHelper(int W, int[] weights, int[] values, int n) {
        // base case no items to check or no more space left
        if (n == 0 || W == 0) {
            return 0;
        }
        // if the current item is too heavy, skip it
        if (weights[n - 1] > W) {
            return knapsackRecursiveHelper(W, weights, values, n - 1);
        } else {
            // return the maximum of two cases:
            // 1. the current item is included
            // 2. the current item is not included
            return Math.max(values[n - 1] + knapsackRecursiveHelper(W - weights[n - 1], weights, values, n - 1),
                            knapsackRecursiveHelper(W, weights, values, n - 1));
        }
    }

    public static void main(String[] args) {
        // test case 1
        int W1 = 15;
        int[] weights1 = {12, 7, 11, 8, 9};
        int[] values1 = {24, 13, 23, 15, 16};
        System.out.println(knapsackRecursive(W1, weights1, values1)); // 28

        // test case 2
        int W2 = 30;
        int[] weights2 = {2, 3, 5, 7, 1, 4, 6, 8, 10, 9, 2, 3, 5, 7, 1};
        int[] values2 = {10, 15, 7, 8, 4, 12, 6, 11, 20, 18, 5, 9, 14, 16, 3};
        System.out.println(knapsackRecursive(W2, weights2, values2)); // 90
    }
}

1.3 — Une solution récursive avec mémoïsation

Une deuxième étape est d’implémenter une solution récursive avec mémoïsation. La fonction doit avoir la signature suivante :

def knapsack_recursive_memo(W: int, weights: List[int], values: List[int]) -> int:
    """ Recursive solution with memoization to the 0-1 knapsack problem.
        In: 
            - W: the maximum weight the knapsack can carry
            - weights: the list of weights of the items
            - values: the list of values of the items
        Out:
            - the maximum value that can be carried in the knapsack
    """
    pass

La solution récursive avec mémoïsation consiste à stocker les résultats des sous-problèmes dans une table pour éviter de les recalculer.

Correction
def knapsack_recursive_memo(W: int, weights: list[int], values: list[int]) -> int:
    """ Recursive solution with memoization to the 0-1 knapsack problem.
        In: 
            - W: the maximum weight the knapsack can carry
            - weights: the list of weights of the items
            - values: the list of values of the items
        Out:
            - the maximum value that can be carried in the knapsack
    """
    # Create a table to store the results of the subproblems
    table = [[-1 for _ in range(W + 1)] for _ in range(len(values) + 1)]
    
    def knapsack_recursive_with_memo(W: int, weights: list[int], values: list[int], n: int) -> int:
        """ Helper function to solve the 0-1 knapsack problem recursively with memoization.
            In: 
                - W: the maximum weight the knapsack can carry
                - weights: the list of weights of the items
                - values: the list of values of the items
                - n: the number of items
            Out:
                - the maximum value that can be carried in the knapsack
        """
        # If the result is not already computed
        if table[n][W] == -1:
            # Base case: no item to take or no space left in the knapsack
            if n == 0 or W == 0:
                table[n][W] = 0
            # If the weight of the nth item is more than the knapsack capacity, then this item cannot be included in the optimal solution
            elif weights[n - 1] > W:
                table[n][W] = knapsack_recursive_with_memo(W, weights, values, n - 1)
            else:
                # Return the maximum of two cases:
                table[n][W] = max(values[n - 1] + knapsack_recursive_with_memo(W - weights[n - 1], weights, values, n - 1),
                                    knapsack_recursive_with_memo(W, weights, values, n - 1))        
        # Return the result
        return table[n][W]
    
    return knapsack_recursive_with_memo(W, weights, values, len(weights))
# Test the function
assert knapsack_recursive_memo(50, [10, 20, 30], [60, 100, 120]) == 220
public class Knapsack {

    public static int knapsackRecursiveMemo(int W, int[] weights, int[] values) {
        int n = values.length;
        int[][] table = new int[n + 1][W + 1];
        
        for (int i = 0; i <= n; i++) {
            for (int w = 0; w <= W; w++) {
                table[i][w] = -1;
            }
        }

        return knapsackRecursiveWithMemo(W, weights, values, n, table);
    }

    private static int knapsackRecursiveWithMemo(int W, int[] weights, int[] values, int n, int[][] table) {
        if (table[n][W] == -1) {
            if (n == 0 || W == 0) {
                table[n][W] = 0;
            } else if (weights[n - 1] > W) {
                table[n][W] = knapsackRecursiveWithMemo(W, weights, values, n - 1, table);
            } else {
                table[n][W] = Math.max(values[n - 1] + knapsackRecursiveWithMemo(W - weights[n - 1], weights, values, n - 1, table),
                                       knapsackRecursiveWithMemo(W, weights, values, n - 1, table));
            }
        }
        return table[n][W];
    }

    public static void main(String[] args) {
        int W = 50;
        int[] weights = {10, 20, 30};
        int[] values = {60, 100, 120};
        assert knapsackRecursiveMemo(W, weights, values) == 220;

        // Test case 1
        W = 15;
        weights = new int[]{12, 7, 11, 8, 9};
        values = new int[]{24, 13, 23, 15, 16};
        long start = System.nanoTime();
        System.out.println(knapsackRecursiveMemo(W, weights, values)); // 28
        System.out.printf("Time taken with memo: %.6f seconds%n", (System.nanoTime() - start) / 1e9);

        // Test case 2
        W = 30;
        weights = new int[]{2, 3, 5, 7, 1, 4, 6, 8, 10, 9, 2, 3, 5, 7, 1};
        values = new int[]{10, 15, 7, 8, 4, 12, 6, 11, 20, 18, 5, 9, 14, 16, 3};
        start = System.nanoTime();
        System.out.println(knapsackRecursiveMemo(W, weights, values)); // 28
        System.out.printf("Time taken with memo: %.6f seconds%n", (System.nanoTime() - start) / 1e9);
    }
}

1.4 — Une solution par programmation dynamique

Une troisième étape est d’implémenter une solution par programmation dynamique au problème du sac à dos 0-1. La fonction doit avoir la signature suivante :

def knapsack_dp(W: int, weights: List[int], values: List[int], n: int) -> int:
    """ Dynamic programming solution to the 0-1 knapsack problem.
        In: 
            - W: the maximum weight the knapsack can carry
            - weights: the list of weights of the items
            - values: the list of values of the items
            - n: the number of items
        Out:
            - the maximum value that can be carried in the knapsack
    """
    pass

Contrairement à la solution avec mémoïsation, la solution par programmation dynamique remplit la table de bas en haut.

Conseils

Pour implémenter la solution par programmation dynamique, suivez ces étapes :

  1. Initialiser la table : Créez une matrice de dimensions (n+1) x (W+1)n est le nombre d’objets et W le poids maximum que le sac à dos peut porter. Initialisez toutes les valeurs à 0.

  2. Remplir la table : Parcourez chaque objet et chaque capacité de poids, en remplissant la table selon les conditions suivantes :

    • Si le poids de l’objet courant est supérieur à la capacité actuelle, la valeur est la même que celle sans inclure l’objet courant.
    • Sinon, la valeur est le maximum entre inclure l’objet courant ou ne pas l’inclure.
  3. Retourner le résultat : La valeur à table[n][W] sera la valeur maximale pouvant être portée dans le sac à dos.

Correction
def knapsack_dp(W: int, weights: list[int], values: list[int], n: int) -> int:
    """ Dynamic programming solution to the 0-1 knapsack problem.
        In: 
            - W: the maximum weight the knapsack can carry
            - weights: the list of weights of the items
            - values: the list of values of the items
            - n: the number of items
        Out:
            - the maximum value that can be carried in the knapsack
    """
    # Create a matrix to store the results of the subproblems
    table = [[0 for _ in range(W + 1)] for _ in range(n + 1)]
    # for each item in the list
    # the first row is for the case of no item, so we start at 1
    for i in range(1, n+1): 
        # for each weight in the knapsack
        for j in range(1, W+1):
            # if the weight of the item is greater than the current weight
            # note that i starts at 1, so the weight of the item is weights[i-1] 
            if weights[i - 1] > j:
                # the value is the same as the value without including the current item
                table[i][j] = table[i - 1][j]
            else:
                # the value is the maximum of either including the current item or not including it
                table[i][j] = max(table[i - 1][j], values[i - 1] + table[i - 1][j - weights[i - 1]])
    return table[n][W]
    
# Test the function
assert knapsack_dp(50, [10, 20, 30], [60, 100, 120], 5) == 220
import java.util.Arrays;

public class KnapsackDP {

    public static int knapsackDP(int W, int[] weights, int[] values, int n) {
        // Create a table to store the results of the subproblems
        int[][] table = new int[n+1][W + 1];
        // for each item in the list
        // the first row is for the case of no item, so we start at 1
        for (int i = 0; i <= values.length; i++) {
            // for each weight in the knapsack from W to the (weight in cell i) - 1
            for (int j = 0; j <= weights.length; w++) {
                // if the weight of the item is greater than the current weight
                // note that i starts at 1, so the weight of the item is weights[i-1]
                if(weights[i - 1] > j){
                    // the value is the same as the value without including the current item
                    table[i][j] = table[i - 1][j]
                }else{
                    // the value is the maximum of either including the current item or not including it
                    table[i][j] = max(table[i - 1][j], values[i - 1] + table[i - 1][j - weights[i - 1]])
                } 
            }
        }
        return table[n][W];
    }

    public static void main(String[] args) {
        // Test the function
        assert knapsackDP(50, new int[]{10, 20, 30}, new int[]{60, 100, 120}, 5) == 220;

        // test case 1
        int W = 15;
        int[] weights = {12, 7, 11, 8, 9};
        int[] values = {24, 13, 23, 15, 16};
        long start = System.nanoTime();
        System.out.println(knapsackDP(W, weights, values, 5)); // 28
        long elapsed = System.nanoTime() - start;
        System.out.printf("Time taken without memo: %.6f seconds%n", elapsed / 1e9);

        W = 30;
        weights = new int[]{2, 3, 5, 7, 1, 4, 6, 8, 10, 9, 2, 3, 5, 7, 1};
        values = new int[]{10, 15, 7, 8, 4, 12, 6, 11, 20, 18, 5, 9, 14, 16, 3};
        start = System.nanoTime();
        System.out.println(knapsackDP(W, weights, values, 15)); // 28
        elapsed = System.nanoTime() - start;
        System.out.printf("Time taken with memo: %.6f seconds%n", elapsed / 1e9);
    }
}

1.5 — Une solution gloutonne

La dernière étape est d’implémenter une solution gloutonne au problème du sac à dos 0-1. La fonction doit avoir la signature suivante :

def knapsack_greedy(W: int, weights: List[int], values: List[int]) -> int:
    """ Greedy solution to the 0-1 knapsack problem.
        In: 
            - W: the maximum weight the knapsack can carry
            - weights: the list of weights of the items
            - values: the list of values of the items
        Out:
            - the maximum value that can be carried in the knapsack
    """
    pass

La solution gloutonne consiste à sélectionner l’objet avec le ratio valeur/poids maximal jusqu’à ce que le sac à dos soit plein.

Correction
def knapsack_greedy(W: int, weights: List[int], values: List[int], n: int) -> int:
    """ Greedy solution to the 0-1 knapsack problem.
        In: 
            - W: the maximum weight the knapsack can carry
            - weights: the list of weights of the items
            - values: the list of values of the items
            - n: the number of items
        Out:
            - the maximum value that can be carried in the knapsack
    """
    # Create a list of tuples (value, weight, value-to-weight ratio)
    items = [(values[i], weights[i], values[i] / weights[i]) for i in range(n)]
    # Sort the items by value-to-weight ratio in descending order
    items.sort(key=lambda x: x[2], reverse=True)
    # Initialize the total value and the remaining weight
    total_value = 0
    remaining_weight = W
    # Iterate through the items
    for item in items:
        # If the item can be included in the knapsack
        if item[1] <= remaining_weight:
            total_value += item[0]
            remaining_weight -= item[1]
    return total_value

# Test the function
assert knapsack_greedy(50, [10, 20, 30], [60, 100, 120], 3) == 220
import java.util.*;

public class KnapsackGreedy {
    public static int knapsackGreedy(int W, int[] weights, int[] values) {
        // Create a list of items with value, weight, and value-to-weight ratio
        List<Item> items = new ArrayList<>();
        for (int i = 0; i < values.length; i++) {
            items.add(new Item(values[i], weights[i]));
        }
        // Sort the items by value-to-weight ratio in descending order
        items.sort((a, b) -> Double.compare(b.ratio, a.ratio));
        // Initialize the total value and the remaining weight
        int totalValue = 0;
        int remainingWeight = W;
        // Iterate through the items
        for (Item item : items) {
            // If the item can be included in the knapsack
            if (item.weight <= remainingWeight) {
                totalValue += item.value;
                remainingWeight -= item.weight;
            }
        }
        return totalValue;
    }

    static class Item {
        int value;
        int weight;
        double ratio;

        Item(int value, int weight) {
            this.value = value;
            this.weight = weight;
            this.ratio = (double) value / weight;
        }
    }

    public static void main(String[] args) {
        // Test the function
        int W = 50;
        int[] weights = {10, 20, 30};
        int[] values = {60, 100, 120};
        System.err.println(knapsackGreedy(W, weights, values));
//        assert knapsackGreedy(W, weights, values) == 220;
    }
}

Questions :

  1. Que pouvez-vous dire de la solution gloutonne comparée aux autres solutions ?
  2. Maintenant, si vous considérez le problème du sac à dos fractionnaire, quelle serait la solution gloutonne ?

2 — Optimisez vos solutions

Ce que vous pouvez faire maintenant est d’utiliser des outils d’IA tels que GitHub Copilot ou ChatGPT, soit pour générer la solution, soit pour améliorer la première solution que vous avez proposée ! Essayez de faire cela pour tous les exercices ci-dessus, pour voir les différences avec vos solutions.

Pour aller plus loin

3 — Le problème du sac à dos fractionnaire

Pour aller au-delà

Il semble que cette section soit vide !

Y a-t-il quelque chose que vous auriez aimé voir ici ? Faites-le nous savoir sur le serveur Discord ! Peut-être que nous pourrons l’ajouter rapidement. Sinon, cela nous aidera à améliorer le cours pour l’année prochaine !