Skip to content

Commit

Permalink
v3.3.2
Browse files Browse the repository at this point in the history
  • Loading branch information
HeWeMel committed Feb 4, 2024
1 parent 1ded9df commit 904503d
Show file tree
Hide file tree
Showing 18 changed files with 220 additions and 80 deletions.
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/source/bidirectional_search.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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**.

Expand Down
5 changes: 5 additions & 0 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
@@ -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
Expand Down
95 changes: 94 additions & 1 deletion docs/source/concept_and_examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,12 @@ Examples

In the following sections, we give some small examples that illustrate the
described `concept elements <concept_and_examples>`
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 <graphs_and_adaptation>`,
the support for `operations on graphs <graph_operations>`
Expand Down Expand Up @@ -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 <traversals>`).
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
Expand Down
2 changes: 1 addition & 1 deletion docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
# -- Project information -----------------------------------------------------

project = 'NoGraphs'
copyright = '2022 - 2023, Helmut Melcher'
copyright = '2022 - 2024, Helmut Melcher'
author = 'Helmut Melcher'


Expand Down
2 changes: 1 addition & 1 deletion docs/source/gadgets.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <infinite_branching>`.
- Example for computing the
`longest path <example-longest-path-acyclic-graph>`
between two vertices in a weighted, acyclic graph.
- `Gadget functions <gadgets>` 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 <edge_gadgets>`
Expand Down
4 changes: 2 additions & 2 deletions docs/source/performance.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.

Expand Down
7 changes: 7 additions & 0 deletions docs/source/traversals.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~~~~~~~~~~~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 6 additions & 12 deletions src/nographs/_extra_edge_gadgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down
8 changes: 5 additions & 3 deletions src/nographs/_extra_infinite_branching.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
34 changes: 22 additions & 12 deletions src/nographs/_extra_tsp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
22 changes: 16 additions & 6 deletions src/nographs/_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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]],
]:
Expand Down Expand Up @@ -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]],
]:
Expand Down
Loading

0 comments on commit 904503d

Please sign in to comment.