I've written the A* search algorithm in C++. My goal is primarily writing concise and understandable code, while making use of some new features of modern C++ (where appropriate) and having generally good performance (importance of these goals in the order they were mentioned).
The goal of the algorithm is to find the best (cheapest) path from a starting node to a target node on a graph. The input to the algorithm is an adjacency list, start and target node IDs and an (admissible and consistent) heuristic function that computes the estimated distance of two nodes. The adjacency list is a map (NodeID -> map (NodeID -> Cost)) and describes the edges in the graph and their cost. The adjacency list is assumed to be symmetric, i.e., adjacency[x][y] == adjacency[y][x]
.
On a high level, the algorithm works as follows:
- Put the starting node into a priority queue
boundary
. This queue contains the set of not-yet-visited nodes that are connected to at least one already visited node by an edge - If
boundary
is empty, return the empty path - Take out the entry
v
fromboundary
with the least cumulative cost - If this node is the target node, reconstruct the path and return
- Add all neighbors of node
v
toboundary
that are not yet visited - Go to step 2
Do you have recommendations on how to improve the following code with regard to my above-mentioned goals? I'm specifically wondering whether the boundary.push
part could be written more concise without a for
loop. What other features of modern C++ did I miss that would improve this code? If you spot some issues with this code, feel free to mention them.
Header file (a_star.h
):
#include <functional>
#include <map>
#include <vector>
typedef size_t NodeID;
typedef float Cost;
typedef std::pair<const NodeID, Cost> Edge;
typedef std::map<const NodeID, std::map<const NodeID, Cost>> AdjacencyList;
typedef std::vector<NodeID> Path;
typedef const std::function<Cost(NodeID, NodeID)> HeuristicFn;
Path a_star(AdjacencyList& adjacency,
NodeID start,
NodeID target,
HeuristicFn& heuristic);
Source file a_star.cpp
:
#include "a_star.h"
#include <memory>
#include <numeric>
#include <queue>
#include <ranges>
#include <set>
struct PathNode {
NodeID node; // node
Cost total_cost; // cost of reaching node from starting node
Cost estimated_cost; // lower bound on the cost for any path from starting to target node through this node
std::shared_ptr<PathNode> previous; // previous record
};
bool operator>(const PathNode& left, const PathNode& right) {
return left.estimated_cost > right.estimated_cost;
}
Path backtrace(PathNode& last_record) {
PathNode current = last_record;
Path path {current.node};
while (current.previous != nullptr) {
current = *current.previous;
path.emplace_back(current.node);
}
std::reverse(path.begin(), path.end());
return path;
}
Path a_star(AdjacencyList& adjacency,
NodeID start,
NodeID target,
HeuristicFn& heuristic) {
std::set<NodeID> visited;
std::priority_queue<PathNode, std::vector<PathNode>, std::greater<>> boundary;
boundary.push({start, 0, heuristic(start, target), nullptr});
visited.emplace(start);
auto was_not_visited_yet = [visited] (Edge& entry) {
return !visited.contains(entry.first);
};
for (; !boundary.empty(); boundary.pop()) {
auto current = boundary.top();
if (current.node == target) {
return backtrace(current);
}
visited.emplace(current.node);
auto edge_to_record = [current, heuristic, target] (Edge& edge) -> PathNode {
Cost total_cost = current.total_cost + edge.second;
Cost estimated_cost = total_cost + heuristic(current.node, target);
return {edge.first, total_cost, estimated_cost, std::make_shared<PathNode>(current)};
};
auto neighbor_records = std::ranges::views::filter(adjacency[current.node], was_not_visited_yet)
| std::ranges::views::transform(edge_to_record);
for (const PathNode& neighbor_record : neighbor_records) {
boundary.push(neighbor_record);
}
}
return {}; // no path to target
}
```
std::mdspan
from C++23. Or astd::vector
of sparse row data. \$\endgroup\$for (; !boundary.empty(); boundary.pop())
could be written aswhile (const auto current = boundary.pop())
or maybefor (const auto current : boundary)
. \$\endgroup\$std::mdspan
. I'll look into it. Using an adjacency matrix could indeed speed up the algorithm. \$\endgroup\$while (const auto current = boundary.pop())
: How does this work exactly? According to the docs, the return value ofboundary.pop()
isvoid
? Regardingfor (const auto current : boundary)
: This looks good. I'll try it. \$\endgroup\$