From 809720f43b3b821327d1333a14961a6c2123146f Mon Sep 17 00:00:00 2001
From: radoskov <radoslav.skoviera@cvut.cz>
Date: Wed, 19 Feb 2025 12:18:30 +0100
Subject: [PATCH] Added README

---
 README.md                                     |   27 +
 .../lecture_01/l1_intro_strctures.ipynb       | 4270 ++++++++---------
 .../lecture_01/l1_intro_strctures.qmd         |    5 +-
 3 files changed, 2125 insertions(+), 2177 deletions(-)
 create mode 100644 README.md

diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c2b7d4f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,27 @@
+# PGE Lectures
+
+## Installation
+
+```bash
+pip install -e .
+```
+
+## Usage
+
+Info:
+
+```bash
+python -m pge_lectures
+```
+
+Run a specific lesson:
+
+```bash
+python -m pge_lectures <lesson_number>
+```
+
+List available lessons:
+
+```bash
+python -m pge_lectures -l
+```
diff --git a/src/pge_lectures/lecture_01/l1_intro_strctures.ipynb b/src/pge_lectures/lecture_01/l1_intro_strctures.ipynb
index 9a45260..b814ab2 100644
--- a/src/pge_lectures/lecture_01/l1_intro_strctures.ipynb
+++ b/src/pge_lectures/lecture_01/l1_intro_strctures.ipynb
@@ -1,2177 +1,2101 @@
 {
- "cells": [
-  {
-   "cell_type": "raw",
-   "metadata": {},
-   "source": [
-    "---\n",
-    "title: Lecture 1\n",
-    "format:\n",
-    "  html:\n",
-    "    code-fold: false\n",
-    "---"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "# Basic data structures, intro to asymptotic complexity\n",
-    "\n",
-    "## Basic data structures (in Python)\n",
-    "\n",
-    "### Arrays (Python lists)\n",
-    "\n",
-    "List is a build-in array-like data structure in Python. Unlike \"traditional\" arrays, Python lists are untyped. This makes their usage simpler but less efficient and sometimes \"dangerous\".\n",
-    "More efficient 'standard' array implementation can be found in the *NumPy* package, which we will discuss in a later lecture.\n",
-    "\n",
-    "\n",
-    "#### Creation\n",
-    "\n",
-    "Creating empty lists can be done in two ways:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 1,
-   "metadata": {},
-   "outputs": [
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "[]\n"
-     ]
-    },
-    {
-     "ename": "TypeError",
-     "evalue": "can only concatenate list (not \"str\") to list",
-     "output_type": "error",
-     "traceback": [
-      "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
-      "\u001b[0;31mTypeError\u001b[0m                                 Traceback (most recent call last)",
-      "\u001b[0;32m<ipython-input-1-43b7407e1e1d>\u001b[0m in \u001b[0;36m<module>\u001b[0;34m\u001b[0m\n\u001b[1;32m      4\u001b[0m \u001b[0mprint\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mmy_list\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m      5\u001b[0m \u001b[0mmy_list\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mlist\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 6\u001b[0;31m \u001b[0mprint\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mmy_list\u001b[0m \u001b[0;34m+\u001b[0m \u001b[0;34m\" s \"\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m",
-      "\u001b[0;31mTypeError\u001b[0m: can only concatenate list (not \"str\") to list"
-     ]
-    }
-   ],
-   "source": [
-    "#| echo: true\n",
-    "# Create an empty list\n",
-    "my_list = []\n",
-    "print(my_list)\n",
-    "my_list = list()\n",
-    "print(my_list)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Using the square brackets `[]` is the preferred way in Python. The function `list()` can also be used to to case any iterable to a list."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "print(list(\"hello\"))"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Create a list with some values\n",
-    "my_list = [4, 5, 6, 7, 8]\n",
-    "print(my_list)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# create a list of 10 copies of the same element\n",
-    "my_list = [7] * 10\n",
-    "print(my_list)\n",
-    "# useful for \"initialization\"\n",
-    "my_list = [0] * 10\n",
-    "print(my_list)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "#### Indexing & Assignment\n",
-    "\n",
-    "Indexing is done using square brackets `[]`. The index is the position (zero-based) of the element in the list. Negative indices count from the end of the list."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_list = [4, 5, 6, 7, 8]\n",
-    "print(my_list[0])  # first element\n",
-    "print(my_list[-1])  # last element"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "#### Slicing\n",
-    "\n",
-    "Slicing allows to take a 'subset' of the list. The syntax is `my_list[start:end]` or `my_list[start:end:step]`. The result will include elements at indices starting from the `start` up to but not including the `end`. If the step is specified, this will be the increment (or decrement if negative) between indices (by default, the step is 1). If the slice should start from the beginning, the `start` value can be omitted. Likewise, if the slice should end at the end, the `end` value can be omitted."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_list = [4, 5, 6, 7, 8]\n",
-    "print(my_list[0:2])  # first two elements\n",
-    "print(my_list[:2])  # first two elements, same as [0:2]\n",
-    "print(my_list[2:5])  # elements from index 2 to end (index 4)\n",
-    "print(my_list[2:])  # elements from index 2 to end (index 4), same as [2:5]"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "##### Negative"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "print(my_list[:-2])  # from beginning till the second to last index (excluding)\n",
-    "print(my_list[-2:])  # last two elements - from the second to last index till end"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "##### Using the step\n",
-    "All of these are the same:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "print(my_list[0:5:2])  # every second element from the beginning to the end\n",
-    "print(my_list[0::2])  # every second element from the beginning to the end\n",
-    "print(my_list[::2])  # every second element from the beginning to the end"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Negative step:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "print(my_list[::-1])  # reverse the list"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "...and any other combination you can imagine.\n",
-    "\n",
-    "#### Deleting elements\n",
-    "\n",
-    "There are several ways of how to remove elements from a list: splicing, 'popping' and deleting.\n",
-    "\n",
-    "Splice (combine two sub-lists, excluding the removed element):"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_list = [4, 5, 6, 7, 8]\n",
-    "my_list = my_list[:2] + my_list[3:]  # remove the element at index 2\n",
-    "print(my_list)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Using the `pop` method:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_list = [4, 5, 6, 7, 8]\n",
-    "print(my_list.pop())  # remove the last element and return it\n",
-    "print(my_list)\n",
-    "\n",
-    "print(my_list.pop(2))  # remove the element at index 2 and return it\n",
-    "print(my_list)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Using the `del` keyword:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_list = [4, 5, 6, 7, 8]\n",
-    "del my_list[2]  # delete the element at index 2\n",
-    "print(my_list)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "There is also the `remove` method, which removes the first occurrence of the specified value."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_list = [4, 5, 6, 7, 8]\n",
-    "my_list.remove(5)\n",
-    "print(my_list)\n",
-    "my_list.remove(my_list[2])  # remove the element at index 2"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Deleting from the end of the array is typically faster than deleting from 'within' the array, which is still faster than deleting from the beginning of the array.\n",
-    "\n",
-    "#### Inserting elements\n",
-    "\n",
-    "Using the `append` method:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_list = [4, 5, 6, 7, 8]\n",
-    "my_list.append(9)\n",
-    "print(my_list)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Using the `insert` method:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_list = [4, 5, 7, 8]\n",
-    "my_list.insert(2, 6)  # insert 6 at index 2\n",
-    "print(my_list)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Inserting at the beginning of the array is typically faster than inserting in the middle or at the end of the array.\n",
-    "\n",
-    "Extending an array with `extend`:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_list = [4, 5, 7, 8]\n",
-    "my_list.extend([6, 9])\n",
-    "print(my_list)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Or adding two lists with `+`:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_list = [4, 5, 7, 8]\n",
-    "my_list = my_list + [6, 9]\n",
-    "print(my_list)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "#### Length, comparison and membership\n",
-    "\n",
-    "Length of a list:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_list = [4, 5, 7, 8]\n",
-    "print(len(my_list))"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Remember the zero-based indexing:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "n = len(my_list)\n",
-    "print(my_list[n - 1])"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Comparison of lists (not very useful, usually you compare elements in a loop):"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "list_a = [1, 2, 3]\n",
-    "list_b = [4, 5, 6]\n",
-    "\n",
-    "# comparison\n",
-    "print(\"\\nComparison:\")\n",
-    "print(list_a == list_b)  # equality\n",
-    "print(list_a != list_b)  # inequality\n",
-    "\n",
-    "print(\"Equality is not strict but 'ordered'\")\n",
-    "print(\"[1, 2, 3.0] == [1, 2, 3]: \", [1, 2, 3.0] == [1, 2, 3])  # True\n",
-    "print(\"[1, 2, 3] == [3, 2, 1]: \", [1, 2, 3] == [3, 2, 1])  # False\n",
-    "\n",
-    "# Be (ALWAYS) aware of precision/numerical issues\n",
-    "print(\"[1, 2, 3.0000000000000000001] == [1, 2, 3]: \", [1, 2, 3.0000000000000000001] == [1, 2, 3])  # True, depends on the precision"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "##### Precision side-quest"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "print(\"3 == 3.00000001\", 3 == 3.00000001)  # False\n",
-    "print(\"3 == 2.99999999\", 3 == 2.99999999)  # False\n",
-    "print(\"1/3 == 0.33333333\", 1/3 == 0.33333333)  # False\n",
-    "print(\"3.00000000000000001 == 3: \", 3.00000000000000001 == 3)  # True\n",
-    "print(\"2.99999999999999999 == 3: \", 2.99999999999999999 == 3)  # True\n",
-    "print(\"1/3 == 0.333333333333333333333: \", 1/3 == 0.333333333333333333333)  # True"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "End of side-quest.\n",
-    "\n",
-    "Inequality of lists:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "print(\"\\nOrdering:\")\n",
-    "print(list_a < list_b)  # Compares maximum value\n",
-    "print(list_a > list_b)\n",
-    "print(list_a <= list_b)\n",
-    "print(list_a >= list_b)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Membership checking:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "print(\"\\nMembership:\")\n",
-    "print(1 in list_a)  # membership\n",
-    "print(7 not in list_a)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "#### Other methods for lists\n",
-    "\n",
-    "`index` - find the index of the first occurrence of the specified value"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_list = [4, 5, 6, 7, 8]\n",
-    "print(f\"Index of 5: {my_list.index(5)}\")"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "`count` - count the number of occurrences of the specified value"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_list = [4, 5, 6, 5, 7, 7, 8, 7]\n",
-    "print(f\"Count of 5: {my_list.count(5)}\")"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "`sort` - sort the list"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_list = [6, 4, 7, 5, 3]\n",
-    "my_list.sort()\n",
-    "print(my_list)\n",
-    "my_list.sort(reverse=True)\n",
-    "print(my_list)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "`reverse` - reverse the list"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_list = [4, 5, 6, 7, 8]\n",
-    "my_list.reverse()\n",
-    "print(my_list)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "`copy` - create a copy of the list"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_list = [4, 5, 6, 7, 8]\n",
-    "not_a_copy = my_list  # not a copy, just a reference\n",
-    "not_a_copy.append(9)\n",
-    "print(my_list)  # 9 was added to the original list\n",
-    "my_list_copy = my_list.copy()  # make a copy\n",
-    "my_list_copy.append(10)\n",
-    "print(my_list_copy)  # 10 was added to the copy\n",
-    "print(my_list)  # the original list is not affected"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Buuuut, `copy` is not a *deep* copy:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_dict = {\"a\": 1, \"b\": 2}\n",
-    "my_list = [[4, 5, 6], my_dict]\n",
-    "my_list_copy = my_list.copy()\n",
-    "my_list_copy[1][\"a\"] = 3\n",
-    "print(my_list)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Although, you shouldn't be using multi-typed lists anyway.\n",
-    "\n",
-    "#### Heterogeneous lists (\"jagged\" and multi-typed)\n",
-    "\n",
-    "It is best to avoid using heterogeneous lists (though sometimes, they are handy)."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Jagged nested list\n",
-    "my_list = [[1, 2, 3], [4, 5], [6]]\n",
-    "print(my_list)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Multi-typed\n",
-    "my_list = [1, 2, 3, \"hello\", [4, 5, 6]]\n",
-    "print(my_list)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "#### Advanced creation\n",
-    "\n",
-    "The `range` function creates a \"range\" object containing a sequence of integers from 0 to some number, excluding the last number. This can be converted to a list using the `list()` function."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Create a list of the first 5 integers\n",
-    "print(list(range(5)))"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "The `range` function allows you to specify the starting number and the step size.\n",
-    "The general syntax is `range(start, stop, step)`."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Numbers from 5 to 10\n",
-    "print(list(range(5, 10)))"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Create a list of even numbers from 0 to 10\n",
-    "print(list(range(0, 10, 2)))"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "List comprehensions are a concise way to create lists. However, they are technically equivalent to a for loop."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_list = [x * 2 for x in range(5)]\n",
-    "print(my_list)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Which is equivalent to:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_list = []\n",
-    "for x in range(5):\n",
-    "    my_list.append(x * 2)\n",
-    "print(my_list)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "The syntax for list comprehensions is:\n",
-    "```python\n",
-    "[<expression> for <item> in <iterable>]\n",
-    "```\n",
-    "\n",
-    "Comprehension, in general, are 'one-liner' creators of collections of objects. They can in some cases make the code more readable and in some cases much less readable. Basic Python datatypes support comprehensions.\n",
-    "\n",
-    "Comprehension with conditions:\n",
-    "```python\n",
-    "[<expression> for <item> in <iterable> if <condition>]\n",
-    "```\n",
-    "`<expression>` will be added to the list only if `<condition>` is `True`."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "odd_numbers = [x for x in range(10) if x % 2 == 1]\n",
-    "print(odd_numbers)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Comprehension with conditions and if-else:\n",
-    "```python\n",
-    "[<expression_one> if <condition> else <expression_two> for <item> in <iterable>]\n",
-    "```\n",
-    "`<expression_one>` will be added to the list if `<condition>` is `True`. Otherwise, `<expression_two>` will be added to the list (`<condition>` is `False`)."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "even_or_negative_numbers = [x if x % 2 == 1 else -x for x in range(10)]\n",
-    "print(even_or_negative_numbers)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "### Dictionaries\n",
-    "\n",
-    "Dictionaries are like lookup tables. They are implemented using a hash table, which means accessing an element is very fast.\n",
-    "\n",
-    "#### Creation\n",
-    "\n",
-    "Empty dictionarys can be created using the `dict()` function or with `{}` (preferred way)."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "empty_dict = dict()\n",
-    "also_empty_dict = {}"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Initialization:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_dict = {\n",
-    "    \"a\": 1,\n",
-    "    \"b\": 2,\n",
-    "}\n",
-    "print(my_dict)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "#### Deleting and inserting\n",
-    "\n",
-    "Insert"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_dict = {}\n",
-    "my_dict[\"a\"] = 1\n",
-    "my_dict[\"b\"] = 2\n",
-    "print(my_dict)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Delete"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_dict = {\"a\": 1, \"b\": 2}\n",
-    "del my_dict[\"a\"]\n",
-    "print(my_dict)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "#### Retrieving value"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_dict = {\"a\": 1, \"b\": 2}\n",
-    "print(my_dict[\"a\"])\n",
-    "print(my_dict.get(\"a\"))  # safe retrieval, return None if \"a\" not in dictionary\n",
-    "print(my_dict.get(\"c\"))  # safe retrieval, return None if \"a\" not in dictionary\n",
-    "print(my_dict.get(\"a\", -1))  # default value\n",
-    "print(my_dict.get(\"c\", -1))  # default value"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Membership"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_dict = {\"a\": 1, \"b\": 2}\n",
-    "print(\"a\" in my_dict)\n",
-    "print(\"c\" in my_dict)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "#### Advanced creation\n",
-    "\n",
-    "Dictionary comprehension:\n",
-    "```python\n",
-    "{x: x**2 for x in range(5)}\n",
-    "```\n",
-    "\n",
-    "#### Hashmaps\n",
-    "\n",
-    "The `hash()` function returns the hash value of an object - seemingly arbitrary but for the same input consistent value (headache warning: the consistency holds only for the current \"session\"!)."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "print(hash(\"hello\"))\n",
-    "print(hash(\"hello\") == hash(\"hello\"))"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Array access, given an index is *fast* (constant time), however, finding a specific value is slow.\n",
-    "Hashmaps solve this by encoding the value into an index."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "def hash_into_index(value, total_items):\n",
-    "    return hash(value) % total_items\n",
-    "\n",
-    "print(hash_into_index(\"hello\", 10))"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "class MyHashmap:\n",
-    "    def __init__(self, total_items):\n",
-    "        self.total_items = total_items\n",
-    "        self.keys = [None] * total_items\n",
-    "        self.values = [None] * total_items\n",
-    "\n",
-    "    def __setitem__(self, key, value):  # magic to allow indexed assignment\n",
-    "        index = hash_into_index(key, self.total_items)\n",
-    "        self.keys[index] = key\n",
-    "        self.values[index] = value\n",
-    "\n",
-    "    def __getitem__(self, key):  # magic to allow indexing\n",
-    "        index = hash_into_index(key, self.total_items)\n",
-    "        return self.values[index]\n",
-    "\n",
-    "    def __contains__(self, key):  # magic to allow the \"in\" keyword\n",
-    "        index = hash_into_index(key, self.total_items)\n",
-    "        return self.keys[index] == key\n",
-    "\n",
-    "    def __iter__(self):  # magic to allow the for-each loop\n",
-    "        for key, value in zip(self.keys, self.values):\n",
-    "            if key is not None:\n",
-    "                yield key, value\n",
-    "\n",
-    "    def print(self):\n",
-    "        for key, value in self:\n",
-    "            print(f\"{key}: {value}\")\n",
-    "\n",
-    "hashmap = MyHashmap(10)\n",
-    "hashmap[\"hello\"] = \"world\"\n",
-    "print(hashmap[\"hello\"])\n",
-    "print(\"hello\" in hashmap)\n",
-    "\n",
-    "print(\"Contents of our hashmap:\")\n",
-    "hashmap[\"water\"] = \"world\"\n",
-    "hashmap[\"hellno\"] = \"word\"\n",
-    "hashmap[\"pi\"] = 3.14\n",
-    "hashmap[\"e\"] = 2.718\n",
-    "hashmap[9] = \"number hashes are the same as the number itself\"\n",
-    "hashmap.print()"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Obviously, this is not a very good implementation of a hashmap:\n",
-    "1) Collisions (multiple keys hash to the same index)\n",
-    "2) Memory usage (None values for empty slots)\n",
-    "\n",
-    "### Tuples & Sets\n",
-    "\n",
-    "Besides lists, Python also has tuples and sets. These can also store multiple values but offer different functionalities.\n",
-    "\n",
-    "#### Creation\n",
-    "\n",
-    "##### Tuples\n",
-    "A tuple is an immutable list. Tuples are created using the parentheses `()`. Similarly, the `tuple()` function can be used to convert an iterable to a tuple.\n",
-    "\n",
-    "Empty tuple:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "#| echo: true\n",
-    "#| code-fold: false\n",
-    "empty_tuple = tuple()  # kinda pointless - tuples cannot be changed\n",
-    "empty_tuple = ()  # kinda pointless - tuples cannot be changed"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Single element tuple:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "single_element_tuple = (1,)  # mind the comma! (1) is just 1 in brackets\n",
-    "print((2) * 10)  # just `2 * 10` => 20\n",
-    "print((2,) * 10)  # tuple of 10 2s"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Creating tuples:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_tuple = (1, 2, 3)\n",
-    "print(my_tuple)\n",
-    "\n",
-    "# Create a tuple from a list\n",
-    "my_list = [4, 5, 6]\n",
-    "my_tuple = tuple(my_list)\n",
-    "print(my_tuple)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Tuple comprehension:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_tuple = (x * 2 for x in range(5))\n",
-    "print(my_tuple)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Oops, that's not what we wanted!\n",
-    "Actual tuple comprehension (we might talk about generators later):"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_tuple = tuple(x * 2 for x in range(5))\n",
-    "print(my_tuple)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "##### Sets\n",
-    "\n",
-    "A set is an unordered collection of unique elements. Sets are created using the `{<iterable>}`. Similarly, the `set()` function can be used to convert an iterable to a set.\n",
-    "\n",
-    "Empty set:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "#| echo: true\n",
-    "#| code-fold: false\n",
-    "empty_set = set()"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Careful, `{}` will not create an empty set but an empty dictionary! (see below)\n",
-    "\n",
-    "Creating sets:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Create a set\n",
-    "my_set = {1, 2, 3}\n",
-    "print(my_set)\n",
-    "\n",
-    "# Create a set from a list\n",
-    "my_list = [4, 5, 6]\n",
-    "my_set = set(my_list)\n",
-    "print(my_set)\n",
-    "my_set.add(7)  # Add an element to the set\n",
-    "print(my_set)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Adding elements to a set is an [idempotent](https://en.wikipedia.org/wiki/Idempotence){.external target=\"_blank\"} operation - adding the same element twice does not change the set."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_set = {1, 2, 3}\n",
-    "print(\"my_set: \", my_set)\n",
-    "my_set.add(4)\n",
-    "print(\"my_set after adding 4: \", my_set)\n",
-    "for i in range(100):\n",
-    "    my_set.add(4)\n",
-    "print(\"my_set after adding 4 one hundred times: \", my_set)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Set comprehension:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_set = {int(x / 2) for x in range(15)}\n",
-    "print(my_set)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Sets are not immutable but they do not support assignment. Rather, you can add or remove elements.\n",
-    "```python\n",
-    "my_set.add(10)  # The set can be changed.\n",
-    "my_set[0] = 10  # This will raise an error!!!\n",
-    "```\n",
-    "\n",
-    "#### Tuple differences with Lists\n",
-    "\n",
-    "Main difference is that tuples are immutable. They cannot be changed once created. Some operations are faster than on lists.\n",
-    "\n",
-    "```python\n",
-    "my_tuple = (1, 2, 3)\n",
-    "my_tuple[1] = 4  # This will raise an error!!!\n",
-    "```\n",
-    "\n",
-    "Tuples are used as the return type in case of functions returning multiple values."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "def my_function():\n",
-    "    n = 5\n",
-    "    l = [i for i in range(n)]\n",
-    "    return l, n\n",
-    "\n",
-    "print(my_function())"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Tuples are \"safe\" to pass around because of their immutability."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "def my_function(iterable_input):\n",
-    "    result = []\n",
-    "    for i in iterable_input:\n",
-    "        result.append(i * 2)\n",
-    "    # sneakily change the input:\n",
-    "    iterable_input[0] = 10\n",
-    "    return result\n",
-    "\n",
-    "my_list = [1, 2, 3]  # precious data we don't want to change\n",
-    "print(f\"My list before running my_function: {my_list}\")\n",
-    "print(my_function(my_list))\n",
-    "print(f\"My list after running my_function: {my_list}\")\n",
-    "\n",
-    "my_tuple = (1, 2, 3)  # precious data we don't want to change\n",
-    "try:\n",
-    "    print(my_function(my_tuple))\n",
-    "except TypeError:  # this will catch the error raised whe the function tries to change the tuple\n",
-    "    print(\"Aha! The function tried to change my tuple!\")"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "#### Set differences with Lists\n",
-    "\n",
-    "Sets are unordered and do not allow duplicates. They are implemented as 'hash set' and thus certain operations are very efficient (e.g. membership checking, union, intersection, etc.)."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_list = [1, 2, 2, 3, 3, 4, 5, 5]\n",
-    "my_set = set(my_list)\n",
-    "print(my_set)\n",
-    "print(3 in my_set)  # True\n",
-    "print(6 in my_set)  # False\n",
-    "my_set.add(5)  # Added element already in list, nothing happens\n",
-    "print(my_set)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Set specific operations:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "print({1, 2, 3} | {4, 5, 6})  # Union\n",
-    "print({1, 2, 3} & {3, 4, 5})  # Intersection\n",
-    "print({1, 2, 3, 4} - {1, 4, 5})  # Difference\n",
-    "print({1, 4, 5} - {1, 2, 3, 4})  # Difference (reversed set order)\n",
-    "print({1, 4, 5} ^ {1, 2, 3, 4})  # Symmetric Difference (XOR)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Since tuple are immutable, unlike lists or sets, they can be used as keys in a dictionary."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_tuple_dict = {\n",
-    "    (1, 2): 3,\n",
-    "    (4, 5): 6\n",
-    "}\n",
-    "\n",
-    "print(my_tuple_dict[(1, 2)])"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "This would throw an error:\n",
-    "\n",
-    "```python\n",
-    "my_list_dict = {\n",
-    "    [1, 2]: 3,\n",
-    "    [4, 5]: 6\n",
-    "}\n",
-    "```\n",
-    "\n",
-    "## Basic operations with data structures\n",
-    "\n",
-    "### Looping through iterables\n",
-    "\n",
-    "#### Indexed loop"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_list = list('alphabet')\n",
-    "n = len(my_list)\n",
-    "for i in range(n):\n",
-    "    value = my_list[i]\n",
-    "    print(f\"Value at index {i}: {value}\")"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "#### 'For each' loop"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "for value in my_list:\n",
-    "    print(f\"Value: {value}\")"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "#### Enumerate\n",
-    "\n",
-    "The `enumerate()` function returns a tuple of the index and the value at that index. This is equivalent to the indexed loop but 'nicer' (no need to explicitly extract the value and index)."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "for index, value in enumerate(my_list):\n",
-    "    print(f\"Value at index {index}: {value}\")"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "#### While loop\n",
-    "\n",
-    "The `while` loop is useful in some special cases (e.g., growing lists - although, this can be dangerous)."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "from random import random\n",
-    "\n",
-    "my_list = list(range(10))\n",
-    "n = len(my_list)\n",
-    "i = 0\n",
-    "while i < len(my_list):\n",
-    "    value = my_list[i]\n",
-    "    if random() < 1 / (i - value + 1.5):\n",
-    "        my_list.append(value)\n",
-    "    i += 1\n",
-    "\n",
-    "print(my_list)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "More typical is to use infinite loops with `while True` and `break` statements.\n",
-    "\n",
-    "#### Looping through sets and tuples\n",
-    "\n",
-    "The same looping \"techniques\" work for sets and tuples."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_set = {1, 2, 3}\n",
-    "for i, value in enumerate(my_set):\n",
-    "    print(f\"Value: {value}\")"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "my_tuple = (1, 2, 3)\n",
-    "for value in my_tuple:\n",
-    "    print(f\"Value: {value}\")"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "#### Looping through dictionaries\n",
-    "\n",
-    "Most common way to iterate over a dictionary is to use the `items()` method, which will return (key, value) pairs."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "import string\n",
-    "\n",
-    "alphabet_dict = {k: v for k, v in zip(string.ascii_letters, range(26)) if v < 12 and v % 2 == 0}\n",
-    "for key, value in alphabet_dict.items():\n",
-    "    print(f\"The letter {key} is at position {value + 1} in the alphabet.\")"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "There are also `keys()` and `values()` methods that return the keys and values respectively."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "for key in alphabet_dict.keys():\n",
-    "    print(f\"The letter {key} is at position {alphabet_dict[key] + 1} in the alphabet.\")"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "positions = []\n",
-    "for value in alphabet_dict.values():\n",
-    "    positions.append(value + 1)\n",
-    "print(f\"The dictionary contains letters at these positions: {positions}\")"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "### 2D Arrays (Matrices)\n",
-    "\n",
-    "Simply \"stack\" lists inside of a list (nested list)."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "matrix = [\n",
-    "    [1, 2, 3],\n",
-    "    [4, 5, 6],\n",
-    "    [7, 8, 9]\n",
-    "]\n",
-    "print(matrix)\n",
-    "also_matrix = [[j + i * 3 for j in range(3)] for i in range(3)]\n",
-    "print(also_matrix)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "#### Accessing elements\n",
-    "`matrix[row][column]`:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "print(matrix[0][1])  # 0th row, 1st column\n",
-    "print(matrix[1][0])  # 1st row, 0th column"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "#### Looping through matrices\n",
-    "\n",
-    "For each row in the matrix, loop through each element in the row."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "for row in matrix:\n",
-    "    for value in row:\n",
-    "        print(value)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Enumerate the rows and columns."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "for row_index, row in enumerate(matrix):\n",
-    "    for column_index, value in enumerate(row):\n",
-    "        print(f\"Value at row {row_index} and column {column_index}: {value}\")"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Assignment to a matrix:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "for row_index, row in enumerate(matrix):\n",
-    "    for column_index, value in enumerate(row):\n",
-    "        matrix[row_index][column_index] = value**2\n",
-    "print(matrix)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "## Types and comparisons\n",
-    "\n",
-    "Checking types of variables is very useful, especially in Python that allows dynamic typing. Many operations are defined for different data types. For example, `1 * 2` is fine, and so is `[1] * 2` and `\"1\" * 2` but they result in different outcomes. It is often important to assert that the variables are of the expected type (use `if` or `assert`).\n",
-    "\n",
-    "### It is or it is not\n",
-    "\n",
-    "The `is` operator can be used to check if two objects are the same object."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "a = [1, 2, 3]\n",
-    "b = [1, 2, 3]\n",
-    "c = a\n",
-    "d = a[:]\n",
-    "e = a.copy()\n",
-    "print(a is b)  # the elements have the same value but `a` is not the same object as `b`\n",
-    "print(a is c)  # `a` and `c` reference the same object\n",
-    "print(a is d)  # `d` got the values from `a` but it is still a different object (different memory location)\n",
-    "print(a is e)  # copy makes a new object with the same values (essentially, similar process to defining `d`)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "It is not?"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "print(a is not b)  # True\n",
-    "print(a is not c)  # False\n",
-    "print(a is not d)  # True\n",
-    "print(a is not e)  # True"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Some 'objects' are the same but it makes no sense to compare them using `is`. Although, `is` will get you a strict equality."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# this will get you a syntax warning\n",
-    "print(1 is 1.0)  # False, because float != int\n",
-    "print(1.0 is 1.0)  # True\n",
-    "print((1, 2, 3) is (1, 2, 3))  # Unexpectedly, True but also not advised; just use equality `==`"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "For number type check, see below.\n",
-    "\n",
-    "### type\n",
-    "\n",
-    "Use the `type(<object>)` build-in function to get the type of an object."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "print(type(1))  # <class 'int'>\n",
-    "print(type(1.0))  # <class 'float'>\n",
-    "\n",
-    "my_list = [1, 2, 3]\n",
-    "print(type(my_list))  # <class 'list'>\n",
-    "print(type(tuple(my_list)))  # <class 'list'>"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "### isinstance\n",
-    "\n",
-    "The `isinstance(<object>, <type>)` build-in function checks whether the object is an instance of the type."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "print(isinstance([1, 2, 3], list))  # True\n",
-    "print(isinstance([1, 2, 3], tuple))  # False\n",
-    "print(isinstance(1, int))  # True\n",
-    "print(isinstance(1, float))  # False"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "### issubclass\n",
-    "\n",
-    "This will become handy much later, when you will be working with classes. However, there is some use with basic datatypes."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 2,
-   "metadata": {},
-   "outputs": [
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "True\n",
-      "False\n",
-      "True\n"
-     ]
-    }
-   ],
-   "source": [
-    "my_integer = 3\n",
-    "print(issubclass(type(my_integer), int))  # True\n",
-    "print(issubclass(type(my_integer), float))  # False\n",
-    "print(issubclass(type(my_integer), (int, float)))  # True"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "### Being assertive in your statements (programmatically speaking)\n",
-    "\n",
-    "Potentially unsafe code:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "def multiply_two_numbers_unchecked(a, b):\n",
-    "    return a * b\n",
-    "\n",
-    "print(multiply_two_numbers_unchecked(3, 2))  # 6 is fine\n",
-    "print(multiply_two_numbers_unchecked(3.0, 2))  # 6.0 is fine\n",
-    "print(multiply_two_numbers_unchecked(\"3\", 2))  # 33 !?"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "Code with run-time type checking and assertions:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 8,
-   "metadata": {},
-   "outputs": [
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "6\n",
-      "6.0\n",
-      "'a' must be a number! But it was: <class 'str'>\n"
-     ]
-    }
-   ],
-   "source": [
-    "def multiply_two_numbers(a, b):\n",
-    "    assert isinstance(a, (int, float)), f\"'a' must be a number! But it was: {type(a)}\"\n",
-    "    assert isinstance(b, (int, float)), f\"'b' must be a number! But it was: {type(b)}\"\n",
-    "    # Alternatively, this will also work:\n",
-    "    assert issubclass(type(my_integer), (int, float)), f\"'a' must be a number! But it was: {type(a)}\"\n",
-    "    return a * b\n",
-    "\n",
-    "print(multiply_two_numbers(3, 2))\n",
-    "print(multiply_two_numbers(3.0, 2))\n",
-    "try:\n",
-    "    print(multiply_two_numbers(\"3\", 2))  # this will cause an error\n",
-    "except AssertionError as e:\n",
-    "    print(e)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "More info about basic types and comparison: https://docs.python.org/3/library/stdtypes.html\n",
-    "\n",
-    "#### But, maybe, don't be too assertive?\n",
-    "\n",
-    "`assert` is fine for testing and debugging, but not for production code! There are better, safer ways of handling incorrect inputs (`try...except` + `warn`/`log.error` and handle the erroneous state \"gracefully\"). Essentially, production code should never halt!!!"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 7,
-   "metadata": {},
-   "outputs": [
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "Using type error & try...except:\n",
-      "6\n",
-      "6.0\n",
-      "One of the inputs had an incorrect type. See the error: 'a' must be a number! But it was: <class 'str'>\n",
-      "\n",
-      "Using None return type:\n",
-      "One of the inputs had an incorrect type.\n"
-     ]
+  "cells": [
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "# Basic data structures, intro to asymptotic complexity"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "---\n",
+        "title: \"Lecture 1\"\n",
+        "format:\n",
+        "  html:\n",
+        "    code-fold: false\n",
+        "jupyter: python3\n",
+        "---\n",
+        "\n",
+        "## Basic data structures (in Python)\n",
+        "\n",
+        "### Arrays (Python lists)\n",
+        "\n",
+        "List is a build-in array-like data structure in Python. Unlike \"traditional\" arrays, Python lists are untyped. This makes their usage simpler but less efficient and sometimes \"dangerous\".\n",
+        "More efficient 'standard' array implementation can be found in the *NumPy* package, which we will discuss in a later lecture.\n",
+        "\n",
+        "\n",
+        "#### Creation\n",
+        "\n",
+        "Creating empty lists can be done in two ways:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "#| echo: true\n",
+        "# Create an empty list\n",
+        "my_list = []\n",
+        "print(my_list)\n",
+        "my_list = list()\n",
+        "print(my_list)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Using the square brackets `[]` is the preferred way in Python. The function `list()` can also be used to to case any iterable to a list."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "print(list(\"hello\"))"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "# Create a list with some values\n",
+        "my_list = [4, 5, 6, 7, 8]\n",
+        "print(my_list)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "# create a list of 10 copies of the same element\n",
+        "my_list = [7] * 10\n",
+        "print(my_list)\n",
+        "# useful for \"initialization\"\n",
+        "my_list = [0] * 10\n",
+        "print(my_list)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### Indexing & Assignment\n",
+        "\n",
+        "Indexing is done using square brackets `[]`. The index is the position (zero-based) of the element in the list. Negative indices count from the end of the list."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_list = [4, 5, 6, 7, 8]\n",
+        "print(my_list[0])  # first element\n",
+        "print(my_list[-1])  # last element"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### Slicing\n",
+        "\n",
+        "Slicing allows to take a 'subset' of the list. The syntax is `my_list[start:end]` or `my_list[start:end:step]`. The result will include elements at indices starting from the `start` up to but not including the `end`. If the step is specified, this will be the increment (or decrement if negative) between indices (by default, the step is 1). If the slice should start from the beginning, the `start` value can be omitted. Likewise, if the slice should end at the end, the `end` value can be omitted."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_list = [4, 5, 6, 7, 8]\n",
+        "print(my_list[0:2])  # first two elements\n",
+        "print(my_list[:2])  # first two elements, same as [0:2]\n",
+        "print(my_list[2:5])  # elements from index 2 to end (index 4)\n",
+        "print(my_list[2:])  # elements from index 2 to end (index 4), same as [2:5]"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "##### Negative"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "print(my_list[:-2])  # from beginning till the second to last index (excluding)\n",
+        "print(my_list[-2:])  # last two elements - from the second to last index till end"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "##### Using the step\n",
+        "All of these are the same:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "print(my_list[0:5:2])  # every second element from the beginning to the end\n",
+        "print(my_list[0::2])  # every second element from the beginning to the end\n",
+        "print(my_list[::2])  # every second element from the beginning to the end"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Negative step:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "print(my_list[::-1])  # reverse the list"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "...and any other combination you can imagine.\n",
+        "\n",
+        "#### Deleting elements\n",
+        "\n",
+        "There are several ways of how to remove elements from a list: splicing, 'popping' and deleting.\n",
+        "\n",
+        "Splice (combine two sub-lists, excluding the removed element):"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_list = [4, 5, 6, 7, 8]\n",
+        "my_list = my_list[:2] + my_list[3:]  # remove the element at index 2\n",
+        "print(my_list)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Using the `pop` method:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_list = [4, 5, 6, 7, 8]\n",
+        "print(my_list.pop())  # remove the last element and return it\n",
+        "print(my_list)\n",
+        "\n",
+        "print(my_list.pop(2))  # remove the element at index 2 and return it\n",
+        "print(my_list)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Using the `del` keyword:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_list = [4, 5, 6, 7, 8]\n",
+        "del my_list[2]  # delete the element at index 2\n",
+        "print(my_list)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "There is also the `remove` method, which removes the first occurrence of the specified value."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_list = [4, 5, 6, 7, 8]\n",
+        "my_list.remove(5)\n",
+        "print(my_list)\n",
+        "my_list.remove(my_list[2])  # remove the element at index 2"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Deleting from the end of the array is typically faster than deleting from 'within' the array, which is still faster than deleting from the beginning of the array.\n",
+        "\n",
+        "#### Inserting elements\n",
+        "\n",
+        "Using the `append` method:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_list = [4, 5, 6, 7, 8]\n",
+        "my_list.append(9)\n",
+        "print(my_list)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Using the `insert` method:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_list = [4, 5, 7, 8]\n",
+        "my_list.insert(2, 6)  # insert 6 at index 2\n",
+        "print(my_list)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Inserting at the beginning of the array is typically faster than inserting in the middle or at the end of the array.\n",
+        "\n",
+        "Extending an array with `extend`:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_list = [4, 5, 7, 8]\n",
+        "my_list.extend([6, 9])\n",
+        "print(my_list)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Or adding two lists with `+`:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_list = [4, 5, 7, 8]\n",
+        "my_list = my_list + [6, 9]\n",
+        "print(my_list)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### Length, comparison and membership\n",
+        "\n",
+        "Length of a list:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_list = [4, 5, 7, 8]\n",
+        "print(len(my_list))"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Remember the zero-based indexing:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "n = len(my_list)\n",
+        "print(my_list[n - 1])"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Comparison of lists (not very useful, usually you compare elements in a loop):"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "list_a = [1, 2, 3]\n",
+        "list_b = [4, 5, 6]\n",
+        "\n",
+        "# comparison\n",
+        "print(\"\\nComparison:\")\n",
+        "print(list_a == list_b)  # equality\n",
+        "print(list_a != list_b)  # inequality\n",
+        "\n",
+        "print(\"Equality is not strict but 'ordered'\")\n",
+        "print(\"[1, 2, 3.0] == [1, 2, 3]: \", [1, 2, 3.0] == [1, 2, 3])  # True\n",
+        "print(\"[1, 2, 3] == [3, 2, 1]: \", [1, 2, 3] == [3, 2, 1])  # False\n",
+        "\n",
+        "# Be (ALWAYS) aware of precision/numerical issues\n",
+        "print(\"[1, 2, 3.0000000000000000001] == [1, 2, 3]: \", [1, 2, 3.0000000000000000001] == [1, 2, 3])  # True, depends on the precision"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "##### Precision side-quest"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "print(\"3 == 3.00000001\", 3 == 3.00000001)  # False\n",
+        "print(\"3 == 2.99999999\", 3 == 2.99999999)  # False\n",
+        "print(\"1/3 == 0.33333333\", 1/3 == 0.33333333)  # False\n",
+        "print(\"3.00000000000000001 == 3: \", 3.00000000000000001 == 3)  # True\n",
+        "print(\"2.99999999999999999 == 3: \", 2.99999999999999999 == 3)  # True\n",
+        "print(\"1/3 == 0.333333333333333333333: \", 1/3 == 0.333333333333333333333)  # True"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "End of side-quest.\n",
+        "\n",
+        "Inequality of lists:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "print(\"\\nOrdering:\")\n",
+        "print(list_a < list_b)  # Compares maximum value\n",
+        "print(list_a > list_b)\n",
+        "print(list_a <= list_b)\n",
+        "print(list_a >= list_b)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Membership checking:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "print(\"\\nMembership:\")\n",
+        "print(1 in list_a)  # membership\n",
+        "print(7 not in list_a)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### Other methods for lists\n",
+        "\n",
+        "`index` - find the index of the first occurrence of the specified value"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_list = [4, 5, 6, 7, 8]\n",
+        "print(f\"Index of 5: {my_list.index(5)}\")"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "`count` - count the number of occurrences of the specified value"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_list = [4, 5, 6, 5, 7, 7, 8, 7]\n",
+        "print(f\"Count of 5: {my_list.count(5)}\")"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "`sort` - sort the list"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_list = [6, 4, 7, 5, 3]\n",
+        "my_list.sort()\n",
+        "print(my_list)\n",
+        "my_list.sort(reverse=True)\n",
+        "print(my_list)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "`reverse` - reverse the list"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_list = [4, 5, 6, 7, 8]\n",
+        "my_list.reverse()\n",
+        "print(my_list)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "`copy` - create a copy of the list"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_list = [4, 5, 6, 7, 8]\n",
+        "not_a_copy = my_list  # not a copy, just a reference\n",
+        "not_a_copy.append(9)\n",
+        "print(my_list)  # 9 was added to the original list\n",
+        "my_list_copy = my_list.copy()  # make a copy\n",
+        "my_list_copy.append(10)\n",
+        "print(my_list_copy)  # 10 was added to the copy\n",
+        "print(my_list)  # the original list is not affected"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Buuuut, `copy` is not a *deep* copy:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_dict = {\"a\": 1, \"b\": 2}\n",
+        "my_list = [[4, 5, 6], my_dict]\n",
+        "my_list_copy = my_list.copy()\n",
+        "my_list_copy[1][\"a\"] = 3\n",
+        "print(my_list)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Although, you shouldn't be using multi-typed lists anyway.\n",
+        "\n",
+        "#### Heterogeneous lists (\"jagged\" and multi-typed)\n",
+        "\n",
+        "It is best to avoid using heterogeneous lists (though sometimes, they are handy)."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "# Jagged nested list\n",
+        "my_list = [[1, 2, 3], [4, 5], [6]]\n",
+        "print(my_list)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "# Multi-typed\n",
+        "my_list = [1, 2, 3, \"hello\", [4, 5, 6]]\n",
+        "print(my_list)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### Advanced creation\n",
+        "\n",
+        "The `range` function creates a \"range\" object containing a sequence of integers from 0 to some number, excluding the last number. This can be converted to a list using the `list()` function."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "# Create a list of the first 5 integers\n",
+        "print(list(range(5)))"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "The `range` function allows you to specify the starting number and the step size.\n",
+        "The general syntax is `range(start, stop, step)`."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "# Numbers from 5 to 10\n",
+        "print(list(range(5, 10)))"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "# Create a list of even numbers from 0 to 10\n",
+        "print(list(range(0, 10, 2)))"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "List comprehensions are a concise way to create lists. However, they are technically equivalent to a for loop."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_list = [x * 2 for x in range(5)]\n",
+        "print(my_list)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Which is equivalent to:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_list = []\n",
+        "for x in range(5):\n",
+        "    my_list.append(x * 2)\n",
+        "print(my_list)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "The syntax for list comprehensions is:\n",
+        "```python\n",
+        "[<expression> for <item> in <iterable>]\n",
+        "```\n",
+        "\n",
+        "Comprehension, in general, are 'one-liner' creators of collections of objects. They can in some cases make the code more readable and in some cases much less readable. Basic Python datatypes support comprehensions.\n",
+        "\n",
+        "Comprehension with conditions:\n",
+        "```python\n",
+        "[<expression> for <item> in <iterable> if <condition>]\n",
+        "```\n",
+        "`<expression>` will be added to the list only if `<condition>` is `True`."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "odd_numbers = [x for x in range(10) if x % 2 == 1]\n",
+        "print(odd_numbers)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Comprehension with conditions and if-else:\n",
+        "```python\n",
+        "[<expression_one> if <condition> else <expression_two> for <item> in <iterable>]\n",
+        "```\n",
+        "`<expression_one>` will be added to the list if `<condition>` is `True`. Otherwise, `<expression_two>` will be added to the list (`<condition>` is `False`)."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "even_or_negative_numbers = [x if x % 2 == 1 else -x for x in range(10)]\n",
+        "print(even_or_negative_numbers)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "### Dictionaries\n",
+        "\n",
+        "Dictionaries are like lookup tables. They are implemented using a hash table, which means accessing an element is very fast.\n",
+        "\n",
+        "#### Creation\n",
+        "\n",
+        "Empty dictionarys can be created using the `dict()` function or with `{}` (preferred way)."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "empty_dict = dict()\n",
+        "also_empty_dict = {}"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Initialization:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_dict = {\n",
+        "    \"a\": 1,\n",
+        "    \"b\": 2,\n",
+        "}\n",
+        "print(my_dict)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### Deleting and inserting\n",
+        "\n",
+        "Insert"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_dict = {}\n",
+        "my_dict[\"a\"] = 1\n",
+        "my_dict[\"b\"] = 2\n",
+        "print(my_dict)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Delete"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_dict = {\"a\": 1, \"b\": 2}\n",
+        "del my_dict[\"a\"]\n",
+        "print(my_dict)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### Retrieving value"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_dict = {\"a\": 1, \"b\": 2}\n",
+        "print(my_dict[\"a\"])\n",
+        "print(my_dict.get(\"a\"))  # safe retrieval, return None if \"a\" not in dictionary\n",
+        "print(my_dict.get(\"c\"))  # safe retrieval, return None if \"a\" not in dictionary\n",
+        "print(my_dict.get(\"a\", -1))  # default value\n",
+        "print(my_dict.get(\"c\", -1))  # default value"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Membership"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_dict = {\"a\": 1, \"b\": 2}\n",
+        "print(\"a\" in my_dict)\n",
+        "print(\"c\" in my_dict)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### Advanced creation\n",
+        "\n",
+        "Dictionary comprehension:\n",
+        "```python\n",
+        "{x: x**2 for x in range(5)}\n",
+        "```\n",
+        "\n",
+        "#### Hashmaps\n",
+        "\n",
+        "The `hash()` function returns the hash value of an object - seemingly arbitrary but for the same input consistent value (headache warning: the consistency holds only for the current \"session\"!)."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "print(hash(\"hello\"))\n",
+        "print(hash(\"hello\") == hash(\"hello\"))"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Array access, given an index is *fast* (constant time), however, finding a specific value is slow.\n",
+        "Hashmaps solve this by encoding the value into an index."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "def hash_into_index(value, total_items):\n",
+        "    return hash(value) % total_items\n",
+        "\n",
+        "print(hash_into_index(\"hello\", 10))"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "class MyHashmap:\n",
+        "    def __init__(self, total_items):\n",
+        "        self.total_items = total_items\n",
+        "        self.keys = [None] * total_items\n",
+        "        self.values = [None] * total_items\n",
+        "\n",
+        "    def __setitem__(self, key, value):  # magic to allow indexed assignment\n",
+        "        index = hash_into_index(key, self.total_items)\n",
+        "        self.keys[index] = key\n",
+        "        self.values[index] = value\n",
+        "\n",
+        "    def __getitem__(self, key):  # magic to allow indexing\n",
+        "        index = hash_into_index(key, self.total_items)\n",
+        "        return self.values[index]\n",
+        "\n",
+        "    def __contains__(self, key):  # magic to allow the \"in\" keyword\n",
+        "        index = hash_into_index(key, self.total_items)\n",
+        "        return self.keys[index] == key\n",
+        "\n",
+        "    def __iter__(self):  # magic to allow the for-each loop\n",
+        "        for key, value in zip(self.keys, self.values):\n",
+        "            if key is not None:\n",
+        "                yield key, value\n",
+        "\n",
+        "    def print(self):\n",
+        "        for key, value in self:\n",
+        "            print(f\"{key}: {value}\")\n",
+        "\n",
+        "hashmap = MyHashmap(10)\n",
+        "hashmap[\"hello\"] = \"world\"\n",
+        "print(hashmap[\"hello\"])\n",
+        "print(\"hello\" in hashmap)\n",
+        "\n",
+        "print(\"Contents of our hashmap:\")\n",
+        "hashmap[\"water\"] = \"world\"\n",
+        "hashmap[\"hellno\"] = \"word\"\n",
+        "hashmap[\"pi\"] = 3.14\n",
+        "hashmap[\"e\"] = 2.718\n",
+        "hashmap[9] = \"number hashes are the same as the number itself\"\n",
+        "hashmap.print()"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Obviously, this is not a very good implementation of a hashmap:\n",
+        "1) Collisions (multiple keys hash to the same index)\n",
+        "2) Memory usage (None values for empty slots)\n",
+        "\n",
+        "### Tuples & Sets\n",
+        "\n",
+        "Besides lists, Python also has tuples and sets. These can also store multiple values but offer different functionalities.\n",
+        "\n",
+        "#### Creation\n",
+        "\n",
+        "##### Tuples\n",
+        "A tuple is an immutable list. Tuples are created using the parentheses `()`. Similarly, the `tuple()` function can be used to convert an iterable to a tuple.\n",
+        "\n",
+        "Empty tuple:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "#| echo: true\n",
+        "#| code-fold: false\n",
+        "empty_tuple = tuple()  # kinda pointless - tuples cannot be changed\n",
+        "empty_tuple = ()  # kinda pointless - tuples cannot be changed"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Single element tuple:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "single_element_tuple = (1,)  # mind the comma! (1) is just 1 in brackets\n",
+        "print((2) * 10)  # just `2 * 10` => 20\n",
+        "print((2,) * 10)  # tuple of 10 2s"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Creating tuples:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_tuple = (1, 2, 3)\n",
+        "print(my_tuple)\n",
+        "\n",
+        "# Create a tuple from a list\n",
+        "my_list = [4, 5, 6]\n",
+        "my_tuple = tuple(my_list)\n",
+        "print(my_tuple)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Tuple comprehension:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_tuple = (x * 2 for x in range(5))\n",
+        "print(my_tuple)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Oops, that's not what we wanted!\n",
+        "Actual tuple comprehension (we might talk about generators later):"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_tuple = tuple(x * 2 for x in range(5))\n",
+        "print(my_tuple)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "##### Sets\n",
+        "\n",
+        "A set is an unordered collection of unique elements. Sets are created using the `{<iterable>}`. Similarly, the `set()` function can be used to convert an iterable to a set.\n",
+        "\n",
+        "Empty set:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "#| echo: true\n",
+        "#| code-fold: false\n",
+        "empty_set = set()"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Careful, `{}` will not create an empty set but an empty dictionary! (see below)\n",
+        "\n",
+        "Creating sets:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "# Create a set\n",
+        "my_set = {1, 2, 3}\n",
+        "print(my_set)\n",
+        "\n",
+        "# Create a set from a list\n",
+        "my_list = [4, 5, 6]\n",
+        "my_set = set(my_list)\n",
+        "print(my_set)\n",
+        "my_set.add(7)  # Add an element to the set\n",
+        "print(my_set)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Adding elements to a set is an [idempotent](https://en.wikipedia.org/wiki/Idempotence){.external target=\"_blank\"} operation - adding the same element twice does not change the set."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_set = {1, 2, 3}\n",
+        "print(\"my_set: \", my_set)\n",
+        "my_set.add(4)\n",
+        "print(\"my_set after adding 4: \", my_set)\n",
+        "for i in range(100):\n",
+        "    my_set.add(4)\n",
+        "print(\"my_set after adding 4 one hundred times: \", my_set)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Set comprehension:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_set = {int(x / 2) for x in range(15)}\n",
+        "print(my_set)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Sets are not immutable but they do not support assignment. Rather, you can add or remove elements.\n",
+        "```python\n",
+        "my_set.add(10)  # The set can be changed.\n",
+        "my_set[0] = 10  # This will raise an error!!!\n",
+        "```\n",
+        "\n",
+        "#### Tuple differences with Lists\n",
+        "\n",
+        "Main difference is that tuples are immutable. They cannot be changed once created. Some operations are faster than on lists.\n",
+        "\n",
+        "```python\n",
+        "my_tuple = (1, 2, 3)\n",
+        "my_tuple[1] = 4  # This will raise an error!!!\n",
+        "```\n",
+        "\n",
+        "Tuples are used as the return type in case of functions returning multiple values."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "def my_function():\n",
+        "    n = 5\n",
+        "    l = [i for i in range(n)]\n",
+        "    return l, n\n",
+        "\n",
+        "print(my_function())"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Tuples are \"safe\" to pass around because of their immutability."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "def my_function(iterable_input):\n",
+        "    result = []\n",
+        "    for i in iterable_input:\n",
+        "        result.append(i * 2)\n",
+        "    # sneakily change the input:\n",
+        "    iterable_input[0] = 10\n",
+        "    return result\n",
+        "\n",
+        "my_list = [1, 2, 3]  # precious data we don't want to change\n",
+        "print(f\"My list before running my_function: {my_list}\")\n",
+        "print(my_function(my_list))\n",
+        "print(f\"My list after running my_function: {my_list}\")\n",
+        "\n",
+        "my_tuple = (1, 2, 3)  # precious data we don't want to change\n",
+        "try:\n",
+        "    print(my_function(my_tuple))\n",
+        "except TypeError:  # this will catch the error raised whe the function tries to change the tuple\n",
+        "    print(\"Aha! The function tried to change my tuple!\")"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### Set differences with Lists\n",
+        "\n",
+        "Sets are unordered and do not allow duplicates. They are implemented as 'hash set' and thus certain operations are very efficient (e.g. membership checking, union, intersection, etc.)."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_list = [1, 2, 2, 3, 3, 4, 5, 5]\n",
+        "my_set = set(my_list)\n",
+        "print(my_set)\n",
+        "print(3 in my_set)  # True\n",
+        "print(6 in my_set)  # False\n",
+        "my_set.add(5)  # Added element already in list, nothing happens\n",
+        "print(my_set)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Set specific operations:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "print({1, 2, 3} | {4, 5, 6})  # Union\n",
+        "print({1, 2, 3} & {3, 4, 5})  # Intersection\n",
+        "print({1, 2, 3, 4} - {1, 4, 5})  # Difference\n",
+        "print({1, 4, 5} - {1, 2, 3, 4})  # Difference (reversed set order)\n",
+        "print({1, 4, 5} ^ {1, 2, 3, 4})  # Symmetric Difference (XOR)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Since tuple are immutable, unlike lists or sets, they can be used as keys in a dictionary."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_tuple_dict = {\n",
+        "    (1, 2): 3,\n",
+        "    (4, 5): 6\n",
+        "}\n",
+        "\n",
+        "print(my_tuple_dict[(1, 2)])"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "This would throw an error:\n",
+        "\n",
+        "```python\n",
+        "my_list_dict = {\n",
+        "    [1, 2]: 3,\n",
+        "    [4, 5]: 6\n",
+        "}\n",
+        "```\n",
+        "\n",
+        "## Basic operations with data structures\n",
+        "\n",
+        "### Looping through iterables\n",
+        "\n",
+        "#### Indexed loop"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_list = list('alphabet')\n",
+        "n = len(my_list)\n",
+        "for i in range(n):\n",
+        "    value = my_list[i]\n",
+        "    print(f\"Value at index {i}: {value}\")"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### 'For each' loop"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "for value in my_list:\n",
+        "    print(f\"Value: {value}\")"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### Enumerate\n",
+        "\n",
+        "The `enumerate()` function returns a tuple of the index and the value at that index. This is equivalent to the indexed loop but 'nicer' (no need to explicitly extract the value and index)."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "for index, value in enumerate(my_list):\n",
+        "    print(f\"Value at index {index}: {value}\")"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### While loop\n",
+        "\n",
+        "The `while` loop is useful in some special cases (e.g., growing lists - although, this can be dangerous)."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "from random import random\n",
+        "\n",
+        "my_list = list(range(10))\n",
+        "n = len(my_list)\n",
+        "i = 0\n",
+        "while i < len(my_list):\n",
+        "    value = my_list[i]\n",
+        "    if random() < 1 / (i - value + 1.5):\n",
+        "        my_list.append(value)\n",
+        "    i += 1\n",
+        "\n",
+        "print(my_list)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "More typical is to use infinite loops with `while True` and `break` statements.\n",
+        "\n",
+        "#### Looping through sets and tuples\n",
+        "\n",
+        "The same looping \"techniques\" work for sets and tuples."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_set = {1, 2, 3}\n",
+        "for i, value in enumerate(my_set):\n",
+        "    print(f\"Value: {value}\")"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_tuple = (1, 2, 3)\n",
+        "for value in my_tuple:\n",
+        "    print(f\"Value: {value}\")"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### Looping through dictionaries\n",
+        "\n",
+        "Most common way to iterate over a dictionary is to use the `items()` method, which will return (key, value) pairs."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "import string\n",
+        "\n",
+        "alphabet_dict = {k: v for k, v in zip(string.ascii_letters, range(26)) if v < 12 and v % 2 == 0}\n",
+        "for key, value in alphabet_dict.items():\n",
+        "    print(f\"The letter {key} is at position {value + 1} in the alphabet.\")"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "There are also `keys()` and `values()` methods that return the keys and values respectively."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "for key in alphabet_dict.keys():\n",
+        "    print(f\"The letter {key} is at position {alphabet_dict[key] + 1} in the alphabet.\")"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "positions = []\n",
+        "for value in alphabet_dict.values():\n",
+        "    positions.append(value + 1)\n",
+        "print(f\"The dictionary contains letters at these positions: {positions}\")"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "### 2D Arrays (Matrices)\n",
+        "\n",
+        "Simply \"stack\" lists inside of a list (nested list)."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "matrix = [\n",
+        "    [1, 2, 3],\n",
+        "    [4, 5, 6],\n",
+        "    [7, 8, 9]\n",
+        "]\n",
+        "print(matrix)\n",
+        "also_matrix = [[j + i * 3 for j in range(3)] for i in range(3)]\n",
+        "print(also_matrix)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### Accessing elements\n",
+        "`matrix[row][column]`:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "print(matrix[0][1])  # 0th row, 1st column\n",
+        "print(matrix[1][0])  # 1st row, 0th column"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "#### Looping through matrices\n",
+        "\n",
+        "For each row in the matrix, loop through each element in the row."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "for row in matrix:\n",
+        "    for value in row:\n",
+        "        print(value)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Enumerate the rows and columns."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "for row_index, row in enumerate(matrix):\n",
+        "    for column_index, value in enumerate(row):\n",
+        "        print(f\"Value at row {row_index} and column {column_index}: {value}\")"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Assignment to a matrix:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "for row_index, row in enumerate(matrix):\n",
+        "    for column_index, value in enumerate(row):\n",
+        "        matrix[row_index][column_index] = value**2\n",
+        "print(matrix)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## Types and comparisons\n",
+        "\n",
+        "Checking types of variables is very useful, especially in Python that allows dynamic typing. Many operations are defined for different data types. For example, `1 * 2` is fine, and so is `[1] * 2` and `\"1\" * 2` but they result in different outcomes. It is often important to assert that the variables are of the expected type (use `if` or `assert`).\n",
+        "\n",
+        "### It is or it is not\n",
+        "\n",
+        "The `is` operator can be used to check if two objects are the same object."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "a = [1, 2, 3]\n",
+        "b = [1, 2, 3]\n",
+        "c = a\n",
+        "d = a[:]\n",
+        "e = a.copy()\n",
+        "print(a is b)  # the elements have the same value but `a` is not the same object as `b`\n",
+        "print(a is c)  # `a` and `c` reference the same object\n",
+        "print(a is d)  # `d` got the values from `a` but it is still a different object (different memory location)\n",
+        "print(a is e)  # copy makes a new object with the same values (essentially, similar process to defining `d`)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "It is not?"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "print(a is not b)  # True\n",
+        "print(a is not c)  # False\n",
+        "print(a is not d)  # True\n",
+        "print(a is not e)  # True"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Some 'objects' are the same but it makes no sense to compare them using `is`. Although, `is` will get you a strict equality."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "# this will get you a syntax warning\n",
+        "print(1 is 1.0)  # False, because float != int\n",
+        "print(1.0 is 1.0)  # True\n",
+        "print((1, 2, 3) is (1, 2, 3))  # Unexpectedly, True but also not advised; just use equality `==`"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "For number type check, see below.\n",
+        "\n",
+        "### type\n",
+        "\n",
+        "Use the `type(<object>)` build-in function to get the type of an object."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "print(type(1))  # <class 'int'>\n",
+        "print(type(1.0))  # <class 'float'>\n",
+        "\n",
+        "my_list = [1, 2, 3]\n",
+        "print(type(my_list))  # <class 'list'>\n",
+        "print(type(tuple(my_list)))  # <class 'list'>"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "### isinstance\n",
+        "\n",
+        "The `isinstance(<object>, <type>)` build-in function checks whether the object is an instance of the type."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "print(isinstance([1, 2, 3], list))  # True\n",
+        "print(isinstance([1, 2, 3], tuple))  # False\n",
+        "print(isinstance(1, int))  # True\n",
+        "print(isinstance(1, float))  # False"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "### issubclass\n",
+        "\n",
+        "This will become handy much later, when you will be working with classes. However, there is some use with basic datatypes."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "my_integer = 3\n",
+        "print(issubclass(type(my_integer), int))  # True\n",
+        "print(issubclass(type(my_integer), float))  # False\n",
+        "print(issubclass(type(my_integer), (int, float)))  # True"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "### Being assertive in your statements (programmatically speaking)\n",
+        "\n",
+        "Potentially unsafe code:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "def multiply_two_numbers_unchecked(a, b):\n",
+        "    return a * b\n",
+        "\n",
+        "print(multiply_two_numbers_unchecked(3, 2))  # 6 is fine\n",
+        "print(multiply_two_numbers_unchecked(3.0, 2))  # 6.0 is fine\n",
+        "print(multiply_two_numbers_unchecked(\"3\", 2))  # 33 !?"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Code with run-time type checking and assertions:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "def multiply_two_numbers(a, b):\n",
+        "    assert isinstance(a, (int, float)), f\"'a' must be a number! But it was: {type(a)}\"\n",
+        "    assert isinstance(b, (int, float)), f\"'b' must be a number! But it was: {type(b)}\"\n",
+        "    # Alternatively, this will also work:\n",
+        "    assert issubclass(type(my_integer), (int, float)), f\"'a' must be a number! But it was: {type(a)}\"\n",
+        "    return a * b\n",
+        "\n",
+        "print(multiply_two_numbers(3, 2))\n",
+        "print(multiply_two_numbers(3.0, 2))\n",
+        "try:\n",
+        "    print(multiply_two_numbers(\"3\", 2))  # this will cause an error\n",
+        "except AssertionError as e:\n",
+        "    print(e)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "More info about basic types and comparison: https://docs.python.org/3/library/stdtypes.html\n",
+        "\n",
+        "#### But, maybe, don't be too assertive?\n",
+        "\n",
+        "`assert` is fine for testing and debugging, but not for production code! There are better, safer ways of handling incorrect inputs (`try...except` + `warn`/`log.error` and handle the erroneous state \"gracefully\"). Essentially, production code should never halt!!!"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "from typing import Union, Optional\n",
+        "\n",
+        "def nice_multiply_two_numbers(a: Union[int, float], b: Union[int, float], raise_error: bool = True) -> Optional[Union[int, float]]:\n",
+        "    if not isinstance(a, (int, float)):\n",
+        "        if raise_error:\n",
+        "            raise TypeError(f\"'a' must be a number! But it was: {type(a)}\")\n",
+        "        else:\n",
+        "            return None  # does not have to be explicit\n",
+        "    if not isinstance(b, (int, float)):\n",
+        "        if raise_error:\n",
+        "            raise TypeError(f\"'b' must be a number! But it was: {type(b)}\")\n",
+        "        else:\n",
+        "            return None  # does not have to be explicit\n",
+        "    return a * b\n",
+        "\n",
+        "print(\"Using type error & try...except:\")\n",
+        "try:\n",
+        "    print(nice_multiply_two_numbers(3, 2))\n",
+        "    print(nice_multiply_two_numbers(3.0, 2))\n",
+        "    print(nice_multiply_two_numbers(\"3\", 2))  # this will cause an error\n",
+        "except TypeError as e:\n",
+        "    print(f\"One of the inputs had an incorrect type. See the error: {e}\")\n",
+        "\n",
+        "print(\"\\nUsing None return type:\")\n",
+        "result = nice_multiply_two_numbers(\"3\", 2, raise_error=False)\n",
+        "if result is None:\n",
+        "    print(\"One of the inputs had an incorrect type.\")\n",
+        "else:\n",
+        "    print(result)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## Algorithmic complexity\n",
+        "\n",
+        "Efficiency of algorithms matter.\n",
+        "\n",
+        "### Basic concepts\n",
+        "\n",
+        "Runtime of an algorithm typically depends on the size of the input. For example, time to loop once through a list will linearly increase with the size of the list.\n",
+        "Thus, if accessing an element of a list takes 1ns, looping through a list of N elements will take N * 1ns.\n",
+        "However, on a different computer, the element access time might be 10ns. Still, the loop will take N * 10ns.\n",
+        "Therefore, we simply say the runtime 'scales' with N.\n",
+        "\n",
+        "Searching for a specific item in a list requires looping through the list. However, the item might be at the beginning of the list, thus the search time will be 1 (x access time). If the idem is at the end of the list, the search time will be N. On average, we can expect the search time to be N/2. We are, however, typically interested in the worst-case scenario, and for simplicity, ignore any constants. Therefore, we simply say that the lookup time 'scales' with N, just as with the 'full' loop.\n",
+        "(There is typically some overhead and randomness, so we are not interested in precise numbers.)\n",
+        "\n",
+        "Asymptotic complexity is a way to describe the runtime of an algorithm as a function of the input size. We can use this to compare the performance of different algorithms - the efficiency of their implementation. For example, we can compare different sorting algorithms.\n",
+        "\n",
+        "### How to \"profile\" in Python\n",
+        "\n",
+        "We can use the `timeit` module to profile the runtime of an algorithm. Usage:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "import timeit\n",
+        "def my_function():\n",
+        "    s = 0\n",
+        "    for i in range(10000):\n",
+        "        s += i\n",
+        "    return s\n",
+        "iterations = 10\n",
+        "timer = timeit.Timer(stmt=my_function)\n",
+        "avg_time = timer.timeit(number=iterations) / iterations\n",
+        "print(f\"Average run time: {avg_time}\")"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "In Jupyter notebooks or IPython, we can use the `%timeit` magic command instead:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "%timeit my_function()"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "This will run the function and time it.\n",
+        "\n",
+        "There are also better, more advanced tools for profiling (e.g. `cProfile`) but we will manage with timeit for now.\n",
+        "\n",
+        "\n",
+        "### Run time examples"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "#| echo: false\n",
+        "import timeit  # for timing\n",
+        "import matplotlib.pyplot as plt  # for plotting\n",
+        "from numpy.random import permutation  # to generate some random values\n",
+        "from random import randint  # to generate some random values\n",
+        "import numpy as np\n",
+        "\n",
+        "\n",
+        "def find_list(number, in_list):\n",
+        "    for i in range(len(in_list)):\n",
+        "        if number == in_list[i]:\n",
+        "            return i\n",
+        "    return -1\n",
+        "\n",
+        "\n",
+        "def find_dict(number, in_dict):\n",
+        "    return in_dict.get(number, -1)\n",
+        "\n",
+        "def bubble_sort(unsorted_list):\n",
+        "    n = len(unsorted_list)\n",
+        "    for i in range(n):\n",
+        "        for j in range(n-1):\n",
+        "            if unsorted_list[j] > unsorted_list[j+1]:\n",
+        "                unsorted_list[j], unsorted_list[j+1] = unsorted_list[j+1], unsorted_list[j]\n",
+        "    return unsorted_list\n",
+        "\n",
+        "def quick_sort(unsorted_list):\n",
+        "    if len(unsorted_list) <= 1:\n",
+        "        return unsorted_list\n",
+        "    pivot = unsorted_list[0]\n",
+        "    left, right = [], []\n",
+        "    for x in unsorted_list[1:]:\n",
+        "        if x < pivot:\n",
+        "            left.append(x)\n",
+        "        else:\n",
+        "            right.append(x)\n",
+        "    return quick_sort(left) + [pivot] + quick_sort(right)\n",
+        "\n",
+        "iterations = 100\n",
+        "N = 300\n",
+        "N_values = range(1, N, 10)\n",
+        "run_times = {\n",
+        "    'list': ([], 'list lookup (N)'),\n",
+        "    'dict': ([], 'dictionary lookup (1)'),\n",
+        "    'bubble': ([], 'bubble sort (N^2)'),\n",
+        "    'quick': ([], 'quick sort (Nlog(N)')\n",
+        "}\n",
+        "\n",
+        "def run_timer(name, func, log_scale=False):\n",
+        "    timer = timeit.Timer(stmt=func)\n",
+        "    avg_time = timer.timeit(number=iterations) / iterations\n",
+        "    if log_scale:\n",
+        "        avg_time = np.log(avg_time)\n",
+        "    run_times[name][0].append(avg_time)\n",
+        "\n",
+        "for n in N_values:\n",
+        "    unordered_list = permutation(n).tolist()\n",
+        "    run_timer('list', lambda: find_list(randint(0, n-1), unordered_list))\n",
+        "    unordered_dict = {i: i**2 for i in unordered_list}\n",
+        "    run_timer('dict', lambda: find_dict(randint(0, n-1), unordered_dict))\n",
+        "    run_timer('bubble', lambda: bubble_sort(unordered_list))\n",
+        "    run_timer('quick', lambda: quick_sort(unordered_list))\n",
+        "\n",
+        "\n",
+        "def plot_data(data_dict, title, x_values=N_values):\n",
+        "    plt.figure(figsize=(8, 5))\n",
+        "    for key, data in data_dict.items():\n",
+        "        times, name = data\n",
+        "        plt.plot(x_values, times, marker='o', linestyle='-', label=name)\n",
+        "    plt.xlabel('Input Size (N)')\n",
+        "    plt.ylabel('Average Execution Time (seconds)')\n",
+        "    plt.xticks(x_values)\n",
+        "    plt.legend()\n",
+        "    plt.title(title)\n",
+        "    plt.suptitle('Function Run Time vs Input Size')\n",
+        "    plt.grid(True)\n",
+        "    plt.show()\n",
+        "\n",
+        "plot_data({k: v for k, v in run_times.items() if k not in ['quick', 'bubble']}, 'lookup')\n",
+        "\n",
+        "plot_data({k: v for k, v in run_times.items() if k in ['quick', 'bubble']}, 'sorting')\n",
+        "\n",
+        "bogosort_data = {\n",
+        "    1: 1.999099913518876e-05,\n",
+        "    2: 6.783099888707511e-05,\n",
+        "    3: 1.7420999938622117e-05,\n",
+        "    4: 7.011499837972224e-05,\n",
+        "    5: 0.000475516000733478,\n",
+        "    6: 0.005492435000633122,\n",
+        "    7: 0.028997863999393303,\n",
+        "    8: 0.01879682900107582,\n",
+        "    9: 4.884004298000946,\n",
+        "    10: 8.281353655002022,\n",
+        "    11: 23.202884651000204,\n",
+        "    12: 120.5691607890003,\n",
+        "    13: 3437.6171427379995,\n",
+        "}\n",
+        "\n",
+        "plot_data({'bogosort': (list(bogosort_data.values()), 'bogosort sort')}, 'bogosort sort', x_values=list(bogosort_data.keys()))"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "##### Pre-allocation\n",
+        "\n",
+        "Pre-allocation of arrays is (slightly) faster than iterative appending. Although, in Python, both are relatively slow. Depending on the task, list comprehension may be more efficient. In general, if the appending overhead is insignificant, it will not have significant impact on runtime whether pre-allocation is used. However, with large loops it might cause memory issues and pre-allocation will be important with more efficient array implementations (e.g., NumPy arrays)."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "import numpy as np\n",
+        "\n",
+        "def my_simple_function(x):\n",
+        "    return x\n",
+        "\n",
+        "def my_complex_function(x):\n",
+        "    return np.sqrt(np.log(x + 1)**min(1e2,  np.exp(np.log(x + 1e-10))**0.33))\n",
+        "\n",
+        "def list_append(n, func):\n",
+        "    a = []\n",
+        "    for i in range(n):\n",
+        "        a.append(func(i))\n",
+        "    return a\n",
+        "\n",
+        "def list_preallocate(n, func):\n",
+        "    a = [0] * n\n",
+        "    for i in range(n):\n",
+        "        a[i] = func(i)\n",
+        "    return a\n",
+        "\n",
+        "def list_comprehension(n, func):\n",
+        "    return [func(i) for i in range(n)]\n",
+        "\n",
+        "N = 10000\n",
+        "print(\"Evaluating my_simple_function\")\n",
+        "%timeit list_append(N, my_simple_function)\n",
+        "%timeit list_preallocate(N, my_simple_function)\n",
+        "%timeit list_comprehension(N, my_simple_function)\n",
+        "\n",
+        "print(\"Evaluating my_complex_function\")\n",
+        "%timeit list_append(N, my_complex_function)\n",
+        "%timeit list_preallocate(N, my_complex_function)\n",
+        "%timeit list_comprehension(N, my_complex_function)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "##### Using the right data structures\n",
+        "\n",
+        "Python lists are fairly universal. However, they come with some overhead and thus are not always the best structure.\n",
+        "In some cases, it is even worth to cast lists to sets or dictionaries."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {},
+      "source": [
+        "N = 10000\n",
+        "# Generate list of randomly shuffled squares of numbers\n",
+        "l = (np.random.permutation(N)**2).tolist()\n",
+        "\n",
+        "# Create a dictionary from the list\n",
+        "d = {x: x**2 for x in l}\n",
+        "\n",
+        "# Create a set from the list\n",
+        "s = set(l)\n",
+        "\n",
+        "# We want to find the index of a square of a number in the list\n",
+        "number_of_interest = int(N / 2)**2\n",
+        "print(\"Lookup time for list\")\n",
+        "%timeit l.index(number_of_interest)\n",
+        "print(\"Lookup time for dictionary\")\n",
+        "%timeit d.get(number_of_interest)\n",
+        "print(\"Lookup time for set\")\n",
+        "%timeit s.intersection({number_of_interest})\n",
+        "print(\"---\")\n",
+        "\n",
+        "# Now we want to simply see if the number is in the list\n",
+        "print(\"Membership check time for list\")\n",
+        "%timeit number_of_interest in l\n",
+        "print(\"Membership check time for dictionary\")\n",
+        "%timeit number_of_interest in d\n",
+        "print(\"Membership check time for set\")\n",
+        "%timeit number_of_interest in s\n",
+        "\n",
+        "print(\"---\")\n",
+        "print(\"'Fair' lookup time for dictionary from list\")\n",
+        "%timeit {x: x**2 for x in l}.get(number_of_interest)\n",
+        "print(f\"'Fair' membership check time for dictionary from list\")\n",
+        "%timeit number_of_interest in {x: x**2 for x in l}"
+      ],
+      "execution_count": null,
+      "outputs": []
     }
-   ],
-   "source": [
-    "from typing import Union, Optional\n",
-    "\n",
-    "def nice_multiply_two_numbers(a: Union[int, float], b: Union[int, float], raise_error: bool = True) -> Optional[Union[int, float]]:\n",
-    "    if not isinstance(a, (int, float)):\n",
-    "        if raise_error:\n",
-    "            raise TypeError(f\"'a' must be a number! But it was: {type(a)}\")\n",
-    "        else:\n",
-    "            return None  # does not have to be explicit\n",
-    "    if not isinstance(b, (int, float)):\n",
-    "        if raise_error:\n",
-    "            raise TypeError(f\"'b' must be a number! But it was: {type(b)}\")\n",
-    "        else:\n",
-    "            return None  # does not have to be explicit\n",
-    "    return a * b\n",
-    "\n",
-    "print(\"Using type error & try...except:\")\n",
-    "try:\n",
-    "    print(nice_multiply_two_numbers(3, 2))\n",
-    "    print(nice_multiply_two_numbers(3.0, 2))\n",
-    "    print(nice_multiply_two_numbers(\"3\", 2))  # this will cause an error\n",
-    "except TypeError as e:\n",
-    "    print(f\"One of the inputs had an incorrect type. See the error: {e}\")\n",
-    "\n",
-    "print(\"\\nUsing None return type:\")\n",
-    "result = nice_multiply_two_numbers(\"3\", 2, raise_error=False)\n",
-    "if result is None:\n",
-    "    print(\"One of the inputs had an incorrect type.\")\n",
-    "else:\n",
-    "    print(result)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "## Algorithmic complexity\n",
-    "\n",
-    "Efficiency of algorithms matter.\n",
-    "\n",
-    "### Basic concepts\n",
-    "\n",
-    "Runtime of an algorithm typically depends on the size of the input. For example, time to loop once through a list will linearly increase with the size of the list.\n",
-    "Thus, if accessing an element of a list takes 1ns, looping through a list of N elements will take N * 1ns.\n",
-    "However, on a different computer, the element access time might be 10ns. Still, the loop will take N * 10ns.\n",
-    "Therefore, we simply say the runtime 'scales' with N.\n",
-    "\n",
-    "Searching for a specific item in a list requires looping through the list. However, the item might be at the beginning of the list, thus the search time will be 1 (x access time). If the idem is at the end of the list, the search time will be N. On average, we can expect the search time to be N/2. We are, however, typically interested in the worst-case scenario, and for simplicity, ignore any constants. Therefore, we simply say that the lookup time 'scales' with N, just as with the 'full' loop.\n",
-    "(There is typically some overhead and randomness, so we are not interested in precise numbers.)\n",
-    "\n",
-    "Asymptotic complexity is a way to describe the runtime of an algorithm as a function of the input size. We can use this to compare the performance of different algorithms - the efficiency of their implementation. For example, we can compare different sorting algorithms.\n",
-    "\n",
-    "### How to \"profile\" in Python\n",
-    "\n",
-    "We can use the `timeit` module to profile the runtime of an algorithm. Usage:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "import timeit\n",
-    "def my_function():\n",
-    "    s = 0\n",
-    "    for i in range(10000):\n",
-    "        s += i\n",
-    "    return s\n",
-    "iterations = 10\n",
-    "timer = timeit.Timer(stmt=my_function)\n",
-    "avg_time = timer.timeit(number=iterations) / iterations\n",
-    "print(f\"Average run time: {avg_time}\")"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "In Jupyter notebooks or IPython, we can use the `%timeit` magic command instead:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "%timeit my_function()"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "This will run the function and time it.\n",
-    "\n",
-    "There are also better, more advanced tools for profiling (e.g. `cProfile`) but we will manage with timeit for now.\n",
-    "\n",
-    "\n",
-    "### Run time examples"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "#| echo: false\n",
-    "import timeit  # for timing\n",
-    "import matplotlib.pyplot as plt  # for plotting\n",
-    "from numpy.random import permutation  # to generate some random values\n",
-    "from random import randint  # to generate some random values\n",
-    "import numpy as np\n",
-    "\n",
-    "\n",
-    "def find_list(number, in_list):\n",
-    "    for i in range(len(in_list)):\n",
-    "        if number == in_list[i]:\n",
-    "            return i\n",
-    "    return -1\n",
-    "\n",
-    "\n",
-    "def find_dict(number, in_dict):\n",
-    "    return in_dict.get(number, -1)\n",
-    "\n",
-    "def bubble_sort(unsorted_list):\n",
-    "    n = len(unsorted_list)\n",
-    "    for i in range(n):\n",
-    "        for j in range(n-1):\n",
-    "            if unsorted_list[j] > unsorted_list[j+1]:\n",
-    "                unsorted_list[j], unsorted_list[j+1] = unsorted_list[j+1], unsorted_list[j]\n",
-    "    return unsorted_list\n",
-    "\n",
-    "def quick_sort(unsorted_list):\n",
-    "    if len(unsorted_list) <= 1:\n",
-    "        return unsorted_list\n",
-    "    pivot = unsorted_list[0]\n",
-    "    left, right = [], []\n",
-    "    for x in unsorted_list[1:]:\n",
-    "        if x < pivot:\n",
-    "            left.append(x)\n",
-    "        else:\n",
-    "            right.append(x)\n",
-    "    return quick_sort(left) + [pivot] + quick_sort(right)\n",
-    "\n",
-    "iterations = 10\n",
-    "N = 300\n",
-    "N_values = range(1, N, 10)\n",
-    "run_times = {\n",
-    "    'list': ([], 'list lookup (N)'),\n",
-    "    'dict': ([], 'dictionary lookup (1)'),\n",
-    "    'bubble': ([], 'bubble sort (N^2)'),\n",
-    "    'quick': ([], 'quick sort (Nlog(N)')\n",
-    "}\n",
-    "\n",
-    "def run_timer(name, func, log_scale=False):\n",
-    "    timer = timeit.Timer(stmt=func)\n",
-    "    avg_time = timer.timeit(number=iterations) / iterations\n",
-    "    if log_scale:\n",
-    "        avg_time = np.log(avg_time)\n",
-    "    run_times[name][0].append(avg_time)\n",
-    "\n",
-    "for n in N_values:\n",
-    "    unordered_list = permutation(n).tolist()\n",
-    "    run_timer('list', lambda: find_list(randint(0, n-1), unordered_list))\n",
-    "    unordered_dict = {i: i**2 for i in unordered_list}\n",
-    "    run_timer('dict', lambda: find_dict(randint(0, n-1), unordered_dict))\n",
-    "    run_timer('bubble', lambda: bubble_sort(unordered_list))\n",
-    "    run_timer('quick', lambda: quick_sort(unordered_list))\n",
-    "\n",
-    "\n",
-    "def plot_data(data_dict, title, x_values=N_values):\n",
-    "    plt.figure(figsize=(8, 5))\n",
-    "    for key, data in data_dict.items():\n",
-    "        times, name = data\n",
-    "        plt.plot(x_values, times, marker='o', linestyle='-', label=name)\n",
-    "    plt.xlabel('Input Size (N)')\n",
-    "    plt.ylabel('Average Execution Time (seconds)')\n",
-    "    plt.xticks(x_values)\n",
-    "    plt.legend()\n",
-    "    plt.title(title)\n",
-    "    plt.suptitle('Function Run Time vs Input Size')\n",
-    "    plt.grid(True)\n",
-    "    plt.show()\n",
-    "\n",
-    "plot_data({k: v for k, v in run_times.items() if k not in ['quick', 'bubble']}, 'lookup')\n",
-    "\n",
-    "plot_data({k: v for k, v in run_times.items() if k in ['quick', 'bubble']}, 'sorting')\n",
-    "\n",
-    "bogosort_data = {\n",
-    "    1: 1.999099913518876e-05,\n",
-    "    2: 6.783099888707511e-05,\n",
-    "    3: 1.7420999938622117e-05,\n",
-    "    4: 7.011499837972224e-05,\n",
-    "    5: 0.000475516000733478,\n",
-    "    6: 0.005492435000633122,\n",
-    "    7: 0.028997863999393303,\n",
-    "    8: 0.01879682900107582,\n",
-    "    9: 4.884004298000946,\n",
-    "    10: 8.281353655002022,\n",
-    "    11: 23.202884651000204,\n",
-    "    12: 120.5691607890003,\n",
-    "    13: 3437.6171427379995,\n",
-    "}\n",
-    "\n",
-    "plot_data({'bogosort': (list(bogosort_data.values()), 'bogosort sort')}, 'bogosort sort', x_values=list(bogosort_data.keys()))"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "### Comparison of operations with the basic data structures\n",
-    "\n",
-    "##### Pre-allocation\n",
-    "\n",
-    "Pre-allocation of arrays is (slightly) faster than iterative appending. Although, in Python, both are relatively slow. Depending on the task, list comprehension may be more efficient. In general, if the appending overhead is insignificant, it will not have significant impact on runtime whether pre-allocation is used. However, with large loops it might cause memory issues and pre-allocation will be important with more efficient array implementations (e.g., NumPy arrays)."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "Evaluating my_simple_function\n",
-      "9.42 ms ± 136 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)\n",
-      "7.93 ms ± 181 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)\n"
-     ]
+  ],
+  "metadata": {
+    "kernelspec": {
+      "display_name": "Python 3",
+      "language": "python",
+      "name": "python3"
     }
-   ],
-   "source": [
-    "import numpy as np\n",
-    "\n",
-    "def my_simple_function(x):\n",
-    "    return x\n",
-    "\n",
-    "def my_complex_function(x):\n",
-    "    return np.sqrt(np.log(x + 1)**min(1e2,  np.exp(np.log(x + 1e-10))**0.33))\n",
-    "\n",
-    "def list_append(n, func):\n",
-    "    a = []\n",
-    "    for i in range(n):\n",
-    "        a.append(func(i))\n",
-    "    return a\n",
-    "\n",
-    "def list_preallocate(n, func):\n",
-    "    a = [0] * n\n",
-    "    for i in range(n):\n",
-    "        a[i] = func(i)\n",
-    "    return a\n",
-    "\n",
-    "def list_comprehension(n, func):\n",
-    "    return [func(i) for i in range(n)]\n",
-    "\n",
-    "N = 10000\n",
-    "print(\"Evaluating my_simple_function\")\n",
-    "%timeit list_append(N, my_simple_function)\n",
-    "%timeit list_preallocate(N, my_simple_function)\n",
-    "%timeit list_comprehension(N, my_simple_function)\n",
-    "\n",
-    "print(\"Evaluating my_complex_function\")\n",
-    "%timeit list_append(N, my_complex_function)\n",
-    "%timeit list_preallocate(N, my_complex_function)\n",
-    "%timeit list_comprehension(N, my_complex_function)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "##### Using the right data structures\n",
-    "\n",
-    "Python lists are fairly universal. However, they come with some overhead and thus are not always the best structure.\n",
-    "In some cases, it is even worth to cast lists to sets or dictionaries."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "N = 10\n",
-    "# Generate list of randomly shuffled squares of numbers\n",
-    "l = (np.random.permutation(N)**2).tolist()\n",
-    "\n",
-    "# Create a dictionary from the list\n",
-    "d = {x: x**2 for x in l}\n",
-    "\n",
-    "# Create a set from the list\n",
-    "s = set(l)\n",
-    "\n",
-    "# We want to find the index of a square of a number in the list\n",
-    "number_of_interest = int(N / 2)**2\n",
-    "print(\"Lookup time for list\")\n",
-    "%timeit l.index(number_of_interest)\n",
-    "print(\"Lookup time for dictionary\")\n",
-    "%timeit d.get(number_of_interest)\n",
-    "print(\"Lookup time for set\")\n",
-    "%timeit s.intersection({number_of_interest})\n",
-    "print(\"---\")\n",
-    "\n",
-    "# Now we want to simply see if the number is in the list\n",
-    "print(\"Membership check time for list\")\n",
-    "%timeit number_of_interest in l\n",
-    "print(\"Membership check time for dictionary\")\n",
-    "%timeit number_of_interest in d\n",
-    "print(\"Membership check time for set\")\n",
-    "%timeit number_of_interest in s\n",
-    "\n",
-    "print(\"---\")\n",
-    "print(\"'Fair' lookup time for dictionary from list\")\n",
-    "%timeit {x: x**2 for x in l}.get(number_of_interest)\n",
-    "print(f\"'Fair' membership check time for dictionary from list\")\n",
-    "%timeit number_of_interest in {x: x**2 for x in l}"
-   ]
-  }
- ],
- "metadata": {
-  "kernelspec": {
-   "display_name": "Python 3",
-   "language": "python",
-   "name": "python3"
   },
-  "language_info": {
-   "codemirror_mode": {
-    "name": "ipython",
-    "version": 3
-   },
-   "file_extension": ".py",
-   "mimetype": "text/x-python",
-   "name": "python",
-   "nbconvert_exporter": "python",
-   "pygments_lexer": "ipython3",
-   "version": "3.8.10"
-  }
- },
- "nbformat": 4,
- "nbformat_minor": 4
-}
+  "nbformat": 4,
+  "nbformat_minor": 4
+}
\ No newline at end of file
diff --git a/src/pge_lectures/lecture_01/l1_intro_strctures.qmd b/src/pge_lectures/lecture_01/l1_intro_strctures.qmd
index 455392a..848300b 100644
--- a/src/pge_lectures/lecture_01/l1_intro_strctures.qmd
+++ b/src/pge_lectures/lecture_01/l1_intro_strctures.qmd
@@ -1,3 +1,4 @@
+# Basic data structures, intro to asymptotic complexity
 ---
 title: "Lecture 1"
 format:
@@ -5,7 +6,6 @@ format:
     code-fold: false
 jupyter: python3
 ---
-# Basic data structures, intro to asymptotic complexity
 
 ## Basic data structures (in Python)
 
@@ -1082,9 +1082,6 @@ bogosort_data = {
 plot_data({'bogosort': (list(bogosort_data.values()), 'bogosort sort')}, 'bogosort sort', x_values=list(bogosort_data.keys()))
 ```
 
-
-### Comparison of operations with the basic data structures
-
 ##### Pre-allocation
 
 Pre-allocation of arrays is (slightly) faster than iterative appending. Although, in Python, both are relatively slow. Depending on the task, list comprehension may be more efficient. In general, if the appending overhead is insignificant, it will not have significant impact on runtime whether pre-allocation is used. However, with large loops it might cause memory issues and pre-allocation will be important with more efficient array implementations (e.g., NumPy arrays).
-- 
GitLab