More Data Structures
Stack
A Stack
is a Last In, First Out (LIFO) data structure, which means that the most recently added element is the first one to be removed. Think of it like a stack of pancakes where you can only add or remove on the top. The basic Stack
commands are push
(add on top), pop
(remove from top), and peek
(see what is on top without removing it)
In Java, you can use the Stack
class of the JDK.
import java.util.Stack;
public class Main {
public static void main(String[] args) {
Stack<String> stack = new Stack<>();
stack.push("a");
stack.push("b");
stack.push("c");
stack.push("d");
System.out.println(stack); // [a, b, c, d]
// get the last element without removing it
System.out.println(stack.peek()); // d
while (!stack.isEmpty()) {
// pop elements from the last (it removes them!)
System.out.println(stack.pop()); // d c b a
}
System.out.println(stack); // empty []
}
}
Note that Stack
of the JDK extends Vector
, and thus allow operations like inserting and removing elements at arbitrary positions in the underlying array. Basically, you can violate the LIFO principle of Stack
if you use the Vector
methods, be careful! However, this also give you flexibility as in some cases you want to change the Stack
without adhere to the LIFO principle.
What is Vector
of the JDK? It’s a legacy (old) class, which does not have as good performance as ArrayList
or LinkedList
. You can use the follwoing alternatives:
Deque<String> stack = new ArrayDeque<>();
or
Deque<String> stack = new LinkedList<>();
A concrete example on when Stack
can be useful is when implementing the “undo” functionality of a text editor. Every time a user modifies the text, the action is pushed
to the stack. When undoing, you simply remove the last action by popping
it off the stack, effectively reversing the recent change.
Get your hands dirty!
Write a method that takes a string containing parentheses (
and )
and checks if the parentheses are balanced. For example, the function should return true
for ((()))
and ()()()
, and false
for (()
and )(
.
Queue
A Queue
is a First In, First Out (FIFO) data structure, which means that the first element added is the first one to be removed. Think of it like a line of people waiting for a ticket, where the first person to join the line is the first one to get the ticket.
The basic Queue
commands are add
, remove
, and peek
In Java, you can implement a Queue using the Queue
interface and LinkedLsit
as implementation.
import java.util.LinkedList;
import java.util.Queue;
public class Main {
public static void main(String[] args) {
Queue<String> queue = new LinkedList<>();
queue.add("a");
queue.add("b");
queue.add("c");
queue.add("d");
System.out.println(queue); // [a, b, c, d]
// get the first element without removing it
System.out.println(queue.peek()); // a
while (!queue.isEmpty()) {
// remove elements from the first
System.out.println(queue.remove()); // a b c d
}
System.out.println(queue); // empty []
}
}
A concrete example on when using Queue
will be appropriate is when implementing a printer, which should adhere to FIFO.
Deque
A Deque
is a double-ended queue, which supports insertion and deletion at both ends. This makes it a hybrid of a stack and a queue. You can use the LinkedList
as implementation using Deque
as interface.
import java.util.LinkedList;
import java.util.Deque;
public class Main {
public static void main(String[] args) {
Deque<String> deque = new LinkedList<>();
deque.add("a");
deque.push("b");
deque.add("c");
deque.push("d");
System.out.println(deque); // [d, b, a, c]
// get the first element without removing it
System.out.println(deque.peek()); // d
System.out.println(deque.peekFirst()); // d
// get the last element without removing it
System.out.println(deque.peekLast()); // c
System.out.println(deque.pop()); // d
System.out.println(deque.remove()); // b
System.out.println(deque); // empty [a, c]
}
}
You can also use ArrayDeque
as implementation using Deque
as interface.
Deque<String> stack = new ArrayDeque<>();
ArrayDeque
has better performance than LinkedList
for situations that need frequently accessed elements at both ends of the deque, LinkedList
has better performance than ArrayDeque
with frequent insertions and removals at arbitrary positions.
A concrete example on when Deque
can be useful is when implementing a web browser’s navigation history. You can use a deque
to implement the back and forward buttons. As the user navigates through pages, URLs are added to the back of the deque
. Pressing the back button removes URLs from the back of the deque
, and pressing the forward button removes URLs from the front of the deque
.
Get your hands dirty!
Write a function that takes a string as input and checks if it’s a palindrome (reads the same forwards and backwards). Use a Deque
to compare characters from both ends of the string.
Graphs
Graph is a data structure for storing connected data. A graph consists of nodes (also called vertices) and edges. A node represents the entity and an edge represents the relationship between entities. For example, you can use graphs to represent network of people on a social media platform (e.g., Facebook); nodes are the people and the edges are friendship relations. Graphs are also very useful when modeling telecommunications and transportation networks.
Does JDK have a Graph Data structure ? No Why? Graph implementations can be very different, a general Graph might not turn out to be very useful. There are many types of Graphs (directed, undirected, weighted).
There are, of course, external libraries that offer Graphs implementations. However, often it is better to have a customized Graph
to better fit the intended usage, let’s implement one.
Node (Vertex)
First, you need to create your Node
class. If you want to use primitive data as nodes, you can use wrapper classes (e.g., Integer
, Double
, and Float
) to represent primitive data types as objects. However, it is often better to have a dedicated class. For example, we want to model a network of people, so we have a class Person
as nodes of the Graph
.
Make sure that you @Override
the equals
and hashcode
methods as we need them to compare and find nodes in the Graph. If you don’t do that, the Graph
will consider two nodes to be different as long as they are different reference in memory regardless of the content of their instance fields.
For example, let’s consider a Person
class:
public class Person {
private String id;
private String name;
private String lastName;
public Person(String id, String name, String lastName) {
this.id = id;
this.name = name;
this.lastName = lastName;
}
@Override
public String toString() {
return "Person [id=" + id + ", name=" + name + ", lastName=" + lastName + "]";
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((id == null) ? 0 : id.hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
Person other = (Person) obj;
if (id == null) {
if (other.id != null)
return false;
} else if (!id.equals(other.id))
return false;
return true;
}
}
Note that the only id
field is used for the hashCode
and equals
methods. Indeed, it’s possible for multiple people to have the same combination of name
and lastname
.
Edges
The implementation of the edges of the Graph depends on what kind of graph we want.
Main types of Graphs are directed (the edges have a direction, the relation is not symmetric) undirected (the relation is symmetric), weighted or unweighted.
For directed and weighted graphs makes sense to have a dedicated class for edges.
class Edge {
private Node to;
private Node from;
private Integer weight;
}
This class specifies the direction of the edge and the weight of the connection. For example, the “follow” relations in X (former Twitter) is one-way relationship, because a person A can follow person B, but not vice-versa. It implies a connection from A to B but not necessarily from B to A. So, for directed graphs we need to specify the direction of the relationship. The class above uses to
and from
to specify the direction.
However, let’s say that we want to model the “friendship” relation of Facebook. If there is an edge between nodes A and B, it implies that there is a friendship connection from A to B and from B to A. As friendship on Facebook are mutual. In this case, we don’t need a separate class, we can keep track of the relationships using a Map
.
public class Graph {
private Map<Person, List<Person>> adjNodes;
public Graph() {
this.adjNodes = new HashMap<>();
}
adjNodes
maps for each node N
in the Graph a list of adjacent nodes, that is the list of nodes that have a relationship with node N
.
Let’s see the addNode
operation:
public void addNode(Person node) {
adjNodes.putIfAbsent(node, new ArrayList<>());
}
The Map
method putIfAbsent
puts in the map a new key
only if it is absent in the map. Otherwise, if we use put
instead of putIfAbsent
we will replace the adjacent list with an empty one. Map
checks for equivalent keys (to know if a key is absent or not) using equals
. As such, make sure you @Override
the default implementation!
public void addEdge(Person node1, Person node2) {
adjNodes.get(node1).add(node2);
adjNodes.get(node2).add(node1);
}
addEdge
adds node2
in the adjacent list of node1
and vice-versa. Note that we are assuming that the nodes are already in the Graph. Otherwise, the method will throw a NullPointerException
. We can modify the method as follows to make sure that the nodes will be added in the Graph
if are not there.
public void addEdge(Person node1, Person node2) {
addNode(node1);
addNode(node2);
adjNodes.get(node1).add(node2);
adjNodes.get(node2).add(node1);
}
For removing a node N
, we need to first remove the entry in the map with N
as the key. Then, we need to scan the adjNodes
maps and remove N
from all the list of adjacent nodes.
public void removeNode(Person node) {
adjNodes.remove(node);
for (Person key : adjNodes.keySet()) {
adjNodes.get(key).remove(node);
}
}
For remove an edge:
public void removeEdge(Person node1, Person node2) {
adjNodes.getOrDefault(node1, new ArrayList<>()).remove(node2);
adjNodes.getOrDefault(node2, new ArrayList<>()).remove(node1);
}
getOrDefault
returns the content associated to the key or empty list new ArrayList<>()
if the map does not contain that key. We need to do this otherwise we will have NullPointerException
. Alternatively, we can check the content of the map before we remove the element.
public void removeEdge(Person node1, Person node2) {
if (adjNodes.containsKey(node1) && adjNodes.containsKey(node2) ){
adjNodes.get(node1).remove(node2);
adjNodes.get(node2).remove(node1);
}
}
To print the adjNodes
we can do like this
public String toString() {
StringBuilder sb = new StringBuilder();
for (Node n : adjNodes.keySet()) {
sb.append(n);
sb.append("->");
sb.append(adjNodes.get(n));
sb.append(System.lineSeparator());
}
return sb.toString();
}
Remember that we should use a StringBuilder
in this case as we should not concatenate a String
inside a loop.
Get your hands dirty!
Consider the example of the Facebook Graph, implement a method that tells the person or persons that have the highest number of friends.
Graph traversal
Given a Graph, we often need to traverse the Graph to search something or to extract properties of the Graph. There are two main algorithms to traverse a graph: Breadth-First Search (BFS) and Depth-First Search (DFS)
Both of these algorithms start the search from a pre-defined node called root
.
Breadth-First Search (BFS):
Starting from the root
, BFS explores all the neighbor nodes at the present depth prior to moving on to nodes at the next depth level. In other words, it traverses the graph level by level.
BFS is typically used to find the shortest path between two nodes or to explore all nodes within a certain distance from the starting node.
BFS can be implemented using a queue data structure, where nodes are visited in the order they were encountered (avoiding infinite loop by making sure to visit each node only one time).
public List<Node> breathFirstTraversal(Node root) {
List<Node> visited = new ArrayList<>();
Queue<Node> queue = new LinkedList<>();
queue.add(root);
visited.add(root);
while (!queue.isEmpty()) {
Node node = queue.poll();
for (Node n : adjNodes.get(node)) {
if (!visited.contains(n)) {
visited.add(n);
queue.add(n);
}
}
}
return visited;
}
Depth-First Search (DFS):
Starting from the root
, BFS explores as far as possible along each branch before backtracking. In other words, it traverses the graph level by level.
DFS can be used to find cycles in a Graph: When performing DFS, if it is encountered an edge that connects a node to one of its ancestors in the DFS tree, it indicates the presence of a cycle.
DFS can be implemented using a stack data structure.
public List<Node> depthFirstTraversal(Node root) {
List<Node> visited = new ArrayList<>();
Stack<Node> stack = new Stack<>();
stack.push(root);
while (!stack.isEmpty()) {
Node node = stack.pop();
if (!visited.contains(node)) {
visited.add(node);
for (Node n : adjNodes.get(node)) {
stack.push(n);
}
}
}
return visited;
}
Here’s a simple example to illustrate the difference between BFS and DFS:
Let’s say we have a graph represented as follows:
A
/ | \
B C D
/ \ |
E F G
If we start the traversal from node A:
BFS: We would visit nodes in the order A, B, C, D, E, F, G.
DFS: We would visit nodes in the order A, B, E, F, C, D, G.
In BFS, we explore all nodes at the current depth level before moving to the next level, while in DFS, we explore as far as possible along each branch before backtracking.
Both algorithms have their own strengths and weaknesses, and the choice between them depends on the specific problem at hand and on the characteristics of the graph.
BFS is better if
- The search graph has cycles or is infinite, or
- We need the shortest path to a solution, or
- There are some solutions at shallow depth.
DFS is better if
- There are only solutions at great depth, or
- Memory is limited
You might wonder why the visited check
if (!visited.contains(node)) {
visited.add(node);
it is outside the innder loop for DFS but inside the inner loop for BFS.
DFS: The check is outside the inner loop, immediately after popping from the stack, because nodes are pushed onto the stack without checking if they have been visited. This ensures nodes are only processed once when they are popped.
BFS: The check is inside the inner loop, ensuring that nodes are added to the queue and marked as visited only if they haven’t been visited before, maintaining the level-order exploration of BFS.
Get your hands dirty!
Consider the example of the Facebook Graph, implement a method that returns the person(s) that have the highest number of friends. Do this with graph traversal using both algorithms: BFS and DFS. Make sure that they return the same result.
Java Generics
Until now, we have worked with examples where the type of the graph nodes is fixed. How can we create a data structure that works for any type of object? We use Java Generics!
- The key idea is to support parametric types.
- Thus, classes and interfaces operate on data types that can be changed like a parameter.
- Classes, interfaces, and methods operating on parametric types are called generic classes, interfaces, or methods.
- This allows us to define a data structure agnostic to the data types it will operate on.
- It ensures type-safe operations. Type safety ensures that expressions and assignments are done using consistent types. This ensures that what the programmer intends at compile time happens at runtime.
You have used generics before!
For example:
List<Integer> list = new ArrayList<>();
Inside <>
, you specify the object type of the elements in the list. This feature is called a generic, and it allows type safety at compile time:
List<String> list = new ArrayList<>();
list.add("Hello");
list.add(123); // COMPILATION ERROR: `123` is an Integer, not a String
With generics, you can do this without needing to cast:
String message = list.get(0);
Without specifying the type, it defaults to java.lang.Object
, and you need to cast:
List list = new ArrayList();
list.add("Hello");
String message = (String) list.get(0); // Explicit cast needed
While with generics:
List<String> list = new ArrayList<>();
list.add("Hello");
String message = list.get(0); // No cast needed, type is known
With generics, we can create a data structure like a graph that can be used with any type.
class Graph<T> {
private Map<T, List<T>> adjacencyMap;
public Graph() {
this.adjacencyMap = new HashMap<>();
}
public void addVertex(T node) {
adjacencyMap.putIfAbsent(node, new LinkedList<>());
}
public void addEdge(T node1, T node2) {
addVertex(node1);
addVertex(node2);
adjacencyMap.get(node1).add(node2);
adjacencyMap.get(node2).add(node1);
}
}
We can then create a graph of Strings, Integers, or any custom object:
Graph<String> stringGraph = new Graph<>();
We can restrict the types that can be used as type parameters with bounded type parameters using extends
:
public class Graph<T extends Number> {
// Class implementation
}
This means the graph can only be used with types that are subclasses of Number
(e.g., Integer
, Double
).
Wildcards
Wildcards allow more flexibility when working with generic types. There are three types of wildcards:
Unbounded Wildcards (?
):
public void printList(List<?> list) {
for (Object elem : list) {
System.out.println(elem);
}
}
This method can accept a list of any type.
Bounded Wildcards with an Upper Bound (? extends Type
):
public void processNumbers(List<? extends Number> list) {
for (Number num : list) {
System.out.println(num.doubleValue());
}
}
This method can accept a list of any type that is a subclass of Number
.
Bounded Wildcards with a Lower Bound (? super Type
):
public void addNumbers(List<? super Integer> list) {
list.add(10); // Can add Integer or any subclass of Integer
}
This method can accept a list of any type that is a superclass of Integer
(including Integer
itself).
Generic Interfaces
Interfaces can also use generic:
public interface Pair<K, V> {
public K getKey();
public V getValue();
}
public class OrderedPair<K, V> implements Pair<K, V> {
private K key;
private V value;
public OrderedPair(K key, V value) {
this.key = key;
this.value = value;
}
public K getKey() {
return key;
}
public V getValue() {
return value;
}
}
REWIND!
Given an undirected and unweigthed graph, implement a method that given a node N finds cycles that start from N and ends in N. The method should use DFS to search for cycles. The method returns the path of the first cycle detected if a cycle exists, null otherwise.
import java.util.*;
class Graph<T> {
private Map<T, List<T>> adjacencyMap;
public Graph() {
this.adjacencyMap = new HashMap<>();
}
public void addVertex(T node) {
adjacencyMap.putIfAbsent(node, new LinkedList<>());
}
public void addEdge(T node1, T node2) {
addVertex(node1);
addVertex(node2);
adjacencyMap.get(node1).add(node2);
adjacencyMap.get(node2).add(node1);
}
public List<T> detectCycleFromNode(T startNode) {
//implement method
}
public static void main(String[] args) {
Graph<Integer> graph = new Graph<>();
graph.addEdge(1, 2);
graph.addEdge(2, 3);
graph.addEdge(3, 4);
graph.addEdge(4, 2); // Creates a cycle: 2 -> 3 -> 4 -> 2
graph.addEdge(3, 5);
graph.addEdge(5, 6);
graph.addEdge(6, 3); // Creates another cycle: 3 -> 5 -> 6 -> 3
findCycle(graph, 1); // No cycle detected starting from node 1 and endind in node 1
findCycle(graph, 2); // Cycle detected starting from node 2 and endind in node 2: [2, 4, 3, 2]
findCycle(graph, 3); // Cycle detected starting from node 3 and endind in node 3: [3, 6, 5, 3]
findCycle(graph, 4); // Cycle detected starting from node 4 and endind in node 4: [4, 2, 3, 4]
findCycle(graph, 5); // Cycle detected starting from node 5 and endind in node 5: [5, 6, 3, 5]
findCycle(graph, 6); // Cycle detected starting from node 6 and endind in node 6: [6, 3, 5, 6]
}
private static void findCycle(Graph<Integer> g, Integer node) {
List<Integer> cycle = g.detectCycleFromNode(node);
if (cycle != null) {
System.out.println(
"Cycle detected starting from node "
+ node
+ " and endind in node "
+ node
+ ": "
+ cycle);
return;
}
System.out.println(
"No cycle detected starting from node " + node + " and endind in node " + node);
}
}