Dijkstra's Minimum Path Cost
Here we cover problems involving finding the shortest path between vertices in a graph with weights (lengths) on the edges. One obvious application is in finding the shortest route from one address to another, however shortest paths have many other application1
Dijkstra's Algorithm
Dijkstra’s is an important algorithm both because it is an efficient algorithm for an important problem, but also because it is a very elegant example of an efficient greedy algorithm that generates optimal solutions on a nontrivial task.
The below animated image demonstrated how the algorithm works
The above depiction performs the below steps
- push the starting node
a
in the priority queue with cost0
- Pop the node with the lowest cost in the queue; at fist this is
a
- if the 'node' matches our target node
b
- extract path with minimum cost
- terminate
- For each
edge node
attached to thenode
- calculate
cost distance
- if
edge node
hascost
larger to the calculatedcost distance
then assign cost toedge node
, otherwise do not update cost - push
(edge node, cost)
to the priority queue and repeat
- calculate
- if the 'node' matches our target node
Prioritised Queue
Dijkstra's differentiating approach is that we must always process next the node with the lowest cost in the queue. To achieve this we have to make use of the BinaryHeap
collection structure. The use of such structure help us to maintain on ordered-queue by node-cost, hence keeping the node with lowest-cost at the top of the heap/queue.
#[derive(Debug,Clone,Copy,Hash,Eq,PartialEq)]
pub enum NodeType {
N(Node),
NC(Node, Cost)
}
impl From<NodeType> for Node {
fn from(nt: NodeType) -> Self {
match nt { NodeType::N(node)|NC(node, _) => node }
}
}
impl From<Node> for NodeType {
fn from(value: Node) -> Self {
NodeType::N(value)
}
}
impl Ord for NodeType {
fn cmp(&self, other: &Self) -> Ordering {
other.partial_cmp(self).unwrap_or_else(|| panic!("Edge::cmp() - cannot compare nodes with type NodeType::N"))
}
}
impl PartialOrd for NodeType {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
match other {
NodeType::N(_) => None,
NC(_, cost) => {
let NC(_,sc) = self else { panic!("Edge::partial_cmp() - cannot compare NodeType::NC against NodeType::N") };
Some(cost.cmp(sc))
}
}
}
}
Implementation
With the ordered-queue logic in place, we still need to have the means to maintain the following information per node and while we are searching the graph
node
state, in terms of processed / not processedparent
node, that is, the node we visited fromunit
in terms of cost or distance
The Tracker
structure simplifies managing the node processing state of the graph, and we will use as part of our implementation.
Both Tracker
and BinaryHeap
structures are part the Graph processing State structure PSState
which in turn, implements the BFSearch abstraction
As a result, the algorithm can now be realised by the following implementation
fn path_shortest(&self, start: Node, goal: Node) -> Option<(Vec<Node>, Cost)> {
/// Structure for maintaining processing state while processing the graph
struct PSState {
tracker: Tracker,
queue: BinaryHeap<NodeType>
}
/// State Constructor from a given Graph and related shortest path initiation requirements
impl PSState {
fn new(g:&Graph) -> PSState {
PSState {
// reset all node costs to MAX value with no path-parent nodes
tracker: g.get_tracker(Undiscovered, Cost::MAX, None),
// We are using a BinaryHeap queue in order to always have first in the queue
// the node with lowest cost to explore next
queue: BinaryHeap::new()
}
}
}
/// Implementation of Path Search abstraction
impl BFSearch for PSState {
type Output = (Vec<Node>,Cost);
type QueueItem = NodeType;
/// Processing of starting node
fn initiate(&mut self, start: Node) -> &mut Self {
// set cost at start node to zero with no parent node
self.tracker[start].distance(0);
// push start node in the BinaryHeap queue
self.queue.push(NC(start,0));
self
}
/// get the element with the lowest cost from the queue
fn pop(&mut self) -> Option<Self::QueueItem> { self.queue.pop() }
/// extract node from the queued item retrieved
fn node_from_queued(&self, qt: &Self::QueueItem) -> Node {
(*qt).into()
}
/// Process current node after all edges have been discovered and marked for processing
fn post_process_node(&mut self, node: Node) {
self.tracker[node].visited(Discovered);
}
/// has the given node been seen before ?
fn is_discovered(&self, node: NodeType) -> bool { self.tracker[node.into()].is_discovered() }
/// Process given edge and return `true` to proceed or `false` to abandon further edge processing
fn pre_process_edge(&mut self, src:Node, dst: NodeType) -> bool {
if let NC(dst, cost) = dst {
// calc the new path cost to edge
let edge_cost = self.tracker[src].dist + cost;
// if new cost is better than previously found
if edge_cost > self.tracker[dst].dist {
// Do no process this edge any further
false
}
else {
// set the new lower cost @node along with related parent Node
self.tracker[dst].distance(edge_cost).parent(src);
// and ensure the edge is processed further
true
}
}
else {
// somehow we got the wrong NodeType here
panic!("pre_process_edge(): Must use NodeType::NC")
}
}
/// Construct the item to be queued, that is, (Node,Cost)
fn node_to_queued(&self, node: Node) -> Self::QueueItem {
NC(node, self.tracker[node].dist )
}
/// Push into (Node,Cost) into the queue
fn push(&mut self, item: Self::QueueItem) { self.queue.push(item) }
/// Get search path discovered so far
fn extract_path(&self, start: Node) -> Self::Output { self.tracker.extract(start) }
}
// Construct the state structure and search for a path that exists between start -> goal
PSState::new(self).path_search(self,start,goal)
}