Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

all_shortest_paths function #1017

Merged
merged 10 commits into from
Jan 1, 2024
Merged

all_shortest_paths function #1017

merged 10 commits into from
Jan 1, 2024

Conversation

lucasvanmol
Copy link
Contributor

@lucasvanmol lucasvanmol commented Oct 23, 2023

  • I ran rustfmt locally
  • I have added the tests to cover my changes.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.

This (WIP) pull request aims to implement an all_shortest_paths algorithm as discussed in #933.

This is a draft pull request because I would like some input on how this is best implemented.

The most straightforward way to implement such a function would not require a change to rustworkx-core; simply run Dijkstra's then look hard enough at the scores DistanceMap returned by Dijkstra's in order to find all possible shortest paths. However, this adds some unnecessary complexity, as we could instead keep track of this information while Dijkstra's is running.

In this initial commit I have included a way this could be implemented by changing the dijkstra function in rustworkx-core. At this point, it does change the public rust interface, hence why I'm asking for input. Would the best way forward be a separate function? If so should this be in a separate file? Or is it not worth it, and I should just implement the approach of the previous paragraph?

@CLAassistant
Copy link

CLAassistant commented Oct 23, 2023

CLA assistant check
All committers have signed the CLA.

@lucasvanmol lucasvanmol changed the title [WIP] all_shortest_paths function [WIP] all_shortest_paths function Oct 23, 2023
Copy link
Collaborator

@IvanIsCoding IvanIsCoding left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for opening your first PR! Don't forget to sign the CLA and I'm looking forward to reviewing your work.

Some thoughts on this early draft. I don't think the approach you chose is the best way to implement this.

all_shortest_paths can return an exponential number of paths, hence it needs to be a separate function. It can be possibly much slower, which would impact existing users of our Dijkstra function.

Another detail is that currently the code computes a lot of paths that are not necessary. The simplest way to find if an edge (u, v) is in a shortest path from s to t is to check if d(s, u) + w(u, v) + d(v, t) == d(s, t). You can perform two shortest path calls, one in the graph and another in the graph with reversed direction. Then, you could write either a backtracking code or iteration-based code to generate all shortest paths.

Pseudo-code for the best way to implement would be along the lines:

def all_shortest_paths(graph, s, t):
    d_fwrd = dijkstra(graph, s)
    d_bckwrd = dijkstra(graph.reverse(), t)
    w = weights(graph)
     
     shortest_paths = [] # list containing the answer
     current_paths = [[s]] #541 
     for k in range(len(graph) - 1):
         next_paths = []
         for path in current_paths:
             u = path[-1]
             for v in neighbors(u):
                 if d_fwrd[u] + w[u][v] + d_bckwrd[v] == d_fwrd[t]:
                      new_path = path + [v]
                      if v == t:
                          shortest_pathd.append(new_path)
                      else:
                          next_paths.append(new_path)
         current_paths = next_paths
    
   return shortest_paths   

There are some optimizations that can be done such as filtering the edges in advance to only include edges that are in at least one shortest path, using persistent data structures to avoid copying the array, etc.

rustworkx-core/src/shortest_path/dijkstra.rs Outdated Show resolved Hide resolved
@lucasvanmol
Copy link
Contributor Author

Hi, thanks for the valuable and detailed feedback.

I've updated the PR with your feedback in mind, however I haven't implemented the digraph function yet, as I have some more comments before proceeding:

  • The algorithm I'm using only calls dijkstra once and then backtracks, as opposed to what you proposed. I'm hoping my implementation is sound?
  • I've implemented it such that a source and target are always required. This is a bit different to the optional target argument of, for example, the dijkstra function. I'm wondering if this is ok, or if you think users will want the ability to find every possible shortest path between source and all other nodes.
  • I'm not sure about the return type yet (also partly due to the above comment), both for the rustworkx-core function and the python function. Additionally, should I return the shortest path length as well as all the paths? Maybe a new class similar to MulitplePathMapping is needed?

Copy link
Collaborator

@IvanIsCoding IvanIsCoding left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall the implementation LGTM, I think the backtracking is fine. Maybe we could try to reuse the all_simple_paths code

I left some comments on the implementation. I will revisit the tests later, but we should focus on expanding it

F: FnMut(G::EdgeRef) -> Result<K, E>,
K: Measure + Copy,
{
let scores: DictMap<G::NodeId, K> = dijkstra(&graph, start, Some(goal), &mut edge_cost, None)?;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't pass Some(goal). Passing that argument can trigger an early termination that leaves scores unfilled

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking briefly at the code and performing some (not quite rigorous) tests, it was my understanding that only scores that aren't part of any shortest path will be left unfilled. I'll try to come up with a proper proof and come back to you

if !scores.contains_key(&goal) {
return Ok(HashSet::default());
}
let mut paths = HashSet::new();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can just be a list

@IvanIsCoding
Copy link
Collaborator

Hi, thanks for the valuable and detailed feedback.

I've updated the PR with your feedback in mind, however I haven't implemented the digraph function yet, as I have some more comments before proceeding:

  • The algorithm I'm using only calls dijkstra once and then backtracks, as opposed to what you proposed. I'm hoping my implementation is sound?
  • I've implemented it such that a source and target are always required. This is a bit different to the optional target argument of, for example, the dijkstra function. I'm wondering if this is ok, or if you think users will want the ability to find every possible shortest path between source and all other nodes.
  • I'm not sure about the return type yet (also partly due to the above comment), both for the rustworkx-core function and the python function. Additionally, should I return the shortest path length as well as all the paths? Maybe a new class similar to MulitplePathMapping is needed?

Also, the return type should be just a list of lists, like all_simple_paths. Goal should be a required argument because it's used to generate the paths, all_pairs_all_shortest_paths is content for a separate function

Copy link
Contributor Author

@lucasvanmol lucasvanmol left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback. Sorry I was away for a little way as I had exams. I've added some comments and started work on more tests on the python side of things.

F: FnMut(G::EdgeRef) -> Result<K, E>,
K: Measure + Copy,
{
let scores: DictMap<G::NodeId, K> = dijkstra(&graph, start, Some(goal), &mut edge_cost, None)?;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking briefly at the code and performing some (not quite rigorous) tests, it was my understanding that only scores that aren't part of any shortest path will be left unfilled. I'll try to come up with a proper proof and come back to you

@IvanIsCoding
Copy link
Collaborator

Thanks for the feedback. Sorry I was away for a little way as I had exams. I've added some comments and started work on more tests on the python side of things.

With regards to the scores: it’s a valid assumption if the weights are positive. We don’t support negative weight in Dijkstra. However, you need to keep in mind that there can be edges with weight zero.

It’s worth adding this test case:

A -> B with weight 0
A -> C with weight 0
B -> C with weight 0

The code could halt and never calculate a distance for C. But the correct answers are A->B and A->C->B

@lucasvanmol
Copy link
Contributor Author

lucasvanmol commented Nov 9, 2023

Indeed, the following graph doesn't explore all the nodes of all shortest paths:

graph LR;
  a((a)) -->|1| b((b)) ---->|2| f((f));
  a((a)) -->|2| c((c)) -->|1| d((d)) -->|0| e((e)) -->|0| f((f));
Loading

The path a, c, d, e, f is not found.

However, I'm not sure what the best way to fix this is because this means graphs can be created using 0 weight cycles which can create infinite paths.

I see networkx only returns simple paths with no repeated nodes, so it's pretty easy to do the same. Hence using None as the goal arg and returning simple paths works for 0 weight edges (with or without cycles).

However, I think there could be a substantial performance increase for using a version of the dijkstra algorithm which guarantees all possible shortest path nodes are scored, and terminates early. Essentially the only occasion when passing the argument Some(goal) does not find solutions is the situation above, when, in addition to some other shortest path, there is another shortest path ending in a chain of 0 weight edges towards the goal. Such a modification to dijkstra would have to make sure some goal node f is explored after any equal-weight nodes, as a tiebreaking rule.

@IvanIsCoding
Copy link
Collaborator

Indeed, the following graph doesn't explore all the nodes of all shortest paths:

graph LR;
  a((a)) -->|1| b((b)) ---->|2| f((f));
  a((a)) -->|2| c((c)) -->|1| d((d)) -->|0| e((e)) -->|0| f((f));
Loading

The path a, c, d, e, f is not found.

However, I'm not sure what the best way to fix this is because this means graphs can be created using 0 weight cycles which can create infinite paths.

I see networkx only returns simple paths with no repeated nodes, so it's pretty easy to do the same. Hence using None as the goal arg and returning simple paths works for 0 weight edges (with or without cycles).

However, I think there could be a substantial performance increase for using a version of the dijkstra algorithm which guarantees all possible shortest path nodes are scored, and terminates early. Essentially the only occasion when passing the argument Some(goal) does not find solutions is the situation above, when, in addition to some other shortest path, there is another shortest path ending in a chain of 0 weight edges towards the goal. Such a modification to dijkstra would have to make sure some goal node f is explored after any equal-weight nodes, as a tiebreaking rule.

I think returning all simple paths is fine. I am not too concerned about optimizations for early return on this function as the number of shortest path could be exponential, so calculating all the shortest distances to all vertices is pretty reasonable

@lucasvanmol
Copy link
Contributor Author

I've implemented your suggestions, and at this point it feels like it's no longer a [WIP] so have updated the PR accordingly. If you'd like me to squash the commits please let me know.

@lucasvanmol lucasvanmol marked this pull request as ready for review November 25, 2023 17:33
@lucasvanmol lucasvanmol changed the title [WIP] all_shortest_paths function all_shortest_paths function Nov 25, 2023
@coveralls
Copy link

coveralls commented Nov 28, 2023

Pull Request Test Coverage Report for Build 7378871554

  • 0 of 0 changed or added relevant lines in 0 files are covered.
  • No unchanged relevant lines lost coverage.
  • Overall coverage increased (+0.03%) to 95.905%

Totals Coverage Status
Change from base Build 7378706598: 0.03%
Covered Lines: 15737
Relevant Lines: 16409

💛 - Coveralls

Copy link
Collaborator

@IvanIsCoding IvanIsCoding left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! I am excited to see this come out on the next release

@IvanIsCoding IvanIsCoding added the automerge Queue a approved PR for merging label Jan 1, 2024
@mergify mergify bot merged commit af00d9b into Qiskit:main Jan 1, 2024
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
automerge Queue a approved PR for merging
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants