Graphs in Python
Representing Graphs in Python
The _Vertex class
- Implement graph as a node-based data structure
- vertex - node; edge - implicit as a variable in the vertex node
- Restrictions on edges: cannot have a vertex with an edge to itself AND all edges are symmetric
from __future__ import annotations
from typing import Any
class _Vertex:
"""A vertex in a graph
Instance Attributes:
- item: The data stored in this vertex
- neighbours: The vertices that are adjacent to this vertex
Representation Invariants:
- self not in self.neighbours
- all(self in u.neighbours for u in self.neighbours)
"""
item: Any
neighbours: set[_Vertex]
def __init__(self, item: Any, neighbours: set[_Vertex]) -> None:
"""Initialize a new vertex with the given item and neighbours"""
self.item = item
self.neighbours = neighboursExample of using this class:
>>> v1 = _Vertex('a', set()) >>> v2 = _Vertex('b', set()) >>> v3 = _Vertex('c', set()) >>> v1.neighbours = {v2, v3} >>> v2.neighbours = {v1, v3} >>> v3.neighbours = {v1, v2}
- Similar to
Tree_subtreeattribute, but this case treats the class as representing a single element, not the whole graph itself. - This collection is unordered (no left-to-right) ordering like trees
The Graph class
class Graph:
"""A graph"""
# Private Instance Attributes:
# - _vertices: A collection of the vertices contained in this graph.
# Maps item to _Vertex instance
_vertices: dict[Any, _Vertex]
def __init__(self) -> None:
"""Initialize an empty graph (no vertices or edges)"""
self._vertics = {}-
This would only great empty graphs. The mutating method is here:
class Graph: def add_vertex(self, item: Any) -> None: """Add a vertex with the given item to this graph The new vertex is not adjacent to any other vertices. Preconditions: - item not in self._vertices """ self._vertices[item] = _Vertex(item, set()) def add_edge(self, item1: Any, item2: Any) -> None: """Add an edge between the two vertices with the given items in this graph Raise a ValueError if item1 and item2 do not appearas vertices in this graph Preconditions: - item1 != item2 """ if item1 in self._vertices and item2 in self.vertices: v1 = self._vertices[item1] v2 = self._vertices[item2] # Add the new edge v1.neighbouts.add(v2) v2.neighbouts.add(v1) else: raise ValueErrorCreate
Graphobjects and populate them with vertices and edges>>> g = Graph() >>> g.add_vertex('a') >>> g.add_vertex('b') >>> g.add_vertex('c') >>> g.add_edge('a', 'b') >>> g.add_edge('a', 'c') >>> g.add_edge('b', 'c')
Checking adjacency
- Implement 2
Graphmethods to check adjacency of a vertex
class Graph:
def adjacent(self, item1: Any, tiem2: Any) -> bool:
"""Return whether item1 and item2 are adjacent vertices in this graph.
Return False if item1 or item2 do not appear as vertices in this graph
"""
if item1 in self._vertices and item2 in self._vertices:
v1 = self.verticies[item1]
return any(v2.item == item2 for v2 in v1.neighours)
else:
return False
def get_neighbours(self, item: Any) -> set:
"""Return a set of the neighbours of the given item
Note that the *item* are returned, not the _Vertex objects themselves
Raise a ValueError if item does not appear as vertex in this graph
"""
if item in self._vertices:
v = self._vertices[item]
return {neighbour.item for neighbour in v.neighbours}
else:
raise ValueErrorCreate
Graphobjects and populate them with vertices and edges>>> g = Graph() >>> g.add_vertex('a') >>> g.add_vertex('b') >>> g.add_vertex('c') >>> g.add_vertex('d') >>> g.add_edge('a', 'b') >>> g.add_edge('b', 'c') >>> g.adjacent('a', 'b') True >>> g.adjacent('a', 'd') False >>> g.get_neighbours('b') == {'a', 'c'} True >>> g.get_neighbours('d') == set() True
Connectivity and Recursive Graph Traversal
-
Goal: Implement a generalization method that checks whether two vertices are connected
-
This method follows a similar structure where it first finds a vertex that correspond to the input
item1 -
But checking connectivity is much harder than checking adjacencies. Use a helper function in the
_Vertexclass calledcheck_connected, write down the code so far:class Graph: def connected(self, item1: Any, item2: Any) -> bool: """Return whether item1 and item2 are connected vertices in this graph Return False if item1 or item2 does not appear as vertices in the graph >>> g = Graph() >>> g.add_vertex(1) >>> g.add_vertex(2) >>> g.add_vertex(3) >>> g.add_vertex(4) >>> g.add_edge(1, 2) >>> g.add_edge(2, 3) >>> g.add_edge(1, 3) >>> g.connected(1, 3) True >>> g.connected(1, 4) False """ if item1 in self._vertices and item2 in self._vertices: v1 = self.vertices[item1] return v1.check_connected(item2, set()) # pass in an empty "visited" set else: return False -
Given 2 vertices and , they are connected when:
- or there exist a neighbour of such that are connected
-
This has a basic recursive definition, but does not strictly follows the recursive structure.
- It does not break down the datatype (graph into a smaller instance)
- This leads to a new type of error
RecursionError
RecursionError and the danger of infinite recursion
- To understand this, do a manual tracing
- Let a graph
ghave 3 vertices, containing the items1, 2, 3. If callg.connected(1, 3), the program will first find the vertex corresponding to1, which can be called . Then the code would call_Vertex.check_connectedon and3. This is the problem
- For ,
self.item ==1, which is not3. This enters the recursive branch. - The only neighbour for is the vertex containing
2, which can be called as . Then the code would make a recursive call to_Vertex.check_connectedof and3 - For ,
self.item == 2, which is not3. This again, enters the recursive branch. The only neighbour of is , then it would make a recursive call on and3 - For …
- For ,
- Infinite recursion - a code has expression a recursion computation that does not stop by reaching the base case
- In the above case, the Python interpreter alternates between calling the
Vertex.check_connectedwith and3and with and3, and never stops - This keeps adding a stack frame onto the function call stack, which at then would reach the limit the Python interpreter has set, and leads to a
RecursionError
- In the above case, the Python interpreter alternates between calling the
Fixing the recursion error
- Modify the recursive condition: Given 2 vertices and , they are connected when:
- or
- There exists a neighbour of such that and are connected by a path that does not use
- Achieve this by adding a
visitedparameter (which is already in theGraphcode above)
class _Vertex:
def check_connected(self, target_item: Any, visited: set[_Vertex]) -> bool:
"""Return whether this vertex is connected to a vertex corresponding to the target_item,
WITHOUT using any of the vertices in visited
Preconditions:
- set not in visited
"""
if self.item == target_item:
return True
else:
visited.add(self) # add self to the set of visited vertices
for u in self.neighbours:
# only recursive on vertices that haven't been visited
if u not in visited and u.check_connected(target_item, visited):
return True
return FalseBut even though this implementation is correct, and much more efficient than the previous one, it does come with an important warning we want you to remember for the future. Whenever you use recursion with a mutable argument, be very careful when choosing whether to mutate that argument or create a modified copy—if you choose to mutate the argument, know that all recursive calls will mutate it as well!