How to Build a Route Planning Application with Breadth First Search and Dijkstra's Algorithm

memgraphdb

Memgraph

Posted on March 9, 2023

How to Build a Route Planning Application with Breadth First Search and Dijkstra's Algorithm

Introduction

One of the most common applications of graph traversal algorithms is route planning and optimization problems. These can vary from simple things like finding the shortest path from a starting point to a destination to more complex scenarios where it is required to pass a particular intermediate node (e.g. A specific city) and satisfy multiple conditions (e.g. Limited number of stops) before reaching the final destination. Some practical examples include:

  • Logistics and supply chain optimization – Planning effective routes to minimize cost, increase vehicle utilization and improve operations by taking into account things like travel distance, vehicle capacity, road tolls, fuel consumption, and border taxes.
  • Travel route planning – Finding the quickest way to get from point A to point B taking into account things like traffic, road closures, and construction.
  • Network optimization – Improving communications and information transfer in telephone and cellular networks by finding optimal paths based on factors such as bandwidth.

In this tutorial, we will build a simple graph application with Memgraph and Cypher to navigate the European road network and learn how to use some of the most popular graph algorithms used to solve routing problems including
breadth-first search (BFS), and Dijkstra's algorithm.

Prerequisites

To follow along, you will need the following:

  • A local installation of Memgraph. You can refer to the Memgraph documentation.
  • A local installation of Memgraph Lab.
  • Basic knowledge of the Cypher query language.
  • [OPTIONAL] If you don't want to install Memgraph & Memgraph Lab on your own you can create a free account on Memgraph Cloud

Step 1 — Defining The Data Model

In this step, you will define your data model as a connected graph of nodes (Vertices) and relationships (Edges) along with their properties and labels.

For this tutorial, you will be using the European road network which consists of 999 major cities and 39 countries. The data model, in this case, is quite simple.

European Road Network Metagraph

The graph contains two node types and two relationship types. Every city is connected to the country it belongs to via a relationship of type(Lable) :In_ and to other cities less than 500 kilometers via a relationship of type :Road. The Distance between cities is specified in the length property of the relationship.

Now that you have defined the data model, you're ready to import the dataset.

Step 2 — Importing Data Into Memgraph Using Cypher

The easiest way to import our dataset into Memgraph is using Cypher queries.

You can download the dataset file containing Cypher queries from here. If you unzip the file, you will get the file with a .cypherl extension. You can load it to Memgraph via Memgraph LAB.

Memgraph LAB has an import/export tab for importing the .cypherl files. If you need help importing it, you can follow docs for importing Cypher queries

If you open the .cyhperl file in any text or code editor, you will notice that the first few Cypher statements are for creating indexes and the rest of the statements for loading data. If you didn't have a file to import the data, you would manually crate index and import the data by executing queries. If you want to find out how to do it, follow the manual import section or feel free to skip it.

Manual import

You can import the data by manually executing queries like this:

CREATE (:Country {name: "Croatia"});
Enter fullscreen mode Exit fullscreen mode

If you want to create an index on the name property of country nodes to improve search performance when looking for specific countries, you can do it with Memgraph!

[NOTE] A database index essentially creates a redundant copy of some of the data in the database to improve the efficiency of searches for the indexed data. However, this comes at the cost of additional storage space and more writing, so deciding what to index and not to the index is important.

To create an index, we can use the following Cypher query:

CREATE INDEX ON :Country(name);
Enter fullscreen mode Exit fullscreen mode

Now that we have created the country nodes, we are ready to add the cities associated with each country. To do this, you can execute the following query:

MATCH (country:Country {name: "Croatia"}) CREATE (city:City {name: "Osijek"})-[:In_]->(country);
Enter fullscreen mode Exit fullscreen mode

Next, we create an index on the name property of City nodes:

CREATE INDEX ON :City(name);
Enter fullscreen mode Exit fullscreen mode

Now that we have created all the nodes in our graph, we will create the relationships between them. As described in our data model, there are two types of relationships. A relationship of type(Lable) :In_, and a relationship of type :Road which contains a property of type Length. To create the relationships you can execute the following query:

MATCH (city1:City {name: "Banja Luka"})-[:In_]->(:Country {name: "Bosnia and Herzegovina"}) 
MATCH (city2:City {name: "Osijek"})-[:In_]->(:Country {name: "Croatia"}) 
CREATE (city1)-[:Road {length: 214.51}]->(city2);
Enter fullscreen mode Exit fullscreen mode

You now have the dataset loaded inside Memgraph and ready to be queried. In the next step, you will run a few basic queries to learn how to use the breadth-first search and Dijkstra’s algorithm for finding the shortest paths between nodes in the graph.

Step 3 — Using the Breadth-First Search Algorithm to Find and Filter Paths

To get started, let's make sure that the data has been correctly imported into Memgraph by running the following simple Cypher query to return all the countries found in the database:

MATCH (c:Country) RETURN c.name;
Enter fullscreen mode Exit fullscreen mode

This query matches and returns all nodes with label Country. If everything works properly, you should get a list of 39 countries.

Now let’s explore a few pathfinding queries to demonstrate the power of graph databases. After all, this is what really sets graph databases apart from SQL and other NoSQL systems.

Say you want to drive from Paris to Berlin and want to plan your route to minimize the number of stops. You also don't want to drive more than 500 Km between stops. Since the edges in our road network don't connect cities that are more than 500 km apart, this is a great use case for the breadth-first search (BFS) algorithm.

Although Cypher, as defined through the openCypher project, doesn't offer a feature for BFS, Memgraph provides a custom implementation.

To execute a breadth-first search (BFS) algorithm, we will run the following query:

MATCH p = (:City {name: "Paris"}) -[:Road * bfs]->(:City {name:"Berlin"}) RETURN nodes(p);
Enter fullscreen mode Exit fullscreen mode

This query will return the nodes along a path p with the smallest number of hopes starting from the city Paris and connecting it to the cityBerlin through the relationship of type or lableRoad. You should get the following result:

+------------------------------------------------------------------------------------------------------------+
| nodes (p)
+------------------------------------------------------------------------------------------------------------+
| [(:City {name: "Paris"}), (:City {name: "Düsseldorf"}), (:City {name: "Kiel"}), (:City {name: "Berlin"})] |
+------------------------------------------------------------------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

If you're working with Memgraph Lab and would like to visualize the path, you would modify the query to return p instead of nodes(p). This will return both nodes and relationships along the shortest path between Paris and Berlin. The query should be:

MATCH p = (:City {name: "Paris"}) -[:Road * bfs]->(:City {name:"Berlin"}) RETURN p;
Enter fullscreen mode Exit fullscreen mode

The output should be:
Shortest Path Between Paris and Berlin

Now, let’s suppose that instead of driving to Berlin, you decided to go on a biking road trip with your friends. As a result, you want to limit yourself to cities that are no more than 200 Km apart since that’s the maximum you want to bike per day. To do this, you can run the following query:

MATCH p = (:City {name: "Paris"})
          -[:Road * bfs (e, v | e.length <= 200)]->
          (:City {name: "Berlin"})
RETURN nodes(p);
Enter fullscreen mode Exit fullscreen mode

The output should be:

+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| nodes(p)                                                                                                                                                                                                        |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| [(:City {name: "Paris"}), (:City {name: "Reims"}), (:City {name: "Metz"}), (:City {name: "Worms"}), (:City {name: "Würzburg"}), (:City {name: "Erfurt"}), (:City {name: "Leipzig"}), (:City {name: "Berlin"})] |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

As you can see, we added a special syntax to the query (e, v | e.length <= 200). This is called a filter lambda. It's a function that takes an edge symbol e and a vertex symbol v and decides whether this edge and vertex pair should be considered valid in breadth-first expansion by returning true or false (or Null). In the above example, lambda is returning true if the edge length is not greater than 200, due to the fact that we don't want to bike more than 200 km in one go.

The filter lambda function can also be extended to support several other constraints. As an example, let's say you don't want to visit the city of Metz. In this case, you will run the following query:

MATCH p = (:City {name: "Paris"})
          -[:Road * bfs (e, v | e.length <= 200 AND v.name != "Metz")]->
          (:City {name: "Berlin"})
RETURN nodes(p);
Enter fullscreen mode Exit fullscreen mode

Now you can see that your result has changed and the new cities you'll be visiting on your trip are:

+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| nodes(p)                                                                                                                                                                                                                        |
+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| [(:City {name: "Paris"}), (:City {name: "Reims"}), (:City {name: "Charleroi"}), (:City {name: "Venlo"}), (:City {name: "Gütersloh"}), (:City {name: "Göttingen"}), (:City {name: "Halle (Saale)"}), (:City {name: "Berlin"})] |
+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

Step 4 — Using Dijkstra's Algorithm to Find the Shortest Path

So far you were planning your trip based by taking into consideration the number of stops you’ll be making, but what if you wanted to find the quickest route or shortest path between Paris and Berlin based on distance? This is where Dijkstra’s algorithm comes in handy. Let’s find out the shortest path between Paris and Berlin and the cities you’ll be stopping at along the way.

To do this, we will run the following Cypher query:

MATCH p = (:City {name: "Paris"})
          -[:Road * wShortest (e, v | e.length) total_weight]->
          (:City {name: "Berlin"})
RETURN nodes(p) as cities, total_weight;
Enter fullscreen mode Exit fullscreen mode

The output should be:

+------------------------------------------------------------------------------------------------------------+---------------+
| cities                                                                                                     | total_weight  |
+------------------------------------------------------------------------------------------------------------+---------------+
| [(:City {name: "Paris"}), (:City {name: "Heerlen"}), (:City {name: "Hannover"}), (:City {name: "Berlin"})] | 1055.63       |
+------------------------------------------------------------------------------------------------------------+---------------+
Enter fullscreen mode Exit fullscreen mode

As you can see, the syntax is quite similar to breadth-first search syntax. Instead of a filter lambda, we need to provide a weight lambda and the total weight symbol. Given an edge and vertex pair, the weight lambda must return the cost of expanding to the given vertex using the given edge. The path returned will have the smallest possible sum of costs and it will be stored in the total weight symbol. A limitation of Dijkstra's algorithm is that the cost can't be negative.

Let’s now assume you want to find the quickest route for your biking road trip where you don’t want to travel more than 200 km between stops. Just like you did for the breadth-first search query, you can add filters to the lambda function. In this case, you will need to specify that the length for every road e.length must be smaller than 200 km.

To get your result, you'll run the following query:

MATCH p = (:City {name: "Paris"})
      -[:Road * wShortest (e, v | e.length) total_weight (e, v | e.length <= 200)]->
      (:City {name: "Berlin"})
RETURN nodes(p) as cities, total_weight;
Enter fullscreen mode Exit fullscreen mode

Your result should be:

+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+--------------+
| cities                                                                                                                                                                                                                | total_weight |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+--------------+
| [(:City {name: "Paris"}), (:City {name: "Reims"}), (:City {name: "Charleroi"}), (:City {name: "Heerlen"}), (:City {name: "Hamm"}), (:City {name: "Hannover"}), (:City {name: "Magdeburg"}), (:City {name: "Berlin"})] | 1105.72      |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+--------------+
Enter fullscreen mode Exit fullscreen mode

Conclusion

Congratulations! You just built a simple route planning application by leveraging Memgraph and Cypher. Along the way, you learned how to use the breadth-first search algorithm and Dijkstra’s algorithm to navigate a complex network of inter-connected data using Cypher queries and filtered your results using the powerful and flexible lambda function.

Read more about graph algorithms on memgraph.com

💖 💪 🙅 🚩
memgraphdb
Memgraph

Posted on March 9, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related