├── .gitignore ├── LICENSE ├── README.md ├── part_01 └── Königsberg.ipynb ├── part_02 └── graph.py ├── part_03 ├── graph.py └── requirements.txt └── part_04 ├── graph.py ├── requirements.txt └── solutions.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore virtual environments 2 | .venv 3 | venv 4 | 5 | # Ignore VS Code settings 6 | .vscode 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 David Amos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Graph Theory With Python 2 | 3 | Join me as we explore the world of Graph Theory with a focus on graph algorithms and using Python as a tool for mathematical research! 4 | 5 | Follow along with the series at: https://youtube.com/playlist?list=PLLIPpKeh9v3ZFEHvNd5xqUrCkqLgXnekL 6 | 7 | Then come here to check out the code! 8 | 9 | Follow me on twitter for updates: https://twitter.com/somacdivad 10 | 11 | ## How To Say Thanks 12 | 13 | If you enjoy this video series and would like to say thanks, I accept many forms of grattitude 🤗 14 | 15 | - Mention me on Twitter [@somacdivad](https://twitter.com/somacdivad) 16 | - Send me a nice email at somacdivad at gmail dot com 17 | - Buy me coffee via [Ko-Fi](https://ko-fi.com/davidamos) 18 | - Tip me on [PayPal](https://www.paypal.com/paypalme/somacdivad) 19 | -------------------------------------------------------------------------------- /part_01/Königsberg.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "nbformat": 4, 3 | "nbformat_minor": 0, 4 | "metadata": { 5 | "colab": { 6 | "name": "Königsberg.ipynb", 7 | "provenance": [], 8 | "collapsed_sections": [], 9 | "authorship_tag": "ABX9TyMYV1loPde4DxAMtDQ8NQWF" 10 | }, 11 | "kernelspec": { 12 | "name": "python3", 13 | "display_name": "Python 3" 14 | } 15 | }, 16 | "cells": [ 17 | { 18 | "cell_type": "code", 19 | "metadata": { 20 | "id": "q2QD2EjUWa4R" 21 | }, 22 | "source": [ 23 | "BRIDGES = [\n", 24 | " \"AaB\",\n", 25 | " \"AbB\",\n", 26 | " \"AcC\",\n", 27 | " \"AdC\",\n", 28 | " \"AeD\",\n", 29 | " \"BfD\",\n", 30 | " \"CgD\",\n", 31 | "]" 32 | ], 33 | "execution_count": null, 34 | "outputs": [] 35 | }, 36 | { 37 | "cell_type": "code", 38 | "metadata": { 39 | "id": "26TA_wL4W4Ug" 40 | }, 41 | "source": [ 42 | "def get_walks_starting_from(area, bridges=BRIDGES):\n", 43 | " walks = []\n", 44 | "\n", 45 | " def make_walks(area, walked=None, bridges_crossed=None):\n", 46 | " walked = walked or area\n", 47 | " bridges_crossed = bridges_crossed or ()\n", 48 | " # Get all of the bridges connected to `area`\n", 49 | " # that haven't been crossed\n", 50 | " available_bridges = [\n", 51 | " bridge\n", 52 | " for bridge in bridges\n", 53 | " if area in bridge and bridge not in bridges_crossed\n", 54 | " ]\n", 55 | "\n", 56 | " # Determine if the walk has ended\n", 57 | " if not available_bridges:\n", 58 | " walks.append(walked)\n", 59 | "\n", 60 | " # Walk the bridge to the adjacent area and recurse\n", 61 | " for bridge in available_bridges:\n", 62 | " crossing = bridge[1:] if bridge[0] == area else bridge[1::-1]\n", 63 | " make_walks(\n", 64 | " area=crossing[-1],\n", 65 | " walked=walked + crossing,\n", 66 | " bridges_crossed=(bridge, *bridges_crossed),\n", 67 | " )\n", 68 | "\n", 69 | " make_walks(area)\n", 70 | " return walks" 71 | ], 72 | "execution_count": null, 73 | "outputs": [] 74 | }, 75 | { 76 | "cell_type": "code", 77 | "metadata": { 78 | "id": "HCoqxIi0XOPx", 79 | "colab": { 80 | "base_uri": "https://localhost:8080/" 81 | }, 82 | "outputId": "18a1a33c-2b34-434c-f1e4-74060000ebd9" 83 | }, 84 | "source": [ 85 | "walks_starting_from = {area: get_walks_starting_from(area) for area in \"ABCD\"}\n", 86 | "num_total_walks = sum(len(walks) for walks in walks_starting_from.values())\n", 87 | "print(num_total_walks)" 88 | ], 89 | "execution_count": null, 90 | "outputs": [ 91 | { 92 | "output_type": "stream", 93 | "text": [ 94 | "372\n" 95 | ], 96 | "name": "stdout" 97 | } 98 | ] 99 | }, 100 | { 101 | "cell_type": "code", 102 | "metadata": { 103 | "id": "ojYcCnAuceCg", 104 | "colab": { 105 | "base_uri": "https://localhost:8080/" 106 | }, 107 | "outputId": "4fe6ac1c-6714-484d-d710-31d1bb807124" 108 | }, 109 | "source": [ 110 | "walks_starting_from[\"A\"][:3]" 111 | ], 112 | "execution_count": null, 113 | "outputs": [ 114 | { 115 | "output_type": "execute_result", 116 | "data": { 117 | "text/plain": [ 118 | "['AaBbAcCdAeDfB', 'AaBbAcCdAeDgC', 'AaBbAcCgDeAdC']" 119 | ] 120 | }, 121 | "metadata": { 122 | "tags": [] 123 | }, 124 | "execution_count": 4 125 | } 126 | ] 127 | }, 128 | { 129 | "cell_type": "code", 130 | "metadata": { 131 | "colab": { 132 | "base_uri": "https://localhost:8080/" 133 | }, 134 | "id": "a6hI7RC2sHvf", 135 | "outputId": "0f826491-533f-4c4c-adc4-5f1ef353b688" 136 | }, 137 | "source": [ 138 | "from itertools import chain\n", 139 | "all_walks = chain.from_iterable(walks_starting_from.values())\n", 140 | "solutions = [walk for walk in all_walks if len(walk) == 15]\n", 141 | "print(len(solutions))" 142 | ], 143 | "execution_count": null, 144 | "outputs": [ 145 | { 146 | "output_type": "stream", 147 | "text": [ 148 | "0\n" 149 | ], 150 | "name": "stdout" 151 | } 152 | ] 153 | } 154 | ] 155 | } -------------------------------------------------------------------------------- /part_02/graph.py: -------------------------------------------------------------------------------- 1 | # G = (V, E) 2 | from collections import namedtuple 3 | 4 | Graph = namedtuple("Graph", ["nodes", "edges", "id_directed"]) 5 | 6 | 7 | def adjacency_dict(graph): 8 | """ 9 | Returns the adjacency list representation 10 | of the graph. 11 | """ 12 | adj = {node: [] for node in graph.nodes} 13 | for edge in graph.edges: 14 | node1, node2 = edge[0], edge[1] 15 | adj[node1].append(node2) 16 | if not graph.is_directed: 17 | adj[node2].append(node1) 18 | return adj 19 | 20 | 21 | def adjacency_matrix(graph): 22 | """ 23 | Returns the adjacency matrix of the graph. 24 | 25 | Assumes that graph.nodes is equivalent to range(len(graph.nodes)). 26 | """ 27 | adj = [[0 for node in graph.nodes] for node in graph.nodes] 28 | for edge in graph.edges: 29 | node1, node2 = edge[0], edge[1] 30 | adj[node1][node2] += 1 31 | if not graph.is_directed: 32 | adj[node2][node1] += 1 33 | return adj 34 | -------------------------------------------------------------------------------- /part_03/graph.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from itertools import combinations 3 | 4 | from pyvis.network import Network 5 | 6 | 7 | Graph = namedtuple("Graph", ["nodes", "edges", "is_directed"]) 8 | 9 | 10 | def adjacency_dict(graph): 11 | """ 12 | Returns the adjacency list representation 13 | of the graph. 14 | """ 15 | adj = {node: [] for node in graph.nodes} 16 | for edge in graph.edges: 17 | node1, node2 = edge[0], edge[1] 18 | adj[node1].append(node2) 19 | if not graph.is_directed: 20 | adj[node2].append(node1) 21 | return adj 22 | 23 | 24 | def adjacency_matrix(graph): 25 | """ 26 | Returns the adjacency matrix of the graph. 27 | 28 | Assumes that graph.nodes is equivalent to range(len(graph.nodes)). 29 | """ 30 | adj = [[0 for node in graph.nodes] for node in graph.nodes] 31 | for edge in graph.edges: 32 | node1, node2 = edge[0], edge[1] 33 | adj[node1][node2] += 1 34 | if not graph.is_directed: 35 | adj[node2][node1] += 1 36 | return adj 37 | 38 | 39 | def show(graph, output_filename): 40 | """ 41 | Saves an HTML file locally containing a 42 | visualization of the graph, and returns 43 | a pyvis Network instance of the graph. 44 | """ 45 | g = Network(directed=graph.is_directed) 46 | g.add_nodes(graph.nodes) 47 | g.add_edges(graph.edges) 48 | g.show(output_filename) 49 | return g 50 | 51 | 52 | def _validate_num_nodes(num_nodes): 53 | """ 54 | Check whether or not `num_nodes` is a 55 | positive integer, and raise a TypeError 56 | or ValueError if it is not. 57 | """ 58 | if not isinstance(num_nodes, int): 59 | raise TypeError(f"num_nodes must be an integer; {type(num_nodes)=}") 60 | if num_nodes < 1: 61 | raise ValueError(f"num_nodes must be positive; {num_nodes=}") 62 | 63 | 64 | def path_graph(num_nodes, is_directed=False): 65 | """ 66 | Return a Graph instance representing 67 | an undirected path on `num_nodes` nodes. 68 | """ 69 | _validate_num_nodes(num_nodes) 70 | nodes = range(num_nodes) 71 | edges = [(i, i + 1) for i in range(num_nodes - 1)] 72 | return Graph(nodes, edges, is_directed=is_directed) 73 | 74 | 75 | def cycle_graph(num_nodes, is_directed=False): 76 | """ 77 | Return a Graph instance representing 78 | an undirected cycle on `num_nodes` nodes. 79 | """ 80 | base_path = path_graph(num_nodes, is_directed) 81 | base_path.edges.append((num_nodes - 1, 0)) 82 | return base_path 83 | 84 | 85 | def complete_graph(num_nodes): 86 | """ 87 | Return a Graph instance representing 88 | the complete graph on `num_nodes` nodes. 89 | """ 90 | _validate_num_nodes(num_nodes) 91 | nodes = range(num_nodes) 92 | edges = list(combinations(nodes, 2)) 93 | return Graph(nodes, edges, is_directed=False) 94 | 95 | 96 | def star_graph(num_nodes): 97 | """ 98 | Return a Graph instance representing 99 | the star graph on `num_nodes` nodes. 100 | """ 101 | _validate_num_nodes(num_nodes) 102 | nodes = range(num_nodes) 103 | edges = [(0, i) for i in range(1, num_nodes)] 104 | return Graph(nodes, edges, is_directed=False) 105 | -------------------------------------------------------------------------------- /part_03/requirements.txt: -------------------------------------------------------------------------------- 1 | appnope==0.1.2 2 | backcall==0.2.0 3 | decorator==4.4.2 4 | ipython==7.21.0 5 | ipython-genutils==0.2.0 6 | jedi==0.18.0 7 | Jinja2==2.11.3 8 | jsonpickle==2.0.0 9 | MarkupSafe==1.1.1 10 | networkx==2.5 11 | parso==0.8.1 12 | pexpect==4.8.0 13 | pickleshare==0.7.5 14 | prompt-toolkit==3.0.17 15 | ptyprocess==0.7.0 16 | Pygments==2.8.1 17 | pyvis==0.1.9 18 | traitlets==5.0.5 19 | wcwidth==0.2.5 20 | -------------------------------------------------------------------------------- /part_04/graph.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from itertools import combinations 3 | 4 | from pyvis.network import Network 5 | 6 | 7 | Graph = namedtuple("Graph", ["nodes", "edges", "is_directed"]) 8 | 9 | 10 | def adjacency_dict(graph): 11 | """ 12 | Returns the adjacency list representation 13 | of the graph. 14 | """ 15 | adj = {node: [] for node in graph.nodes} 16 | for edge in graph.edges: 17 | node1, node2 = edge[0], edge[1] 18 | adj[node1].append(node2) 19 | if not graph.is_directed: 20 | adj[node2].append(node1) 21 | return adj 22 | 23 | 24 | def adjacency_matrix(graph): 25 | """ 26 | Returns the adjacency matrix of the graph. 27 | 28 | Assumes that graph.nodes is equivalent to range(len(graph.nodes)). 29 | """ 30 | adj = [[0 for node in graph.nodes] for node in graph.nodes] 31 | for edge in graph.edges: 32 | node1, node2 = edge[0], edge[1] 33 | adj[node1][node2] += 1 34 | if not graph.is_directed: 35 | adj[node2][node1] += 1 36 | return adj 37 | 38 | 39 | def show(graph, output_filename, notebook=False): 40 | """ 41 | Saves an HTML file locally containing a 42 | visualization of the graph, and returns 43 | a pyvis Network instance of the graph. 44 | """ 45 | g = Network(directed=graph.is_directed, notebook=notebook) 46 | g.add_nodes(graph.nodes) 47 | g.add_edges(graph.edges) 48 | g.show(output_filename) 49 | return g 50 | 51 | 52 | def _validate_num_nodes(num_nodes): 53 | """ 54 | Check whether or not `num_nodes` is a 55 | positive integer, and raise a TypeError 56 | or ValueError if it is not. 57 | """ 58 | if not isinstance(num_nodes, int): 59 | raise TypeError(f"num_nodes must be an integer; {type(num_nodes)=}") 60 | if num_nodes < 1: 61 | raise ValueError(f"num_nodes must be positive; {num_nodes=}") 62 | 63 | 64 | def path_graph(num_nodes, is_directed=False): 65 | """ 66 | Return a Graph instance representing 67 | an undirected path on `num_nodes` nodes. 68 | """ 69 | _validate_num_nodes(num_nodes) 70 | nodes = range(num_nodes) 71 | edges = [(i, i + 1) for i in range(num_nodes - 1)] 72 | return Graph(nodes, edges, is_directed=is_directed) 73 | 74 | 75 | def cycle_graph(num_nodes, is_directed=False): 76 | """ 77 | Return a Graph instance representing 78 | an undirected cycle on `num_nodes` nodes. 79 | """ 80 | base_path = path_graph(num_nodes, is_directed) 81 | base_path.edges.append((num_nodes - 1, 0)) 82 | return base_path 83 | 84 | 85 | def complete_graph(num_nodes): 86 | """ 87 | Return a Graph instance representing 88 | the complete graph on `num_nodes` nodes. 89 | """ 90 | _validate_num_nodes(num_nodes) 91 | nodes = range(num_nodes) 92 | edges = list(combinations(nodes, 2)) 93 | return Graph(nodes, edges, is_directed=False) 94 | 95 | 96 | def star_graph(num_nodes): 97 | """ 98 | Return a Graph instance representing 99 | the star graph on `num_nodes` nodes. 100 | """ 101 | _validate_num_nodes(num_nodes) 102 | nodes = range(num_nodes) 103 | edges = [(0, i) for i in range(1, num_nodes)] 104 | return Graph(nodes, edges, is_directed=False) 105 | 106 | 107 | def _degrees(graph): 108 | """Return a dictionary of degrees for each node in the graph""" 109 | adj_list = adjacency_dict(graph) 110 | degrees = {node: len(neighbors) for node, neighbors in adj_list.items()} 111 | return degrees 112 | 113 | 114 | def degrees(graph): 115 | """ 116 | Return a dictionary of degrees for each node in 117 | an undirected graph. 118 | """ 119 | if graph.is_directed: 120 | raise ValueError("Cannot call degrees() on a directed graph") 121 | return _degrees(graph) 122 | 123 | 124 | def out_degrees(graph): 125 | """ 126 | Return a dictionary of out degrees for each node in 127 | a directed graph. 128 | """ 129 | if graph.is_directed: 130 | return _degrees(graph) 131 | raise ValueError("Cannot call out_degrees() on an undirected graph") 132 | -------------------------------------------------------------------------------- /part_04/requirements.txt: -------------------------------------------------------------------------------- 1 | appnope==0.1.2 2 | backcall==0.2.0 3 | decorator==4.4.2 4 | ipython==7.21.0 5 | ipython-genutils==0.2.0 6 | jedi==0.18.0 7 | Jinja2==2.11.3 8 | jsonpickle==2.0.0 9 | MarkupSafe==1.1.1 10 | networkx==2.5 11 | parso==0.8.1 12 | pexpect==4.8.0 13 | pickleshare==0.7.5 14 | prompt-toolkit==3.0.17 15 | ptyprocess==0.7.0 16 | Pygments==2.8.1 17 | pyvis==0.1.9 18 | traitlets==5.0.5 19 | wcwidth==0.2.5 20 | -------------------------------------------------------------------------------- /part_04/solutions.py: -------------------------------------------------------------------------------- 1 | from graph import Graph, degrees, out_degrees 2 | 3 | 4 | # Solution for calculating the total degrees in a directed graph 5 | def total_degrees(graph): 6 | """ 7 | Return a dictionary of total degrees for each node in a 8 | directed graph. 9 | """ 10 | if not graph.is_directed: 11 | raise ValueError("Cannot call total_degrees() on an undirected graph") 12 | 13 | # The total degree of a node is the same as the total number of 14 | # edges connected to that node. So you can get the total degree 15 | # by calling `degrees()` on an undirected graph with the same 16 | # nodes and edges as `graph`. 17 | undirected_graph = Graph(graph.nodes, graph.edges, is_directed=False) 18 | return degrees(undirected_graph) 19 | 20 | 21 | # Solution #1 for calculating the in degrees of a directed graph 22 | # This solution uses the `total_degrees()` function degined above. 23 | # Although it works, it is not ideal because it calls both 24 | # `total_degrees()` and `out_degrees()`, both of which end up calling 25 | # `_degrees()`. In other words, it builds the adjacency list twice. 26 | 27 | 28 | def in_degrees1(graph): 29 | """ 30 | Return a dictionary of in degrees for each node in a 31 | directed graph 32 | """ 33 | # Since total degree = in degree + out degree, you can calculate 34 | # the in degrees by subtracting the out degrees from the total 35 | # degrees for each node 36 | graph_total_degrees = total_degrees(graph) 37 | graph_out_degrees = out_degrees(graph) 38 | return { 39 | node: graph_total_degrees[node] - graph_out_degrees[node] 40 | for node in graph.nodes 41 | } 42 | 43 | 44 | # Solution #2 for calculating the in degrees of a directed graph 45 | def in_degrees2(graph): 46 | """ 47 | Return a dictionary of in degrees for each node in a 48 | directed graph 49 | """ 50 | # If you reverse the edges of a directed graph then out neighbors 51 | # in the reversed graph are in neighbors in the original graph. 52 | reversed_edges = [(node2, node1) for node1, node2 in graph.edges] 53 | reversed_graph = Graph(graph.nodes, reversed_edges, is_directed=True) 54 | return out_degrees(reversed_graph) 55 | 56 | 57 | # ~~~~~~~~~~~~~~~~~ BONUS ~~~~~~~~~~~~~~~~~ 58 | 59 | 60 | def min_degree(graph): 61 | """Return the minimum degree of an undirected graph.""" 62 | return min(degrees(graph).values()) 63 | 64 | 65 | def max_degree(graph): 66 | """Return the maximum degree of an undirected graph.""" 67 | return max(degrees(graph).values()) 68 | 69 | 70 | def min_in_degree(graph): 71 | """Return the minimum in degree of a directed graph.""" 72 | return min(in_degrees2(graph).values()) 73 | 74 | 75 | def max_in_degree(graph): 76 | """Return the maximum in degree of a directed graph.""" 77 | return max(in_degrees2(graph).values()) 78 | 79 | 80 | def min_out_degree(graph): 81 | """Return the minimum out degree of a directed graph.""" 82 | return min(out_degrees(graph).values()) 83 | 84 | 85 | def max_out_degree(graph): 86 | """Return the maximum out degree of a directed graph.""" 87 | return max(out_degrees(graph).values()) 88 | 89 | 90 | def min_total_degree(graph): 91 | """Return the minimum total degree of a directed graph.""" 92 | return min(total_degrees(graph).values()) 93 | 94 | 95 | def max_total_degree(graph): 96 | """Return the maximum total degree of a directed graph.""" 97 | return max(total_degrees(graph).values()) 98 | --------------------------------------------------------------------------------