diff --git a/LICENSE b/LICENSE index 4868c78..77a406b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 - 2023 HeWeMel +Copyright (c) 2022 - 2024 HeWeMel Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.rst b/README.rst index 0cc8453..3b61fd6 100644 --- a/README.rst +++ b/README.rst @@ -119,6 +119,9 @@ Think of it as *graph analysis - the lazy (evaluation) way*. positive / zero / negative edge weights, graph does not need to be complete) - Dijkstra shortest paths algorithm for infinitely branching graphs with locally sorted edges. +- Example for computing the + longest path + between two vertices in a weighted, acyclic graph. - Gadget functions for test purposes. They make the easy task of adapting existing explicit test graphs a no brainer, may they be stored in edge indices or edge iterables diff --git a/docs/source/bidirectional_search.rst b/docs/source/bidirectional_search.rst index 0511325..86ecede 100644 --- a/docs/source/bidirectional_search.rst +++ b/docs/source/bidirectional_search.rst @@ -212,7 +212,7 @@ Then, we do the same again, but we use the bidirectional search strategy >>> print(f"{length=}, {evaluated_positions=}") length=58, evaluated_positions=68 -Again, of cause, we get the same path length in both cases. +Again, of course, we get the same path length in both cases. But **the bidirectional search regards only 68 positions** before it finds an optimal solution, whilst **the unidirectional search regards 257 positions**. diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 632c91f..80e4111 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -1,6 +1,11 @@ ChangeLog --------- +**v3.3.2** (2024-02-04) + +- Added method paths.predecessor +- Source formatting adapted to new black default + **v3.3.1** (2023-10-09) - Adapted tests to Python 3.12 diff --git a/docs/source/concept_and_examples.rst b/docs/source/concept_and_examples.rst index d3ef2dd..0e9a4af 100644 --- a/docs/source/concept_and_examples.rst +++ b/docs/source/concept_and_examples.rst @@ -107,7 +107,12 @@ Examples In the following sections, we give some small examples that illustrate the described `concept elements ` -of NoGraphs and their use in the context of different traversal algorithms. +of NoGraphs and their use in the context of a selection of traversal algorithms. +More examples and algorithms can be found in the respective sections of this +tutorial. + +We start with algorithms that do not require edge weights. Then, we give +examples with weighted edges. The `building blocks of graph adaptation `, the support for `operations on graphs ` @@ -426,6 +431,94 @@ we need to *make coffee* before we can *make coffee*): ['drink coffee', 'make coffee', 'heat water', 'get water', 'make coffee'] +.. _example-longest-path-acyclic-graph: + +Longest path in a weighted, acyclic graph (using topological search) +.................................................................... + +Here, vertices are tuples of *x* and *y* coordinates. A coordinate is an +integer between 0 and 4. There is a cost value assigned to each vertex. + +.. code-block:: python + + >>> costs = ''' + ... 02141 + ... 30401 + ... 12121 + ... 50404 + ... 12111 + ... '''.strip().splitlines() + +The successors of a vertex are its neighbors to the right and below, +but only those with non-zero costs. +The graph is acyclic, meaning that a path +starting at some vertex cannot lead back to this vertex. +The weight of an edge to a neighbor is determined by the costs of this neighbor. + +.. code-block:: python + + >>> def next_edges(vertex, _): + ... x, y = vertex + ... for dx, dy in [(1, 0), (0, 1)]: + ... nx, ny = x + dx, y + dy + ... if nx <= 4 and ny <= 4 and (weight := int(costs[ny][nx])) > 0: + ... yield (nx, ny), weight + +We are searching for a path with the highest total of edge weights from +the start vertex (0, 0) to the goal vertex (4, 4). In other words, we +**are searching for a longest path between two vertices in a weighted, acyclic graph**. + +**First, for each vertex reachable from the start vertex, we compute the maximal** +**length of a path to the goal vertex.** + +We use the *TraversalTopologicalSort* strategy of NoGraphs (see +`Traversal algorithms `). +We traverse the vertices backwards, in topological sorting. This means: When a vertex +is visited, all its successors have already been visited. And we can simply calculate +the maximal length of a path from there to the goal vertex based on the maximal values +we have calculated for its successors and on the respective weights of the edges +leading to them. The value computed for the start vertex is the path length we are +looking for. + +.. code-block:: python + + >>> start_vertex, goal_vertex = (0, 0), (4, 4) + >>> goal_distance = dict() + >>> traversal = nog.TraversalTopologicalSort(next_edges=next_edges) + >>> for position in traversal.start_from(start_vertex): + ... if position == goal_vertex: + ... goal_distance[position] = 0 + ... else: + ... goal_distance[position] = max( + ... (goal_distance[successor] + weight + ... for successor, weight in next_edges(position, None) + ... ), default=-float('inf')) + >>> goal_distance[start_vertex] + 16 + +We did not get *-float('inf')* as result, so we can be sure, there is a path to +the goal, and we know its maximal length. + +**Then, we compute a longest path, i.e., a path with the already known** +**maximal length.** + +For this, we start at the start vertex, and for each vertex, we follow an edge to a +neighbor with the maximal distance to the goal under all neighbors: + +.. code-block:: python + + >>> path = [] + >>> vertex = start_vertex + >>> while vertex != goal_vertex: + ... largest = -float('inf') + ... for neighbor, weight in next_edges(vertex, None): + ... if largest < (distance := goal_distance[neighbor]): + ... largest, vertex = distance, neighbor + ... path.append(vertex) + >>> path + [(1, 0), (2, 0), (2, 1), (2, 2), (3, 2), (4, 2), (4, 3), (4, 4)] + + .. _example-shortest-paths-in-maze: Shortest paths in a maze with weights diff --git a/docs/source/conf.py b/docs/source/conf.py index a575361..cc731f8 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -18,7 +18,7 @@ # -- Project information ----------------------------------------------------- project = 'NoGraphs' -copyright = '2022 - 2023, Helmut Melcher' +copyright = '2022 - 2024, Helmut Melcher' author = 'Helmut Melcher' diff --git a/docs/source/gadgets.rst b/docs/source/gadgets.rst index 957b39e..a8eaab7 100644 --- a/docs/source/gadgets.rst +++ b/docs/source/gadgets.rst @@ -440,7 +440,7 @@ given coordinate limits: ((4, 5), (5, 4)) >>> tuple(v.neighbors(moves, limits=((0, 6), (0, 6)), wrap=True)) ((4, 5), (5, 4), (5, 0), (0, 5)) - >>> # When we choose wrapping, of cause we have to provide limits + >>> # When we choose wrapping, of course we have to provide limits >>> tuple(v.neighbors(moves, wrap=True)) Traceback (most recent call last): RuntimeError: Limits for Option wrap missing diff --git a/docs/source/index.rst b/docs/source/index.rst index 0844d99..9f155d4 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -109,6 +109,9 @@ Think of it as *graph analysis - the lazy (evaluation) way*. positive / zero / negative edge weights, graph does not need to be complete). - Dijkstra shortest paths algorithm for `infinitely branching graphs with locally sorted edges `. +- Example for computing the + `longest path ` + between two vertices in a weighted, acyclic graph. - `Gadget functions ` for test purposes. They make the easy task of adapting existing explicit test graphs a no brainer, may they be stored in `edge indices or edge iterables ` diff --git a/docs/source/performance.rst b/docs/source/performance.rst index 787b2c9..fe7bde9 100644 --- a/docs/source/performance.rst +++ b/docs/source/performance.rst @@ -596,7 +596,7 @@ task 2 of scenario 1. **This means: In scenario B, the lists (resp. arrays) in the bookkeeping of** **the sequence-based gears are half empty!** This reduces the memory-saving effect of using sequence- instead of set- and -dict-based gears. But, of cause, a reduction of the needed memory to +dict-based gears. But, of course, a reduction of the needed memory to 22.8% (resp. 11.9%) is still better than nothing. Sometimes, counter-measures are possible: @@ -612,7 +612,7 @@ The runtime increases by 6 percent points, this is the time needed for calling and executing the conversion function for each vertex, when a (converted) vertex id for the vertex is needed. -Of cause, such a conversion can only be applied if the range of relevant vertices +of course, such a conversion can only be applied if the range of relevant vertices is roughly known. Otherwise, we have to live with empty list or array cells, or we need to use a mapping-based (e.g., dict-based) gear. diff --git a/docs/source/traversals.rst b/docs/source/traversals.rst index 326021d..c744aba 100644 --- a/docs/source/traversals.rst +++ b/docs/source/traversals.rst @@ -11,6 +11,13 @@ of iterators**. You can use them to **traverse your graph** following some specific traversal strategy and to compute values like depths, distances, paths and trees. +Note that these strategies and their description below do not fully specify +the behaviour of the implemented algorithms. For instance, if a vertex has +edges to different successor vertices, the order in which these vertices are +reported is often undefined. In such cases, the implementation, and the order, +might change without prior notice. + + Classes for all graphs ~~~~~~~~~~~~~~~~~~~~~~ diff --git a/setup.py b/setup.py index f18c62a..44b1255 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="nographs", - version="3.3.1", + version="3.3.2", description=("Graph analysis – the lazy (evaluation) way: Analysis on the fly, " + "for graphs, that are computed and/or adapted on the fly."), long_description=long_description, diff --git a/src/nographs/_extra_edge_gadgets.py b/src/nographs/_extra_edge_gadgets.py index 20fa35c..7a96b09 100644 --- a/src/nographs/_extra_edge_gadgets.py +++ b/src/nographs/_extra_edge_gadgets.py @@ -24,8 +24,7 @@ def adapt_edge_index( *, add_inverted: bool, attributes: Literal[False], -) -> Callable[[T_vertex, Any], Iterable[T_vertex]]: - ... +) -> Callable[[T_vertex, Any], Iterable[T_vertex]]: ... @overload @@ -34,8 +33,7 @@ def adapt_edge_index( *, add_inverted: bool, attributes: Literal[True], -) -> Callable[[T_vertex, Any], Iterable[OutEdge[T_vertex, T_weight, T_labels]]]: - ... +) -> Callable[[T_vertex, Any], Iterable[OutEdge[T_vertex, T_weight, T_labels]]]: ... @overload @@ -44,8 +42,7 @@ def adapt_edge_index( *, add_inverted: bool, attributes: Literal[False], -) -> Callable[[int, Any], Iterable[T_vertex]]: - ... +) -> Callable[[int, Any], Iterable[T_vertex]]: ... @overload @@ -54,8 +51,7 @@ def adapt_edge_index( *, add_inverted: bool, attributes: Literal[True], -) -> Callable[[int, Any], Iterable[OutEdge[int, T_weight, T_labels]]]: - ... +) -> Callable[[int, Any], Iterable[OutEdge[int, T_weight, T_labels]]]: ... def adapt_edge_index( @@ -155,8 +151,7 @@ def adapt_edge_iterable( *, add_inverted: bool, attributes: Literal[False], -) -> Callable[[T_vertex, Any], Iterable[T_vertex]]: - ... +) -> Callable[[T_vertex, Any], Iterable[T_vertex]]: ... @overload @@ -165,8 +160,7 @@ def adapt_edge_iterable( *, add_inverted: bool, attributes: Literal[True], -) -> Callable[[T_vertex, Any], Iterable[OutEdge[T_vertex, T_weight, T_labels]]]: - ... +) -> Callable[[T_vertex, Any], Iterable[OutEdge[T_vertex, T_weight, T_labels]]]: ... def adapt_edge_iterable( diff --git a/src/nographs/_extra_infinite_branching.py b/src/nographs/_extra_infinite_branching.py index 43c12d8..dba104f 100644 --- a/src/nographs/_extra_infinite_branching.py +++ b/src/nographs/_extra_infinite_branching.py @@ -179,9 +179,11 @@ def start_from( self.distances = define_distances( self._gear, None, - ((vertex, zero) for vertex in self._start_vertices) - if store_distances - else (), + ( + ((vertex, zero) for vertex in self._start_vertices) + if store_distances + else () + ), self._is_tree, ) diff --git a/src/nographs/_extra_tsp.py b/src/nographs/_extra_tsp.py index f15c2a5..8463543 100644 --- a/src/nographs/_extra_tsp.py +++ b/src/nographs/_extra_tsp.py @@ -198,9 +198,11 @@ def _traveling_salesman_int_vertices( # Pack transformed edge weights in nested tuples graph_forwards = tuple( tuple( - None - if (weight := graph[v][w]) is None - else (zero - weight if negate_weights else weight) + weight_offset + ( + None + if (weight := graph[v][w]) is None + else (zero - weight if negate_weights else weight) + weight_offset + ) for w in range(no_of_vertices) ) for v in range(no_of_vertices) @@ -209,9 +211,11 @@ def _traveling_salesman_int_vertices( # The same, but for for incoming (!) edges of some vertex graph_backwards = tuple( tuple( - None - if (weight := graph[w][v]) is None - else (zero - weight if negate_weights else weight) + weight_offset + ( + None + if (weight := graph[w][v]) is None + else (zero - weight if negate_weights else weight) + weight_offset + ) for w in range(no_of_vertices) ) for v in range(no_of_vertices) @@ -244,9 +248,12 @@ def _traveling_salesman_int_vertices( functools.reduce( operator.or_, ( - 1 << to_vertex - if from_vertex != to_vertex and edges_from_vertex[to_vertex] is not None - else 0 + ( + 1 << to_vertex + if from_vertex != to_vertex + and edges_from_vertex[to_vertex] is not None + else 0 + ) for to_vertex in range(no_of_vertices) ), 0, @@ -261,9 +268,12 @@ def _traveling_salesman_int_vertices( functools.reduce( operator.or_, ( - 1 << to_vertex - if from_vertex != to_vertex and edges_from_vertex[to_vertex] is not None - else 0 + ( + 1 << to_vertex + if from_vertex != to_vertex + and edges_from_vertex[to_vertex] is not None + else 0 + ) for to_vertex in range(no_of_vertices) ), 0, diff --git a/src/nographs/_paths.py b/src/nographs/_paths.py index 420c65b..989a536 100644 --- a/src/nographs/_paths.py +++ b/src/nographs/_paths.py @@ -108,6 +108,20 @@ def _check_vertex(self, vertex: T_vertex) -> T_vertex_id: raise RuntimeError("Paths: No path for given vertex.") return vertex_id + def predecessor(self, vertex: T_vertex) -> Optional[T_vertex]: + """Return predecessor of *vertex* in path to *vertex*. Return *None* if the + path is empty. Raise RuntimeError is no path to *vertex* is stored. + + :param vertex: The predecessor of this vertex will be returned. + """ + vertex_id = self._check_vertex(vertex) + predecessor_collection = self._predecessor_collection + + from_vertex = predecessor_collection[vertex_id] + if vertex == from_vertex: + return None # self loop denotes empty path / end of the path + return from_vertex + def iter_vertices_to_start(self, vertex: T_vertex) -> Iterator[T_vertex]: """Iterate the vertices on the path to *vertex* from the last to the first. @@ -221,9 +235,7 @@ def iter_labeled_edges_from_start( ) @abstractmethod - def __getitem__( - self, vertex: T_vertex - ) -> Union[ + def __getitem__(self, vertex: T_vertex) -> Union[ Sequence[T_vertex], Sequence[UnweightedLabeledFullEdge[T_vertex, T_labels]], ]: @@ -291,9 +303,7 @@ def _check_vertex(self, vertex: T_vertex) -> T_vertex_id: "No paths available: " + "Traversal not started or no paths requested." ) - def __getitem__( - self, vertex: T_vertex - ) -> Union[ + def __getitem__(self, vertex: T_vertex) -> Union[ Sequence[T_vertex], Sequence[UnweightedLabeledFullEdge[T_vertex, T_labels]], ]: diff --git a/src/nographs/_traversals.py b/src/nographs/_traversals.py index 6445630..2b17302 100644 --- a/src/nographs/_traversals.py +++ b/src/nographs/_traversals.py @@ -172,12 +172,12 @@ def __init__( demanded. If labeled edges were provided (parameter *next_labeled_edges*), the paths contain them instead of just vertices. """ - self._predecessors: VertexIdToVertexMapping[ - T_vertex_id, T_vertex - ] = DummyPredecessorOrAttributesMapping[T_vertex_id, T_vertex]() - self._attributes: VertexIdToPathEdgeDataMapping[ - T_vertex_id, T_labels - ] = DummyPredecessorOrAttributesMapping[T_vertex_id, T_labels]() + self._predecessors: VertexIdToVertexMapping[T_vertex_id, T_vertex] = ( + DummyPredecessorOrAttributesMapping[T_vertex_id, T_vertex]() + ) + self._attributes: VertexIdToPathEdgeDataMapping[T_vertex_id, T_labels] = ( + DummyPredecessorOrAttributesMapping[T_vertex_id, T_labels]() + ) def __iter__( self, @@ -271,14 +271,12 @@ def my_generator() -> Iterator[T_vertex]: @overload def go_to( self, vertex: T_vertex, fail_silently: Literal[False] = False - ) -> T_vertex: - ... + ) -> T_vertex: ... @overload def go_to( self, vertex: T_vertex, fail_silently: Literal[True] - ) -> Optional[T_vertex]: - ... + ) -> Optional[T_vertex]: ... def go_to( self, vertex: T_vertex, fail_silently: bool = False @@ -531,7 +529,6 @@ def start_from( class TraversalBreadthFirstFlex( _TraversalWithoutWeightsBasic[T_vertex, T_vertex_id, T_labels] ): - """ Bases: `Traversal` [`T_vertex`, `T_vertex_id`, `T_labels`] @@ -2242,9 +2239,9 @@ def __init__( ) -> None: super().__init__(labeled_edges, is_tree, vertex_to_id, gear) - self.distances: VertexIdToDistanceMapping[ - T_vertex_id, T_weight - ] = NoDistancesMapping[T_vertex_id, T_weight]() + self.distances: VertexIdToDistanceMapping[T_vertex_id, T_weight] = ( + NoDistancesMapping[T_vertex_id, T_weight]() + ) """ Provisional or final distance values of some vertices (distance from a start vertex). Without option *keep_distances*, the value for a vertex is removed once the vertex has been reported. With diff --git a/tests/test_paths.py b/tests/test_paths.py index 15201a6..dc1e86a 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -14,44 +14,44 @@ class PathsHandling: -- Unlabeled paths -- >>> gear = nog.GearDefault() - >>> path_unlabeled = _paths.PathsOfUnlabeledEdges( + >>> paths_unlabeled = _paths.PathsOfUnlabeledEdges( ... gear.vertex_id_to_vertex_mapping(()), ... None ... ) - >>> path_unlabeled[None] # Calls p.iter_vertices_to_start + >>> paths_unlabeled[None] # Calls p.iter_vertices_to_start Traceback (most recent call last): RuntimeError: Paths: None instead of vertex given. - >>> None in path_unlabeled + >>> None in paths_unlabeled Traceback (most recent call last): RuntimeError: Paths: None instead of vertex given. - >>> tuple(path_unlabeled.iter_vertices_to_start(None)) + >>> tuple(paths_unlabeled.iter_vertices_to_start(None)) Traceback (most recent call last): RuntimeError: Paths: None instead of vertex given. - >>> tuple(path_unlabeled.iter_edges_to_start(None)) + >>> tuple(paths_unlabeled.iter_edges_to_start(None)) Traceback (most recent call last): RuntimeError: Paths: None instead of vertex given. - >>> tuple(path_unlabeled.iter_edges_to_start(2)) + >>> tuple(paths_unlabeled.iter_edges_to_start(2)) Traceback (most recent call last): RuntimeError: Paths: No path for given vertex. - >>> path_unlabeled.append_edge(0, 0, None) - >>> path_unlabeled.append_edge(0, 1, None) + >>> paths_unlabeled.append_edge(0, 0, None) + >>> paths_unlabeled.append_edge(0, 1, None) >>> # Calls p.iter_vertices_from_start and p.iter_vertices_to_start - >>> path_unlabeled[1] + >>> paths_unlabeled[1] (0, 1) >>> # Also calls p.iter_edges_to_start - >>> tuple(path_unlabeled.iter_edges_from_start(1)) + >>> tuple(paths_unlabeled.iter_edges_from_start(1)) ((0, 1),) - >>> path_unlabeled.append_edge(1, 2, None) - >>> path_unlabeled[2] + >>> paths_unlabeled.append_edge(1, 2, None) + >>> paths_unlabeled[2] (0, 1, 2) - >>> tuple(path_unlabeled.iter_edges_from_start(2)) + >>> tuple(paths_unlabeled.iter_edges_from_start(2)) ((0, 1), (1, 2)) -- Paths (and not overridden in PathsOfUNlabeledEdges) -- - >>> path_unlabeled.iter_labeled_edges_from_start(2) + >>> paths_unlabeled.iter_labeled_edges_from_start(2) Traceback (most recent call last): RuntimeError: Edges with labels needed, and Traversal needs to know about them - >>> path_unlabeled.iter_labeled_edges_to_start(2) + >>> paths_unlabeled.iter_labeled_edges_to_start(2) Traceback (most recent call last): RuntimeError: Edges with labels needed, and Traversal needs to know about them @@ -105,25 +105,25 @@ class PathsHandling: -- Unlabeled Path with sequence based predecessor-- >>> gear = nog.GearForIntVertexIDsAndCFloats() - >>> path = _paths.PathsOfUnlabeledEdges( + >>> paths = _paths.PathsOfUnlabeledEdges( ... gear.vertex_id_to_vertex_mapping(()), ... None ... ) - >>> path.append_edge(0, 0, [0]) - >>> path.append_edge(0, 1, [1]) - >>> path[1] + >>> paths.append_edge(0, 0, [0]) + >>> paths.append_edge(0, 1, [1]) + >>> paths[1] (0, 1) -- Labeled Path with sequence based predecessor-- >>> gear = nog.GearForIntVertexIDsAndCFloats() - >>> path = _paths.PathsOfLabeledEdges( + >>> paths = _paths.PathsOfLabeledEdges( ... gear.vertex_id_to_vertex_mapping(()), ... gear.vertex_id_to_path_attributes_mapping(()), ... None ... ) - >>> path.append_edge(0, 0, [0]) - >>> path.append_edge(0, 1, [1]) - >>> path[1] + >>> paths.append_edge(0, 0, [0]) + >>> paths.append_edge(0, 1, [1]) + >>> paths[1] ((0, 1, 1),) @@ -152,4 +152,21 @@ class PathsHandling: RuntimeError: Cannot add a path, traversal not started or no paths requested. >>> tuple(iter(predecessor)), len(predecessor), None in predecessor ((), 0, False) + + + -- Method predecessor -- + >>> gear = nog.GearDefault() + >>> paths = _paths.PathsOfUnlabeledEdges(gear.vertex_id_to_vertex_mapping(()), None) + >>> paths.append_edge(0, 0, None) + >>> paths.append_edge(0, 1, None) + >>> paths.predecessor(0) is None + True + >>> paths.predecessor(1) + 0 + >>> paths.predecessor(2) + Traceback (most recent call last): + RuntimeError: Paths: No path for given vertex. + >>> paths.predecessor(None) + Traceback (most recent call last): + RuntimeError: Paths: None instead of vertex given. """ diff --git a/tests/test_traversals_and_searches.py b/tests/test_traversals_and_searches.py index 7d08d9b..d63c97d 100644 --- a/tests/test_traversals_and_searches.py +++ b/tests/test_traversals_and_searches.py @@ -35,8 +35,7 @@ def print_filled(s: str) -> None: class Comparable(Protocol): @abstractmethod - def __lt__(self: T_bound_comparable, other: T_bound_comparable) -> bool: - ... + def __lt__(self: T_bound_comparable, other: T_bound_comparable) -> bool: ... class HashableComparable(Hashable, Comparable, Protocol):