Social networks & stuff
So, we’re getting to the end of this part. Graphs are the last piece of data structures I’ll investigate as part of this series.
So, what are graphs? Well, graphs are a form or trees. But not really.
They are really similar - we have nodes that are connected. As such, all trees are graphs.
But not all graphs are trees. Graph is a big thing in mathematics - Graph Theory is a big concept, and there’s a lot of terminology to be used:
An undirected graph is one that has vertices and nodes, but no path is forced. An example is cities and roads leading to them on the map.
A directed graph, on the other hand, is one that has a forced path. Example of this can be social network (I’m following you, you’re not following me).
Finally, each of them can be either weighted or unweighted. The cities and roads leading to them exist, but some roads are longer than others and take more time. That’s the example of weighted graph
Finally, an unweighted graph has no value along the edge.
If you think about it, a 2D array can also be viewed as a graph in some cases:
const arr = [
[1,2,3],
[4,5,6],
[7,8,9],
]
Consider that you have this array and you want to find a cheapest way from top right to bottom left.
Graphs are all around us and a lot of things can be described by graphs. They are very important, and the most successful products (= social networks) use them extensively.
One thing to note that all the data structures we’ve gone through can be stored in databases made for those specific use cases. There are some databases to store graphs especially:
Since there are quite a few graphs, I’d like to do a couple things here.
Finally, I’ll apply all of these on some other concepts (trees, arrays, objects).
Let’s start with directed and undirected graphs, because it’s fairly straightforward. Look at the images above:
Well, that’s nice. But how do we achieve it? Let’s create a vertex for that:
class Vertex {
value;
adjacentVertices;
constructor(value) {
this.value = value;
this.adjacentVertices = [];
}
}
In the vertex above, we have:
Notice that we don’t have a class called “Graph”. Only individual nodes. So to connect them, we just add links within the array
So, let’s do that real quick and define both directed and undirected vertices:
class DirectedVertex extends Vertex {
constructor(value) {
super(value);
}
addAdjacentVertex(vertex) {
this.adjacentVertices.push(vertex);
}
}
class UndirectedVertex extends Vertex {
constructor(value) {
super(value);
}
addAdjacentVertex(vertex) {
if (this.adjacentVertices.includes(vertex)) return;
this.adjacentVertices.push(vertex);
vertex.addAdjacentVertex(this);
}
}
And that’s actually all for directed and undirected graphs! The weighted will be pretty much the same, just with different weight! But we’ll see that later.
So, with the code above, we have all we need for creating unweighted graphs, because there is no weight taken into account whatsoever!
Now, you don’t actually need to use classes for that. It’s just easier. You might as well have a simple object to do the same thing:
const friends = {
"Bob": ["Cynthia"]
"Cynthia": ["Bob"],
"Elise": ["Fred"],
"Fred": ["Elise"]
}
That is actually a undirected graph. Let’s look closer:
Now, let’s make this directed - let’s look at followers!
const followees = {
"Bob": ["Cynthia"],
"Cynthia": ["Bob", "Fred", "Elise"],
"Elise": ["Fred"],
"Fred": ["Elise"]
}
While friends was always 1-1 relationship, in here, we can potentially have 1-0 (as in individual links).
The reason I used classes above is to have an easier access to adding the vertices. But, for the sake of recap, let’s do it in objects as well:
const followees2 = {
Bob: {
follows: ["Cynthia"],
addFollower(follower) {
this.follows.push(follower);
},
follow(who) {
followees2[who].addFollower();
},
},
Cynthia: {
follows: ["Bob"],
addFollower(follower) {
this.follows.push(follower);
},
follow(who) {
followees2[who].addFollower();
},
},
};
You can see that both function addFollower
and follow
are identical. We could use a factory pattern in here. Or classes. But the idea is the same as
with classes above.
In any way, no matter the direction, we could still potentially get to the same node. Consider the following:
const friends = {
"Bob": ["Cynthia"],
"Cynthia": ["Bob"],
}
const followees = {
"Bob": ["Cynthia"],
"Cynthia": ["Daniel"],
"Daniel": ["Bob"]
}
While the first object is undirected, the second object is directed. In both cases, we can get to the original node. This is why some graphs are not trees - they can be circular.
And that’s the main problem we’ll be tackling here with algorithms. What we need to solve is basically:
For weighted graphs, there’s another rule, But we’ll leave that for later. Without further ado, let’s get started with Depth-first search!
Depth-first search is an algorithm for traversing graphs (and by extension, trees).
The idea is:
This applies to graphs as much as it applies to trees. But for the sake of simplicity, I’ll describe the example with trees. Consider the following tree:
If we perform binary search on the tree above, we’ll visit the nodes in order 1, 2, 4, 5, 3, 6, 7
. That is:
Now, this works for charts as well - we’ll go to the furthest connected link and then go back up. Let’s use the weighted example from above:
Now, there are weights here, but let’s just ignore them here because I’m to lazy to steal another image from wikipedia, and focus at the nodes. In this case, DFS will be as follows:
0, 1, 2, 3, 4
0
. But that’d be circular! So, we won’t visit 0
again!Note: The left node I mentioned above is not the rule. You COULD apply it, but more often than not, you’ll likely just go through whatever is first in the array of links.
And another note - the algorithm is called search. That’s because it’s often used to find a value and return it.
If you’d search for node 2
, you would do an early return and not search through the entire graph
That being said, you can use it for traversing the entire graph.
To perform the search, I’ll use the code below to created vertices with the object-oriented way:
const alice = new UndirectedVertex("Alice");
const candy = new UndirectedVertex("Candy");
const bob = new UndirectedVertex("Bob");
const fred = new UndirectedVertex("Fred");
const helen = new UndirectedVertex("Helen");
const derek = new UndirectedVertex("Derek");
const elaine = new UndirectedVertex("Elaine");
const gina = new UndirectedVertex("Gina");
const irena = new UndirectedVertex("Irena");
alice.addAdjacentVertex(bob);
alice.addAdjacentVertex(candy);
bob.addAdjacentVertex(fred);
fred.addAdjacentVertex(helen);
candy.addAdjacentVertex(helen);
alice.addAdjacentVertex(derek);
alice.addAdjacentVertex(elaine);
derek.addAdjacentVertex(elaine);
derek.addAdjacentVertex(gina);
gina.addAdjacentVertex(irena);
The code looks as follows:
const depthFirstSearch = (current, visited = {}) => {
console.log(current.value)
visited[current.value] = true;
current.adjacentVertices.forEach(adjacent => {
if (visited[adjacent.value]) return;
visited[adjacent.value] = true;
depthFirstSearch(adjacent, visited)
});
}
depthFirstSearch(alice)
With the above code, we’ll get the following logs: ‘Alice’ -> ‘Bob’ -> ‘Fred’ -> ‘Helen’ -> ‘Candy’ -> ‘Derek’ -> ‘Elaine’ -> ‘Gina’ -> ‘Irena’
If we look at the way how vertices were created, it becomes clear:
Now, I could go on and on, but we already have what we need
So, now that this is clear, let’s get to the “search” part:
So, the final search could be:
const depthFirstSearch = (current, searchFor, visited = {}) => {
if (current.value === searchFor) return current;
visited[current.value] = true;
for (let i = 0; i < current.adjacentVertices.length; i++) {
const adjacent = current.adjacentVertices[i]
if (visited[adjacent.value]) continue;
visited[adjacent.value] = true;
const found = depthFirstSearch(adjacent, searchFor, visited)
if (found) return found;
}
return null;
}
depthFirstSearch(alice, "Fred")
I’ve made to things here:
forEach
to for
because you can’t do an early return from forEach
If we’d log the individual nodes, we’d get “Alice” -> “Bob” -> “Fred” and just return early.
And that’s it for depth-first search! We’ve just managed to create one using recursion! Onto BFS!
Breadth-first Search is very similar to DFS, and use I’ll exactly the same code here for the setup.
Now, using the same examples, let’s see what happens when we go through the following tree with BFS:
With DFS, we’ve gone through it in order 1, 2, 4, 5, 3, 6, 7
.
With BFS, the order would be 1,2,3,4,5,6,7
. The reason for that is that we’re moving per level (breadth), rather than going deep first.
Now, using the same vertices as before, let’s write BFS for the same setup. The following code is copied from above:
const alice = new UndirectedVertex("Alice");
const candy = new UndirectedVertex("Candy");
const bob = new UndirectedVertex("Bob");
const fred = new UndirectedVertex("Fred");
const helen = new UndirectedVertex("Helen");
const derek = new UndirectedVertex("Derek");
const elaine = new UndirectedVertex("Elaine");
const gina = new UndirectedVertex("Gina");
const irena = new UndirectedVertex("Irena");
alice.addAdjacentVertex(bob);
alice.addAdjacentVertex(candy);
bob.addAdjacentVertex(fred);
fred.addAdjacentVertex(helen);
candy.addAdjacentVertex(helen);
alice.addAdjacentVertex(derek);
alice.addAdjacentVertex(elaine);
derek.addAdjacentVertex(elaine);
derek.addAdjacentVertex(gina);
gina.addAdjacentVertex(irena);
Now, for the breadth first search, since we’re moving by levels, we’ll do the following:
Finally, it’ll look liker this:
const breadthFirstSearch = (initialNode) => {
const visited = {};
const queue = [initialNode];
visited[initialNode.value] = true
while (queue.length > 0) {
const current = queue.shift();
current.adjacentVertices.forEach(vertex => {
if (!visited[vertex.value]) {
queue.push(vertex)
}
visited[vertex.value] = true
});
}
}
breadthFirstSearch(alice)
Note the positions of the visited
definitions:
Now, if we look at the initilization, we’ve started on node ‘Alice’.
If we put a print call on the current node in the while function, we’ll see the following order:
Alice -> Bob -> Candy -> Derek -> Elaine -> Fred -> Helen -> Gina -> Irena
So, we can easily see that we are going by sibling nodes. And finally, if we wanted to search for a value, we’d just early return it within the while
loop
After writing the code for both, let’s compare them now! For the following setup:
const alice = new UndirectedVertex("Alice");
const candy = new UndirectedVertex("Candy");
const bob = new UndirectedVertex("Bob");
const fred = new UndirectedVertex("Fred");
const helen = new UndirectedVertex("Helen");
const derek = new UndirectedVertex("Derek");
const elaine = new UndirectedVertex("Elaine");
const gina = new UndirectedVertex("Gina");
const irena = new UndirectedVertex("Irena");
alice.addAdjacentVertex(bob);
alice.addAdjacentVertex(candy);
bob.addAdjacentVertex(fred);
fred.addAdjacentVertex(helen);
candy.addAdjacentVertex(helen);
alice.addAdjacentVertex(derek);
alice.addAdjacentVertex(elaine);
derek.addAdjacentVertex(elaine);
derek.addAdjacentVertex(gina);
gina.addAdjacentVertex(irena);
We’ve the following prints:
Alice -> Bob -> Fred -> Helen -> Candy -> Derek -> Elaine -> Gina -> Irena
Alice -> Bob -> Candy -> Derek -> Elaine -> Fred -> Helen -> Gina -> Irena
With both, we need to traverse the entire graph if we’re using it for traversal, and it’s O(N).
However, if we were searching for a value, it might be different. Consider searching for “Fred” and “Candy” in the above example:
Depending on the structure of your graph, you may want to use different searching algorithm.
Now that we’ve gone through unweighted graphs, let’s look at weighted graphs. Before going into detail of them, there are basically 2 differences:
So, looking at the above, let’s create a class again:
class Vertex {
name;
adjacentVertices;
constructor(name) {
this.name = name;
this.adjacentVertices = new Map();
}
}
class WeightedVertex extends Vertex {
constructor(name) {
super(name);
}
// Adjacency list
addAdjacentVertex(vertex, weight) {
this.adjacentVertices.set(vertex, weight);
}
}
Now, we can see 2 things here:
Now, why did I say that weighted graphs are always directed? Well, directed doesn’t strictly mean one-way. In unweighted, it does, but with weighted ones:
Both directions can have same value, sure. But it’s a weighted value that could potentially at some point change.
Again, as was the case in previous part, this is all we neded to create a bunch of vertices. Let’s do a couple here. I’ll use cities in this case rather than friends:
Now that we have it defined, let’s discuss a little more about the use cases. Because weighted basically means you want to follow some path. In other words, using weighted graphs is often used in pathfinding, and we’ll use pathfinding algorithms here. There’s quite a few of them
In this case, I’ll show Dijkstra’s algorithm.
const denver = new WeightedVertex("Denver");
const elPaso = new WeightedVertex("El Paso");
const chicago = new WeightedVertex("Chicago");
const boston = new WeightedVertex("Boston");
const atlanta = new WeightedVertex("Atlanta");
denver.addAdjacentVertex(elPaso, 140);
denver.addAdjacentVertex(chicago, 40);
chicago.addAdjacentVertex(elPaso, 80);
elPaso.addAdjacentVertex(boston, 100);
boston.addAdjacentVertex(chicago, 120);
boston.addAdjacentVertex(denver, 180);
atlanta.addAdjacentVertex(boston, 100);
atlanta.addAdjacentVertex(denver, 160);
So, as mentioned above, let’s find to shortest path between some cities. To visualize it, I’ve attempted the following draw.io diagram:
Now, some things to see here. Consider the links from Denver
Now, that’s important. Because if you’ll look closely, the shortest path from Denver to El Paso is not the direct link - it’s actually through Chicago!
So, let’s consider we want to go from denver to el paso. How’d we do that?
120
as the cost to get from “Denver” to “El Paso”, following this pathNow, remember that back tracking part, because I’ll refer to it later.
So, how does Dijkstra work? Well, let’s look at it:
function getCityStopsAndPriceList(startVertex) {
const cheapestCityToValue = {};
const previousStopsMap = {};
const queue = [startVertex];
const visited = {};
cheapestCityToValue[startVertex.name] = 0;
while (queue.length > 0) {
const current = queue.shift();
visited[current.name] = true;
/* There will be something here */
}
}
For visiting the adjacent nodes, we’ll perform the following:
Now, that’s a little chaotic. So, let’s make it easier with a smaller example:
cheapestCityToValue
hash mapcheapestCityToValue
hash mapcheapestCityToValue
hash map{ Denver: 0, El Paso: 140, Chicago: 40 }
Chicago: 40
- and the current cost - 80
80 + 40
is less than 140
cheapestCityToValue
{ Denver: 0, El Paso: 120, Chicago: 40 }
So, the passing value is basically the path to current node we’re at.
So, the code will do exactly that:
function visitAdjacentVertices() {
const adjacentEntries = current.adjacentVertices.entries();
for (const [adjacentVertex, value] of adjacentEntries) {
if (!visited[adjacentVertex.name]) queue.push(adjacentVertex);
const passingValue = (cheapestCityToValue[current.name] || 0) + value;
const alreadyFoundValue = cheapestCityToValue[adjacentVertex.name];
const currentHasValue = alreadyFoundValue != null;
if (!currentHasValue || passingValue < alreadyFoundValue) {
cheapestCityToValue[adjacentVertex.name] = passingValue;
previousStopsMap[adjacentVertex.name] = current.name;
}
}
}
We put it all together:
function dijkstra(startVertex) {
const cheapestCityToValue = {};
const previousStopsMap = {};
const queue = [startVertex];
const visited = {};
cheapestCityToValue[startVertex.name] = 0;
while (queue.length > 0) {
const current = queue.shift();
visited[current.name] = true;
const adjacentEntries = current.adjacentVertices.entries();
for (const [adjacentVertex, value] of adjacentEntries) {
if (!visited[adjacentVertex.name]) queue.push(adjacentVertex);
const passingValue = (cheapestCityToValue[current.name] || 0) + value;
const alreadyFoundValue = cheapestCityToValue[adjacentVertex.name];
const currentHasValue = alreadyFoundValue != null;
if (!currentHasValue || passingValue < alreadyFoundValue) {
cheapestCityToValue[adjacentVertex.name] = passingValue;
previousStopsMap[adjacentVertex.name] = current.name;
}
}
}
}
Now, if you look at it, it’s not really returning any short path. And here comes the important bit. Let’s compare it to BFS algorithm:
const breadthFirstSearch = (initialNode) => {
const visited = {};
const queue = [initialNode];
visited[initialNode.value] = true
while (queue.length > 0) {
const current = queue.shift();
current.adjacentVertices.forEach(vertex => {
if (!visited[vertex.value]) {
queue.push(vertex)
}
visited[vertex.value] = true
});
}
}
If you compare them, you’ll notice that what I’ve just described is BFS with a couple additions:
So, the only change actually is:
And that’s it. The only thing we are doing is we are traversing the array. And now you could say “Hey, but there’s finding the path, is there?”
Well, you’re completely right. Because this algorithm uses BFS, but extends it. And also, it’s not really dijkstra yet.
At this point, we’ve found the value to go to any city. Let’s look what we get if we console log the cheapestCityToValue and previousStopsMap
console.log(cheapestCityToValue) // { Denver: 0, 'El Paso': 120, Chicago: 40, Boston: 240 }
console.log(previousStopsMap) // { 'El Paso': 'Chicago', Chicago: 'Denver', Boston: 'El Paso' }
Now, you probably see what I meant when I mentioned the “Looking forward” - we have value to get to Boston, even though we never visited it. That’s a side effect of looking forward.
Anyways, now that we have all this data, we can find the shortest path!
previousStopsMap
and backtrack from thereEl Paso, Chicago, Denver
. We reverse the array and we have the path!function shortestPath(startVertex, destination) {
const { cheapestCityToValue, previousStopsMap } = dijkstra(startVertex);
const shortestPath = [];
let currentCityName = destination;
while (currentCityName !== startVertex.name) {
shortestPath.push(currentCityName);
currentCityName = previousStopsMap[currentCityName];
}
shortestPath.push(currentCityName);
return {
path: shortestPath.reverse(),
price: cheapestCityToValue[destination],
};
}
function dijkstra(startVertex) {
const cheapestCityToValue = {};
const previousStopsMap = {};
const queue = [startVertex];
const visited = {};
cheapestCityToValue[startVertex.name] = 0;
while (queue.length > 0) {
const current = queue.shift();
visited[current.name] = true;
const adjacentEntries = current.adjacentVertices.entries();
for (const [adjacentVertex, value] of adjacentEntries) {
if (!visited[adjacentVertex.name]) queue.push(adjacentVertex);
const passingValue = (cheapestCityToValue[current.name] || 0) + value;
const alreadyFoundValue = cheapestCityToValue[adjacentVertex.name];
const currentHasValue = alreadyFoundValue != null;
if (!currentHasValue || passingValue < alreadyFoundValue) {
cheapestCityToValue[adjacentVertex.name] = passingValue;
previousStopsMap[adjacentVertex.name] = current.name;
}
}
}
}
And we’ve successfully found a pathing through a chart!
DFS:
const depthFirstSearch = (current, visited = {}) => {
console.log(current.value)
visited[current.value] = true;
current.adjacentVertices.forEach(adjacent => {
if (visited[adjacent.value]) return;
visited[adjacent.value] = true;
depthFirstSearch(adjacent, visited)
});
}
BFS:
const breadthFirstSearch = (initialNode) => {
const visited = {};
const queue = [initialNode];
visited[initialNode.value] = true
while (queue.length > 0) {
const current = queue.shift();
current.adjacentVertices.forEach(vertex => {
if (!visited[vertex.value]) {
queue.push(vertex)
}
visited[vertex.value] = true
});
}
}
Dijkstra:
function shortestPath(startVertex, destination) {
const { cheapestCityToValue, previousStopsMap } = dijkstra(startVertex);
const shortestPath = [];
let currentCityName = destination;
while (currentCityName !== startVertex.name) {
shortestPath.push(currentCityName);
currentCityName = previousStopsMap[currentCityName];
}
shortestPath.push(currentCityName);
return {
path: shortestPath.reverse(),
price: cheapestCityToValue[destination],
};
}
In this part, we’ve gone through graphs and algorithms to deal with them.
That’s it for this part. There will be one final part where I will use these on other use cases!