Data Structures and Algorithms
Data Structures and Algorithms
jamiebones2000@yahoo.co.uk
Data Structures
and Algorithms
in JavaScript
LYDIA HALLIE
Table of Contents
Big O Notation 4
1.1 Constant - O(1) 6
1.2 Linear - O(n) 7
1.3 Quadratic - O(n2) 8
1.4 Logarithmic - O(log(n)) 9
Sorting algorithms 11
2.1 Bubble sort 12
2.2 Insertion sort 16
2.3 Merge sort 20
2.4 Quicksort 25
2.5 Selection sort 32
2.6 Counting Sort 36
2.7 Bucket sort 42
2.8 Radix sort 48
2.9 Heap Sort 54
Implementation 56
Data Structures 59
3.1 Stacks 61
3.2 Queues 63
3.3 Linked List 65
3.3.1 Singly Linked List 65
3.3.2 Doubly Linked List 72
3.4 Binary Search Tree 80
3.4.1 Adding a node 82
3.4.2 Removing a node 85
3.4.3 Removing a leaf 86
3.4.4 Traversing 90
3.4.4.1 Depth-first traversal 92
3.4.4.2 Breadth-first traversal 94
3.5 Hash table 98
3.6 Graphs 104
3.6.1 DFS traverse 108
3.6.2 BFS Traverse 112
This is a collection of all my personal notes on common data structures and
algorithms, implemented in JavaScript. These notes are not meant to
replace any study method, and I highly recommend you to go through all
the sources I’ve listed in the repl.it folder, in order to strengthen your
knowledge in data structures and algorithms. Although a lot of research has
been done, they might contain false, outdated or incomplete information,
and they are solely meant to give you a clear view on how certain algorithms
and data structures work, how to implement them, and what their
time/space complexity is. In these notes, I assume that the reader knows
JavaScript (ES6).
Constant O(1)
Linear O(n)
Logarithmic O(log n)
Quadratic O(n2)
Exponential O(2n)
The colors represent how good the time complexities are, green is excellent,
red is horrible. A graph showing the difference in efficiency of the
complexities makes clear why the constant and logarithmic runtimes are the
most efficient, and the exponential and quadratic the least:
When considering the runtime of an algorithm, there is a worst case,
average case, and best case scenario, which all have different time
complexities. Normally, the worst case scenario is the most important one,
as we have to calculate how long it could take to execute an algorithm.
However, it is very hard to actually determine the worst case scenario.
Instead, we usually consider a scenario that’s bad, but not necessarily the
absolute worst case. This could lead to either an optimistic result, when
we might end up with a situation that’s way worse than we expected, or a
pessimistic result, which is when we end up with an expectation that’s way
worse than it actually would ever be!
1.1 Constant - O(1)
It always takes the same amount of time to find the first element in the
array. In a graph, it would look like:
1.2 Linear - O(n)
The time it takes to execute is directly dependent on the size of the input! In
a graph, it would look like:
1.3 Quadratic - O(n2)
For every element in the array, we loop over the array, and compare their
values. In a graph, it would look like:
1.4 Logarithmic - O(log(n))
If an algorithm’s time complexity is O(2n), its runtime doubles after every
addition to the input size. If 5 items would take 30 seconds, 6 items would
take 60 seconds.
With sorting algorithms, we can put items of a list in the right order. There
are many different ways to sort a list, which all have different time
complexities! Sorting a list can be extremely efficient before using other
algorithms, such as search or merge algorithms. In these notes, I will cover
9 popular sorting algorithms:
● Bubble sort
● Insertion sort
● Merge sort
● Quicksort
● Selection sort
● Counting sort
● Bucket sort
● Radix sort
● Heap sort
2.1 Bubble sort
Bubble sort sorts an array, by swapping elements that are in the wrong
order. It starts from the first element in the array until the last element in
the array, and keeps on doing so until there is one entire pass without
swapping! This creates a “bubble” effect, hence the name.
function bubbleSort(array) {
}
Next, it’s important to know whether we should swap at all. If the array has
already been swapped, meaning an entire pass has gone without swapping,
we won’t have to go through the array again. We need to create a variable
that holds a boolean value, and only if that value is true, we want to swap
values.
let swapped;
do {
swapped = false;
// Swap logic here! In here, swapped will be set to true.
} while (swapped);
Right now, if swapped keeps on being false, the do-while will be stopped.
We need to create the swapping logic. First, we need to loop over the array.
Then, we check whether the current value, if that exists, is bigger than the
next item’s value, if that exists, meaning that they should swap.
function bubbleSort(array) {
let swapped;
do {
swapped = false;
for (let i = 0; i < array.length; i++) {
if (array[i] && array[i + 1] && array[i] > array[i + 1]) {
const temp = array[i];
array[i] = array[i + 1];
array[i + 1] = temp;
swapped = true;
}
}
} while (swapped);
return array;
}
TIME SPACE
With insertion sort, you move an element that’s not in the right position all
the way to the point where it should be. The current element stays the
current element after swapping, until the element is in the right position!
The element then gets “inserted” in front of all the values that are higher
than its own value, and after the value that’s lower than its value.
We need to create a function that receives the array that we want to sort as
an argument.
function insertionSort(array) {
}
Just like with bubble sort, we need to loop over every item in the array. We
declare a temporary variable in order to store the value of the current item,
and declare a new variable j, which is equal to the index of the element that
we will compare our current element to. The initial value of this index, is one
index lower than the currently checked item’s index.
function insertionSort(array) {
for (let i = 0; i < array.length; i++) {
const temp = array[i];
let j = i - 1;
}
}
array[j + 1] = temp;
function insertionSort(array) {
for (let i = 0; i < array.length; i++) {
let temp = array[i];
let j = i - 1;
while (j >= 0 && array[j] > temp) {
array[j + 1] = array[j];
j--;
}
array[j + 1] = temp;
}
return array;
}
TIME SPACE
Merge sort divides an array into halves, calls itself for the two halves, and
then merges the two halves.
return result.concat(left.slice(leftIndex)).concat(right.slice(rightIndex));
}
https://codesandbox.io/s/481wj5n2wx
We invoke the mergeSort function with the array we
want to have sorted. The array gets split in two
parts: a left, and a right part. Then, we return the
merge function with two arguments, where we call the mergeSort function for
the left side of the array, and the mergeSort function for the right side of the
array.
The mergeSort function gets invoked with the items on the left
side of the array, which are 6 and 4. Again, this array gets split
in two parts: a left, and a right part. Then, we again return the
merge function, where we call the merge sort function for the left side of the
array, and the mergeSort function for the right side of the array.
The mergeSort function gets invoked with the items on the left side
of the array, which is only the item 6 right now. This means that
array.length === 1 returns true, which returns the array.
For the first time, we invoke the mergeSort function for the right
side of the array! Again, this is only one item, so array.length === 1
returns true. The array gets returned.
This means that the mergeSort(left) in the merge function we invoked first,
finally returned. The exact same logic now applies to mergeSort(right).
The results array, displayed as the purple box, is by default empty. The
index of both the left and the right array are 0, which are displayed with the
yellow box. l eft[leftIndex] < right[rightIndex] is 4 < 1 in this case, which
returns false. right[rightIndex] gets pushed to the results array, and the
rightIndex get incremented by one.
As the rightIndex gets incremented by one,
right[1] is now 5. 4 < 5 returns true, so
left[leftIndex] gets pushed to the results
array.
TIME SPACE
Best, average and worst: Each partitioning takes O(n) operations, and
every partitioning splits the array O(log(n)). This results in O(n log(n)).
Worst space: We save three variables for each element in the array.
2.4 Quicksort
As quicksort doesn’t use any space, it’s an inplace sorting algorithm. The
way quicksort works, is by choosing a pivot (an element in the array, often
random), and check whether values in the array are higher or lower than
that pivot. The values lower than the pivot should be on the left side, and
the values higher than the pivot should be on the right side!
Next, we need to create a function that chooses the pivot. In this example, I
use the Hoare partition scheme. Again, this function should receive the
array we want to sort, the left value, and the right value.
Then, we need to check whether the left and right values are in the correct
position. If left is bigger or equal to right, they need to swap.
We return the left value, as this will be our pivot. Right now, the entire
function looks like:
Best and average: Each partitioning takes O(n) operations, and every
partitioning splits the array O(log(n)). This results in O(n log(n)).
Worst: If you always picked a pivot that is the highest of lowest value, you
need to iterate through the entire array.
Worst space: The amount of variables that are stored
2.5 Selection sort
Selection sort is a simple sorting algorithm, that loops over the array and
saves the absolute lowest value. The lowest value is then swapped with the
first item in the unsorted array.
The exact same logic applies to the rest of the array, starting from the first
element of the unsorted array. In the end, the entire (sorted) array will be
on the sorted side.
Implementation
function selectionSort(array) {
}
Then, we loop over our array. We create a variable that holds the value of
the minimum value.
For every item, we loop over the array from the element after the minimum
value. We need to create another loop, that checks whether there is an item
that’s lower than the minimum value. If that is the case, then we set the min
variable equal to that value!
function selectionSort(array) {
for (let i = 0; i < array.length; i++) {
let min = i;
for (let j = i + 1; j < array.length; j++) {
if (array[j] < array[min]) {
min = j;
}
}
if (i !== min) {
[array[i], array[min]] = [array[min], array[i]];
}
}
return array;
}
TIME SPACE
Best, average and worst: For every element in the array, we loop over
the array. This means that for n elements, we have to loop over n elements.
Worst space: We have one variable in the for-loop.
2.6 Counting Sort
We create a function that takes 3 arguments: the array, the minimum value,
and the maximum value. In the example above, the minimum value was 0,
and the maximum value was 9.
First, we need to initialize an empty index array based on the minimum and
maximum values that have been passed as parameters. The initial value of
all elements in this array is 0.
After initializing the index array, we need to fill this array with the
occurrences of the elements in the original array. We do this by looping over
the original array, and incrementing the value of the element in the index
array that corresponds to the value in the original array.
Then, we start modifying the original array so that the elements are in the
correct position. We do this by checking whether the elements in the index
array aren’t 0, meaning that they never occur in the original array. If they
do, we place them in the correct position in the array. We know the correct
position, as as we declare a new variable.
let z = 0;
We loop over the index array, and check whether the element’s value is
bigger than 0. If that’s the case, then we increment the value of variable z
by one, as we go to the next element, and set it equal to the index of the
value in the index array.
Best, average and worst: As we have three separate for loops, the time it
takes for the entire function to run is dependent on the individual loops. If
the first for-loop has linear O(n), and the second has linear O(k), where k is
the difference between the highest and lowest value in the array we want to
sort. We add them together, which makes O(n + k).
Worst space: The length of the count array grows with the same amount as
the size of the input.
2.7 Bucket sort
Bucket sort is a very useful sorting algorithm when working with floating
point numbers. We store the values in buckets, which we then sort using
insertion sort, and merge.
function bucketSort(array) {
const n = array.length;
const allBuckets = new Array(n);
const sortedArray = [];
For every bucket, we initialize an empty array. We will later push the
elements that belong to that bucket to its corresponding array.
In order to push elements to these bucket arrays, we loop over the array we
want to sort. For every element, we calculate to which bucket they should be
pushed. In this example, I use Math.floor(n * array[i] / 10) in order to
calculate this, however this function can be different.
for (let i = 0; i < n; i++) {
const index = Math.floor(n * array[i] / 10);
allBuckets[index].push(array[i]);
};
Now, we have all the buckets. If we would log the buckets now, it would
look like:
[
[],
[ 1.8, 2
.3, 2.2 ],
[],
[ 5.2, 4 .8 ],
[ 5.9, 6 .5 ],
[],
[]
]
Now, we want to sort every individual array using insertion sort. We loop
over the buckets array, and invoke the insertionSort function on every
array. Then, once the bucket array has been sorted, we push it to the
sortedArray array! We do this for all the arrays in the allBuckets array.
allBuckets.forEach(bucket => {
insertionSort(bucket);
bucket.forEach(element => sortedArray.push(element))
});
The entire function would look like :
function bucketSort(array) {
const n = array.length;
const allBuckets = new Array(n);
const sortedArray = [];
allBuckets.forEach(bucket => {
insertionSort(bucket);
bucket.forEach(element => sortedArray.push(element))
});
return sortedArray;
}
TIME SPACE
Best and average: As we have two separate for-loops, the time it takes for
the entire function to run is dependent on the individual loops.
Worst: Every element gets allocated at the same bucket. First, we loop over
n items, to then loop over n items again in the bucket array. n * n = n2
Worst space: The length of the sorted and bucket array grows with the
same amount as the size of the input.
2.8 Radix sort
Radix sort is used to sort numbers, and works by sorting the least significant
number to the most significant number. A significant number are is a
number that isn’t a zero at the beginning.
20: two significant numbers, 2 and 0. The least significant number is 0, the
most significant number is 2.
02: one significant number: 2. The least significant number is 2.
12.005: five significant numbers: 2, 2, 0, 0, and 5. The least significant
number is 5, the most significant number is 1.
Radix sort uses both counting sort and bucket sort. In order to implement
radix sort, we need to have a radixSort function that receives the array we
want to sort.
function radixSort(array) {
}
Right now, we need to store the largest digit of the maximum number in the
given array, initialize a digit bucket list where we store the values, and the
current index.
function radixSort(array) {
const max = Math.max(...array).toString().length;
let digitBuckets = [];
let index = 0;
}
Next, we want to initialize a bucket for every digit that’s possible. Let’s say
that we have the array [8, 23, 12223, 901, 2990, 12] that we want to sort.
Now, max would be equal to 5, as the length of the maximum 12223 value is
5.
Now, we want to loop over the numbers, and put the numbers in the buckets
they belongs, considering the currently active digit! While doing so, we also
need to create a function that lets us know what the currently active digit is.
To the getDigit function, we pass the number num and the currently active
significant value nth. Then, we initialize a default value of 0 as the number
on the currently active digit: not all numbers have the same amount of
significant numbers! Then, we have to calculate the value of the currently
active digit, by num % 10. If the number would be 1234, value would now be
4. Then, we change the value of num, as we just checked one significant
value. By subtracting the currently active digit, and then dividing it by 10,
we have our new value of num. If it was 1234 previously, num is now equal to
123. This keeps on going, until nth-- returns false when nth is 0. Then, we
return the value, which is equal to the digit that we currently care about.
Now, we want to store every value in the same array, based on their value.
For example, in the first round when we check the least significant digit, we
would want the digitBuckets array to look like [[10], [31], [22, 902], [3]]
The index of the array they should be pushed to, is equal to the value of
value that got returned from the getDigit function! If that array doesn’t
exist yet, we have to initialize it first.
idx = 0;
for (let t = 0; t < digitBuckets.length; t++) {
}
Now, we need to check whether the current array has a length. That’s not
always the case, the digitBuckets array could look like: [[10], [], [52]].
Then, we want to loop over the separate arrays, and place the element in
the correct position in the original array.
Right now, we’ve modified the original list, with the items in the correct
order.
The entire radixSort function looks like:
function radixSort(array) {
const max = Math.max(...array).toString().length;
let digitBuckets = [];
let index = 0;
for (let i = 0; i < max + 1; i++) {
digitBuckets = [];
for (let j = 0; j < list.length; j++) {
const digit = getDigit(list[j], i + 1);
digitBuckets[digit] = digitBuckets[digit] || [];
digitBuckets[digit].push(list[j]);
}
idx = 0;
for (let t = 0; t < digitBuckets.length; t++) {
if (digitBuckets[t] && digitBuckets[t].length > 0) {
for (let m = 0; m < digitBuckets[m].length; j++) {
list[idx++] = digitBuckets[t][m];
}
}
}
}
}
TIME SPACE
Best, average and worst: There are nested for-loops. We iterate over the
outer for-loop n times, and the inner for loop k times. This results in O(n*k).
Worst space: Outside the for-loops, there are four constant variables O(n),
and inside the for-loops there are four constant variables O(k). This results
in O(n) + O(k) = O(n + k).
2.9 Heap Sort
In a heap, all nodes are stored based on the value of their parent node.
This is a minimum heap: the children of a node are always smaller than or
equal to their parent. In a maximum heap, the children of a node are always
bigger or equal to their parent. It is not
sorted yet!
However, this means that the added node might not be in the right place,
just like here with number 2. In order to solve this, we compare the node
with its parent node. If the node’s value is smaller than the parents node,
we swap them, until the node is in the right position.
Implementation
In order to make a heap out of an array, we first need to go over all the
items in the array, from right to left. This is necessary, as we start at the
leaves. It receives the array we want to sort, and invokes the function that
makes a heap on every element.
function makeHeap(arr) {
const n = arr.length;
for (let i = n - 1; i >= 0; i--) {
// Heapify function.
}
}
The heapify function should receive the array, the length of the array, and
the current index. In order to make the heap, this order is very important to
remember:
If we have a maximum heap, the node with the highest value should be on
top. Next, we have the left node, and the right node.
function maxHeapify(arr, n, i) {
let max = i;
const left = 2 * i + 1;
const right = 2 * i + 2;
}
By default, we make i the maximum value. However, if the value of i is not
the maximum value, we need to swap elements.
If there is a left or right value, and if the maximum value is smaller than
the left or right value, we make the left or right value the maximum value.
However, we’re not actually swapping yet! We’re just redeclaring the max
variable! If the value of the max variable changed, the last if-statement
returns true, and we swap the elements. The maxHeapify function gets called
again, only this time for the values based on the maximum value. This way,
we go through the array, and maximum heapify the entire array!
function maxHeapify(arr, n, i) {
let max = i;
const left = 2 * i + 1;
const right = 2 * i + 2;
function makeHeap(arr) {
const n = arr.length;
for (let i = n - 1; i >= 0; i--) {
maxHeapify(arr, n, i);
}
}
TIME SPACE
Best, average and worst: The size of the input array that we want to sort,
gets heapified n times.
Worst space: There are five constant variables: n, i, max, left and
right.
Data Structures
Data structures are a collection of values, that are all connected to each
other in a different way. There are many different data structures, who all
have their pros and cons in different scenarios. In these notes, I will talk
about the most common data structures:
● Stack
● Queue
● Linked List
● Binary Search Tree
● Hash Table
● Graph
3.1 Stacks
You can see the stack as a container, to which we can add items, and
remove them. Only the top of this “container” is open, so the item we put in
first will be taken out last, and the items we put in last will be taken out
first. This is called the last-in-first-out principle.
class Stack {
constructor() {
this.stack = [];
}
}
The initial value of the stack is an empty array: there are no nodes in the
stack unless we push them!
In order to push a node, we can simply use the built-in push method that
JavaScript provides.
push(data) {
this.stack.push(data);
}
The same goes for popping a node, however this time we don’t need to
provide any data, as the pop method always pops the last item in the list:
pop() {
return this.stack.pop();
}
These are the two most important methods of a stack, as we’re able to add
values and pop them off again. Let’s say that we want to reverse the word
“Lydia”:
First, we make an array of the string “Lydia”. The values get pushed to the
stack, and the value on top of the stack gets popped off first. This results in
the word reversed!
TIME SPACE
Get and Search: In order to get or search for a certain value, we’d have to
walk over all the items in the stack. The amount of time needed is directly
proportional to the amount of items in the stack.
Insertion and Deletion: When we insert new data onto the stack, we
simply add it at the top of the stack. When we delete an item, we simply pop
the first one on the stack off the stack. No need to iterate through any data.
Worst space: The more items, the bigger the stack array.
3.2 Queues
Where the stack used the last-in-first-out principle, queues use the
first-in-first-out principle.
The queue will just be an array, just like we did with the stack.
class Queue {
constructor() {
this.queue = [];
}
}
enqueue(data) {
this.queue.push(data);
}
In order to dequeue, you use shift instead of pop! This causes the first index
of the array to be returned, which means that the first value added to the
array will also be removed first. The first-in-first-out principle!
dequeue() {
return this.queue.shift();
}
TIME SPACE
Get and Search: In order to get or search for a certain value, we’d have to
walk over all the items in the queue. The amount of time needed is directly
proportional to the amount of items in the queue.
Insertion: When we insert new data in the queue, we simply push it to the
end of the queue.
Deletion: Do to the internals of the JavaScript shift method, which walks
over the entire array and returns the last item, the time complexity for
deletion in linear.
Worst space: The more items, the bigger the queue array.
3.3 Linked List
A singly linked list is a linear data structure. Each element, called a node, is
connected to the other, by pointers (or references) to the next node.
{
data: 23,
next: {
data: 16,
next: {
data: 3,
next: null
}
}
}
Implementation
In a singly linked list, we should be able to find, remove, and insert nodes.
Before we can make the list, we first need to create the nodes.
function Node(data) {
this.data = data;
this.next = null;
}
We now created a constructor function that we can use each time we create
a new node. By default, the new node’s next value is null, and it’s data is
equal to the data we pass as an argument.
The linked list will be a class. This class will have several properties, such as
the remove function, add function, and find function. However, before we
can do any of that, we need to create its constructor.
class SinglyLinkedList {
constructor() {
this.head = null;
this.tail = null;
}
}
By default, the list doesn’t have any nodes and the length is equal to 0. If
the list doesn’t have any nodes, both the head (the first node in the list) and
the tail (the last node in the list) don’t exist, so their values are equal to
null.
The function to add a node to the tail is as follows:
addNode(data) {
const node = new Node(data);
if (!this.head) {
this.tail = node;
this.head = node;
} else {
this.tail.next = node;
this.tail = node;
}
}
There’s only one node in the list, which represents both the head and the
tail.
insertAfter(data, toNodeData) {
let current = this.head;
while (current) {
if (current.data === toNodeData) {
const node = new Node(data);
if (current === this.tail) {
this.tail.next = node;
this.tail = node;
} else {
node.next = current.next;
current.next = node;
}
}
current = current.next;
}
}
The insertAfter function receives two arguments: the data for the new
node, and the data of the node after which we want to add the new node. As
we start traversing the list again, we set the default value of the currently
checked node to the head. While there is a head, meaning that the list isn’t
empty, we can start walking through the list. If the data of the currently
checked node equals the data of the node we wanted to find, in order to add
a new node after this node, we create the new node with the data we passed
as an argument. If the currently checked node is the tail of the list, meaning
that we’re actually just adding a new node to the end of the list, the tail’s
next value is equal to the new node, and now the new node equals the tail.
Else, the new node’s next value equals the currently checked node’s next
value.
1. Filter through the list, until you find the node with the right data.
2. Create a new node, and set the new node’s next value equal to the
current node next value.
3. Set the current node’s next value equal to the new node. Now, the new
node has been inserted!
The function to remove a node is as follows:
removeNode(data) {
let previous = this.head;
let current = this.head;
while (current) {
if (current.data === data) {
if (current === this.head) {
this.head = this.head.next;
}
if (current === this.tail) {
this.tail = previous;
}
previous.next = current.next;
} else {
previous = current;
}
current = current.next;
}
}
There are two variables, previous and current. current represents the
currently checked node, previous represents the currently checked node’s
previous node. In order to find the node we want to remove, we always start
from the beginning of the list, the head. The values of the previous and
current variables are now equal to the head. If there’s a current value,
meaning that the lists consists of at least one node, we start to traverse the
list.
If the currently checked node’s data is equal to the data we want to delete,
we found the right node! Now, we need to check where this node is placed.
Let’s say that we want to remove the node with the data equal to 9. We find
the node, and set the previous node’s next value equal to the next node.
If the node we want to delete is the head of the list, we set the current
head’s node next value equal to this.head.
BEFORE DELETION AFTER DELETION
{ {
data: 23, data: 23,
next: { next: {
data: 16, data: 16,
next: { next: {
data: 9, data: 40,
next: { next: null
data: 40, }
next: null }
} }
}
}
}
https://codesandbox.io/s/1ryqj76l3j
3.3.2 Doubly Linked List
In a doubly linked list, each node has two references: one to its previous
node, and one to its next node (instead of only the next node in the singly
linked list). This would look like:
{
data: 4,
previous: null
next: {
data: 15,
previous: {
data: 4,
previous: null,
next: { … }
},
next: {
data: 92,
previous: {
data: 15,
previous: { … },
next: { … }
},
next: {
data: 56,
previous: {
data: 95,
previous: { … },
next: { … },
}
next: null
}
}
}
}
First of all, just like a singly linked list, we need to write a constructor
function in order to create new nodes.
function Node(data) {
this.data = data;
this.next = null;
this.previous = null;
}
The linked list will again be a class. This class will have several properties,
just like the singly linked list, such as the remove function, add function, and
find function. We create a constructor with properties that every list has by
default.
class DoublyLinkedList {
constructor() {
this.head = null;
this.tail = null;
this.length = 0;
}
}
Just like the singly linked list, we start off with the tail and head value equal
to null: the list doesn’t have any nodes yet, until we add them.
The function to add a node to the tail is as follows:
addNode(data) {
const node = new Node(data);
if (!this.head) {
this.tail = node;
this.head = node;
} else {
node.previous = this.tail;
this.tail.next = node;
this.tail = node;
}
this.length++;
}
{
data: 23,
previous: null,
next: null
}
There’s only one node in the list, which represents both the head and the
tail. It’s almost exactly the same as the singly linked list, the only difference
is that we have to define the node’s previous value in case the node isn’t the
head!
insertAfter(data, toNodeData) {
let current = this.head;
while (current) {
if (current.data === toNodeData) {
const node = new Node(data);
if (current === this.tail) {
this.addNode(data);
} else {
current.next.previous = node;
node.previous = current;
node.next = current.next;
current.next = node;
this.length++;
}
}
current = current.next;
}
}
Again, we start traversing the list from the head. While there is a head,
meaning that the list isn’t empty, we check whether the currently checked
node’s value is equal to the node after which we want to insert the new
node. If we find that node, and the node happens to be the tail, we invoke
the addNode function as that function adds nodes to the end of the list, so we
won’t have to repeat that logic. Else, if the node is somewhere in the middle
of the list (for example, if data is equal to 32 and toNodeData is 15):
Set the currently checked node’s next value’s previous value (yes, bear with
me) equal to the new node.
Then, we set the new node’s previous value equal to the current node’s next
value.
Then, we set the node’s next value equal to the current next value.
And lastly, we set the current node’s next value equal to the new node.
We’ve successfully inserted the new node in the list!
Removing a node:
removeNode(data) {
let current = this.head;
while (current) {
if (current.data === data) {
if (current === this.head && current === this.tail) {
this.head = null;
this.tail = null;
} else if (current === this.head) {
this.head = this.head.next;
this.head.previous = null;
} else if (current === this.tail) {
this.tail = this.tail.previous;
this.tail.next = null;
} else {
current.previous.next = current.next;
current.next.previous = current.previous;
}
this.length--
}
current = current.next;
}
}
If the node we want to remove is the head, we first set the head’s next node
equal to the head. Then, we set the new head’s previous node equal to null,
which results in the deletion of the previous head node.
The same logic applies to deleting the tail node, just the other way around.
TIME SPACE
Get, Search, Insertion and Deletion: In order to get to a node in the list,
we would have to walk through the list in order to find the node we’re
looking for. It is possible to use pointers instead, which would be constant,
but in these examples the time complexity would be linear.
Worst space: The more items, the bigger the list.
3.4 Binary Search Tree
The node represented with the orange background, is called the root node.
The node’s value on the left is always smaller than the node’s value, and
the node’s value on the right is always bigger than the node’s value. This
way we can easily search through the tree: let’s say we want to find the
value 17. Is it bigger or smaller than 15? Bigger, so we go right. Is 17
smaller or bigger than 18? Smaller, so we go left. And we found the node!
The algorithm to balance the tree won’t be covered in these notes, I will
assume the tree is already balanced. An example of an unbalanced tree:
Before we write the tree, we need to create the constructor function for each
node.
function Node(data) {
this.data = data;
this.left = null;
this.right = null;
}
Every node has a node on its left side, the node smaller than the current
node’s value, and on its right side, the node bigger than the current node’s
value.
class Tree {
constructor() {
this.root = null;
}
}
The tree is a class, which constructor contains the value of the root node. By
default, the root’s value is equal to null: there are no nodes in the tree until
we add them!
3.4.1 Adding a node
addNode(data) {
const newNode = new Node(data);
if (!this.root) {
this.root = newNode;
} else {
this.insertNode(this.root, newNode);
}
}
insertNode(node, newNode) {
if (newNode.data < node.data) {
if (!node.left) {
node.left = newNode;
} else {
this.insertNode(node.left, newNode);
}
} else {
if (!node.right) {
node.right = newNode;
} else {
this.insertNode(node.right, newNode);
}
}
}
First, we create our new node. If there is no root in the tree, meaning that
there are no nodes at all, the new node is the new root. Otherwise, if there
is a root already, we invoke the insertNode function that receives two
arguments: the root, as we need to check all values in the tree later on, and
the new node.
In the insertNode function, we check if the currently checked node’s data is
lower or higher than the new node’s data. If it’s lower, meaning we go left,
we first check if there is a value already. If there isn’t, the currently checked
node’s left value is equal to the new node. Otherwise, we invoke the
insertNode function again, in order to keep on walking through the tree until
we find the right value. The node’s left node is now passed as the first
argument, and we check again whether it’s value is lower or higher than the
new node’s value. This keeps on going, until we find the place for the new
node. The same goes for the right values, but the other way around! Let’s
say we want to add a new node with data 5.
remove(data) {
this.root = this.removeNode(this.root, data)
}
removeNode(node, data) {
if (!node) {
return null;
}
if (data < node.data) {
node.left = this.removeNode(node.left, data);
return node;
} else if (data > node.data) {
node.right = this.removeNode(node.right, data);
return node;
} else {
if (!node.left && !node.right) {
node = null;
return node;
}
if (!node.left) {
node = node.right;
return node;
}
if (!node.right) {
node = node.left;
return node;
}
First, we invoke the remove function. We set the root’s value equal to
whatever gets returned from the removeNode function. We pass two
arguments to this function: the root, and the node’s value that we want to
delete, 9 in this case.
Inside the removeNode function, we get to the first if-statement. There are
nodes, so !node returns false. We get to the second if-statement, which
returns true as data (9) is smaller than node.data (27). Now, we set the left
node’s value equal to whatever gets returned from the removeNode function,
which we invoke again, only now with the value of the left node (10) as the
first argument.
Again, we get to the first if-statement, which returns false. The second
if-statement returns true again, as data (9) is smaller than node.data (10).
Again, we set the left node’s value equal to whatever gets returned from the
removeNode function, with the new left node (9) as the first argument.
The second if-statement now returns false, because data (9) is not smaller
than node.data (9). The else-if statement also returns false, because 9 is not
bigger than 9. We get into the else block, where we get our first
if-statement. This one, !node.left && !node.right, returns true: the node
is a leaf and doesn’t have a left and right node. We set the node equal to
null: the node gets deleted.
First, we invoke the remove function. We set the root’s value equal to
whatever gets returned from the removeNode function. We pass two
arguments to this function: the root, and the node’s value that we want to
delete, 10 in this case.
Inside the removeNode function, we get to the first if-statement. There are
nodes, so !node returns false. We get to the second if-statement, which
returns true as data (10) is smaller than node.data (27). Now, we set the
left node’s value equal to whatever gets returned from the removeNode
function, which we invoke again, only now with the value of the left node
(10) as the first argument.
Again, we get to the first if-statement, which returns false. The second
if-statement returns false, as data (10) is not smaller than node.data (10).
Also the else-if statement returns false, as 10 is not bigger than 10. We get
into the else block, where we get to the first if-statement. This one,
!node.left && !node.right, returns false, as the node we want to delete
has a node.right, namely the one with the value 12. The second
if-statement in the else-block, !node.left, returns true: there is no left
node! We now replace the current node with it’s node on the right. The node
has now been deleted and replaced.
EXAMPLE 1:
First, we invoke the remove function. We set the root’s value equal to
whatever gets returned from the removeNode function. We pass two
arguments to this function: the root, and the node’s value that we want to
delete, 35 in this case.
Inside the removeNode function, we get to the first if-statement. There are
nodes, so !node returns false. We get to the second if-statement, which
returns false as data (35) is bigger than node.data (27). Now, we set the
right node’s value equal to whatever gets returned from the removeNode
function, which we invoke again, only now with the value of the right node
(35) as the first argument.
Again, we get to the first if-statement, which returns false. The second
if-statement returns false as well, as data (35) is not smaller than node.data
(35). Also the else-if statement returns false, as 35 is not bigger than 35.
We get into the else block, where all if-statements return false: there is a
left node and a right node. This means that we get to the last part:
3.4.4 Traversing
There are three ways to traverse (go through) a binary search tree:
Inorder
1) Left subtree
2) Root node
3) Right subtree
The inorder way is important if you want to flatten the tree back into its
original sequence.
inorder(data) {
if (node) {
this.inorder(node.left);
console.log(node.data);
this.inorder(node.right);
}
}
Preorder
1. Root node
2. Left subtree
3. Right subtree
The preorder way is important if you need to inspect roots before inspecting
the leaves.
preOrder(data) {
if (node) {
console.log(node.data);
this.inOrder(node.left);
this.inOrder(node.right);
}
}
Postorder
1) Left subtree
2) Right subtree
3) Root node
postOrder(data) {
if (node) {
this.inOrder(node.left);
this.inOrder(node.right);
console.log(node.data);
}
}
3.4.4.1 Depth-first traversal
Depth-first traversal uses a stack data structure. The stack keeps track of
all the visited nodes! However, the stack is implemented implicitly.
The first one in the queue gets added to the output sequence, and its child
nodes get pushed to the queue. This keeps on going, until we’ve reached the
end of the tree!
traverseBFS() {
if (!this.root) return;
this.queue = [];
this.queue.push(this.root);
while (this.queue.length) {
const node = this.queue.shift();
if (node.left) {
this.queue.push(node.left);
}
if (node.right) {
this.queue.push(node.right);
}
return node.data;
}
}
First, we need to check whether the tree has nodes at all. If that’s not the
case, we can’t traverse anything, so we return from the function. Then, we
initialize the queue. The first node that needs to be pushed to the queue, is
the root. This means that the queue has a length, and the while condition
returns true. We declare a variable called node, and set it equal to the last
item in the queue, which now gets removed from the queue. Does this item
have a node on the left? If yes, then that item gets pushed to the queue, the
same logic gets repeated for its right node. The node that got removed from
the queue gets returned!
15
11
30
7
13
32
A successful breadth-first traverse!
Finding minimum and maximum value
Finding the minimum and maximum value in a binary search tree is rather
easy, as you always know that the left subtree’s values are lower than the
current node, and the right subtree’s values are higher than the current
node.
getMin() { getMax() {
let node = this.root; let node = this.root;
while (node.left) { while (node.right) {
node = node.left; node = node.right;
} }
return node.data; return node.data;
} }
TIME SPACE
Hash tables are extremely efficient. Let’s say we want to look up a specific
person in an array: we would have to walk through every item in order to
look for that person! The space complexity would be O(n), as the space
depends on the size of the array.
In order to look things up way more efficiently, you can use hashtables!
Hash tables are made up of two parts: an object with the table where the
data will be stored, and a hash function (or mapping function). This
function creates a mapping by assigning the inputted data to a very specific
index within the array! This function takes a key, and always returns the
same index for the same key! If we would run the same key through the
function twice, it gives us the same index.
If we would log this hash table (after implementing it):
{
values: {
0: { "Mara": "BN" },
1: { "Sarah": "US" },
3: { "Emil": "SE" },
5: { "Lydia": "NL" },
},
length: 4,
size: 6,
}
As the hash function always gives the same hash for every key, we can
easily look up key/value pairs. Instead of having to map over an entire
object, we simply pass the key we want to the hash function, which then
returns the index where this key/value pair is stored!
However, sometimes the hash function returns the same index for different
keys. This is called collision.
There are now two key/value pairs at hash 0. If we now want to find the
key/value pair with the key “Lydia”, we first have to iterate through this
bucket, until we find the right key (the bucket is shown in green).
In order to implement a hash table, we create a class.
class HashTable {
constructor() {
this.values = {};
this.length = 0;
this.size = 0;
}
}
The constructor contains an object in which we’re going to store the values,
the length of the values, and the entire size of the hash table: meaning how
many buckets the hash table contains. Somewhere in these buckets, we can
store our data.
calculateHash(key) {
return key.toString().length % this.size;
}
Let’s say we have the string “Hello” and we create a hash table with the size
of 10. “Hello” has the size of 5, so 5 % 10 becomes 5! String “Hello” is now
stored in the bucket with hash 5. If later on, we want to see where the
key/value pair with the key “Hello” has been stored, it would return the
same hash as the length of “Hello” hasn’t changed, and neither has the size
of the hash table!
add(key, value) {
const hash = this.calculateHash(key);
if (!this.values.hasOwnProperty(hash)) {
this.values[hash] = {};
}
if (!this.values[hash].hasOwnProperty(key)) {
this.length++;
}
this.values[hash][key] = value;
}
In order to add a key/value pair, we first need to calculate the hash with the
key provided. If this hash is brand new, meaning that no other key/value
pair used it yet and it’s not in the values object, we initialize an empty object
for that hash. Next, we check whether the has has a property with the same
key name! If that’s not the case, it means we will add a new key/value pair,
and the length of the hash table grows. Lastly, we add the key/value pair to
the right hash.
The length of “Italy” is 5, so the hash function returns the hash 2.
The hash’s key value on the object doesn’t have the value yet, so we
increment the length by one.
We set the key equal to the value in the hash table, which adds it.
Searching in a hash table goes very fast. As with an array we have to go
through all of the elements until we find it, with a hash table we simply get
the index. This means that its runtime is constant, O(1).
search(key) {
const hash = this.calculateHash(key);
if (this.values.hasOwnProperty(hash) && this.values[hash].hasOwnProperty(key)) {
return this.values[hash][key];
} else {
return null;
}
}
First, we calculate the hash again. As the length of the string and the size of
the hash table haven’t changed, the hash remains the same. Then, we check
whether the hash is within the object, and whether that hash points to the
key we’re looking for. If that’s the case, return the value that that pair
stores, else nothing gets returned.
TIME SPACE
Average: As we get the right location directly from the hash, we don’t need
to iterate over anything. The data is directly accessible.
Worst: It could happen that all items get stored in one bucket, which we
would then have to iterate through.
Worst space: The more items, the bigger the hash table.
3.6 Graphs
In the matrices, 0 means that there is no edge between the nodes, and 1
means that there is an edge. In JavaScript, the matrices would look like
these 2D arrays:
class Graph {
constructor() {
this.numberOfVertices = 0;
this.adjList = new Map();
}
}
addVertex(vertex) {
this.adjList.set(vertex, []);
}
Let’s say that we now want to add the vertex 3 and 5. Our matrix would look
like:
{
: [] },
{ 3
{ 5 : [] }
}
In order to connect these two nodes, we need to create a function that takes
care of adding edges.
addEdge(vertex1, vertex2) {
this.adjList.get(vertex1).push(vertex2);
this.adjList.get(vertex2).push(vertex1);
}
If we would add an edge between 3 and 5, the matrix would look like:
{
: [5] },
{ 3
{ 5 : [3] }
}
print() {
const keys = this.adjList.keys();
for (let i of keys) {
const values = this.adjList.get(i);
let value = "";
for (let j of values) {
value += j + " ";
}
console.log(`${i} => ${value}`);
}
}
5 => 3
3 => 5
3.6.1 DFS traverse
The stack is empty: this is the sign that we have successfully traversed the
entire graph!
Implementation
DFSStart(startNode) {
let visited = [];
for (let i = 0; i < this.numberOfVertices; i++) {
visited[i] = false;
}
this.traverseDFS(startNode, visited);
}
traverseDFS(vertex, visited) {
visited[vertex] = true;
let neighbors = this.adjList.get(vertex);
for (let i in neighbors) {
let element = neighbors[i];
if (!visited[element]) {
this.traverseDFS(element, visited);
}
}
}
We visit the active node’s child nodes, mark them as visited, push them to
the queue numerically, and add them to the output sequence! Node 1
doesn’t have any other child nodes, so we set the first node in the queue
to be the active node now, which is node 2.
Node 2 gets removed from the queue, and its child nodes get pushed to the
queue and output sequence in numerical order.
The next node in the queue is 4, however 4 doesn’t have any unvisited child
nodes. We keep on removing nodes from the queue, until we find a node
that has unvisited child nodes.
The next in the queue is node 3, which has an unvisited child node 6! 6 gets
pushed to the queue and sequence array.
Right now, the graph doesn’t have any unvisited nodes anymore! The queue
is empty, which is a sign that traversing has been successfully completed.
Implementation
class Queue { … }
traverseBFS(startNode) {
let visited = [];
for (let i = 0; i < this.numberOfVertices; i++) {
visited[i] = false;
}
const queue = new Queue();
visited[startNode] = true;
queue.enqueue(startNode);
while (!queue.isEmpty()) {
const queueElement = queue.dequeue();
const list = this.adjList.get(queueElement);
for (let i in list) {
const neighbor = list[i];
if (!visited[neighbor]) {
visited[neighbor] = true;
queue.enqueue(neighbor);
}
}
}