Practical activity

Duration2h30

Presentation & objectives

This activity is dedicated to the manipulation of data structures and to see empirically the impact of the choice of structure over another.

Important

The aim of this session is to help you master important notions in computer science. An intelligent programming assistant such as GitHub Copilot, that you may have installed already, will be able to provide you with a solution to these exercises based only on a wisely chosen file name.

For the sake of training, we advise you to disable such tools first.

At the end of the practical activity, we suggest you to work on the exercise again with these tools activated. Following these two steps will improve your skills both fundamentally and practically.

Also, we provide you the solutions to the exercises. Make sure to check them only after you have a solution to the exercises, for comparison purpose! Even if you are sure your solution is correct, please have a look at them, as they sometimes provide additional elements you may have missed.

Activity contents

1 — List-based implementation of a stack

In a file named list_stack.py, give an implementation of a stack using the built-in python list to store the data. Here are the primitives that should be implemented.

  • initialize – Initializes the stack.
  • push – Adds an element to the top of the stack.
  • pop – Removes the top element from the stack.
  • is_empty – Returns true if the stack is empty, and false otherwise.

Then, in a second file named test_stack.py, perform some operations on a stack to check its primitives.

Correction
"""
    This module provides an implementation of the primitives to handle a stack of values. 
    The stack is materialized by means of a list with a size limit to simulate an array.
"""

# Needed imports
from typing import List,Any

# define your own custom type
Stack = List[Any]



def initialize () -> Stack:

    """
        Initializes an empty stack.
        In:
            * None.
        Out:
            * An empty stack.
    """

    # We use lists to represent stacks
    return []



def is_empty (stack: Stack) -> bool:

    """
        This function checks if the stack is empty.
        In:
            * stack: Stack to check.
        Out:
            * True if the stack is empty, False otherwise.
    """

    # Check list length
    return len(stack) == 0



def push (stack: Stack, item: Any) -> None:

    """
        This function adds an element to the stack.
        The element is added at the last position of the storage structure.
        In:
            * stack: Stack to modify.
            * item:  Element to add.
    """

    # Add the element to the end of the list
    stack.append(item)



def pop (stack: Stack) -> Any:
    
    """
        This function removes the next element of the stack.
        In:
            * stack: Stack to modify.
        Out:
            * The next element of the stack.
    """
    
    # Extract and return the last element of the list
    return stack.pop()
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;

public class ListStack {
    private List<Object> stack;

    //The constructeur replace the initialise primitive
    public ListStack() {
        this.stack = new ArrayList<>();
    }

    public boolean isEmpty() {
        /**
         * This function checks if the stack is empty.
         * @return True if the stack is empty, False otherwise.
         */
        return this.stack.isEmpty();
    }

    public void push(Object item) {
        /**
         * This function adds an element to the stack.
         * The element is added at the end.
         * @param item Element to add.
         */
        this.stack.add(item);
    }

    public Object pop() {
        /**
         * This function removes the last element of the stack.
         * @return The last element of the stack.
         */
        if (this.stack.isEmpty()) {
            throw new IllegalStateException("Stack is empty");
        }
        return this.stack.remove(this.stack.size() - 1);
    }
}

2 — Calculate a postfix arithmetic expression

In this exercise we will use a stack to calculate an arithmetic expression in postfix notation, i.e., where each operator is preceded by its operands, such as 3 12 3 - 3 / 1 - *. The equivalent infix expression using parentheses is 3 * (((12 - 3) / 3) - 1).

In the file test_stack.py write a function evaluate_postfix that calculates the result of a postfix integer arithmetic expression. The function takes as input a string containing an arithmetic expression and return its final result.

  • The result of the calculation will be a real number.
  • The input is a sequence of characters.
  • Each piece of data is separated from the others by a space.
  • The method consists of using a stack to store the numerical values as they are read, and performing a processing operation when an operator is read.
  • This processing operation removes the last two values in the stack, performs the operation, and then pushes the result.
  • The final result is stored in the stack.
  • Allowed operators are +, -, *, /.

It is for the moment considered that the expression is correctly written and only integers are used.

Information

You will need to use different programming tools to implement this function.

  • to split a string according to a separator "3 2 * 25 +".split(" ") will split the string using a space as a delimiter.
  • to check if a string can be translated into an int "3".isnumeric() returns true whereas "afea".isnumeric() returns false.
  • to translate a string into an integer int("34") returns 34.

Modify the entry point of your test_stack.py file to ask for the input of an expression and to then display the result of its evaluation.

Correction
def evaluate_postfix (expression: str) -> float:

    """
        This function uses a stack to evaluate a postfix expression.
        The function takes a string as input, which is a postfix expression.
        The function returns the result of the expression.
        In:
            * expression: Expression to be evaluated.
        Out:
            * Evaluated of the evaluated expression.
    """

    # Create an empty stack
    stack : Stack = initialize()

    # Iterate over the expression
    split_expression = expression.split(" ")
    for i in split_expression:

        # If the character is a number, push it to the stack
        if i.isnumeric():
            push(stack, int(i))

        # If the character is an operator, apply it
        else:

            # Pop the last two elements from the stack
            a = pop(stack)
            b = pop(stack)

            # Apply the operator
            if i == '+':
                push(stack, b + a)
            elif i == '-':
                push(stack, b - a)
            elif i == '*':
                push(stack, b * a)
            elif i == '/':
                push(stack, b / a)
    
    # Return the result
    return pop(stack)
/**
 * To run this code, you need to have Java installed on your computer, then:
 * - Create a file named `Main.java` in a directory of your choice.
 * - Copy this code in the file.
 * - Open a terminal in the directory where the file is located.
 * - Run the command `javac Main.java` to compile the code.
 * - Run the command `java -ea Main` to execute the compiled code.
 * Note: '-ea' is an option to enable assertions in Java.
 */

// Needed imports
import java.util.Stack;

public class Main {

    /**
     * This function uses a stack to evaluate a postfix expression.
     * The function takes a string as input, which is a postfix expression.
     * The function returns the result of the expression.
     *
     * @param expression Expression to be evaluated.
     * @return Evaluated result of the postfix expression.
     */
    public static double evaluatePostfix(String expression) {
        // Create an empty stack
        Stack<Double> stack = new Stack<>();

        // Iterate over the expression
        String[] splitExpression = expression.split(" ");
        for (String i : splitExpression) {

            // If the character is a number, push it to the stack
            if (i.matches("-?\\d+(\\.\\d+)?")) {
                stack.push(Double.parseDouble(i));
            }

            // If the character is an operator, apply it
            else {

                // Pop the last two elements from the stack
                double a = stack.pop();
                double b = stack.pop();

                // Apply the operator
                switch (i) {
                    case "+":
                        stack.push(b + a);
                        break;
                    case "-":
                        stack.push(b - a);
                        break;
                    case "*":
                        stack.push(b * a);
                        break;
                    case "/":
                        stack.push(b / a);
                        break;
                    default:
                        throw new IllegalArgumentException("Invalid operator: " + i);
                }
            }
        }

        // Return the result
        return stack.pop();
    }
}

3 — Compare different implementations of queues

3.1 — Using a built-in list

As you defined an implementation of a stack using the built-in list type (file list_stack.py), create a file name list_queue.py that implements the primitives of a queue using a list.

Recall of the primitives:

  • initialize – Initializes the queue.
  • push – Adds an element to the end of the queue.
  • pop – Removes the first element from the queue.
  • is_empty – Returns true if the queue is empty, and false otherwise.
Correction
"""
    This module provides an implementation of the primitives to handle a queue of values. 
    The queue is materialized by means of a list with a size limit to simulate an array.

"""

# Needed imports
from typing import List,Any

# Define your own custom type
Queue = List[Any]



def initialize() -> Queue:

    """
        Initializes an empty queue.
        In:
            * None.
        Out:
            * An empty queue.
    """

    # We use lists to represent queues
    return []



def is_empty (queue: Queue) -> bool:

    """
        This function checks if the queue is empty.
        In:
            * queue: Queue to check.
        Out:
            * True if the queue is empty, False otherwise.
    """

    # Check list length
    return len(queue) == 0



def push (queue: Queue, item: Any) -> None:

    """
        This function adds an element to the queue.
        The element is added at the last position of the storage structure.
        In:
            * queue: Queue to modify.
            * item:  Element to add.
    """

    # Add the element to the end of the list
    queue.append(item)



def pop (queue: Queue) -> Any:
    
    """
        This function removes the next element of the queue.
        In:
            * queue: Queue to modify.
        Out:
            * The next element of the queue.
    """
    
    # Extract and return the first element of the list
    return queue.pop(0)
import java.util.ArrayList;
import java.util.List;

public class ListQueue {
    private List<Object> queue;

    public ListQueue() {
        this.queue = new ArrayList<>();
    }

    public boolean isEmpty() {
        /**
         * This function checks if the queue is empty.
         * @return True if the queue is empty, False otherwise.
         */
        return this.queue.isEmpty();
    }

    public void push(Object item) {
        /**
         * This function adds an element to the queue.
         * The element is added at the end.
         * @param item Element to add.
         */
        this.queue.add(item);
    }

    public Object pop() {
        /**
         * This function removes the last element of the queue.
         * @return The removed element.
         */
        if (this.queue.isEmpty()) {
            throw new IllegalStateException("Queue is empty");
        }
        return this.queue.remove(0);
    }
}

3.2 — Using doubly-linked list

The doubly-linked list is a data structure giving an access to the first and last element of a list. Write an implementation of the primitives to manage a doubly-linked list in a file dll_queue.py.

Recall of the primitives necessary for this practical session (there can be others):

  • initialize – Initializes an empty list.
  • insert_at_end – Adds an element to the end of the list.
  • pop_from_beginning – Returns and removes the head element of the list.
  • is_empty – Returns true if the list does not contain any data.

Then test your implementation for example by enqueueing a few elements and dequeueing them.

Correction
"""
    This module provides functions to manipulate a doubly-linked list.
    The list is represented as a dictionary with 'head' and 'tail' keys pointing to the first and last elements, respectively.
    Each element is also represented as a dictionary with 'data', 'prev', and 'next' keys.
"""

# Needed imports
from typing import Any, Dict, Optional

# Create your own custom type
Element = Dict[str, Any]



def create_element (data: Any) -> Element:

    """
        Creates a new element with the given data.
        In:
            * data: The data to store in the element.
        Out:
            * A dictionary representing the new element.
    """

    # Return the new element
    return {'data': data, 'prev': None, 'next': None}



def initialize () -> Dict[str, Optional[Element]]:

    """
        Initializes an empty doubly-linked list.
        In:
            * None.
        Out:
            * A dictionary representing the doubly-linked list, with 'head' and 'tail' keys.
    """

    # Initialize the default pointers
    return {'head': None, 'tail': None}



def is_empty (dll: Dict[str, Optional[Element]]) -> bool:

    """
        Checks if the doubly-linked list is empty.
        In:
            * dll: The doubly-linked list.
        Out:
            * True if the list is empty, False otherwise.
    """

    # Check if the head pointer is uninitialized
    return dll['head'] is None



def insert_at_beginning (dll: Dict[str, Optional[Element]], data: Any) -> None:
    
    """
        Insert a new element with the given data at the beginning of the list.
        In:
            * dll:  The doubly-linked list.
            * data: The data to store in the new element.
        Out:
            * None.
    """

    # Create the element to store
    new_element = create_element(data)

    # Adapt the pointers
    if dll['head'] is None:
        dll['head'] = new_element
        dll['tail'] = new_element
    else:
        new_element['next'] = dll['head']
        dll['head']['prev'] = new_element
        dll['head'] = new_element



def insert_at_end (dll: Dict[str, Optional[Element]], data: Any) -> None:
    
    """
        Insert a new element with the given data at the end of the list.
        In:
            * dll:  The doubly-linked list.
            * data: The data to store in the new element.
        Out:
            * None.
    """

    # Create the element to store
    new_element = create_element(data)

    # Adapt the pointers
    if dll['tail'] is None:
        dll['head'] = new_element
        dll['tail'] = new_element
    else:
        new_element['prev'] = dll['tail']
        dll['tail']['next'] = new_element
        dll['tail'] = new_element



def pop_from_beginning (dll: Dict[str, Optional[Element]]) -> Any:
    
    """
        Removes and returns the data of the element at the beginning of the list.
        In:
            * dll: The doubly-linked list.
        Out:
            * The data of the element removed from the beginning of the list.
    """

    # Verify it is not empty
    if is_empty(dll):
        return None
    
    # Extract the data
    data = dll['head']['data']

    # Adapt the pointers
    if dll['head'] == dll['tail']:
        dll['head'] = None
        dll['tail'] = None
    else:
        dll['head'] = dll['head']['next']
        dll['head']['prev'] = None
    
    # Return the data
    return data



def pop_from_end (dll: Dict[str, Optional[Element]]) -> Any:

    """
        Removes and returns the data of the element at the end of the list.
        In:
            * dll: The doubly-linked list.
        Out:
            * The data of the element removed from the end of the list.
    """

    # Verify it is not empty
    if is_empty(dll):
        return None

    # Extract the data
    data = dll['tail']['data']

    # Adapt the pointers
    if dll['head'] == dll['tail']:
        dll['head'] = None
        dll['tail'] = None
    else:
        dll['tail'] = dll['tail']['prev']
        dll['tail']['next'] = None

    # Return the data
    return data
/**
 * This class provides methods to manipulate a doubly-linked list.
 * The list is represented with a head and tail pointing to the first and last nodes, respectively.
 * Each node is represented with data, prev, and next fields.
 */

import java.util.Optional;

 class DoublyLinkedList {
     // Define the Node structure
     static class Node {
         int data;
         Node prev;
         Node next;
 
         Node(int data) {
             this.data = data;
             this.prev = null;
             this.next = null;
         }
     }
 
     private Node head;
     private Node tail;
 
     // Initialize the list
     public DoublyLinkedList() {
         this.head = null;
         this.tail = null;
     }

    
    /**
     * Check if the list is empty.
     *
     * @return true if the list is empty, false otherwise.
     */
    public boolean isEmpty() {
        return head == null;
    }
    
     /**
      * Insert a new node with the given data at the beginning of the list.
      *
      * @param data The data to store in the new node.
      */
     public void insertAtBeginning(int data) {
         Node newNode = new Node(data);
         if (head == null) {
             head = tail = newNode;
         } else {
             newNode.next = head;
             head.prev = newNode;
             head = newNode;
         }
     }
 
     /**
      * Insert a new node with the given data at the end of the list.
      *
      * @param data The data to store in the new node.
      */
     public void insertAtEnd(int data) {
         Node newNode = new Node(data);
         if (tail == null) {
             head = tail = newNode;
         } else {
             newNode.prev = tail;
             tail.next = newNode;
             tail = newNode;
         }
     }
 
     /**
      * Delete the first node with the given data from the list.
      *
      * @param data The data of the node to delete.
      */
     public void deleteNode(int data) {
         Node current = head;
         while (current != null) {
             if (current.data == data) {
                 if (current.prev != null) {
                     current.prev.next = current.next;
                 } else {
                     head = current.next;
                 }
                 if (current.next != null) {
                     current.next.prev = current.prev;
                 } else {
                     tail = current.prev;
                 }
                 return;
             }
             current = current.next;
         }
     }

      /**
     * Remove and return the data from the node at the head of the list.
     *
     * @return The data from the node at the head of the list, or -1 if the list is empty.
     */
    public int popFromHead() {
        if (head == null) {
            throw new RuntimeException("Empty list");//notion of Exception seen in prog session 
        }
        int data = head.data;
        head = head.next;
        if (head != null) {
            head.prev = null;
        } else {
            tail = null;
        }
        return data;
    }

    /**
     * Remove and return the data from the node at the tail of the list.
     *
     * @return The data from the node at the tail of the list, or -1 if the list is empty.
     */
    public int popFromTail() {
        if (tail == null) {
            throw new RuntimeException("Empty list");
        }
        int data = tail.data;
        tail = tail.prev;
        if (tail != null) {
            tail.next = null;
        } else {
            head = null;
        }
        return data;
    }
 
     /**
      * Search for a node with the given data.
      *
      * @param data The data to search for.
      * @return An Optional containing the found node, or an empty Optional if no node was found.
      */
     public Optional<Node> searchNode(int data) {
         Node current = head;
         while (current != null) {
             if (current.data == data) {
                 return Optional.of(current);
             }
             current = current.next;
         }
         return Optional.empty();
     }
 
     /**
      * Display the data of all nodes in the list.
      */
     public void displayList() {
         Node current = head;
         while (current != null) {
             System.out.print(current.data + " ");
             current = current.next;
         }
         System.out.println();
     }
 }

3.3 — Comparing implementations

At this point of the practical activity, you should have available two possible implementations of a queue:

  • Using the built-in list type (in list_queue.py),
  • Using the home-made, though perfectible, doubly-linked list type (in dll_queue.py).

The idea now is to see the impact of choosing an implementation over another. In addition to the two previously mentioned implementation, a third implementation using the deque python type will be used. The deque type is similar to the doubly-linked list but optimized compared to our home-made version.

The following code aims at comparing three implementations of a queue focusing on the queueing primitive. 10,000 values are added to each implementation of the queue and the average time needed to perform this queueing operation is calculated and displayed.

Copy the following code in a file named test_perf_queueing.py and to run this code. Also, check that the files named list_queue.py and dll_queue.py are located in the same directory as test_perf_queueing.py.

Output
"""
    compares the time needed to add elements to the front of a list and deque.
"""

# Needed imports
from collections import deque
from time import perf_counter
from typing import List, Deque, Callable, Any
import list_queue as lq
import dll_queue as dllq

# Number of insertions to perform
NB_OPS = 10_000

# Initialize the various structures
queue_using_list = lq.initialize()
queue_using_dll = dllq.initialize()
queue_using_deque = deque()


def average_time (func: Callable[[Any], None], times: int) -> float:
    
    """
        This function computes the average time needed to execute a function.
        In:
            * func:  Function to execute.
            * times: Number of times to execute the function.
        Out:
            * Average time needed to execute the function.
    """ 

    # Run the function multiple times
    total = 0.0
    for i in range(times):
        start = perf_counter()
        func(i)
        total += (perf_counter() - start) * 1e9
    
    # Return the average time
    return total / times



# Compute the average time needed to insert an element in the list-based queue
list_time = average_time(lambda i: lq.push(queue_using_list, i), NB_OPS)

# Compute the average time needed to insert an element in the doubly linked list-based queue
dll_time = average_time(lambda i: dllq.insert_at_end(queue_using_dll, i), NB_OPS)

# Compute the average time needed to insert an element in the deque-based queue
deque_time = average_time(lambda i: queue_using_deque.appendleft(i), NB_OPS)

# Evaluate the gain
gain = list_time / deque_time
gain2 = list_time / dll_time
gain3 = dll_time / deque_time

# Print results
print("----\n INSERTION \n---")
print(f"Average time list queue:               {list_time:.6} ns")
print(f"Average time doubly-linked list queue: {dll_time:.6} ns")
print(f"Average time deque:                    {deque_time:.6} ns")
print(f"deque over list:               {gain:.6}x faster")
print(f"doubly-linked list over list:  {gain2:.6}x faster")
print(f"deque over doubly-linked list: {gain3:.6}x faster")

# Compute the average time needed to remove an element in the list-based queue
list_time = average_time(lambda i: lq.pop(queue_using_list), NB_OPS)

# Compute the average time needed to remove an element in the doubly linked list-based queue
dll_time = average_time(lambda i: dllq.pop_from_beginning(queue_using_dll), NB_OPS)

# Compute the average time needed to remove an element in the deque-based queue
deque_time = average_time(lambda i: queue_using_deque.pop(), NB_OPS)

# Evaluate the gain
gain = list_time / deque_time
gain2 = list_time / dll_time
gain3 = dll_time / deque_time

# Print results
print("----\n DELETION \n---")
print(f"Average time list queue:               {list_time:.6} ns")
print(f"Average time doubly-linked list queue: {dll_time:.6} ns")
print(f"Average time deque:                    {deque_time:.6} ns")
print(f"deque over list:               {gain:.6}x faster")
print(f"doubly-linked list over list:  {gain2:.6}x faster")
print(f"deque over doubly-linked list: {gain3:.6}x faster")

What do you observe?

Correction

Here are the kind of results you should obtain:

Output
----
 INSERTION 
---
Average time list queue:               133.448 ns
Average time doubly-linked list queue: 429.624 ns
Average time deque:                    97.645 ns
deque over list:               1.36666x faster
doubly-linked list over list:  0.310616x faster
deque over doubly-linked list: 4.39986x faster
----
 DELETION 
---
Average time list queue:               763.207 ns
Average time doubly-linked list queue: 375.372 ns
Average time deque:                    91.0943 ns
deque over list:               8.3782x faster
doubly-linked list over list:  2.0332x faster
deque over doubly-linked list: 4.1207x faster

As it can be seen, the type of data structure used to implement a queue may strongly affects the efficiency of some primitives. Here, deleting an element at the first position of a list, even using the optimized built-in list type, is costly as it requires to physically shift, in memory, all the elements in the list. So a dequeue operation leads to N shift operations when the list contains N elements. This explains the significative difference in terms of efficiency compared to the doubly-linked list version, which only updates pointers.

Also, comparing the home-made version of doubly-linked list and the deque shows you should always favor official implementation when they are available.

4 — Removing elements in a doubly-linked list

Complete the home-made implementation of the doubly-linked list type with a function that removes the first element of the list having a given value (passed as a parameter of the function).

Correction
def delete_element (dll: Dict[str, Optional[Element]], data: Any) -> None:
    
    """
        Deletes the first element with the given data from the list.
        In:
            * dll: The doubly-linked list.
            * data: The data of the element to delete.
        Out:
            * None.
    """

    # Iterate over the list from the head to the tail until data is found
    current = dll['head']
    while current is not None:
        if current['data'] == data:

            # Adapt the pointers
            if current['prev'] is not None:
                current['prev']['next'] = current['next']
            else:
                dll['head'] = current['next']
            if current['next'] is not None:
                current['next']['prev'] = current['prev']
            else:
                dll['tail'] = current['prev']
            
            # Stop here
            return
        
        # Next element
        current = current['next']
/**
      * Delete the first node with the given data from the list.
      *
      * @param data The data of the node to delete.
      */
     public void deleteNode(int data) {
         Node current = head;
         while (current != null) {
             if (current.data == data) {
                 if (current.prev != null) {
                     current.prev.next = current.next;
                 } else {
                     head = current.next;
                 }
                 if (current.next != null) {
                     current.next.prev = current.prev;
                 } else {
                     tail = current.prev;
                 }
                 return;
             }
             current = current.next;
         }
     }

5 — Sort a (doubly-linked) list

Apply the basic selection sort algorithm to turn any doubly-linked list into a sorted list (in an increasing order).

Correction
def selection_sort (dll: Dict[str, Optional[Element]]) -> None:
    
    """
        Sorts the list using the basic selection sort algorithm.
        Here, sorting is done in place.
        In:
            * dll: The doubly linked list to be sorted.
        Out:
            * None.
    """

    # Nothing to do if empty structure
    if dll['head'] is None:
        return

    # Iterate over the structure
    current = dll['head']
    while current is not None:

        # Find the smallest value in the remaining part of the list (i.e., from current)
        min_node = current
        next_node = current['next']
        while next_node is not None:
            if next_node['data'] < min_node['data']:
                min_node = next_node
            next_node = next_node['next']

        # Swap the data
        if min_node != current:
            current['data'], min_node['data'] = min_node['data'], current['data']

        # Next element
        current = current['next']
 /**
     * Apply the selection sort algorithm on the list
     */
    public void selectionSort() {
        if (head != null) {
    
            Node current = head;
            while (current != null) {
                Node minNode = current;
                Node nextNode = current.next;
        
                while (nextNode != null) {
                    if (nextNode.data < minNode.data) {
                        minNode = nextNode;
                    }
                    nextNode = nextNode.next;
                }
        
                // Swap the data of the current node and the minNode
                if (minNode != current) {
                    int temp = current.data;
                    current.data = minNode.data;
                    minNode.data = temp;
                }
        
                current = current.next;
            }
        }
    }

6 — Sorted doubly-linked list

In some application cases, where the stored values are ordinal, it may be interesting (to possibly speed up value retrieval) to maintain an order of the values within the list. Given that integer values are stored in the list, suggest an implementation of an insert_element function that keeps the values in descending (or ascending) order.

Correction
def insert_order (dll: Dict[str, Optional[Element]], data: Any) -> None:
    
    """
        Insert a new element with the given data into the list, maintaining the elements in increasing order.
        In:
            * dll:  The doubly-linked list.
            * data: The data to store in the new Element.
        Out:
            * None.
    """

    # Create a new Element
    new_element = create_element(data)

    # If the list is empty, initialize it
    if dll['head'] is None:
        dll['head'] = dll['tail'] = new_element
    
    # Otherwise, find where to insert the data
    else:

        # Iterate over elements
        current = dll['head']
        while current is not None and current['data'] < data:
            current = current['next']

        # If it is the largest data seen, add to the end
        if current is None:
            insert_at_end(dll, data)

        # If it is the largest data seen, add to the beginning
        elif current['prev'] is None:
            insert_at_beginning(dll, data)

        # otherwise, adapt the pointers
        else:
            new_element['next'] = current
            new_element['prev'] = current['prev']
            current['prev']['next'] = new_element
            current['prev'] = new_element

7 — Optimize your solutions

What you can do now is to use AI tools such as GitHub Copilot or ChatGPT, either to generate the solution, or to improve the first solution you came up with! Try to do this for all exercises above, to see the differences with your solutions.

To go further

8 — Convert infix expression to postfix expression

Write a function infix_to_prefix that converts an infix expression with integers (in brackets) to postfix notation. You will need to use both a stack and a queue.

For instance, the expression 3*(((12-3)/3)-1) will be converted to: 3 12 3 - 3 / 1 - *

  • The valid operators are +, -, * and /.
  • The algorithm reads an arithmetic infix expression and stores the result of the conversion in a queue, which is displayed.
  • The algorithm will use a stack to manage the operators, and a queue to construct the postfix expression.
  • The numeric values must be directly stored in the queue, while the operators must wait before adding them to the queue after their operands.
  • The treatment of an operator, poping then enqueuing, must occur when a closing parenthesis is encountered.
  • Also, beware of operators priority!
Correction
# Needed imports
import re


def infix_to_postfix (infix_expression: str) -> str:

    """
        This function uses a stack and a queue to transform an infix expression to a postfix expression.
        All expressions are supposed to be well parenthesized.
        In:
            * infix_expression: Expression to be transformed to postfix.
        Out:
            * Postfix expression equivalent to the input infix expression.
    """

    # Define the precedence of the operators
    precedence = {"*": 2, "/": 2, "+": 1, "-": 1, "(": 0, ")": 0}

    # Initialize the stack and the queue
    stack = []
    queue = []

    # Split the infix expression into tokens
    tokens = [t for t in re.split(r"(\+|\-|\*|\/|\(|\))", infix_expression) if t.strip() != ""]

    # Iterate over tokens
    for token in tokens:
        
        # If the token is an operand, add it to the queue
        if token.isnumeric():
            queue.append(token)

        # If the token is a left parenthesis, add it to the stack
        elif token == "(":
            stack.append(token)

        # If the token is a right parenthesis, pop all the operators from the stack and add them to the queue until a left parenthesis is found
        elif token == ")":
            while stack and stack[-1] != "(":
                queue.append(stack.pop())
            stack.pop()

        # If the token is an operator, pop all the operators from the stack with higher precedence and add them to the queue
        # Then, add the operator to the stack
        else:
            while stack and precedence[stack[-1]] >= precedence[token]:
                queue.append(stack.pop())
            stack.append(token)

    # Add the remaining operators to the queue
    while stack:
        queue.append(stack.pop())

    # Return the postfix expression
    return " ".join(queue)


# Test the function
print("3*(((12-3)/3)-1) transformed into", infix_to_postfix("3*(((12-3)/3)-1)"))
print("1+(2*3) transformed into", infix_to_postfix("1+(2*3)"))
print("(1+2)*3 transformed into", infix_to_postfix("(1+2)*3"))
print("3*(2+1) transformed into", infix_to_postfix("3*(2+1)"))
/**
 * To run this code, you need to have Java installed on your computer, then:
 * - Create a file named `Main.java` in a directory of your choice.
 * - Copy this code in the file.
 * - Open a terminal in the directory where the file is located.
 * - Run the command `javac Main.java` to compile the code.
 * - Run the command `java -ea Main` to execute the compiled code.
 * Note: '-ea' is an option to enable assertions in Java.
 */

// Needed imports
import java.util.Stack;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class Main {

    /**
     * This function uses a stack and a queue to transform an infix expression to a postfix expression.
     * All expressions are supposed to be well parenthesized.
     *
     * @param infixExpression Expression to be transformed to postfix.
     * @return Postfix expression equivalent to the input infix expression.
     */
    public static String infixToPostfix(String infixExpression) {
        // Define the precedence of the operators
        var precedence = new java.util.HashMap<String, Integer>();
        precedence.put("*", 2);
        precedence.put("/", 2);
        precedence.put("+", 1);
        precedence.put("-", 1);
        precedence.put("(", 0);
        precedence.put(")", 0);

        // Initialize the stack and the queue
        Stack<String> stack = new Stack<>();
        List<String> queue = new ArrayList<>();

        // Split the infix expression into tokens
        Pattern pattern = Pattern.compile("(\\+|\\-|\\*|\\/|\\(|\\))");
        Matcher matcher = pattern.matcher(infixExpression);
        List<String> tokens = new ArrayList<>();
        int lastIndex = 0;
        while (matcher.find()) {
            if (matcher.start() > lastIndex) {
                tokens.add(infixExpression.substring(lastIndex, matcher.start()).trim());
            }
            tokens.add(matcher.group());
            lastIndex = matcher.end();
        }
        if (lastIndex < infixExpression.length()) {
            tokens.add(infixExpression.substring(lastIndex).trim());
        }

        // Iterate over tokens
        for (String token : tokens) {
            // If the token is an operand, add it to the queue
            if (token.matches("\\d+")) {
                queue.add(token);
            }

            // If the token is a left parenthesis, add it to the stack
            else if (token.equals("(")) {
                stack.push(token);
            }

            // If the token is a right parenthesis, pop all the operators from the stack and add them to the queue until a left parenthesis is found
            else if (token.equals(")")) {
                while (!stack.isEmpty() && !stack.peek().equals("(")) {
                    queue.add(stack.pop());
                }
                stack.pop();
            }

            // If the token is an operator, pop all the operators from the stack with higher precedence and add them to the queue
            // Then, add the operator to the stack
            else {
                while (!stack.isEmpty() && precedence.get(stack.peek()) >= precedence.get(token)) {
                    queue.add(stack.pop());
                }
                stack.push(token);
            }
        }

        // Add the remaining operators to the queue
        while (!stack.isEmpty()) {
            queue.add(stack.pop());
        }

        // Return the postfix expression
        return String.join(" ", queue);
    }


    /**
     * This is the entry point of your program.
     * It contains the first codes that are going to be executed.
     *
     * @param args Command line arguments received.
     */
    public static void main(String[] args) {
        // Test the function
        System.out.println("3*(((12-3)/3)-1) transformed into " + infixToPostfix("3*(((12-3)/3)-1)"));
        System.out.println("1+(2*3) transformed into " + infixToPostfix("1+(2*3)"));
        System.out.println("(1+2)*3 transformed into " + infixToPostfix("(1+2)*3"));
        System.out.println("3*(2+1) transformed into " + infixToPostfix("3*(2+1)"));
    }

}

9 — Compute the Graham convex hull

The Graham scan algorithm is a computational geometry method used to find the convex hull of a set of points in a 2D plane, with complexity $O(n \cdot log(n))$. The convex hull is the smallest convex polygon that can enclose all the given points. It uses a stack to detect and remove concavities in the boundary efficiently.

Write the graham algorithm which takes:

  • As input – A set of n points in the plane, each represented by its coordinates (abscissa and ordinate).
  • As output – A list of points that are the vertices of the polygon surrounding the convex envelope, in the order in which they appear on this polygon.

You must follow the steps of the Graham algorithm:

  1. Find the point with the lowest y-coordinate. If there are multiple points with the same lowest y-coordinate, choose the one with the lowest x-coordinate. This point is called the pivot point.
  2. Sort the set of points in increasing order of the angle they and the pivot point make with the x-axis. Any general-purpose sorting algorithm is appropriate for this.
  3. Iterate through the sorted list of points to construct the convex hull:
    • We start with the pivot.
    • Then the sorted points are examined. For each point, we first determine whether travelling from the two points immediately preceding this point is a left turn (counter-clockwise orientation) or a right turn (clockwise orientation).
    • In the case of a right turn, this means that the point that ended the polygon being created (the point in red) is to the left of the segment formed by the penultimate point of the polygon being created and the point under consideration (the point in black). In this case, the last point (the one in red) is not part of the convex envelope and must therefore be rejected. These rejections are repeated as long as there is a “right turn”.
    • If the algorithm encounters a “left turn”, it moves on to the next point in the sorted array.

Graham algorithm right turn

Determining whether three points constitute a “left turn” or a “right turn” does not require computing the actual angle between the two line segments, and can be achieved with simple arithmetic only. For three points $(x_1, y_1)$, $(x_2, y_2)$ and $(x_3, y_3)$, simply calculate the direction of the vector product of the two vectors $[(x_1, y_1), (x_2, y_2)]$ and $[(x_1, y_1), (x_3, y_3)]$, given by the sign of the expression $(x_2 - x_1)(y_3 - y_1) - (y_2 - y_1)(x_3 - x_1)$:

  • If the result is zero, the points are aligned.
  • If it is positive, the three points form a “left turn” or counter-clockwise orientation.
  • If the result is negative, it is a “right turn” or clockwise orientation.
Correction
# Needed imports
import math
import matplotlib.pyplot as plt



def min_y (points: list[tuple[int, int]]) -> tuple[int, int]:
    
    """
        Returns the points having the smallest y coordinate.
        In case of tie, the point with the smallest x coordinate is returned.
        In:
            * points: List of points.
        Out:
            * The point with the smallest y coordinate.
    """

    # Check all the points
    min_idx = 0
    for i in range(1, len(points)):
        if points[i][1] < points[min_idx][1]:
            min_idx = i
        elif points[i][1] == points[min_idx][1] and points[i][0] < points[min_idx][0]:
            min_idx = i
    
    # Return the found one
    return points[min_idx]



def right_turn (p1: tuple[int, int], p2: tuple[int, int], p3: tuple[int, int]) -> None:
    
    """
        Determines if the points p1, p2, p3 make a right turn.
        In:
            * p1: Point 1.
            * p2: Point 2.
            * p3: Point 3.
        Out:
            * None.

    """

    # Check value of Euclidean distance
    d = (p2[0] - p1[0]) * (p3[1] - p1[1]) - (p2[1] - p1[1]) * (p3[0] - p1[0])
    return d < 0



def graham_scan (points: list[tuple[int, int]]) -> list[tuple[int, int]]:

    """
        Determines the convex hull of a set of points using the Graham scan algorithm.
        In:
            * points: List of points.
        Out:
            * Points forming the convex hull.
    """

    # Get the pivot
    pivot = min_y(points)

    # Sort the points by the angle they make with the pivot and the x-axis
    sorted_points = sorted(points, key=lambda x: math.atan2(x[1] - pivot[1], x[0] - pivot[0]))
    hull = []
    for point in sorted_points:

        # Remove the point from the stack if one finds a right turn
        while len(hull) > 1 and right_turn(hull[-2], hull[-1], point):
            hull.pop()

        # Add the point to the stack and move to the next one
        hull.append(point)
    
    # Return the hull
    return hull



# Make some tests
if __name__ == "__main__":

    # Test on a few poins
    points = [(0, 0), (1, 1), (1.5, 2.5), (3.5, 1), (2, 2), (3, 0), (3.5, 2), (0, 3), (3, 3), (2, 3.5)]        
    hull = graham_scan(points)
    print("Convex hull:", hull)

    # Plot the hull
    plt.plot([x for x, y in points], [y for x, y in points], 'ro')
    plt.plot([x for x, y in hull] + [hull[0][0]], [y for x, y in hull] + [hull[0][1]], 'bo-')
    plt.show()
 import java.util.*;

public class ConvexHull {
    /**
     * This function calculates the Euclidean distance between two points.
     * @param p1 point 1 defined by its two coordinates - array of 2 integers
     * @param  p2 point 2 defined by its two coordinates - array of 2 integers
     * @return the Euclidean distance.
     */

    public static double euclideanDistance(int[] p1, int[] p2) {
        return Math.pow(p2[0] - p1[0], 2) + Math.pow(p2[1] - p1[1], 2);
    }

    /**
     * This function calculates the direction of the vector product of the two vectors defined by the points p, q and p, r
     * @param p point 1 defined by its two coordinates - array of 2 integers
     * @param q point 2 defined by its two coordinates - array of 2 integers
     * @param r point 3 defined by its two coordinates - array of 2 integers
     * @return  true if the vector product is negative, false otherwise
     */

    public static boolean rightTurn(int[] p, int[] q, int[] r) {
        int direction = (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
        return direction < 0;
    }

    /**
     * This function implements the Graham algorithm to compute the convex hull of point in a 2D plane.  
     * @param points list of points in the plane, each represented by its coordinates (abscissa, ordinate) - a tuple with two integer elements.
     * @return list of integer arrays representing the points forming the convex envelope.
     */

    public static List<int[]> grahamScan(List<int[]> points) {
        // Find the point with the lowest y-coordinate, if there are multiple points with the same y-coordinate, pick the one with the smallest x-coordinate
        int[] minY = Collections.min(points, Comparator.comparingInt((int[] p) -> p[1]).thenComparingInt(p -> p[0]));

        // Sort the set of points in increasing order of the angle they and the pivot point make with the x-axis.
        points.sort((p1, p2) -> {
            double angle1 = Math.atan2(p1[1] - minY[1], p1[0] - minY[0]);
            double angle2 = Math.atan2(p2[1] - minY[1], p2[0] - minY[0]);
            if (angle1 != angle2) {
                return Double.compare(angle1, angle2);
            } else {
                return Double.compare(euclideanDistance(minY, p1), euclideanDistance(minY, p2));
            }
        });

        // Create the convexe hull
        Stack<int[]> hull = new Stack<>();
        for (int[] point : points) {
            while (hull.size() > 1 && rightTurn(hull.get(hull.size() - 2), hull.peek(), point)) {
                hull.pop(); // remove the point from the stack
            }
            hull.push(point); // add the point to the stack and move to the next one
        }

        return new ArrayList<>(hull);
    }
    public static void main(String[] args) {
        // Example 
        List<int[]> points = Arrays.asList(
            new int[]{0, 0}, new int[]{1, 1}, new int[]{1, 2}, new int[]{2, 2},
            new int[]{3, 0}, new int[]{3, 2}, new int[]{0, 3}, new int[]{3, 3},
            new int[]{2, 4}
        );

        List<int[]> hull = grahamScan(points);
        System.out.println("Convex hull:");
        for (int[] point : hull) {
            System.out.println(Arrays.toString(point));
        }
    }
}

To go beyond

10 — Use pointers in a language where they are explicit

Try the implementation of a list of integers in C following the tutorial available at the following url. You may need an introductory tutorial on programming with C though :)