Practical activity
Duration1h15Presentation & objectives
This activity is dedicated to the manipulation of data structures and to see empirically the impact of the choice of structure over another.
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
Gather the code presented in the introductory course about stacks to obtain an implementation of a stack relying on the built-in list type. Here are the primitives that should be implemented in your file name list_stack.py
.
- initialise – Initialises 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
performs some operations on a stack to check its primitives.
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.
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 expressoin and to then display the result of its evaluation.
3 — Compare different implementations of queues
Using 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. The code is already available and explained in the following course, you just have to regroup all the functions and to test them, for example by enqueueing a few elements and dequeueing them.
Recall of the primitives:
- initialise – Initializes the queue.
- enqueue – Adds an element to the end of the queue.
- dequeue – Removes the first element from the queue.
- is_empty ** Returns true if the queue is empty, and false otherwise.
Using doubly-linked list
Doubly-linked list is a data structure giving an access to the first and last element of a list. You shall find below a home-made implementations of the primitives to manage a doubly-linked list. This implementation is not optimised and just used here for pedagogical purpose.
"""
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.
"""
from typing import Any, Dict, Optional
# Define the Element structure
Element = Dict[str, Any]
def create_Element(data: Any) -> Element:
"""
Create a new Element with the given data.
Args:
data (Any): The data to store in the Element.
Returns:
Element: A dictionary representing the new Element.
"""
return {'data': data, 'prev': None, 'next': None}
def initialise_list() -> Dict[str, Optional[Element]]:
"""
Initialise an empty doubly-linked list.
Returns:
Dict[str, Optional[Element]]: A dictionary representing the doubly-linked list with 'head' and 'tail' keys.
"""
return {'head': None, 'tail': 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.
Args:
dll (Dict[str, Optional[Element]]): The doubly-linked list.
data (Any): The data to store in the new Element.
"""
new_Element = create_Element(data)
if dll['head'] is None:
dll['head'] = dll['tail'] = new_Element
else:
new_Element['next'] = dll['head']
dll['head']['prev'] = new_Element
dll['head'] = new_Element
def is_empty(dll: Dict[str, Optional[Element]]) -> bool:
"""
Check if the doubly-linked list is empty.
Args:
dll (Dict[str, Optional[Element]]): The doubly-linked list.
Returns:
bool: True if the list is empty, False otherwise.
"""
return dll['head'] is None
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.
Args:
dll (Dict[str, Optional[Element]]): The doubly-linked list.
data (Any): The data to store in the new Element.
"""
new_Element = create_Element(data)
if dll['tail'] is None:
dll['head'] = 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:
"""
Remove and return the data of the Element at the beginning of the list.
Args:
dll (Dict[str, Optional[Element]]): The doubly-linked list.
Returns:
Any: The data of the Element removed from the beginning of the list.
"""
if dll['head'] is None:
return None
data = dll['head']['data']
if dll['head'] == dll['tail']:
dll['head'] = dll['tail'] = None
else:
dll['head'] = dll['head']['next']
dll['head']['prev'] = None
return data
def pop_from_end(dll: Dict[str, Optional[Element]]) -> Any:
"""
Remove and return the data of the Element at the end of the list.
Args:
dll (Dict[str, Optional[Element]]): The doubly-linked list.
Returns:
Any: The data of the Element removed from the end of the list.
"""
if dll['tail'] is None:
return None
data = dll['tail']['data']
if dll['head'] == dll['tail']:
dll['head'] = dll['tail'] = None
else:
dll['tail'] = dll['tail']['prev']
dll['tail']['next'] = None
return data
def delete_Element(dll: Dict[str, Optional[Element]], data: Any) -> None:
"""
Delete the first Element with the given data from the list.
Args:
dll (Dict[str, Optional[Element]]): The doubly-linked list.
data (Any): The data of the Element to delete.
"""
current = dll['head']
while current is not None:
if current['data'] == data:
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']
return
current = current['next']
/**
* 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();
}
}
As you defined an implementation of a queue using the built-in list type (file list_queue.py
), create a file name ddl_queue.py
that implements the primitives of a queue using a doubly-linked list. Then test your implementation for example by enqueueing a few elements and dequeueing them.
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, thus perfectible, doubly-linked list type (in
ddl_queue.py
).
The idea now is to see the impact of chosing an implementation over another. In addition to the two previously mentioned implementation, a third implementation using the deque
python time 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, check that:
- the files named
list_queue.py
andddl_queue.py
are located in the same directory astest_perf_queueing.py
- the primitives
initialise()
andpush(queue, element)
are available.
"""
compares the time needed to add elements to the front of a list and a deque
"""
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
NBINSERT : int = 10_000
qlq : lq.Queue = lq.initialise() #Queue with list
qdll : dllq.Queue = dllq.initialise()#Queue with doubly linked list
a_deque : Deque[int] = deque()#Queue with deque
def average_time(func : Callable[[Any], None], times : int) -> float:
total : float = 0.0
for i in range(times):
start = perf_counter()
func(i)
total += (perf_counter() - start) * 1e9
return total / times
list_time = average_time(lambda i: lq.push(qlq, i), NBINSERT)
dll_time = average_time(lambda i: dllq.push(qdll, i), NBINSERT)
deque_time = average_time(lambda i: a_deque.appendleft(i), NBINSERT)
gain = list_time / deque_time
gain2 = list_time / dll_time
gain3 = dll_time / deque_time
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)")
Here are the kind of results you should obtain:
Average time list queue 2455.53 ns
Average time doubly_linked list queue 229.59 ns
Average time deque 53.9438 ns
deque over list (45.5202x faster)
doubly-linked list over list (10.6953x faster)
deque over doubly-linked list (4.2561x faster)
Two main conclusions to draw from this first experiment. The type of data structure used to implement a queue may strongly affects the efficiency of some primitives. Here, inserting an element at the first position of a list, even using the optimised built-in list type, is costly as it requires to physically shift, in memory, all the elements in the list. So an queue 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. Comparing the home-made version of doubly-linked list and the deque
shows you should always favor official implementation when they are available.
To go further
The content of this section is optional. It contains additional material for you to consolidate your understanding of the current topic.
Here are some additional exercices for those of you who would like to go further in the use of data structures.
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).
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.
5 — 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!
4 — 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 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:
- 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.
- 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.
- 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.
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 (x1, y1), (x2, y2)
and (x3, y3)
, simply calculate the direction of the vector product of the two vectors defined by the points (x1, y1), (x2, y2)
and (x1, y1), (x3, y3)
, given by the sign of the expression (x2 - x1)(y3 - y1) - (y2 - y1)(x3 - x1)
:
- 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.
To go beyond
The content of this section is very optional. We suggest you directions to explore if you wish to go deeper in the current topic.