Absolute Beginner S Guide To Algorithms
Absolute Beginner S Guide To Algorithms
Kirupa Chinnathambi
With Early Release eBooks, you get books in their earliest form—the
author’s raw and unedited content as they write—so you can take advantage
of these technologies long before the official release of these titles.
If you have comments about how we might improve the content and/or
examples in this book, or if you notice missing material within this chapter,
please reach out to Pearson at [email protected]
Contents
Chapter 1 Introduction to Data Structures
Chapter 2 Big-O Notation and Complexity Analysis
Chapter 3 Arrays
Chapter 4 Linked List
Chapter 5 Stacks
Chapter 6 Queues
Chapter 7 Trees
Chapter 8 Binary Trees
Chapter 9 Binary Search Trees
Chapter 10 Heaps
Chapter 11 Hashtable (aka Hashmap or Dictionary)
Chapter 12 Trie (aka Prefix Tree)
Chapter 13 Graphs
Chapter 14 Introduction to Recursion
Chapter 15 Fibonacci and Going Beyond Recursion
Chapter 16 Towers of Hanoi
Chapter 17 Search Algorithms and Linear Search
Chapter 18 Binary Search
Chapter 19 Binary Tree Traversal
Chapter 20 Depth-First Search (DFS) and Breadth-First Search (BFS)
Chapter 21 Quicksort
Chapter 22 Bubble Sort
Chapter 23 Insertion Sort
Chapter 24 Selection Sort
Chapter 25 Merge Sort
Author Bio
Table of Contents
Chapter 1 Introduction to Data Structures
Right Tool for the Right Job
Back to Data Structures
Conclusion
Chapter 2 Big-O Notation and Complexity Analysis
It’s Example Time
It’s Big-O Notation Time!
Conclusion
Chapter 3 Arrays
What Is an Array?
Array Implementation / Use Cases
Arrays and Memory
Performance Considerations
Conclusion
Chapter 4 Linked List
Meet the Linked List
Linked List: Time and Space Complexity
Linked List Variations
Implementation
Conclusion
Chapter 5 Stacks
Meet the Stack
A JavaScript Implementation
Stacks: Time and Space Complexity
Conclusion
Chapter 6 Queues
Meet the Queue
A JavaScript Implementation
Queues: Time and Space Complexity
Conclusion
Chapter 7 Trees
Trees 101
Height and Depth
Conclusion
Chapter 8 Binary Trees
Meet the Binary Tree
A Simple Binary Tree Implementation
Conclusion
Chapter 9 Binary Search Trees
It’s Just a Data Structure
Implementing a Binary Search Tree
Performance and Memory Characteristics
Conclusion
Chapter 10 Heaps
Meet the Heap
Heap Implementation
Performance Characteristics
Conclusion
Chapter 11 Hashtable (aka Hashmap or Dictionary)
A Very Efficient Robot
From Robots to Hashing Functions
From Hashing Functions to Hashtables
JavaScript Implementation/Usage
Dealing with Collisions
Performance and Memory
Conclusion
Chapter 12 Trie (aka Prefix Tree)
What Is a Trie?
Diving Deeper into Tries
Many More Examples Abound!
Implementation Time
Performance
Conclusion
Chapter 13 Graphs
What Is a Graph?
Graph Implementation
Conclusion
Chapter 14 Introduction to Recursion
Our Giant Cookie Problem
Recursion in Programming
Conclusion
Chapter 15 Fibonacci and Going Beyond Recursion
Recursively Solving the Fibonacci Sequence
Recursion with Memoization
Taking an Iteration-Based Approach
Going Deeper on the Speed
Conclusion
Chapter 16 Towers of Hanoi
How Towers of Hanoi Is Played
The Single Disk Case
It’s Two Disk Time
Three Disks
The Algorithm
The Code Solution
Check Out the Recursiveness!
It’s Math Time
Conclusion
Chapter 17 Search Algorithms and Linear Search
Linear Search
Conclusion
Chapter 18 Binary Search
Binary Search in Action
The JavaScript Implementation
Runtime Performance
Conclusion
Chapter 19 Binary Tree Traversal
Breadth-First Traversal
Depth-First Traversal
Implementing Our Traversal Approaches
Performance of Our Traversal Approaches
Conclusion
Chapter 20 Depth-First Search (DFS) and Breadth-First Search (BFS)
A Tale of Two Exploration Approaches
It’s Example Time
When to Use DFS? When to Use BFS?
A JavaScript Implementation
Performance Details
Conclusion
Chapter 21 Quicksort
A Look at How Quicksort Works
Another Simple Look
It’s Implementation Time
Performance Characteristics
Conclusion
Chapter 22 Bubble Sort
How Bubble Sort Works
Walkthrough
The Code
Conclusion
Chapter 23 Insertion Sort
How Insertion Sort Works
One More Example
Algorithm Overview and Implementation
Performance Analysis
Conclusion
Chapter 24 Selection Sort
Selection Sort Walkthrough
Algorithm Deep Dive
The JavaScript Implementation
Conclusion
Chapter 25 Merge Sort
How Mergesort Works
Mergesort: The Algorithm Details
Looking at the Code
Conclusion
Author Bio
1. Introduction to Data Structures
Programming is all about taking data and manipulating it in all sorts of
interesting ways. Now, depending on what we are doing, our data needs to
be represented in a form that makes it easy for us to actually use. This form
is better known as a data structure. As we will see shortly, data structures
give the data we are dealing with a heavy dose of organization and
scaffolding. This makes manipulating our data easier and (often) more
efficient. In the following sections, we find out how that is possible!
Onward!
What we want to do is store these tools for easy access later. One solution is
to simply throw all of the tools in a giant cardboard box and call it a day
(Figure 1-2).
Figure 1-2 Tools, meet box!
Knowing which data structure to use and when is an important part of being
an effective developer, and the data structures we need to deeply familiarize
ourselves with are
Arrays
Linked lists
Stacks
Queues
Introduction to trees
Binary trees
Binary search trees
Heap data structure
Hashtable (aka hashmap or dictionary)
Trie (aka prefix tree)
Conclusion
Over the next many chapters, we’ll learn more about what each data
structure is good at and, more important, what types of operations each is
not very good at. By the end of it, you and I will have created a mental map
connecting the right data structure to the right programming situation we
are trying to address.
Some additional resources:
Ask a question: https://forum.kirupa.com
Errors/Known issues:
https://bit.ly/algorithms_errata
Source repository: https://bit.ly/algorithms_source
2. Big-O Notation and Complexity Analysis
When analyzing the things our code does, we are interested in two things:
time complexity and space complexity. Time complexity refers to how
much time our code takes to run, and space complexity refers to how much
additional memory our code requires.
In an ideal world, we want our code to run as fast as possible and take up as
little memory as possible in doing so. The real world is a bit messier, so we
need a way to consistently talk about how our code runs, how long it takes
to run, and how much space it takes up. We need a way to compare whether
one approach to solving a problem is more efficient than another. What we
need is the Big-O (pronounced Big Oh) notation, and in the following
sections, we’re going to learn all about it.
Onward!
If our input number is 241,539, the number of digits will be 6 (Figure 2-2).
Figure 2-2 For larger numbers, the number of digits will be larger as
well.
The larger the number we provide as the input, the more digits we have to
count through to get the final answer. The important detail is that the
number of steps in our calculation won’t grow abnormally large (or small)
with each additional digit in our number. We can visualize this by plotting
the size of our input vs. the number of steps required to get the count
(Figure 2-4).
Figure 2-4 The amount of work scales linearly with the size of input.
In this case, it doesn’t really matter how large our input number is. The
amount of work we do never changes. We always check the last digit and
quickly determine whether the entire number is odd or even. We can
simplify this by saying that our calculation here takes a constant amount of
work (Figure 2-6).
Figure 2-6 The amount of work is a constant.
Notice that, in this graph of the steps required vs. the input size, the amount
of work doesn’t change based on the size of our input. It stays the same. It
stays … constant!
The Big-O notation looks a bit like a function call, but we shouldn’t think
of it as such. If we had to decompose each part of this notation, it would be
as follows:
The O in Big-O, as well as O(…), stands for “order of.” It represents
the growth rate of the algorithm. To reiterate an earlier point, the
growth rate can be measured in terms of time (how long it takes to
run) or space (how much memory it uses).
The n, or argument, for the O represents the number of operations our
code will perform in the worst case.
For example, if we say our code has a Big-O notation of O(n), it means that
our code’s running time or space requirements grow linearly with the input
size. If the input size doubles, the time or space required will also double.
On the other hand, if our code has a Big-O notation of O(n2), it means that
the algorithm’s running time or space requirements grow quadratically with
the input size. If the input size doubles, the time or space required will
increase fourfold. The scary part is that quadratic growth isn’t the worst
offender, and we cover those in a few moments.
Now, what we don’t do with Big-O notation is focus on extraneous
modifiers. Using the linear case as an example, it doesn’t matter if the true
value for n is where we have O(2n) or O(n + 5) or O(4n − n/2), and so on.
We only focus on the most significant factor. This means we ignore
modifiers and simplify the time or space complexity of our code down to
just O(n). Now, it may seem like going from O(n) to O(2n) will result in a
lot more work (Figure 2-8).
Figure 2-8 O(2n) versus O(n)
When we zoom all the way out and talk about really large input sizes, this
difference will be trivial. This is especially true when we look at what the
other various classes of values for n can be! The best way to understand all
of this is by looking at each major value for n and what its input versus
complexity graph looks like (Figure 2-9).
Figure 2-9 Input versus complexity graphs
Let’s add some words and additional details to the preceding pictures to
better explain the complexity:
O(1)—Constant Complexity: This notation represents code that has
a constant running time or space increase, regardless of the input
size. It means that the execution complexity remains the same,
regardless of how large the dataset becomes. Examples include
accessing an element in an array by its index or performing a simple
mathematical operation, such as calculating whether a value is even
or odd.
O(log n)—Logarithmic Complexity: Code with logarithmic time or
space complexity has growth that scales slowly as the input size
increases. These coding approaches typically divide the input into
smaller parts repeatedly, such as binary search. As the input size
doubles, the number of steps required increases by a small factor, not
proportionally.
O(n)—Linear Complexity: Linear time complexity means that the
running time or space of our code grows linearly with the input size.
As the input size increases, the time or space required also increases
proportionally. Examples include iterating through an array or a
linked list to perform an operation on each element.
O(n log n)—Linearithmic Complexity: Algorithms with
linearithmic complexity have running values that are a product of
linear and logarithmic growth rates. These algorithms are commonly
found in efficient sorting algorithms such as mergesort and quicksort.
O(n^2)—Quadratic Complexity: Quadratic time complexity means
that the running time or space growth of our code increases
quadratically with the input size. These coding approaches often
involve nested iterations, where the number of operations is
proportional to the square of the input size. Examples include
bubblesort and selection sort.
O(2^n)—Exponential Complexity: Exponential time complexity
represents code whose running time (or space taken up) grows
exponentially with the input size. These coding approaches are
highly inefficient and become impractical for larger input sizes. We
get exponential time complexity when we solve problems with brute-
force or exhaustive search strategies.
O(n!)—Factorial Complexity: Factorial time complexity is the most
severe and inefficient badge to tag a piece of code with. It represents
coding approaches that have running times proportional to the
factorial of the input size. As the input size increases, the number of
required operations grows at an astronomical rate. We will run into
factorial time and space complexity when we try to solve a problem
using a brute-force approach that explores all possible permutations
or combinations of a problem.
As we look at data structures and algorithms together, we will frequently
use the Big-O notation to describe how efficient (or inefficient) certain
operations are. That is why it is important for us to get a broad
understanding of what the various values of Big-O represent and how to
read this notation when we encounter it.
Note
Big-O, Big-Theta, and Big-Omega
In some situations, we may run into other notations for describing
the time or space complexity of how our code behaves. We saw here
that Big-O represents the worst-case scenario. The Big-Theta (Θ)
notation represents the average-case scenario, and the Big-Omega
(Ω) notation represents the best-case scenario. When we see these
non-Big-O notations make an appearance, now we know how to
read them.
Conclusion
Okay! It is time to wrap things up. The Big-O notation is a mathematical
notation used to describe the upper bound or worst-case scenario of a code’s
time or space complexity. To get all mathy on us, it provides an asymptotic
upper limit on our code’s growth rate. By using the Big-O notation, we can
talk about code complexity in a universally understood and consistent way.
It allows us to analyze and compare the efficiency of different coding
approaches, helping us decide what tradeoffs are worth making given the
context our code will be running in.
Some additional resources:
Ask a question: https://forum.kirupa.com
Errors/Known issues: https://bit.ly/algorithms_errata
Source repository: https://bit.ly/algorithms_source
3. Arrays
We start our deep dive into data structures by looking at arrays. Arrays, as
we will find out soon enough, are one of the most popular data structures
that many other data structures use as part of their functioning. In the
following sections, we look at what arrays are, why they are so popular,
situations they are good in (as well as ones they are bad in!), how to use
them, and more.
Onward!
What Is an Array?
Let’s imagine we are jotting down a list on a piece of paper. Let’s call the
piece of paper groceries. Now, in this paper, we write a numbered list
starting with zero with all the items that we need to pick from the store
(Figure 3-1).
Figure 3-1 The grocery list
This list of grocery items exists in the real world. If we had to represent it
digitally, the data structure that we would use to store all of our grocery
items would be an array! Here’s why: an array is a data structure that is
designed for storing a collection of data in a sequential order. If we
turned our grocery list into an array, what we would have would look like
Figure 3-2.
Each item in our grocery list is represented at as an item in our array. These
items are adjacent to each other, and they are numbered sequentially,
starting with zero. Let’s take our array and put it through some common
data operations to help us better understand how it works.
Adding an Item
With an array, one of the things we will frequently do is add items to it.
Where exactly in the array we add our items is important. The location
determines how much work is involved. Adding items to the end of our
array is a walk in the park (Figure 3-3).
Figure 3-3 Adding items to the array
We append a new item to the end. This new item gets the next index value
associated with it. Life is simple and good.
When we add an item at the middle or beginning of the array, we first have
to make room for the new content (Figure 3-4).
Figure 3-4 Making room for the new content
Because arrays are arranged sequentially, making room is a code word for
shifting a bunch of array items over and recalculating their index positions.
The more items we have to shift, the slower this operation becomes. The
worst case is when we insert an item at the beginning, for this means that
every item needs to be shifted with its index position updated. That’s a
whole lot of shifting!
Deleting an Item
When deleting an item from our array, the same challenges we saw with
adding items earlier apply. If we are removing an item at the end, the
disturbance is minimal (Figure 3-5).
For example, we removed the first item from our array. Every other item in
our array now has to shift and recount to account for this change. Phew!
Searching for an Item
Besides adding and deleting items, we will spend a lot of time searching for
items. The most common approach is a linear search in which we start at
the beginning of our array and go item by item until we find what we are
looking for (Figure 3-7).
Depending on the exact shape of our data, there may be some optimizations
we can make. For example, if we know our array’s data is ordered in some
way (alphabetically, numerically, etc.), we can employ a binary search to
make our search go much faster (Figure 3-8).
Figure 3-8 A binary search goes faster.
We cover binary searches, linear searches, and other search algorithms a bit
later.
Accessing an Item
We talked about the index position a few times so far, but it is time to go a
bit deeper. The index position acts as an identifier. If we want to access a
particular array item (via a search or otherwise!), we refer to it by its index
position in the form of array[index_position], as shown in Figure 3-9.
A few tricks to keep in mind are that the first item will always have an
index position of 0. The last item will always have an index position that is
one less than the total number of items in our array. If we try to provide an
invalid index position, we will get an error!
}
}
For a thorough deep dive into learning the ins and outs of everything arrays
do, check out my comprehensive arrays guide at
www.kirupa.com/javascript/learn_arrays.htm. If you aren’t yet proficient
with arrays, take a few moments and get really familiar with them. Many of
the subsequent data structures and algorithms we’ll be learning about use
arrays extensively under the covers.
Now, our memory is never going to be as clean as what we see here. Our
computer is juggling a bunch of other things that take up space (Figure 3-
11).
Figure 3-11 Memory at work
Any new items we add to our memory need to go into the available free
regions. This gets us back to arrays. When we create an array, we first need
to allocate some space in our memory where it can live. The thing about
arrays is that they need to store their items in adjacent (aka contiguous)
regions of memory. They can’t be spread across free and used regions.
When we initialize an array, we allocate a fixed amount of space in memory
and keep increasing this fixed amount as our array keeps growing. Let’s say
we create an empty array. Even though our array is empty right now, we
allocate extra regions of memory (Figure 3-12).
Figure 3-12 Memory allocation for the array
We keep adding data into our array until we fill up all of our allocated space
(Figure 3-14).
Figure 3-14 The array is filled with data.
Once it finds this region of memory, it is time to move our entire array to
this new location (Figure 3-16).
Figure 3-16 The array data is moved to the new memory location.
After our array is fully moved, which is definitely not a cheap operation
because every item needs to go to a new position, we can add more array
items, and the old memory location our array was in has free space into
which other things can now go (Figure 3-17).
Figure 3-17 New data can now go into the memory locations freed up
by the move.
More traditional programming languages were strict about making sure you
and I were thinking really hard about memory and how to ensure we don’t
go beyond it. Modern languages like JavaScript handle all of this for us, but
the performance implications of going beyond our allocated memory size
and needing to move our array to a larger region still do apply. We talk
about that next.
Performance Considerations
In the previous sections, we got a glimpse at the sorts of activities that
arrays are really fast at (and the activities they are slow at). Table 3-1
summarizes the performance and space considerations.
Let’s dive into a bit more detail on why our table has the values that it has
by looking at each major class of operation!
Access
Array access is highly efficient and has constant time complexity (O(1)).
This means that accessing an element at a specific index in an array takes
the same amount of time, regardless of the size of the array. Arrays achieve
this performance by storing elements in contiguous memory locations,
allowing direct access using the index.
Insertion
Inserting an element at the beginning of an array is inefficient, for it
requires shifting all the existing elements to make room for the new
element. This operation has a time complexity of O(n), where n is the
number of elements in the array.
Inserting an element at the end of an array is more efficient,
particularly when the array has sufficient adjacent memory
capacity. It can be done in constant time (O(1)).
Inserting an element at a specific index within an array also requires
shifting all the subsequent elements to make room. Thus, it has a
time complexity of O(n), where n is the number of elements in the
array.
As we saw earlier, there will be situations in which the array does not
have sufficient adjacent memory capacity to add a new item. In such
cases, the time will always go up to O(n) because our array will need
to move all of its contents to a newer, larger region of memory.
Deletion
Deleting an element from the beginning of an array involves shifting
all the subsequent elements to fill the gap, resulting in a time
complexity of O(n), where n is the number of elements in the array.
Deleting an element from the end of an array is efficient and can be
done in constant time (O(1)).
Deleting an element from a specific index within an array requires
shifting all the subsequent elements to fill the gap, resulting in a time
complexity of O(n), where n is the number of elements in the array.
Searching
There are two classes of search approaches we can take:
Linear search: Searching for an element in an unsorted array
requires iterating through each element until a match is found or the
end of the array is reached. In the worst case, this operation has a
time complexity of O(n), where n is the number of elements in the
array.
Binary search: Searching for an element in a sorted array can be
done using binary search, which repeatedly divides the search space
in half. This operation has a time complexity of O(log n), where n is
the number of elements in the array. However, binary search requires
a sorted array, so if the array is unsorted, an additional sorting
operation may be needed, resulting in a higher time complexity.
We cover both linear and binary searches in greater detail later when
covering algorithms, so keep this information under your hat until then.
Conclusion
Arrays are one of the more fundamental data structures we will use. Almost
all programming languages, no matter how low-level, provide built-in
support for arrays. There are several reasons for this. In programming, we
deal with collections of data all the time. It would be odd to have to re-
create the array for every project. The other reason has to do with how
arrays work. They closely map continuous regions of memory, and it would
be difficult for us (especially higher-level languages) to re-create an array
data structure from scratch and maintain the performance that a more native
implementation will provide. This is why, as we will see shortly, arrays are
actually a part of other data structures that we will be looking at.
Some additional resources:
Ask a question: https://forum.kirupa.com
Errors/Known issues: https://bit.ly/algorithms_errata
Source repository: https://bit.ly/algorithms_source
4. Linked List
Linked lists are pretty sweet. They provide an elegant solution for dealing
with large amounts of data that are constantly changing, and they have
some tricks up their sleeve for doing all of this dealing quickly and
efficiently. In this chapter, we explore the ins and outs of linked lists, such
as their basic structure, performance characteristics, code implementation,
and more! It’s going to be a hoot.
Onward!
Linked lists work by relying on individual nodes that are connected to each
other. Each node is responsible for two things:
Whatever data it is storing, such as a letter
A next pointer (aka reference) that points to the next node in the list
It goes without saying that the node is a big deal. We can zoom in on a node
and visualize it, as shown in Figure 4-2.
Finding a Value
We have a linked list with a bunch of data, and we want to find something.
This is one of the most common operations we’ll perform. We find a value
by starting with the first node (aka head node) and traversing through each
node as referenced by the next pointer (Figure 4-3).
What we need to do is update the next pointer for our tail or last E node to
the new node F we are adding (Figure 4-5).
It doesn’t matter where in our linked list we are adding our new node. The
behavior is mostly the same. Let’s say that we want to add a new node Q
between our existing nodes of C and D (Figure 4-6).
Figure 4-6 We want add a new node Q between C and D.
Deleting a Node
When we want to delete a node, the steps we take are similar-ish to what we
did when adding nodes. Let’s say that we want to delete node D in our
linked list (Figure 4-8).
We also clear the next pointer on the D node. All of this makes node D
unreachable via a traversal and removes any connection this node has with
the rest of the nodes in our linked list. Unreachable does not mean deleted,
though. When does node D actually get deleted? The exact moment varies,
but it happens automatically as part of something known as garbage
collection when our computer reclaims memory by getting rid of unwanted
things.
Space Complexity
From a memory/space point of view, linked lists require O(n) space. For
each piece of data we want our linked list to store, we wrap that data into a
node. The node itself is a very lightweight structure: all it contains is a thin
wrapper to store our data and a reference to the next node.
In a singly linked list, each node has exactly one pointer that references the
next node. For many situations, this one-way behavior is perfectly adequate.
Using double links allows for easier traversal in both directions, similar to
moving from a one-lane road to a two-lane one. We typically see a doubly
linked list being used in implementations of associative arrays and other
complex data structures.
This type of linked list can be used in situations where items need to be
accessed in a circular fashion, such as a scheduling algorithm, picking the
next player in a game of poker, and more. Speaking of poker, check out
Figure 4-13.
Figure 4-13 Poker is a circular activity.
Notice that each level of our skip list gives us faster access to certain
elements. Depending on what data we are looking for, we will be traversing
both horizontally as well as up and down each level to minimize the number
of nodes we need to examine.
Skip lists are often used in situations where we need to perform frequent
lookups or searches on a large dataset. By adding skip links to a linked list,
we can reduce the amount of time it takes to find a specific element while
still maintaining the benefits of a linked list (such as constant time insertion
and deletion).
Implementation
With our linked list, a handful of operations are going to be critical for us to
support:
Creating a new linked list
Adding item at the beginning
Adding item at the end
Adding an item before an existing item
Adding an item after an existing item
Checking if our linked list contains a specific item
Removing the first item
Removing the last item
Removing a specific item
Converting our items into an array
Getting the length of our linked list
Here is our implementation that supports all of these operations:
class LinkedListNode {
constructor(data, next = null) {
this.data = data;
this.next = next;
}
}
class LinkedList {
constructor() {
constructor() {
this.head = null;
this.tail = null;
this.size = 0;
}
addFirst(data) {
const newNode = new LinkedListNode(data, this.
this.head = newNode;
if (!this.tail) {
this.tail = newNode;
}
this.size++;
}
addLast(data) {
const newNode = new LinkedListNode(data);
if (!this.head) {
this.head = newNode;
this.tail = newNode;
} else {
this.tail.next = newNode;
this.tail = newNode;
}
this.size++;
}
addBefore(beforeData, data) {
const newNode = new LinkedListNode(data);
if (this.size === 0) {
this head = newNode;
this.head = newNode;
this.size++;
return;
}
while (current) {
if (current.data === beforeData) {
newNode.next = current;
prev.next = newNode;
this.size++;
return;
}
prev = current;
current = current.next;
}
addAfter(afterData, data) {
const newNode = new LinkedListNode(data);
if (this.size === 0) {
this.head = newNode;
this.size++;
return;
return;
}
while (current) {
if (current.data === afterData) {
newNode.next = current.next;
current.next = newNode;
this.size++;
return;
}
current = current.next;
}
contains(data) {
let current = this.head;
while (current) {
if (current.data === data) {
return true;
}
current = current.next;
}
return false;
}
removeFirst() {
if (!this.head) {
throw new Error('List is empty');
}
}
this.head = this.head.next;
if (!this.head) {
this.tail = null;
}
this.size--;
}
removeLast() {
if (!this.tail) {
throw new Error('List is empty');
}
while (current.next) {
prev = current;
current = current.next;
}
prev.next = null;
this.tail = prev;
this.size--;
}
remove(data) {
if (this.size === 0) {
throw new Error("List is empty");
throw new Error("List is empty");
}
while (current.next) {
if (current.next.data === data) {
current.next = current.next.next;
this.size--;
return;
}
current = current.next;
}
toArray() {
const arr = [];
while (current) {
arr.push(current.data);
current = current.next;
}
return arr;
}
get length() {
return this.size;
}
}
letters.addFirst("AA");
letters.addLast("Z");
letters.remove("C");
letters.removeFirst();
letters.removeLast();
letters.addAfter("D", "Q");
letters.addAfter("Q", "H");
letters.addBefore("A", "5");
console.log(letters.toArray()); // ['5', 'A', 'B',
console.log(letters.length); // 7
To see a live example of all the preceding code, visit this Codepen demo:
https://bit.ly/kirupa_linkedlist. In the future, if we need to use this
LinkedList in our code, we can either copy/paste all of this code or
reference it directly by adding the following script tag:
<script src="https://www.kirupa.com/js/linkedlist_
As we’ll see shortly, the linked list plays a crucial role in how several other
data structures and algorithms are implemented.
Conclusion
Phew! As we saw across the many words and diagrams, linked lists provide
an efficient way to store and manipulate data. They allow for constant time
insertion and deletion, and they can be easily traversed to perform
operations such as searching. While they aren’t the most efficient data
structure out there, they can safely claim the top spot in their simplicity. As
we will see in the next chapter, building a linked list in JavaScript is just as
elegant as our explanation of how they work.
Some additional resources:
Ask a question: https://forum.kirupa.com
Errors/Known issues: https://bit.ly/algorithms_errata
Source repository: https://bit.ly/algorithms_source
5. Stacks
Have you ever used Undo or Redo when working on something (Figure 5-
1)?
Have you ever wondered how your favorite programming languages tend to
turn the things you write into sequential steps so that your computer knows
what to do? Have you ever gone forward and backward in your browser?
Do you like pancakes?
If you answered yes to any of the above questions, then you have probably
run into the star of this tutorial, the stack data structure. In the following
sections, we learn more about stacks and how you can use one in
JavaScript.
Onward!
The thing about stacks of things is that we always add items to the top. We
remove items from the top as well (Figure 5-3).
Figure 5-3 Add to and remove from the top of the stack.
This concept applies to things in the computer world as well. The stack is a
well-known data structure we will frequently encounter where, just like our
pancakes, we keep adding data sequentially (Figure 5-4).
Figure 5-4 Data is added sequentially to a stack.
We remove the data from the end of our stack in the same order we added
them (Figure 5-5).
Figure 5-5 Data is removed from the end of the stack in the order it
was added.
A JavaScript Implementation
Now that we have an overview of what stacks are and how they work, let’s
go one level deeper. The following is an implementation of a Stack in
JavaScript:
class Stack {
constructor(...items) {
this.items = items;
}
clear() {
this.items.length = 0;
}
clone() {
return new Stack(...this.items);
}
contains(item) {
return this.items.includes(item);
}
peek() {
let itemsLength = this.items.length;
let item = this.items[itemsLength - 1];
return item;
}
pop() {
let removedItem = this.items.pop();
return removedItem;
}
push(item) {
this.items.push(item);
return item;
}
}
This code defines our Stack object and the various methods that we can
use to add items, remove items, peek at the last item, and more. To use it,
we can do something like the following:
// Add items
myStack.push("One");
myStack.push("Two");
myStack.push("Three!");
// Remove item
let lastItem = myStack.pop();
console.log(lastItem) // Three
myStack.peek(); // Two
The first thing we need to do is create a new Stack object. We can create
an empty stack, as shown, or prefill it with some items, as follows:
let stuffStack = new Stack("One", "Two", "Three");
To add items to the stack, use the push method and pass in whatever you
wish to add. To remove an item, use the pop method. If you want to
preview what the last item is without removing it, the peek method will
help you out. The clone method returns a copy of your stack, and the
contains method allows you to see if an item exists in the stack or not.
The stack data structure is used quite a bit in other data structures and
algorithms we’ll be seeing throughout the book. We can copy/paste the code
each time or reference this same implementation via
www.kirupa.com/js/stack_v1.js.
Memory Performance
The memory performance of a stack in JavaScript is fairly efficient with
O(n) growth, and this doesn’t change based on whether the stack is
implemented using arrays or linked lists. As we saw earlier, arrays in
JavaScript are dynamically sized, so they can grow or shrink as elements
are added or removed. However, this dynamic resizing might cause
occasional memory reallocation, which can lead to some hiccups.
When using a linked list to implement the stack, memory allocation is done
incrementally. There are no major reallocations similar to what we would
have seen with arrays, so a linked list approach has its advantages when
dealing with large stacks
Conclusion
If you glance at the code, our stack implementation is just a wrapper over
the Array object (for more about arrays in JavaScript, visit
https://bit.ly/kirupaArrays). Because items are added to the end and
removed from the end, using the array’s push and pop methods works
without any extra modification. The performance of adding and removing
items from the end of an array is really good—constant time, or O(1), if you
are keeping track.
Some additional resources:
Ask a question: https://forum.kirupa.com
Errors/Known issues: https://bit.ly/algorithms_errata
Source repository: https://bit.ly/algorithms_source
6. Queues
In Chapter 5, we saw that stacks are a last in, first out (LIFO) data structure
where items are added and removed from the end. Contrasting that, we have
the other popular data structure, the queue. This is an interesting one that
we’ll learn more about in the following sections.
Onward!
Figure 6-2 People leave the beginning of the queue, and they join at
the end.
Given that behavior, a queue follows a first in, first out policy, more
commonly shortened to FIFO. Except for the little big detail about which
items get removed first, queues and stacks are pretty similar otherwise.
When adding items, the behavior with stacks is identical (Figure 6-3).
Figure 6-3 Items get added to the top.
Items are added to the end of the queue. When removing items, they are
removed sequentially, starting with the first item that populated the data
structure in a queue-based world (Figure 6-4).
Now, you may be wondering when you’ll ever end up needing to use a
queue. Besides helping you appreciate standing in line, queues have a lot of
practical uses in the digital world. Pretty much any situation that requires
you to maintain an order of something relies on a queue-like data structure.
High-traffic situations like travel booking, waiting to purchase a ticket for a
popular concert, prioritizing e-mails by an e-mail server, and more are all
situations in which a queue is used. You’ll see queues used a lot by various
search algorithms as well, so queues are here to stay! Get friendly with
them.
A JavaScript Implementation
To turn all of those words and images into working code, take a look at the
following Queue implementation:
class Queue {
constructor() {
this.items = new LinkedList();
}
clear() {
this.items = new LinkedList();
}
contains(item) {
return this.items.contains(item);
}
peek() {
return this.items.head.data;
}
dequeue() {
let removedItem = this.items.head.data;
this.items.removeFirst();
return removedItem;
}
enqueue(item) {
this.items.addLast(item);
}
get length() {
return this.items.length;
}
}
// remove item
let removedItem = my.dequeue(); // returns Item 1
We can easily create a copy of our queue by using the clone method,
check whether an item exists using contains, and peek at what the
removed item might be (without actually removing it) by using … um …
peek! The implementation very closely mimics that of our stack, but the
important detail is that we are using a linked list and its implementation
along as well.
Runtime Performance
• Enqueue (Insertion): The enqueue operation in a linked list–based
queue has a constant time complexity of O(1) because elements are
added to the rear of the linked list, and there is no need to shift or
move existing elements.
• Dequeue (Deletion): The dequeue operation in a linked list–based
queue also has a constant time complexity of O(1). Elements are
removed from the front of the linked list, and like the enqueue
operation, no shifting of elements is required.
• Peek: The peek operation, which allows us to access the front
element without removing it, is also an O(1) operation. It directly
retrieves the value from the head of the linked list.
• Search: Searching for an element in a linked list–based queue is less
efficient than its insertion or deletion operations. The search
operation requires traversing the linked list from the front to the rear,
resulting in a linear time complexity of O(n), where n is the number
of elements in the queue.
Memory Performance
The overall memory usage of a queue is O(n) where each element in the
queue is represented by a node, which contains the data and a
pointer/reference to the next node. Therefore, the space required grows
linearly with the number of elements in the queue. With that said, there are
a few additional details to keep in mind:
• Dynamic Memory Allocation: Linked lists are dynamically
allocated, meaning that memory is allocated for each node as
elements are added to the queue. This allows the queue to
dynamically resize and efficiently handle varying numbers of
elements.
• Memory Overhead: Linked lists require additional memory for
maintaining the pointers between nodes. We know that already! This
overhead, compared to an array-based implementation, can be a
disadvantage when dealing with a large number of small elements.
• Cache Performance: Linked lists can suffer from cache
performance issues because the elements are not stored in contiguous
memory locations. This might lead to more cache misses, affecting
the overall performance for certain operations.
Not too shabby, right? A queue implemented using a linked list provides
efficient insertion and deletion operations with a constant time complexity
of O(1). Searching for an element in the queue is slower with a linear time
complexity of O(n). When it comes to memory, things are pretty consistent
with a linear O(n) growth based on the number of items our queue is
storing.
Conclusion
Between what we saw earlier with stacks and what we saw just now with
queues, we covered two of the most popular data structures that mimic how
we model how data enters and leaves. A queue is known as a FIFO data
structure where items get added to the end but removed from the beginning.
This “removed from the beginning” part is where our reliance on a linked
list data structure comes in. Arrays, as we have seen a few times, are not
very efficient when it comes to removing or adding items at the front.
Some additional resources:
Ask a question: https://forum.kirupa.com
Errors/Known issues: https://bit.ly/algorithms_errata
Source repository: https://bit.ly/algorithms_source
7. Trees
When we look around, a lot of the data we work with is hierarchical, with a
clear relationship between a parent and child. Common examples include
family trees, organizational charts, flow charts/diagrams, and more. Figure
7-1 is a famous example popularized by xkcd (https://xkcd.com/518/).
Trees 101
To retrace our steps a bit, a tree data structure is a way of organizing data in
a hierarchical manner. Just as in nature, trees in our computer world come
in many shapes and sizes. For our purposes, let’s visualize one that looks
like Figure 7-2.
Figure 7-2 Example of a tree
We see a bunch of circles and lines connecting each circle. Each circle in
the tree is known as a node. The node plays an important role in a tree. It is
responsible for storing data, and it is also responsible for linking to other
nodes. The link (visualized as a line) between each node is known as an
edge (Figure 7-3).
Figure 7-3 Edges connect nodes
Now, just saying that our tree has a bunch of nodes connected by edges isn’t
very enlightening. To help give the tree more clarity, we give the nodes
additional labels, such as children, parents, siblings, root, and leaves.
The easiest nodes to classify are the children. There are many of them, for a
child node is any node that is a direct extension of another node. Except for
the very first node at the very top, all of the nodes we see in Figure 7-4 fit
that description and are considered to be children.
Figure 7-4 Child nodes
When we have child nodes, we also have parent nodes. A parent node is
any node that has children (Figure 7-5).
Figure 7-5 Parent nodes
One thing to call out is that the meaning of parent or children is relative
depending on what part of the tree we are looking at. A node can be a child,
a parent, a grandparent, a grandchild, and more, all at the same time (Figure
7-6).
Figure 7-6 Nodes can be multiple family types.
We are almost done here. Earlier, we said that all nodes are children except
for the first node at the very top, which has no parent. This node is better
known to friends, family, and computer scientists as the root (Figure 7-8).
Figure 7-8 The all-powerful root node
While the root is a node that has no parent, on the other end are the nodes
that don’t have any children. These nodes are commonly known as leaves
(Figure 7-9).
Some things to note: The value for height is relative to each node,
depending entirely on far away the furthest leaf node is. The value for depth
is global to the tree, and it doesn’t matter what the shape of our tree is. The
root of the tree has a depth of 0, the next layer of children has a depth of 1,
and so on.
Conclusion
All right, my leaf-loving friends, we’ve finally come to the end of our little
deep dive through the zany world of the tree data structure. While thinking
through how our data will fit into this tree-like format may seem a little
daunting at first, we will go further in subsequent chapters to ensure we all
become tree hugging experts! So, the next time you’re feeling a little
stumped, just remember to tree-t yourself to a nice cup of coffee, put on
your thinking cap, and branch out … okay, I’ll leaf now.
Some additional resources:
Ask a question: https://forum.kirupa.com
Errors/Known issues: https://bit.ly/algorithms_errata
Source repository: https://bit.ly/algorithms_source
8. Binary Trees
Earlier, we looked at the tree data structure and learned a whole lot about
what all of the various nodes and edges mean. It’s time to branch out (ha!)
and go deeper. We are going to build upon that foundation by looking at a
specific implementation of a tree data structure, the binary tree.
Onward!
What makes binary trees different is that, unlike regular trees where
anything goes, we have three strict rules our tree must adhere to in order to
be classified as a binary tree:
1. Each node can have only zero, one, or to children.
2. The tree can have only a single root node.
3. There can be only one path to a node from the root.
Let’s dive a bit deeper into these rules, for they are important to understand.
They help explain why the binary tree works the way it does, and they set
us up for learning about other tree variants, such as the binary search tree.
Rules Explained
The first rule is that each node in a binary tree can have only zero, one, or
two children. If a node happens to have more than two children, that’s a
problem (Figure 8-2).
Figure 8-2 No more than two children allowed
The second rule is that a binary tree must have only a single root node
(Figure 8-3).
In this example, we have both the A node and the AA node competing for
who gets to be the primary root. While multiple root nodes are acceptable in
certain other tree-based data structures, they aren’t allowed for binary trees.
Now, we get to the last rule. The last rule is that there can be only one path
from the root to any node in the tree (Figure 8-4).
Figure 8-4 Can’t have multiple paths between the root and a node
As we can see in this example, using node D as our destination, we can get
there in two ways from our root. One way is by A - B - D. The other way is
by A - B - E - D. We can’t have that and call the data structure a binary tree.
In this example, we can see that the non-leaf nodes A, B, and E have two
children each.
For this last row, there are some rules on how the nodes should appear. If
the last row has any nodes, those nodes need to be filled continuously,
starting from the left with no gaps. What you see in Figure 8-7 wouldn’t
be acceptable, for example.
Figure 8-7 An incomplete binary tree
There is a gap where the D node is missing its right child, yet the I node is
parented under the E node. This means we weren’t continuously filling in
the last row of nodes from the left. If the I node were instead inserted as the
D node’s right child, then things would be good.
As a consequence of that requirement, all the leaf nodes are also at the same
level.
In other words, this means that the tree is not lopsided. All nodes can be
accessed efficiently.
The tree is essentially a linear data structure, like an array, with all nodes
connected in a single path. Any advantages a tree-like structure provides are
lost here; hence the degenerate classifier.
class Node {
constructor(data) {
this.data = data;
this.left = null;
this.right = null;
}
}
We have a Node class, and it takes a data value as its argument, which it
stores as a property called data on itself. Our node also stores two
additional properties for left and right.
Let’s re-create the following binary tree using what we have (Figure 8-11).
Figure 8-11 Example of the binary tree we are going to re-create
The full code for re-creating this binary tree using our Node class will look
as follows:
class Node {
constructor(data) {
this.data = data;
this.left = null;
this.right = null;
}
}
rootNodeA.left = nodeB;
rootNodeA.right = nodeC;
nodeB.left = nodeD;
nodeB.right = nodeE;
nodeE.left = nodeF;
nodeE.right = nodeG;
Notice that we are creating a new Node object for each node in our tree,
and the argument we pass in to the constructor is the letter value of each
node:
rootNodeA.left = nodeB;
rootNodeA.right = nodeC;
nodeB.left = nodeD;
nodeB.right = nodeE;
nodeE.left = nodeF;
nodeE.right = nodeG;
Conclusion
In this chapter, through lots of words and countless diagrams, we learned
about binary trees! The 411 is that a binary tree is a data structure that
consists of nodes with an important constraint: each node can have at
most two child nodes. The unique constraint of the binary tree allows us to
use them to efficiently search, sort, and store data. Now, we didn’t cover
any of that here. The reason is that a binary tree by itself is too generic. The
more useful variant of the binary tree is the binary search tree, and we
look at it in Chapter 9.
Some additional resources:
Ask a question: https://forum.kirupa.com
Errors/Known issues: https://bit.ly/algorithms_errata
Source repository: https://bit.ly/algorithms_source
9. Binary Search Trees
It’s time for us to look at another awesome data structure, the binary
search tree. If we squint at a binary search tree from a distance, it will look
a whole lot like a binary tree (Figure 9-1).
Figure 9-1 This could be anything!
That’s by design. Binary trees set a solid foundation with their node/edge
rules and branching behavior that is desirable. Binary search trees improve
upon plain binary trees by adding some extra logic on how they store data,
and it is this extra logic that helps make them quite efficient when dealing
with the sorts of data operations we may throw at it.
At a very high level, a binary search tree is designed in such a way that the
location of each node is determined on the basis of the size of the value it is
storing. Nodes with smaller values go left, and nodes with larger values go
right. Take a look at the binary search tree in Figure 9-2.
Figure 9-2 Example of a binary search tree
At each node, starting with the root, pay close attention to the node values
and the values of their children, if applicable. At each layer:
The child node to the left is less than the parent node’s value.
The child node to the right is greater than the parent node’s value.
These two additional rules build on the three rules we saw for plain binary
trees to give us our blueprint for how to think about binary search trees.
What we are going to do next is dive deeper into how binary search trees
work by looking at how to perform common add and remove operations.
Onward!
Yes, that’s right! We are going to start with an empty binary search tree and
build our knowledge of how to work with them from there.
Adding Nodes
We are going to have our binary search tree store some numbers. The first
number we want to store is 42, and Figure 9-4 is what our binary search tree
will look like after we have added it.
Figure 9-4 The beginning of our binary tree
It doesn’t look like much of a tree, and that is because our binary search tree
was empty. What we have is just a single node (which also happens to be
the root!) with a value of 42.
Next, let’s add the number 24 (Figure 9-5). Every new node we add from
here on out has to be a child of another node. In our case, we have only our
root node of 42, so our 24 node will be a child of it. The question is, will it
go left, or will it go right?
The answer to this question is core to how binary search trees work. To
restate what we mentioned earlier:
If the value we are adding is less than the parent node, the value goes
left.
If the value we are adding is greater than the parent node, the value
goes right.
We start at the root node and start looking around. In our tree, we have only
one node, the root node of 42. The number we are trying to add is 24.
Because 24 is less than 42, we add our node as a left child (Figure 9-6).
Figure 9-6 The smaller node relative to the parent goes left.
Let’s add another number. This time, the number we want to add is 99. We
follow the same steps as earlier. We start at the root, 42. The value we are
adding is 99, and it is greater than the root node. It goes right (Figure 9-7).
Figure 9-7 The 99 node goes to the right.
We are not done with adding more numbers to our tree. Now that we have a
few extra nodes beyond our root node, things get a tad bit more interesting.
The next number we want to add is 15. We start at the root. The root value
is 42, so we look left because 15 is less than 42. Left of 42 is the 24 node.
We now check whether 15 is less than 24. It is, so we look left again. There
are no more nodes to the left of 24, so we can safely park 15 there (Figure
9-8).
You should see a pattern starting to emerge. When adding a new node, we
ask, Is the value greater than or less than the current node? at each node
we encounter, starting at the root. If we encounter a leaf node, this node
now becomes our parent. Whether we are a child at the left position or right
position is, again, based on whether the value of the node we are adding is
less than or greater than our new parent.
We will go a bit faster now. The next value we want to add is 50. We start
with our root node of 42. Our 50 value is greater than 42, so we look right.
On the right, we have our 99 node. 99 is greater than 50, so we look left.
There is no node to the left of our 99 node, so we plop our 50 value there
(Figure 9-9).
Figure 9-9 This node goes to the right branch from the root.
The next value we want to add is 120. Using the same steps we’ve seen a
bunch of times, this value will find itself to the right of the 99 node (Figure
9-10).
Figure 9-10 Where 120 ends up
The last number we are going to add is 64. Take a moment to see where it
will land. If everything goes as planned, it will find itself as a right child of
the 50 node (Figure 9-11).
Figure 9-11 Our tree is getting popular!
Walking through our steps, we know that 64 is greater than our root node of
42. It is less than our 99 node, so we look left where we have the 50 node.
The value 64 is greater than 50, so right of it … it goes!
By now, we have looked at a bunch of examples of how to add nodes to our
binary search tree. The biggest thing to note is that every node we add ends
up as a leaf somewhere in our tree. Where exactly it ends up is determined
solely by its value and the value of the various nodes starting at the root that
it needs to navigate through.
Removing Nodes
There will be times when we’ll be adding nodes. Then there will be times
when we will be removing nodes as well. Removing nodes from a binary
search tree is slightly more involved than adding nodes, for the behavior
varies depending on which node we are removing. We walk through those
cases next.
There is nothing else for us to do. Because it is a leaf node, nothing else in
our tree is impacted. That isn’t the case with what we are going to see next.
When we remove a node with a single child, that child takes the place of the
removed node. In our example, when we remove the 24 node, the 15 node
takes its place (Figure 9-15).
Figure 9-15 The child node takes the place of the parent node.
Because of how we add nodes to our binary search tree in the first place,
promoting a child node to its parent position will not break the overall
integrity of our tree, where values to the left of each node are smaller than
values to the right of each node.
There is another point to clarify. When we are talking about the behavior of
deleting a node with a single child, we mean a single immediate child. Our
immediate child can have more children of its own. Take a look at Figure 9-
16.
Figure 9-16 What will happen when we remove the 24 node?
We want to remove our 24 node, and it has the 15 node as its child. The 15
node has two children of its own, but this detail doesn’t change the behavior
we are describing. As long as the parent node we are removing has only a
single immediate child, that single immediate child will take the parent’s
place and bring along any children it may have as well (Figure 9-17).
Figure 9-17 Phew! The 24 node has been removed.
If we walk through all the nodes in the tree after this shift, we’ll again see
that the integrity of the tree is still maintained. No node is out of place.
When removing a node with two children, we can’t just pick one of the
children nodes and call it a successful operation. If we do that, we may find
that our tree is no longer valid. Some of the nodes may find themselves in
the wrong places.
What we do in this case is look in the right subtree for the node with the
next highest value, also known as the inorder successor. For our situation
where we are removing our node with a value of 99, the right subtree is as
shown in (Figure 9-19).
Figure 9-19 The right subtree
Which node in our subtree has the next highest value from 99? To describe
the same thing differently, when we look at all the children to the right of
our 99 node, which node has the smallest value? The answer to both of
these questions is the node whose value is 104. What we do next is remove
our 99 node and replace it with our 104 node (Figure 9-20).
Figure 9-20 This removal required a swapping of node values.
When we look at our binary search tree after this removal and swap, the
integrity of all of the nodes is maintained. This isn’t an accident, of course.
The inorder successor node will always have a value that ensures it can be
safely plopped into the place of the node we are removing. That was the
case with our 104 node that took over for our 99 node. That will be the case
for other nodes we wish to remove as well.
class Node {
constructor(data) {
this.data = data;
this.left = null;
this.right = null;
}
}
class BinarySearchTree {
constructor() {
this.root = null;
}
insert(value) {
// Create a new node with the given value
g
const newNode = new Node(value);
while (true) {
if (value === currentNode.data) {
// If the value already exists in the tree
return undefined;
remove(value) {
// Start at the root of the tree
let currentNode = this.root;
let parentNode = null;
currentNode.data = successor.data;
return true;
}
} else if (value < currentNode.data) {
// If the value we're looking for is less
// the current node's value, go left
parentNode = currentNode;
currentNode = currentNode.left;
} else {
// If the value we're looking for is great
// the current node's value, go right
parentNode = currentNode;
currentNode = currentNode.right;
}
}
Take a brief glance through the preceding lines of code. The comments call
out important landmarks, especially as they relate to the binary search tree
behavior we have been looking at. To see this code in action, here is an
example:
myBST.insert(10);
myBST.insert(5);
myBST.insert(15);
myBST.insert(3);
myBST.insert(7);
myBST.insert(13);
myBST.insert(18);
myBST.insert(20);
myBST.insert(12);
myBST.insert(14);
myBST.insert(19);
myBST.insert(30);
We are creating a new binary search tree and adding some nodes to it. This
tree will look like Figure 9-21.
Figure 9-21 Our new binary tree
myBST.remove(15);
The 15 node is gone, but the 18 node takes its place as the rightful inorder
successor. Feel free to play with more node additions and removals to see
how things will look. To easily see how all of the nodes are related to each
other, the easiest way is to inspect your binary search tree in the Console
and expand each left and right node until you have a good idea of how
things shape up (Figure 9-23).
Figure 9-23 Output of running our code
If you want to go above and beyond, you can create a method that will print
an ASCII-art representation of a tree in our console, so do let me know if
you have already done something like that.
This is because we can avoid taking very uniquely long paths to find any
single node. The worst-case scenario is when our tree is heavily unbalanced
(Figure 9-25).
Figure 9-25 Our final binary search tree
In this tree, if our node happens to be deep in the right subtree, we’ll be
exploring a lot of nodes relative to the total number of nodes in a tree. This
gets us closer to a running time of O(n), which is the worst-case scenario.
As for the amount of memory a binary search tree takes up, that doesn’t
depend on how balanced or unbalanced our tree is. It is always O(n) where
each node takes up a fixed amount of memory.
Conclusion
Binary search trees are pretty sweet. They are a type of binary tree with
some added constraints to make them more suited for heavy-duty data
wrangling. The constraints are to ensure the left child is always smaller than
the parent and the right child is always greater. There are a few more rules
around how nodes should arrange and rearrange themselves when they get
added or removed.
This type of structure allows us to efficiently perform search, insert, and
delete operations in O(log n) time complexity, making binary search trees a
popular data structure. However, as we saw a few moments ago, the
performance of a binary search tree can be impacted by its balancedness.
For heavily unbalanced trees, this can lead to worst-case scenarios with the
time complexity of O(n).
Some additional resources:
Ask a question: https://forum.kirupa.com
Errors/Known issues: https://bit.ly/algorithms_errata
Source repository: https://bit.ly/algorithms_source
10. Heaps
If you are anything like me, you probably have a bunch of ideas and too
little time to act on them. To help bring some order, we may rely on a tool
that is designed to help us prioritize tasks (Figure 10-1).
There are a billion tools out there for managing our projects, and they all do
a variation of the following things:
Allow us to catalog all of the items that we want to work
Give us the ability to prioritize things
Provide a way to help us easily see what the highest priority items are
Allow us to add and remove items while maintaining our prioritized
order
Building our own tool that does all of this sounds like a fun activity, but we
are going to stay focused on the data structures side of the house. There is a
very efficient data structure that we can use to represent all of the things we
want to do, and that data structure is the heap. We learn all about it in the
following sections.
Onward!
Inserting a Node
Let’s start with inserting a node, which is also the place to start when we
have a blank slate and want to build our heap from scratch. The first item
we want to add is the item with the value 13 (Figure 10-6).
This is our first item, and it becomes our root node by default. This is the
easy case. For all subsequent items we wish to add, we need to follow these
rules:
1. We add the new node to the bottom level of the heap, on the leftmost
available spot, with no gaps. This ensures that the tree remains
complete.
2. We compare the value of the new node with the value of its parent
node. If the value of the new node is greater than the value of its
parent node, we swap the new node with its parent node. We repeat
this process until either the new node’s value is not greater than its
parent’s value or we have reached the root node.
3. After swapping, we repeat step 2 with the new parent and its parent
until the heap property is restored.
The important detail to note is that all we are checking between the parent
and child is that the parent has a larger value than the child. This is a much
less constrained approach than what we have with binary search trees where
there are a few more constraints. All of this will make more sense as we
walk through some more insertions.
We now want to insert node 10. We add it to the bottom level of our heap
on the first leftmost available spot (Figure 10-7).
Figure 10-7 Adding the 10 node
Our 10 value is less than the parent value of 13, so this is a valid heap
structure that maintains our heap property. The next number we want to add
is 5. We add it to the leftmost available spot at the bottom of our heap
(Figure 10-8).
Our newly inserted 5 value is less than the parent 13 value, so our heap
property is still maintained. The next number we want to add is 24. We
insert it at the leftmost spot in our bottom row (Figure 10-9).
Figure 10-9 Our new node is added at the leftmost part of our tree.
Now, is 24 less than the parent value of 10? No. So, we swap the parent and
child to ensure the child is always less than the value of the parent (Figure
10-10).
Figure 10-10 Swapping node values
We repeat our parent–child check with our newly swapped 24 node and its
new parent. Is 24 less than 13? The answer again is no, so we swap the
nodes one more time (Figure 10-11).
Figure 10-11 Swapping node values again
At this point, our 24 node reaches the root. It doesn’t have a parent to
compare itself against, so our heap is now in a good state again. There is a
name for what we just did. It is called bubbling up, where we insert our
node at the bottom and keep checking (and swapping, if needed!) against
the parent to ensure the heap property is maintained.
We’ll go a bit faster now. The next node we want to add is 1. We add it to
the leftmost location on our lowest level (Figure 10-12).
Figure 10-12 Repeating the earlier steps for adding the 1 node
This is valid, and no further adjustments need to be made. The next value
we want to add is 15. We insert this as the left child of the 5 node (Figure
10-13).
Figure 10-13 Let’s see what happens when we add the 15 node.
The parent (5) is lower than our newly added child (15), so we swap the 15
and 5 (Figure 10-14).
Figure 10-14 Swapping node values
Our newly swapped 15 node is correctly less than our parent node, whose
value is 24, so we are back to maintaining our heap property.
The next number we add is 36. Our 36 starts off as the right child of our 15
node. That location is only temporary! To maintain the heap property, our
36 node will swap with the 15 node and then swap with the 24 node as well
(Figure 10-15).
Figure 10-15 Maintaining the heap property through further swaps
Our node containing the newly added 36 is now the largest number in our
heap and is located at the root. Good job, 36! Let us add one last item—the
number 3 (Figure 10-16).
Figure 10-16 Adding the number 3 to our heap
We add it at the leftmost level on the bottom level, and our node containing
the 3 value is a child of the 10 node. This maintains the heap property, so
we don’t need to do anything additional. Our heap is in a good spot, and we
have just seen what inserting nodes into a heap looks like and the role
bubbling up plays in ensuring our nodes are properly positioned.
When removing the root node from a heap, we still want to ensure that the
heap property is maintained. This means that the new root node should be
the largest value in the heap, and the binary tree should be restructured so
that it remains complete.
Here are the steps to remove the root node from our heap:
1. We remove the root node from the heap and replace it with the last
node in the heap.
2. We compare the value of the new root node with the values of its
children. If the value of the new root node is less than the value of
either of its children, we swap the new root node with the larger of its
children. We repeat this process until either the new root node’s
value is greater than or equal to the values of its children or it has no
children. This process is called bubbling down.
3. After swapping, we repeat step 2 with the new child node and its
children until the heap property is restored.
Let’s put these steps into action by walking through what happens when we
remove our root node 36. The first thing we do is remove our 36 root node
and swap it with the last node in our heap, which will always be the
rightmost node at the lowest level of our heap (Figure 10-18).
Figure 10-18 Rebalancing our tree after removing the root node
When we remove our 36 node and swap it with our 3 node, our heap will
look as shown in Figure 10-19.
Figure 10-19 The root has been replaced with our last node.
We aren’t done yet. We now repeat our parent–child check at the new
location our 3 node is in. In this case, our 3 node is less than both its child
values of 5 and 15. So, we swap our 3 node with the larger of its children,
the 15 node (Figure 10-21).
Figure 10-21 Doing one more swap
At this point, our 3 node is a leaf with no children to compare its value
against. This means it is now at its intended location, and our heap property
is now restored.
Let’s go through the removal steps just one more time to make sure we have
all of our i’s dotted and t’s crossed. Our new root node has a value of 24,
and we want to remove it (Figure 10-22).
Figure 10-22 Let’s remove node 24
The first thing we do is remove it and replace it with our last node, which is
our 3 node again (Figure 10-23).
Figure 10-23 The last node takes the place of the removed root node.
After we do this, we compare our 3 node with the values of its children. It is
less than both of them, so we swap it with the largest of its children, the 15
node (Figure 10-24).
Figure 10-24 Time to maintain the heap property
After this swap, we are not done yet. We now check whether our 3 node
happens to be less than any of its children. Its only child is the 5 node, and 3
is not less than 5. We do one more swap (Figure 10-25).
Figure 10-25 All is well with our heap.
At this point, our 3 node is back where it belongs, our root contains our
heap’s largest value, and all child nodes are safely located in their own
rooms. The world is right again.
Heap Implementation
Now that we have a good idea of how a heap works when we are adding
items or removing the root node, it’s time to look at how exactly we will
build it.
Heaps as Arrays
One cool and interesting detail is how our heap is represented under the
covers. Yes, we have been talking about it as if it is a binary tree. But we
are not going to be representing it as a binary tree in our implementation.
We are going to be representing as an array in which each item represents a
node in our heap.
Let’s look at a visual first (Figure 10-26), then talk about how exactly this
mapping works.
Figure 10-26 The relationship between a heap and an array
The Code
The following JavaScript takes all of the words and diagrams we have seen
so far and turns them into working code:
class Heap {
constructor() {
// The heap is stored as an array
this.heap = [];
}
The way we would use this code and many of the preceding operations is as
follows:
console.log(myHeap.getMax()); // 50
console.log(myHeap.extractMax()); // 50
console.log(myHeap.extractMax()); // 18
console.log(myHeap.extractMax()); // 15
console.log(myHeap.extractMax()); // 14
We are re-creating the example heap we saw earlier and putting many of the
operations we called out into action.
Performance Characteristics
In a heap, we called out earlier that removing the root node and inserting
items into our heap are the two fundamental operations we care about. Let’s
look into how these fare from a performance point of view.
The second step of re-heapifying the heap takes logarithmic time because
we must compare the new root node with its children and swap it with the
larger of the two until the heap property is restored. Because the height of a
complete binary tree is O(log n), where n is the number of nodes in the tree,
the worst-case time complexity of removing the root node from a heap is
O(log n).
Inserting an Item
There are two concepts relevant to inserting an item into a heap:
Time complexity: O(log n), where n is the number of elements in the
heap
Space complexity: O(1)
Inserting an item into a heap involves two main steps: inserting the new
item at the end of the heap and then re-heapifying (via the #bubbleUp
method in our code) the heap by sifting the new item up the heap until the
heap property is restored.
The first step of inserting the new item at the end of the heap takes constant
time because we are simply appending a new element to the end of the
array, like the 15 we are adding to the heap (Figure 10-28).
Figure 10-28 Adding an item
Because we are using an array to implement our heap, adding items to the
end is pretty fast as well. That’s something our arrays are really, REALLY
efficient at.
The second step of re-heapifying the heap takes logarithmic time because
we must compare the new item with its parent and swap it with the parent if
it is larger. We keep repeating this until the heap property is restored. Just
like with our root removal case earlier, because the height of a complete
binary tree is O(log n), where n is the number of nodes in the tree, the
worst-case time complexity of inserting an item into a heap is also O(log n).
Performance Summary
Putting all of this together, removing the root node and inserting items into
a heap both have a worst-case time complexity of O(log n), where n is the
number of elements in the heap. The space complexity of these operations
is O(1) because we only need to store temporary variables during the re-
heapification process.
Conclusion
To tie it all up, heaps are an incredibly useful data structure that greatly
simplify a wide range of algorithms and problems. By organizing elements
in a binary tree structure that satisfies the heap property, heaps enable two
things:
Efficient retrieval of the maximum element in constant time
Fast insertion and removal of elements in logarithmic time
Because of their efficiency, heaps are used in a variety of applications, such
as heapsort, priority queues, and Dijkstra’s algorithm for finding the
shortest path in a graph. Yes, they can also make our goal of building a task
organizer really snappy (Figure 10-29).
Figure 10-29 Another example of a task organizer
We also have a bunch of boxes to store this food into (Figure 11-2).
Figure 11-2 Boxes
Our goal is to take some of our food and store it in these boxes for
safekeeping. To help us here, we are going to rely on a trusted robot helper
(Figure 11-3).
Figure 11-3 A helpful (and trusted) robot
As our first action, we decide to store our watermelon. Our robot comes up
to the watermelon and analyzes it (Figure 11-4).
Figure 11-4 Our robot analyzing our watermelon
This analysis tells our robot which box to put our watermelon into. The
exact logic our robot uses isn’t important for us to focus on right now. The
important part is that, at the end of this analysis, our robot has a clear idea
of where to store our watermelon (Figure 11-5).
Figure 11-5 Storing an item
Next, we want to store the hamburger. The robot repeats the same steps. It
analyzes it, determines which box to store it in, and then stores it in the
appropriate box (Figure 11-6).
Figure 11-6 Storing another item
We repeat this process a few more times for different types of food that we
want to store, and our robot analyzes and stores the food in the appropriate
box (Figure 11-7).
Figure 11-7 All of our items are now stored.
Now, we are going to shift gears a bit. We want to retrieve a food item that
we had stored earlier. We are in the mood for some fish, so we tell our robot
to retrieve our fish. We have an exact replica of it (possibly a picture!), and
the first thing our robot does is analyze the replica. This analysis helps our
robot to determine which box our actual edible fish is stored in (Figure 11-
8).
Figure 11-8 Time to retrieve an item
Once it has figured out where our fish is, it goes directly to the right box
and brings it back to us (Figure 11-9).
Figure 11-9 Our robot knows exactly where an item is.
The important thing to note, just as we saw earlier when our robot was
storing items, is that our robot goes directly to the correct box. It doesn’t
scan other boxes looking for our fish. It doesn’t guess. Based on its analysis
of the fish replica, it knows where to go and it goes there without any dilly-
dallying.
What exactly does our robot do? It analyzes the item we want to store and
maps it to a location to store it in. Let’s adjust our visualization a little bit
(Figure 11-11).
Figure 11-11 A hashing function
Every single time our hashing function encounters this exact pancake, it
will always return position #5. It will do so very quickly. We can generalize
this relationship between the input, the hashing function, and the output as
shown in Figure 11-13.
Figure 11-13 What our hashing function returns
What this means is that our hashtables can pull off constant-time, aka O(1),
lookup and insertion operations. This speedy ability makes them perfect for
the many data-caching and indexing-related activities we perform
frequently. We are going to see how they work by looking at some common
operations.
The input is both our keys and values. The key is sent to our hashing
function to determine the storage location. Once the storage location is
determined, the value is placed there.
Reading Items from Our Hashtable
Continuing our example from earlier, let’s say we want to read a value from
our hashtable. We want to get Mega Man’s phone number. What we do is
provide our hashtable with our key Mega Man. Our hashing function will
quickly compute the storage location the phone number is living at and
return the correct value to us (Figure 11-16).
Figure 11-16 Retrieving a phone number efficiently
If we provide a key that doesn’t exist (for example, Batman), our hashtable
will return a message such as undefined because the hashing function will
point to a storage location that doesn’t exist.
JavaScript Implementation/Usage
Almost all modern programming languages provide a hashtable
implementation, so we won’t attempt to re-create one here. In JavaScript,
we have our Map object that supports common operations like adding,
retrieving, and removing items. We can use the Map as follows:
// set values
characterInfo.set("Link", "(555) 123-4567");
characterInfo.set("Zelda", "(555) 987-6543");
characterInfo.set("Mario", "(555) 555-1212");
characterInfo.set("Mega Man", "(555) 867-5309");
characterInfo.set("Ryu", "(555) 246-8135");
characterInfo.set("Corvo", "(555) 369-1472");
// get values
console.log(characterInfo.get("Ryu")); // (555) 24
console.log(characterInfo.get("Batman")); // undef
// get size
console.log(characterInfo.size()); // 6
// delete item
console.log(characterInfo.delete("Corvo")); // tru
console.log(characterInfo.size()); // 5
characterInfo.clear();
console.log(characterInfo.size()); // 0
Behind the scenes, a hashing function is used to ensure our values can be
quickly accessed when provided with their associated key. We can assume
that this hashing function is a good-quality one. If you are curious to see
what a basic hashing function might look like, take a look at the following:
For any character-based input we throw at it, this hashing function will
return a number that fits safely within our 100-item myArray array. Here
is an interesting problem. What if we want to store 101 items? Or what if
we want to store 1000 items? Let’s imagine that, for these cases, we are in a
language other than JavaScript where going beyond the fixed size of the
array will throw an error.
What if the following happens?
Notice that the returned hash value for Yur is the same 20 as it is for Ryu.
This doesn’t seem desirable, so let’s discuss it next!
Dealing with Collisions
In a perfect world, our hashing function will return a unique storage
location for every unique key (and value) we ask it to store. This perfect
world requires two things:
1. Our hashing function is designed in such a way that it is capable of
generating a unique key for each unique input.
2. We have enough storage available that each value has its own
location to place itself in.
In our actual world, neither of these things is true. While our hashing
functions are good at generating unique keys, they aren’t perfect. There will
be moments when, for certain types of input, our hashing functions return
the same hash code that points to the same storage location. When we are
storing a lot of items, we may run out of actual storage locations to put our
unique items into.
Both of these situations result in what is known as a collision, and it results
in our storage locations holding multiple values, as highlighted in the
example in Figure 11-17).
Figure 11-17 Example of collisions
What happens when a storage location is now storing multiple values? For
the most part, nothing too eventful to our hashtable functionality. Our
hashtable implementations will handle this situation gracefully.
These are some good results! All of the warnings and worst-case scenarios
aside, there isn’t a more efficient data structure for allowing us to quickly
store and retrieve values, and we’ll see this play out when we see our
hashtable used by other data structures and algorithms.
Note
Is a Hashtable a Dictionary?
We mentioned a few times that a hashtable is sometimes referred to
as a hashmap or a dictionary. Different programming languages use
these names or similar ones to provide hashtable capabilities. The
controversial one here is the dictionary. If we are strict in our
interpretation, a dictionary is not a hashtable. It is a data structure
that allows us to store key and value pairs, but it often doesn’t
implement a hashing function.
Complicating this story a bit, some dictionary implementations can
use a hashtable under the covers. An example of this is the dict
object we have in Python. It is a dictionary, but it uses a hashtable
under the covers for all of the efficiency perks we learned about.
To answer our question directly, is a hashtable also a dictionary?
It depends!
Conclusion
The beauty of hashtables lies in their ability to provide constant-time
performance for key-based operations like insertion, retrieval, and deletion.
By using a hash function to compute the index of each element, hashtables
eliminate the need for linear searches and enable direct access to data,
making operations incredibly fast. All of this is still true even for large
amounts of information.
This performance superpower makes hashtables particularly useful in
scenarios where quick access to data is critical, such as implementing
caches, symbol tables, or dictionaries. Moreover, hashtables have built-in
mechanisms to handle collisions where two key inputs produce the same
hash code. Hashtables are like the unicorns of the data structure world!
Some additional resources:
Ask a question: https://forum.kirupa.com
Errors/Known issues: https://bit.ly/algorithms_errata
Source repository: https://bit.ly/algorithms_source
12. Trie (aka Prefix Tree)
We are on some web page, we encounter an input field, and we start typing.
As we type, we start seeing partial results based on the few characters we
have already typed (Figure 12-1).
Figure 12-1 An example of autocomplete
As we keep typing, the partial results keep getting refined until it nearly
predicts the word or phrase we were trying to type fully. This
autocompletion-like interaction is one we take for granted these days.
Almost all of our UIs have some form of it. Why is this interesting for us at
this very moment?
Behind the scenes, there is a very good chance that the data structure
powering this autocomplete capability is the star of this chapter, the trie
(sometimes also called a prefix tree). In the following sections, we learn
more about it.
Onward!
What Is a Trie?
Let’s get the boring textbook definition out of the way:
This calls for an example and visual walkthrough to examine the most
common operations we’ll be performing on a trie.
Inserting Words
What we want to do is store the word apple inside a trie. The first thing we
do is break our word into individual characters: a, p, p, l, and e. Next, it’s
time to start building our trie tree structure.
The start of our trie is an empty root node (Figure 12-3).
Our next step is to take the first letter (a) from the word (apple) we are
trying to store and add it to our trie as a child of our root node (Figure 12-
4).
We repeat this step for the next letter (p) and add it as a child of our a node
(Figure 12-5).
Figure 12-5 The letters of the word continue being added as children.
We keep taking each letter of our word and adding it as a child of the
previous letter. For apple, the final trie structure would look like Figure 12-
6.
Figure 12-6 The word apple stored in a trie
There is one additional thing that we do once our entire word is represented
in the tree. We tag the last letter of our input to indicate that it is complete
(Figure 12-7).
Figure 12-7 Designating the end of our word
We’ll see later why marking the end is important. For now, let’s go ahead
and add a few more words to our trie. We are going to add cat, dog, duck,
and monkey. When we add cat and dog, our trie will look like Figure 12-8.
Figure 12-8 More words stored in our trie
The next word we are going to add is duck. Notice that the first letter of our
word is d, and we already have a d node at the top as a child of our root.
What we do is start from our existing d node instead of creating a new d
node. The next letter is u, but we don’t have an existing child of d with the
value of u. So, we create a new child node whose value is u and continue on
with the remaining letters in our word.
The part to emphasize here is that our letter d is now a common prefix for
our dog and duck words (Figure 12-9).
Figure 12-9 Our letter d has two words starting from it.
The next word we want to add is monkey, and this will be represented as
follows once we add it to our tree (Figure 12-10).
Figure 12-10 Our trie is getting pretty large!
Because the starting letter m is not already a child of our root, we create a
new node for m. Every subsequent letter in monkey follows from it. We are
almost done here, so let’s go a little faster as well.
The next word we want to represent is dune. We know the letters d and u
are already in our trie, so what we do is add the letters n and e that build off
the common prefix, du (Figure 12-11).
Figure 12-11 We build off of common prefixes.
The next two words we want to add are app and monk. Both of these words
are contained within the larger words of apple and monkey respectively, so
what we need to do is just designate the last letters in app and monk as
being the end of a word (Figure 12-12).
Figure 12-12 A word within a word
Ok. At this point, our trie contains apple, app, cat, dog, duck, dune, monkey,
and monk. We have enough items in our trie now. Let’s look at some
additional operations.
Finding Items
Once we have our trie filled in with our data, one of the most common
operations we’ll perform is searching our trie to see if a value or values
exist in it. Continuing with our trie from earlier, let’s say we want to see if
the word eagle exists. What we do is break our input word into its
individual characters: e, a, g, l, e.
We check whether the first letter exists as a child of our root node (Figure
12-13).
Figure 12-13 The start of a search
The starting letters we have are a, c, d, and m. The letter e isn’t present, so
we can stop the search right here. If the first letter isn’t available, we can
safely state that all subsequent letters won’t be present either.
Our next task is to see if the word monk exists in our trie. The process is the
same. We check whether the first letter of the word we are looking for (m)
exists as the first letter in our trie. The answer is yes (Figure 12-14).
We then continue down the path of the m node and check whether the
second letter (o) is an immediate child (Figure 12-15).
Figure 12-15 We check letter by letter.
The remaining letters in monk match what we have in our trie. Depending
on the operation we are doing, there may be one more step:
1. If we are checking whether the complete word exists in our trie,
then we check to make sure the last letter is designated as the end of
a word. In our case, monk was added to our trie as a final word, and
the letter k has been marked as the end of it. We are good on this
front.
2. If we are checking whether the prefix exists in our trie, then we
don’t have to check whether the last character is also marked as the
end of the word. Our word monk would still pass the test, but so
would other prefixes leading up to here, such as m, mo, and mon.
This distinction between a complete word and prefix when we are searching
our trie becomes important in various situations. The complete word search
is important if we want to check whether monk was added to our trie at
some point as a full word. If we wanted to find all words that start with
monk, then the prefix search is the approach we use. We’ll see some
examples of both of these approaches when diving into our implementation
later.
Deleting Items
The last step we look at is how to delete an item from our trie. Because we
went into detail on how to add and find items in our trie, how we delete
items is more straightforward. There are a few additional tricks we need to
keep in mind. In our trie, let’s say that we want to delete the word duck
(Figure 12-17).
Figure 12-17 Deleting items is a bit more involved.
What we can’t do is just traverse this tree and delete all the characters
because:
We first need to make sure that the word we are deleting actually
exists in our tree.
We also need to ensure that if a particular letter is shared by other
words, we don’t remove it. In the case of duck, the letter d is shared
with dog and dune, and the letter u is shared with dune.
We also need to ensure that the letter we are removing isn’t part of
another word.
So, what do we do? Well, we have our three checks to perform. We first
check to make sure the word we are deleting exists in our tree, and we
check the last node by making sure it is flagged as being the end of our
word. If all of that checks out, at this point, we are at the last character of
the word we are interested in removing (Figure 12-18).
What we do next is traverse up our tree in reverse order. For each letter we
encounter, we check that the current node has no other children and is not
the end of another word. If the node we encounter passes these checks, we
remove the node and keep moving up the tree. This process continues until
we encounter a node that has other children or is the end of another word.
At that point, the deletion process stops.
For our example where we want to remove duck from our trie, we start at
the end with the letter k. This node is safe to delete, so we delete it. We then
move up to the letter c. This node is also safe to delete, so our trie now
looks like Figure 12-19.
Let’s start with the obvious one. Our trie is a tree-based data structure. We
can see that is the case (Figure 12-20).
If instead what we are doing is checking whether or not our input characters
(i.e., d-o-g) is a word, then the trie is the wrong data structure. We probably
want something like a hashmap that can quickly tell us if our complete input
is among a set of stored values.
Now, what are the situations where we may have incomplete input that may
still have just enough detail to give us a shortcut to a final value? Let’s take
a look at a few of them:
Autocomplete and predictive text: Reiterating a point we started off
our look at tries with, when we start typing a word or a phrase in a
search engine, email client, or messaging app, we often see
suggestions that complete our input (Figure 12-22).
Each node in the trie represents a part of the IP address, and the
edges correspond to the possible values of that part. By traversing the
trie on the basis of the IP address, routers can determine the next hop
for routing packets in the network efficiently.
Word games and puzzles: Tries can be handy for word games like
Scrabble or Wordle where players need to quickly find valid words
given a set of letters (Figure 12-25).
Figure 12-25 Word games commonly use tries under the hood.
Note
Why Are Tries Sometimes Called Prefix Trees?
Tries are sometimes called prefix trees because their entire
functionality revolves around prefixes! Tries store words in a tree-
like structure that emphasizes common letters (aka a prefix). Each
node in the trie represents a character, and the path from the root to a
node forms a prefix. We can even go one step further and think of a
complete word as just a prefix with the last character having a flag
that designates it as a word. For these reasons and more, we’ll often
see tries referred to as prefix trees in various other books and online
resources.
Implementation Time
Now that we can verbally describe how a trie works, let’s turn all of the
words and visuals into code. Our trie implementation will support the
following operations:
Inserting a word
Searching for whether a word exists
Checking whether words that match a given prefix exist
Returning all words that match a given prefix
And … without further delay, here is our code:
class TrieNode {
constructor() {
// Each TrieNode has a map of children nodes,
// where the key is the character and the valu
// child TrieNode
this.children = new Map();
class Trie {
constructor() {
// The root of the Trie is an empty TrieNode
this.root = new TrieNode();
}
return words;
}
delete(word) {
let current = this.root;
let index = 0;
if (!current.children.get(char)) {
// Word doesn't exist in the Trie, nothing
return;
}
current = current.children.get(char);
index++;
}
if (!current.isEndOfWord) {
// Word doesn't exist in the Trie, nothing t
return;
}
// Mark the last node as not representing the
current.isEndOfWord = false;
#findNode(prefix) {
let current = this.root;
for (let i = 0; i < prefix.length; i++) {
const char = prefix[i];
trie.insert("apple");
trie.insert("app");
trie.insert("monkey");
trie.insert("monk");
trie.insert("cat");
trie.insert("dog");
trie.insert("duck");
trie.insert("dune");
console.log(trie.search("apple")); // true
console.log(trie.search("app")); // true
console.log(trie.search("monk")); // true
console.log(trie.search("elephant")); // false
console.log(trie.getAllWords("ap")); // ['apple',
console.log(trie.getAllWords("b")); // []
console.log(trie.getAllWords("c")); // ['cat']
console.log(trie.getAllWords("m")); // ['monk', 'm
trie.delete("monkey");
console.log(trie.getAllWords("m")); // ['monk']
Performance
We are almost done here. Let’s talk about the performance of our trie,
focusing on a trie implementation that uses a hashmap under the covers. At
a high level, all of our trie operations are impacted by two things:
1. How long the words or prefix we are dealing with are
2. How many child nodes exist for a given letter
Insertion, search, and deletion operations in a trie typically have a linear
time complexity of O(k) where k is the number of characters in our input
word. For example, if we add the word duck to our trie, we process the d,
the u, the c, and the k individually (Figure 12-26).
For longer inputs involving large numbers of characters, more work needs
to be done. Smaller inputs require less work. The amount of work is
proportional to the size of our input, though. That makes this situation a
perfect linear one.
Here is one more detail to keep in mind. Our trie implementation uses a
hashtable (see
www.kirupa.com/data_structures_algorithms/hashtable_hashmap_dictionar
y.htm), which we just learned about earlier, to keep track of character and
node mappings. Checking whether a character exists as a child has an
average time complexity of O(1). In the worst case, the time complexity can
be O(N) if a particular node has an abnormally large number of children.
Our hashing implementation uses JavaScript’s built-in Map object, so the
performance is well taken care of. If you are using your own hashing
implementation or using an alternate data structure like an array, the
performance can get degraded.
Note
Why O(k) as opposed to O(n)?
Why did we not just use O(n) to describe the time complexity?
There isn’t a strong reason for this. The argument N typically refers
to the total number of items we are dealing with and the number of
operations relative to that. For our insert, search, and delete
operations, the total size of N items in the trie doesn’t matter. The
only thing that matters is our input size, so it seemed reasonable to
use a different notation.
Lastly, let’s talk about memory. The memory usage of a trie is typically
O(N). The amount of memory we take is related to the number of entries we
have in our trie. Using a hashmap for tracking children adds a bit of
overhead as well, but it isn’t large enough to move us away from the O(N)
upper boundary.
Long story short, the elevator pitch is this: Tries are very efficient data
structures. That is something you can take to the bank!
Conclusion
The trie data structure, also known as a prefix tree, provides an efficient
solution for working with words and their prefixes. Tries are like special
trees that allow us to store and search for words on the basis of their
common beginnings. By representing words as paths from the root to
specific nodes, tries organize information in a way that makes it easy to find
all words with a given prefix.
What makes tries totally sweet is how efficiently they work. As we
highlighted in some of the earlier examples, there are a boatload of
situations where we will be dealing with words. In those situations, the trie
is going to end up becoming your best friend.
Some additional resources:
Ask a question: https://forum.kirupa.com
Errors/Known issues: https://bit.ly/algorithms_errata
Source repository: https://bit.ly/algorithms_source
13. Graphs
It is time for us to learn about the graph data structure. This particular data
structure is used in so many applications and has so much going for it, an
entire field of study called graph theory exists for it. Smart people every
year get advanced degrees in it. There are walls of books dedicated to just
this topic. Famous musicians sing songs about … okay, maybe not.
The point to emphasize is that there is a lot to learn when it comes to
graphs. We will certainly not cover everything in our time together, but
cover the big topics that we will run into the most in our everyday
programming life.
Onward!
What Is a Graph?
Graphs are a way to organize information and understand how different
things are connected to each other. This connected to each other part is
important. Graphs help us to find and analyze the relationships between
things. Let’s start with an example.
Meet Jerry, a fairly successful comedian who lives in New York City
(Figure 13-1).
Figure 13-1 The first item in our graph
What we have here is a graph. The nodes (aka vertexes or points) are Jerry,
Elaine, Kramer, and George. The connection between the nodes is known as
an edge (Figure 13-3).
Figure 13-3 Nodes and edges
Right now, the edges don’t have any direction to them. They are considered
to be bidirectional where the relationship between the connected nodes is
mutual. A graph made up of only bidirectional edges is known as an
undirected graph (Figure 13-4).
Figure 13-4 Our undirected graph
In many real-life cases, our graphs will rarely be undirected. They will have
a specific order in the relationship where some connections may be one
way. Continuing with our example, Jerry has an acquaintance named
Newman. Newman considers Jerry a friend (Figure 13-6).
This consideration isn’t mutual. Jerry does not consider Newman a friend,
so there won’t be a reciprocating edge from Jerry pointing toward Newman.
A graph where some of the edges have a direction, kind of like what we
have right now, is known as a directed graph, or digraph for short.
Let’s go ahead and detail more of the relationships between Jerry, Elaine,
Kramer, George, and Newman (Figure 13-7).
These graphs are known as acyclic graphs, and what we see in the figure is
a more specific variation known as a directed acyclic graph (aka dag)
because the edges have a direction to them. We also have acyclic graphs
that are undirected. Can you guess what these types of graphs are also more
commonly known as? Spoiler alert! Look at Figure 13-9.
Figure 13-9 Is this a tree I see?
They are known as trees, a data structure we spent a fair amount of time
looking into earlier. Yes, trees are a very specific type of graph. They are
acyclic in that there aren’t multiple paths that start from and end at the same
node. They are undirected in that the edges are bidirectional. There is one
more detail: the graphs that represent a tree are connected. Connected
means that there is a path between every pair of nodes.
The best way to visualize a connected graph is to look at one that is
unconnected (Figure 13-10).
Notice that nodes B and C are floating on an island with no way to get to
either B or C from any of the other nodes. For example, is there a path from
F to either B or C? Nope. A connected graph will not have this problem
(Figure 13-11).
Figure 13-11 A graph that is totally connected
The path created by A and B brings B and C back into connectedness. Now,
every pair of nodes in our graph can be reached by some path.
Graph Implementation
Now that we have a good overview of what graphs are and the variations
they come in, it’s time to shift gears and look at how we can actually
implement one. If we take many steps back, the most common operations
we’ll do with a graph are:
Add nodes
Define edges between nodes
Identify neighbors:
If our graph is directional, make sure we respect the direction of the
edge
If our graph is nondirectional, all immediate nodes connected from
a particular node will qualify as a neighbor
Remove nodes
Representing Nodes
Before we dive into the implementation, an interesting detail here has to do
with how exactly we will represent our node and its relationship with its
neighbors. Let’s say that we have a graph and a node called A that has the
connections shown in Figure 13-12.
Figure 13-12 Another example of a graph
The nodes are A, B, C, and D. We have edges that connect A-B, A-C, and
A-D. Because our graph is undirected, the direction of the edges is
bidirectional. This means we also have edges that connect B-A, C-A, and
D-A.
Getting back to our A node, its neighbors are the nodes B, C, and D. Some
nodes will have fewer neighbors, and some nodes can have significantly
more. It all boils down to both the type and volume of data our graph
represents. So, how would we represent a node’s neighbors? One really
popular way is by using what is known as an adjacency list.
When using an adjacency list, each node is associated with a list of adjacent
nodes. A rough visualization using our current example can look as follows:
A: [B, C, D]
B: [A]
C: [A]
D: [A]
This list can take many forms in a potential graph implementation. Our list
can be an array, map, hashtable, or host of other data structures. Because we
mentioned earlier that a node can have a large number of neighbors, we will
want to go with a data structure that makes finding a node lightning fast.
This is why, in a few moments, you’ll see us representing our adjacency list
using a map (aka hashtable) data structure (see
www.kirupa.com/data_structures_algorithms/hashtable_hashmap_dictionar
y.htm).
By using a map, we can have the key be a node. The value will be a set data
structure whose contents will be all of the neighboring nodes. Sets are great
because they don’t allow duplicate values. This ensures we avoid a situation
where we are going in a loop and adding the same node repeatedly.
The Code
With the background out of the way, let’s dive right in and look at our
implementation for the graph data structure:
class Graph {
constructor() {
// Map to store nodes and their adjacent nodes
this.nodes = new Map();
// Add nodes
characters.addNode('Jerry');
characters.addNode('Elaine');
characters.addNode('Kramer');
characters.addNode('George');
characters.addNode('Newman');
// Add edges
characters.addEdge('Jerry', 'Elaine');
characters.addEdge('Jerry', 'George');
characters.addEdge('Jerry', 'Kramer');
characters.addEdge('Elaine', 'Jerry');
characters.addEdge('Elaine', 'George');
characters.addEdge('Elaine', 'Kramer');
characters.addEdge('George', 'Elaine');
h t ddEd ('G ' 'J ')
characters.addEdge('George', 'Jerry');
characters.addEdge('George', 'Kramer');
characters.addEdge('Kramer', 'Elaine');
characters.addEdge('Kramer', 'George');
characters.addEdge('Kramer', 'Jerry');
characters.addEdge('Kramer', 'Newman');
characters.addEdge('Newman', 'Kramer');
characters.addEdge('Newman', 'Jerry');
// Remove a node
console.log("Remove the node, Newman: ")
characters.removeNode("Newman");
console.log(characters.getAllNodes());
// ['J ' 'El i ' 'K ' 'G ']
// ['Jerry', 'Elaine', 'Kramer', 'George']
Conclusion
The graph data structure is one of those fundamental concepts in computer
science that you and I can’t avoid running into. Because graphs provide a
powerful way to model relationships between things, their usefulness is
through the roof. So many activities we take for granted, such as navigating
using an online map, joining a multiplayer game, analyzing data, navigating
to anywhere on the Internet, and doing a billion other activities, all rely on
the core capabilities the graph data structure provides. We’ve only scratched
the surface of what graphs are capable of, so we are going to cover more
graph-related things in this book.
Some additional resources:
Ask a question: https://forum.kirupa.com
Errors/Known issues: https://bit.ly/algorithms_errata
Source repository: https://bit.ly/algorithms_source
14. Introduction to Recursion
Recursion is a powerful programming technique that allows us to break
down large, complicated problems into smaller, more manageable pieces.
Not only is it a valuable tool in our coding toolkit, but understanding
recursion will also help us improve our logical thinking and problem-
solving skills. So why wait? In this chapter, we get a good overview on
what recursion is and why knowing more about it will kick our coding skills
up a bunch of notches!
Onward!
Because of its size, most people will have no way to eat this entire cookie in
one bite. What we can do is break it into smaller pieces (Figure 14-2).
Figure 14-2 Breaking our cookie into smaller pieces
As we can see, these smaller pieces are still too big to eat in one bite. What
we need to do is keep breaking our pieces down into even smaller pieces.
Eventually, we will have broken our cookie down into a bite-sized piece
that we can easily eat (Figure 14-3).
Figure 14-3 Breaking our cookie down further into even smaller
pieces
The part to notice is that we now have many bite-sized cookies that we need
to eat. We don’t have just one big cookie, nor do we have just one small
cookie. The quantity of cookies we eat remains the same. The only
difference is that the quantity is now spread across many cookies. This
process of taking something large and breaking it into ever-smaller pieces is
very similar to how recursion works in the context of our programming
problems.
Recursion in Programming
While thinking about recursion in the form of a cookie is all good and great
—and delicious—we’ll find the biggest bang when we throw recursion at
thorny programming problems. Echoing what we started off with, the
textbook definition of recursion involves two parts: a function that calls
itself repeatedly and a condition that stops our function from calling itself.
Let’s go into more detail on what these two parts do.
function hello() {
console.log("I'm a little function, short and st
hello();
}
We have a function called hello, and inside the function body, we are
calling hello again. Now, this code won’t actually run if we try it. It will
cause an infinite loop because the code will never stop running. More
specifically, our code will cause a stack overflow error (Figure 14-4).
Figure 14-4 Example of what a stack overflow can look like
The way we avoid this problem brings us to the second part of our
discussion.
Terminating Condition
The second part is a condition that stops our function from calling itself
forever, the scary-sounding terminating condition. Leaning on our cookie
example, we kept dividing our cookies into smaller pieces until it became
bite-sized. The terminating condition is to check whether the size of the
cookie is bite-sized. If the size is bite-sized, then eat the cookie. If the size
isn’t bite-sized, then divide the cookie further into smaller pieces.
Turning all of those words into code, let’s say we want our hello function
to act as an accumulator where we pass in a number, and it returns the sum
of all the numbers leading up to the number we passed in. For example, if
pass in the number 5, our code will add up all numbers leading up to it,
where it will calculate 5 + 4 + 3 + 2 + 1 and return a final value of 15.
Following is what our revised hello function will look like if we do all of
this:
function hello(num) {
if (num <= 1) {
// terminating condition
return num;
} else {
// recursive function call
return num + hello(num - 1);
}
}
console.log(hello(5)); // 15
At each row where our num argument is greater than 1, we show the result
of num + hello(num - 1) (Figure 14-6).
Figure 14-6 Our recursive call highlighted
We keep repeating this until our num value hits 1. When this happens, we
hit our terminating condition (num <= 1) and return the value of num
itself, which is just 1 in our case (Figure 14-7).
Figure 14-7 When our code stops running
Taking a step back, for just one more time, we can see how we started with
a large problem and, with each recursive call, broke the problem down into
much smaller steps. We continued until we hit our terminating condition
and were left with an easily digestible nugget, a plain old number. Really
cool, right?
Conclusion
As we can see, recursion is the gift that keeps on giving … and giving …
and giving! Jokes aside, while our hello accumulator function is a bit
contrived, it does do a good job of highlighting the two basic ingredients
needed to solve a problem using recursion:
Recursive function call
Terminating condition
We go a bit deeper in future chapters where we apply recursive techniques
to solve more realistic and more complicated problems. Also, as with any
problem-solving tool, recursion is the cure only for some things. There are
situations (quite a bunch, as it turns out) where we need to look beyond
recursion to solve problems in a more efficient way, and we’ll cross that
bridge shortly as well.
Some additional resources:
Ask a question: https://forum.kirupa.com
Errors/Known issues: https://bit.ly/algorithms_errata
Source repository: https://bit.ly/algorithms_source
15. Fibonacci and Going Beyond Recursion
If there was a Greatest Hits list of popular algorithms, the Fibonacci
sequence would be right at the top. It would be the Beatles or the Rolling
Stones of its generation. The Fibonacci sequence is a series of numbers in
which each number is the sum of the previous two numbers. The sequence
starts with 0 and 1, and then each subsequent number is the sum of the
previous two. So, the sequence goes 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, and so on.
To dive into that a bit deeper, here is how the sequence is calculated:
fibonacci(0) = 0
fibonacci(1) = 1
fibonacci(2) = 1 // Sum of fibonacci(1) + fibonacc
fibonacci(3) = 2 // Sum of fibonacci(2) + fibonacc
fibonacci(4) = 3 // Sum of fibonacci(3) + fibonacc
.
.
.
fibonacci(n) = fibonacci(n-1) + fibonacci(n-2)
This is cool … sort of. Why do we care about it? Besides its many practical
uses, the Fibonacci sequence is a great example of an algorithm that can be
solved recursively (Figure 15-1).
Figure 15-1 Somebody here likes recursion!
If we turn all of this into JavaScript, here is a recursive way to identify any
number in the Fibonacci sequence:
function fibonacci(n) {
if (n == 0) {
return 0;
} else if (n == 1) {
return 1;
} else {
return fibonacci(n - 1) + fibonacci(n - 2);
}
}
This function takes a number n and returns the nth number in the Fibonacci
sequence. The function works recursively by calling itself repeatedly with
smaller and smaller values of n until it reaches one of the terminating
conditions (where n is 0 or 1).
For example, if we call fibonacci(3), the function will first call itself
with n equal to 2, and then with n equal to 1 (Figure 15-3).
This leads to the next step, where fibonacci(1) hits our terminating
condition and returns a 1. The fibonacci(2) call will expand further
(Figure 15-4).
Figure 15-4 Going deeper into Fibonacci
function fibonacci(n) {
if (n == 0) {
return 0;
} else if (n == 1) {
return 1;
} else {
return fibonacci(n - 1) + fibonacci(n - 2);
}
}
Your eyes aren’t deceiving you. To calculate, for example, the 20th number
in the Fibonacci sequence, there are 21,891 function calls. Just four
numbers later, the 24th number in the Fibonacci sequence is a whopping
150,049 function calls.
That’s a lot of calls, and each function call is an expensive operation. The
running time here is O(2^n), which puts it toward the high end in terms of
operation cost. There are faster ways that improve upon the recursive
approach we are taking, and we look into them next.
Note
Why Are Function Calls Expensive?
Function calls, especially in long-running recursive operations, can
be expensive (take a long time to run, take up a lot of memory, or
both) for a few reasons:
Function calls require additional memory: When a
function is called, the interpreter needs to store the current
state of the program (including the values of all the variables)
on the call stack. This can consume a lot of memory,
especially if the recursion goes deep.
Function calls require additional processing time: Each
function call requires the interpreter to push the current state
onto the call stack and then pop it off again when the function
returns. This can be time consuming, especially if the
function calls itself multiple times.
Function calls can cause stack overflow errors: If the
recursion goes too deep (e.g., if the function calls itself a
whole bunch of times in short succession), it can cause the
call stack to overflow, which can lead to a runtime error.
The more we can reduce the number of function calls, the faster our
code will run. In the next few sections, we take our recursive-only
approach for calculating the Fibonacci sequence and look at ways
we can greatly reduce the number of recursive function calls we
need to make!
Notice that we have an array called cache that stores the result of each
calculation we make. Each time we are about to make a recursive call, we
check to see if we already have a result stored in our cache array:
function fibonacci(n) {
if (n == 0) {
return 0;
} else if (n == 1) {
return 1;
} else {
let a = 0;
let b = 1;
for (let i = 2; i <= n; i++) {
let c = a + b;
a = b;
b = c;
}
return b;
}
}
What you are seeing here isn’t a glitch. The time for calculating the
Fibonacci sequence for the first 30 numbers is almost 0 in the recursive +
memoization and iteration-based approaches. The purely recursive
approach starts to take increasing amounts of time at around the 17th
Fibonacci number, and it grows exponentially from there on out. There is a
reason why the chart includes only the first 30 numbers of the Fibonacci
sequence. The recursive-only approach couldn’t handle larger numbers
without massive delays and, ultimately, a stack overflow error.
If we ignore the recursive-only approach and focus our attention on the
memoization and iteration approaches, Figure 15-10 shows the time for
calculating the Fibonacci sequence for the first 300 (!!!) numbers.
Figure 15-10 Iteration is much more efficient!
Here, we can see our iteration-based approach being a much faster solution
when we compare it to the recursive approach with memoization. The
reason is that, no matter how effective our memoization strategy is,
recursive function calls are expensive operations. If we can avoid them
entirely, as we do in the iteration-based approach, we will get the best
results.
Conclusion
We covered a huge boatload of ground here. The Fibonacci sequence is an
important concept in computer science (and family dinner conversations
involving algorithms!) because it illustrates the basic idea of recursion,
which is a technique where a function calls itself to solve a problem. The
problem with recursion is that it can be slow. This is especially true when
we are dealing with large inputs or complex calculations that result in many
recursive function calls. To address this shortcoming, we looked at two
additional approaches that greatly sped up our Fibonacci calculations:
Recursive with memoization: This involves storing the results of
expensive function calls so that they can be reused later.
Iterative: This involves using a loop to iterate through the numbers
in the sequence and compute the next number in the series using the
previous two.
While we looked at all of this in the context of calculating a number in the
Fibonacci sequence, the concepts we saw in this chapter will continue to
carry over into other problems we’ll see in later chapters. It’s going to be a
fun ride!
Some additional resources:
Ask a question: https://forum.kirupa.com
Errors/Known issues: https://bit.ly/algorithms_errata
Source repository: https://bit.ly/algorithms_source
16. Towers of Hanoi
As puzzles go, nobody really did it better than the monks who came up with
the one we are going to learn about, the Towers of Hanoi. Besides being a
really cool puzzle, it has a lot of practical (and historical!) significance as
we learn about recursion.
Onward!
At the beginning, all of the disks are stacked on top of each other and start
off in the first pillar. At the end, the stack of disks is shifted over to the last
pillar (Figure 16-2).
Figure 16-2 How the puzzle will end
This seems pretty straightforward, but there are a few conditions that make
things frustratingly complex:
You can move only one disk at a time.
At each move, you take the disk from the top of any of the stacks and
place it on another tower.
You can place only a smaller disk on top of a larger disk.
Victory is achieved when all of the starting disks are arranged in their
same starting order on a destination tower. You can use any tower
other than the one you started from as your destination. We’ll use
tower 3.
These conditions probably don’t make a whole lot of sense. To fix that, let’s
walk through a handful of examples and see how a typical game with these
conditions is played.
We have one disk at the beginning. Because there are no other disks to
worry about, we can win in just one single move (Figure 16-4).
We can move our single disk directly to the destination without doing
anything extra. With just a single disk, the puzzle isn’t challenging at all. To
see what is really going on, we need more disks.
Our goal is still the same. We want to shift these disks to our destination,
the third tower, while maintaining the same stacking order and ensuring that
a smaller disk is always placed on top of a larger disk at every move along
the day.
The first thing we need to do is clear a path for our larger disk to reach its
destination. We do that by first shifting our topmost disk to our temporary
second tower (Figure 16-6).
Once we’ve made this move, our larger disk has a direct path to the
destination. Our next move is to shift that disk to tower 3 (Figure 16-7).
Figure 16-7 Our larger disk reaches its destination.
The final step is to move our smaller disk from the temporary tower to the
destination as well (Figure 16-8).
At this point, we’ve successfully shifted all of the disks from our starting
point to the destination while respecting the various conditions. Now, with
two disks we can see a little bit more about what makes this puzzle
challenging. To see how challenging the Towers of Hanoi can be, we look
at one more example in great detail. We are going to throw another disk into
the mix!
Three Disks
All right. With three disks, the training wheels come off and we really see
what the monks who inspired this puzzle were up against. We start off with
all of our disks at the starting point (Figure 16-9).
The first thing we do is move our largest disk to the destination. To do so,
we must first move our smallest disk out of the way (Figure 16-10).
This leaves our third and largest disk almost ready to be moved to the
destination tower. Our smallest disk currently stands in the way, but we can
move that to our temporary tower (Figure 16-12).
With the decks cleared, we move our third disk to the destination (Figure
16-13).
Figure 16-13 Our largest disk reaches its destination.
At this point, we have only two disks in play. They are both in our
temporary tower. What we do now is no different than what we started out
doing earlier. We need to move our largest disk to the destination tower.
This time around, that disk is our second one because our third disk is
already safe at home in the destination tower. You may start to see a pattern
emerging.
To make progress, let’s move our topmost (and first … and smallest!) disk
from our temporary tower to our starting tower (Figure 16-14).
Next, let’s move our second disk to the destination tower (Figure 16-15).
Figure 16-15 The end is near for our puzzle
The remaining step is easy. Our first disk is patiently waiting at the starting
tower. Let’s move it to the destination (Figure 16-16).
We are now done moving three disks from our starting tower to the
destination tower. We can repeat all of these steps for more disks, but we’ve
seen all the interesting details to note about this puzzle by now. Now let’s
look more formally at all that this puzzle has going on and figure out how
we can get our computers to solve it.
The Algorithm
As humans, talking out loud and visually solving the problem totally works
for us. Our computers are far less evolved. For them, we need to simplify
our solution in terms that they understand. We can do that by restating what
we know about how to solve this problem:
The goal is to get the largest, bottommost disk to the destination
tower.
We do that by moving all of the disks (except the largest) from the
starting tower to our temporary tower.
Once that has been done, we move our largest disk to the destination
tower.
We then move all of the disks (except the new largest) from our
temporary tower to the destination tower.
To state this a bit more formally, it will look like this:
1. Move the top N-1 disks from the starting tower to the temporary
tower.
2. Move the bottom most (aka Nth) disk from the starting tower to the
destination tower.
3. Move the remaining N-1 disks from the temporary tower to the
destination tower.
There is one additional detail that is subtle but important to call out. The
role our towers play while solving this puzzle is fluid. While we have given
our towers strict names like starting, temporary, and destination, these
names are really just helpful for us to understand what is going on. As part
of moving the disks around, each tower plays an interim role as the
temporary location, the destination location, or the starting location. For
example, let’s say you have a disk moving from the temporary tower to the
destination (Figure 16-17).
Figure 16-17 Algorithm explained
In this case, for just this move, our starting tower is really the temporary
tower. The destination tower remains the same, but you can imagine the
destination might be our starting tower in some intermediate step. It is this
fluidity in our tower roles that makes it difficult for us to mentally make
sense of this puzzle! But it is exactly this fluidity that makes solving it
using code much easier as well.
var numberOfDisks = 3;
If you run this code for the three disks, your console will look like Figure
16-18.
Figure 16-18 Example of the output for three disks
If you follow the path the output displays, you’ll see what our code does to
ultimately solve the puzzle for three disks. If you change the value for
numberOfDisks from 3 to another (larger) number, you’ll see a lot more
stuff getting printed to your console. If you plot the path shown in the
console, you’ll again see what the solution looks like and the path each disk
took in getting there. What we’ve just done is looked at the full code needed
to solve our monks’ Towers of Hanoi puzzle. We aren’t done yet, though.
Let’s look at this solution in greater detail for a few moments.
var numberOfDisks = 3;
Our hanoi function is really solving the sub-problem of moving N-1 disks
from one location to another. This function keeps getting called until you
are on your last disk (aka n > 0).
If we had to draw out the full recursive call for three disks, it would look as
follows:
I get that this doesn’t look very nice, but take a moment to follow through
with what is going on. Pay special attention to how we swapped the values
for where a disk needs to end up by jumping between the starting,
temporary, and destination towers. The end result of all of this is still the
same: our disks move from their starting point to the destination without
breaking those annoying rules.
Tn = 2Tn-1 + 1
This is the number of total moves involved in solving the puzzle, where n
stands for the number of disks. For T0, we know the number of moves is 0.
For T1, we know the number of moves is 1. Extending this to our formula,
we can do something like the following:
T0 = 0
T1 = 2T0 + 1 = 2(0) + 1 = 1
T2 = 2T1 + 1 = 2(1) + 1 = 3
T3 = 2T2 + 1 = 2(3) + 1 = 7
This seems to check out, so let’s prove that this form maps to the Tn = 2n −
1 equation figured out earlier. Let’s assume that this formula holds for n −
1. This would mean that our equation could be rewritten as Tn-1 = 2n − 1 −
1.
Let’s combine this with what we looked at earlier:
Tn = 2Tn-1 + 1
Tn = 2(2n-1 − 1) + 1
Tn = 2(2n-1) − 2 + 1
Tn = 2(2n-1) − 2 + 1
Tn = 2n-1+1 − 1
Tn = 2n − 1
This proves out that the answer we came up with earlier holds for all ranges
n where n is 1 or greater. This is a less rigorous form of an induction proof
that doesn’t dot all the i’s and cross the t’s, so don’t use it as the proof if you
are asked to formally prove it.
Conclusion
Do you know why the monks were moving 64 disks in the first place? They
believed that the world would end once the last disk was placed in its
rightful location. If that were true, how long do we all have? Using the
formula we have for the number of moves, and knowing from legend that
each move takes one second, how long will our monks take to complete the
puzzle? Unfortunately for them, using the 264 − 1 formula, the amount of
time it will take them is somewhere around 585 billion years. That’s good
for us, though! To learn more about the history of this puzzle and the
French mathematician Édouard Lucas who actually introduced it to
everyone, visit https://en.wikipedia.org/wiki/Tower_of_Hanoi.
Some additional resources:
Ask a question: https://forum.kirupa.com
Errors/Known issues: https://bit.ly/algorithms_errata
Source repository: https://bit.ly/algorithms_source
17. Search Algorithms and Linear Search
In the real world, when we talk about search or searching, we are trying
find something. We might be looking for a sock in a pile of clothes, a
needle in a haystack, a matching fork to go with a knife, Waldo (and
possibly his dog companion Woof), car keys, a particular star in our solar
system, or a billion other things (Figure 17-1).
Linear Search
As search algorithms go, linear search is easy to explain. It works by
iterating through each item in a collection of data and checking whether the
item you are on matches the item you are looking for. This search continues
until either we find the item or we reach the end of our collection and end
up not finding the item. Let’s look at an example.
With linear search, we start at the beginning with the first item (aka array
index position 0) and ask ourselves this question: Is the value at this
location the same as what I am looking for? For our example, we check
whether our first item, 5, is the same as 3—which we know isn’t the case
(Figure 17-4).
Figure 17-4 Let’s start our search at the beginning.
Because we don’t have a match, we move to the next item in our array and
repeat the same process. We ask: Is the value at this location the same as
what we are looking for? We know that our second item’s value is 8, and 8
isn’t equal to 3 either (Figure 17-5).
Figure 17-5 We continue going down our list.
This pattern keeps repeating. We continue going down each item in our
array until we eventually get to the item storing our 3 value (Figure 17-6).
At this point, we have a match. We found what we were looking, and it was
at array index position 6.
If our array never contained the item we were looking for, we would have
examined every item in our array and returned the equivalent of a “Not
found” result upon reaching our last item.
JavaScript Implementation
If we turn all of our words and diagrams into code, following is what one
implementation of our linear search algorithm can look like:
If the item we are looking for is not found, our code returns a -1:
Runtime Characteristics
Our linear search algorithm runs in O(n) linear time. The best-case scenario
is when the item we are looking for happens to be the first item in our
collection of data. In this case, we can just stop after reaching the first item.
The worst-case scenario happens in one of two cases:
The item we are looking for happens to be in the last spot in our
collection of data.
The item we are looking for doesn’t existing in our collection of data
at all.
In both of these cases, we had to go and examine every item in our array
until we reached the end. The number of operations this takes is directly
related to the number of items in our collection. There are no shortcuts here.
If we have a bunch of items, our linear search will start at the beginning and
go through each item to find what we are looking for.
Note
The Global Linear Search
There is a variation of our linear search algorithm that we should be
aware of, and that is the global linear search. When finding
something in a linear search, the moment we find a match, we stop
everything and return the position of the found item. If another item
is also a match elsewhere in our collection, we will never find it, for
we stop searching after finding the first match.
What happens in a global linear search is that every matching item
in our collection of data is returned. This means what we return is
not a single position value. Nope. What we return is an array of
position values where the position of every matching item is
returned. The code for a global linear search will look as follows:
if (foundPositions.length > 0) {
return foundPositions;
} else {
// No items found
return -1;
}
}
The major difference is that our result when items are found is an
array of index positions as opposed to a single index position
returned as a number.
Conclusion
The linear search algorithm is a simple and straightforward approach for
finding an item within a collection of data. Because we iterate through each
item in our collection, linear search isn’t considered to be a fast algorithm.
It is quite inefficient for large collections of data. This doesn’t mean that we
won’t ever have a practical use for it, though. Linear search is useful in
situations where our collection of data is small, our data is unsorted, or the
item we are looking for is going to be stashed somewhere near the
beginning of our collection.
Some additional resources:
Ask a question: https://forum.kirupa.com
Errors/Known issues: https://bit.ly/algorithms_errata
Source repository: https://bit.ly/algorithms_source
18. Faster Searching with Binary Search
In Chapter 17, we looked at linear search, a simple algorithm that works by
scanning every item in a collection until it finds what it is looking for.
While simple is good, the linear approach is slow. The more items you
have, the longer it will take to find something. Its running time is O(n).
There is a faster way to search, and that is the star of this chapter: the
binary search.
In the following sections, we learn all about binary search, how it is
implemented, its performance characteristics, and a whole lot more.
Onward!
In this collection, we want to find the number 60 and return its index
position. The rest of our steps walk through how we can use binary search
to accomplish this.
Sorted Items Only, Please
Before we take our first step, there is an important prerequisite that we need
to meet. Our collection of items must already be sorted, so let’s go ahead
and do that (Figure 18-2).
Later, we’ll look into the various ways we have to properly take an unsorted
collection and sort the items inside it. For now, let’s keep it simple and
ensure that the items we throw into our binary search algorithm are already
sorted.
How did we get 32? This is especially confusing when we have an even
number of items, as we do in this example. Let’s talk about this for a brief
moment.
When dealing with a collection of odd numbers of elements, we have a
clear middle element we can pick (Figure 18-4).
Figure 18-4 The middle element is easy to spot when we have an odd
number of items.
As we see here, we have five elements. The third element would be the
middle.
For arrays with even numbers of elements, there is a trick we need to use.
The middle element will be one that is to the left of the ideal midpoint. This
can be visualized as in Figure 18-5.
Figure 18-5 For an even number of elements, keep left of the ideal
midpoint.
Later on, we’ll look at a more formal way of finding the middle element
that takes our hand-wavy explanation into something more concrete. For
now, we are good!
OK, where were we? Yes! We need to check whether our middle element
contains the target value of 60 that we are looking for. We can tell that 32 is
not equal to 60. What we do next is the famed division operation that binary
search is known for.
Dividing FTW!
With our middle element not matching our target value, our next step is to
figure out where to go and continue our search. We put our finger on the
middle element and mentally divide our list into a left half and a right half
(Figure 18-7).
Figure 18-7 Setting up for a divide operation
For this next step, we ask ourselves whether the value we are looking for
(60) is greater than or less than the value in our current middle element
(32):
If the value we are looking for is greater than our middle element
value, the value we are looking for must be in the right half.
If the value we are looking for is less than our middle element value,
the value we are looking for must be in the left half.
In our case, 60 is greater than 32, so it must mean that we need to continue
our search by looking in the right half (Figure 18-8).
With only the right half of our collection in play, we repeat our earlier steps.
We find the middle element, check whether the middle element’s value
matches the target value we are looking for, and if the value doesn’t match,
divide and decide whether to go deeper into the remaining left half or right
half of our collection.
If we apply these steps to the right half, the first thing we do is find our
middle element (Figure 18-10).
Figure 18-10 We look for the middle element in the right half of the
array.
The middle element value is 71, and it isn’t the 60 value we are looking for.
Next, we check whether 71 is greater than or less than our target 60 value.
Because 71 is greater than 60, it means the half of the collection we want to
focus on is the left half (Figure 18-11).
When we look at our left half, we have only two items. It doesn’t matter
how many items we have; we must still follow our binary search algorithm
step by step. We must rinse and repeat what we’ve done a few times
already. The value of our middle element will be 40 (Figure 18-12).
Our 40 value is not the same as 60. Because our current middle point value
of 40 is less than 60, we focus on the right half (Figure 18-13).
Figure 18-13 We are almost at the end.
We only have one item at this point, and this item will also be our middle
element for the next step (Figure 18-14).
The value of our middle element is going to be 60. When we ask ourselves
if the middle element value of 60 is what we are looking for, the answer
will be a ginormous YES. This is exactly what we have been looking for,
and we will return the index position of where our 60 value is located.
Iterative Approach
The efficient iterative approach looks as follows:
// Iterative Approach
function binarySearch(arr, val) {
let start = 0;
let end = arr.length - 1;
return -1;
}
For reasons we saw in Chapter 15, this approach doesn’t inflate the function
call stack. We keep a lot of the heavy lifting localized to the loop itself.
Recursive Approach
For completeness, a less efficient recursive approach is provided as well:
// Recursive Approach
function binarySearch(arr, val, start = 0, end = a
const middleIndex = Math.floor((start + end) / 2
In our example, the numbers array is the same collection we spent much
of this chapter deconstructing, so it should look welcomingly familiar. The
value that gets returned by our binarySearch function is the index
position of the found item or -1 if the item isn’t found. In our case, 60
exists and is located at index position 6. Also, outside of the runtime
behavior we briefly talked about, the values that get returned are the same
across both the iterative and recursive binary search implementations.
We take the average of the starting and ending index positions, and then
round the result down (via Math.floor) to ensure we always end on a
whole number. If we had to visualize this, Figure 18-15 shows how we
would calculate the middle point for the highlighted region taken from an
intermediate step from the walkthrough we looked at earlier.
The values for left and right are the corresponding 5 and 9 index positions
of our array, so if we substitute in those values and calculate the middle
point, the earlier expression will look like Figure 18-16.
Figure 18-16 Finding the middle element
Our middle index position for this region is 7, and the value here is 71. A
trippy detail to note is that, even though we are examining only a subset of
our collection, our index positions are relative to the entire collection itself.
Runtime Performance
We started off by talking about how fast binary search is, especially when
we compare it to the slower linear search. Binary search runs at a scorching
O(log n), and it is able to pull this off because it follows a divide and
discard approach. As we saw in the walkthrough, at each stage of trying to
hone in on our target value, our binary search algorithm ignores half of the
remaining items.
If we had to talk about this generally, let’s say we start with a sorted
collection of n items (Figure 18-17).
In the first step, we work with the full n items. At the next step, we work
with n/2 items (Figure 18-18).
Figure 18-18 Working with n/2 items
Assuming we never find the value we are looking for (or the value we are
looking for is the very last item), this pattern will keep going where each
step discards another half of the items from the previous step (Figure 18-
19).
Figure 18-19 We keep whittling down our problem space.
We keep going until we reach the last step, where all we are left with is a
single element. There is a pattern here. We can see this pattern by observing
the number of elements in play at each step (Figure 18-20).
Figure 18-20 Number of elements at each step
If each divide step in our binary search is represented by k, the total number
of steps we take can be represented by n/2k. How does all of this lead to the
statement that binary search runs in log(n) time? After k iterations, when we
reach the last item in our collection, the number of elements we have left
are … well, just 1. This means we can solve for k as shown in Figure 18-21.
Figure 18-21 How we solve for k and get the running time
The way to read this in the context of binary search is that if we have n
items, in the worst case, we need to run through the various stages of our
algorithm log(n) times before we reach the end!
Conclusion
We just covered a lot of ground here. We learned how binary search works,
how we can use it in our code, what its runtime behavior looks like, and a
whole lot more. What makes binary search interesting is that its core
approach of dividing the problem space into something smaller is a
common pattern that we’ll repeatedly see, especially in algorithms that
classify themselves as divide and conquer. We will get to those algorithms
shortly, but you should kick back and relax for now. You’ve earned it!
Some additional resources:
Ask a question: https://forum.kirupa.com
Errors/Known issues: https://bit.ly/algorithms_errata
Source repository: https://bit.ly/algorithms_source
19. Binary Tree Traversal
With linear data structures like our array or linked list, navigating (aka
traversing) through all of the items is fairly straightforward. We start at the
beginning and just keep going to the next item until we hit the end, where
there are no more items left (Figure 19-1).
This nice and easy approach doesn’t work with trees (Figure 19-2).
Figure 19-2 Working with a nonlinear data structure like a tree can be
stressful.
Trees are hierarchical data structures, so the way we traverse through them
must be different. There are two types of traversal approaches we can use.
One approach is breadth-first, and another approach is depth-first. In the
following sections, we go both broader and deeper (ha!) into what all of this
means.
Onward!
Breadth-First Traversal
Echoing a detail from when we first looked at trees, our trees have a depth
or a level to them that indicates how far away they are from the root (Figure
19-3).
Level 0 (or Depth 0) is always going to be the root. All of the children
nodes from there will find themselves on their own level depending on how
far from the root they are.
In a breadth-first approach, these levels play an important role in how we
traverse our tree. We traverse the tree level by level, starting from the root
node and visiting all the nodes at each level before moving on to the next
level.
This process will be clearer with an example. So, we start with our root
node, A (Figure 19-4).
At this point, we don’t know much about our tree or what it looks like. The
only thing we know is that we have a root node, so we explore it. We
discover that our root node has two children, B and C (Figure 19-5).
Here lies a subtle but important detail: Discovering is not the same thing
as exploring. When we discover nodes, we are aware that they exist. When
we explore them, we actively inspect them and see if they have any
children. In the figures that follow, when a node has simply been
discovered, it’s displayed in a dotted outline. Once a node has been
explored, it’s displayed in a solid outline.
From our discovered nodes B and C, we explore B first. It has two children
(Figure 19-6).
We are done with the current row made up of our B and C nodes, and we
now repeat the process for the next row of nodes. We start on the left with
the D node and explore it to see if it has any children. The D node does
have two children (Figure 19-8).
Figure 19-8 We are exploring by going row by row first.
We then move to the E node, the F node, and the G node and explore them
to see if they have any children. They don’t have any children (Figure 19-
9).
Figure 19-9 Nodes E, F, and G have no children.
At this point, we are done exploring one more row. Now, it’s time to go into
the next row and see what is going on with the H and I nodes. We find that
neither H nor I contains children. There are no more nodes to discover and
explore, so we have reached the end of the line and have fully explored our
tree (Figure 19-10).
Figure 19-10 Our tree is now fully explored.
We explore it to see how many children it has, and we find that it has two
children, B and C (Figure 19-13).
Figure 19-13 We discover two children.
Next, we explore the B node and see if it has any children. As it turns out,
we discover that it has two children (Figure 19-14).
Figure 19-14 We continue our exploration.
Up until this point, what we have seen is consistent with the first few steps
we took in our breadth-first approach earlier. Now, here is where things get
different. Instead of exploring the C node next, we go deeper into exploring
B’s children, starting with the first leftmost node. That would be the D
node, so we explore it next (Figure 19-15).
Figure 19-15 We explore deep first.
D’s children are H and I, but (under depth-first rules) we explore H next
because it is the leftmost first child node (Figure 19-16).
Figure 19-16 We explored the left branch fully.
Our I node has no children either, so we backtrack even further to the next
spot that has any unexplored children: the E node (Figure 19-18).
Figure 19-18 Continue backtracking to the next node with unexplored
children.
If we trace our steps, we did a lot of jumping around here. The order in
which we explored our nodes is A-B-D-H-I-E-C-F-G. We can visualize the
order as shown in Figure 19-21.
Figure 19-21 The order in which we explored our tree using DFS
If we explicitly call out what we have explored and discovered, we’ll see
that our explored nodes are A and B. Our discovered nodes are C, D, and E.
It’s time to go exploring! In a breadth-first approach, the next node we
explore is going to be the first item in our discovered collection of nodes.
That would be node C, so let’s explore it next (Figure 19-23).
Figure 19-23 Exploring by rows, BFS style!
Notice the order of our discovered nodes in the collection. They are not left
to right, as the nodes appear visually in the tree. They are the opposite.
They go from right to left. There is a reason for this. The next node we
explore is taken from the end of our discovered nodes. This would be node
D (Figure 19-25).
Figure 19-25 With DFS, the order in which we explore is different and
is reflected in our general approach.
Our D node goes into the explored collection at the end, and we discover it
has two child nodes: H and I. Because we add the children from right to
left, we add our I node to the end of our discovered collection first. We next
add the H node to the end of our discovered collection, which ends this step
of our traversal. Our next step is to continue exploring, and we (you
guessed it) pick the last item in our discovered collection. This process
keeps repeating until we have no more nodes to discover.
If we had to summarize the behavior for our depth-first approach, we would
add newly discovered nodes to the end of our discovered collection. The
next node we explore will also come from the end of our discovered
collection. This is the behavior of a stack. Items are removed from the
back. Items are added to the back as well.
class Node {
constructor(data) {
this.data = data;
this.left = null;
this.right = null;
}
}
rootNodeA.left = nodeB;
rootNodeA.right = nodeC;
nodeB.left = nodeD;
nodeB.right = nodeE;
nodeC.left = nodeF;
nodeC.right = nodeG;
nodeD.left = nodeH;
nodeD.right = nodeI;
We use this tree for both our examples when testing our breadth-first and
depth-first traversal implementations. The most important thing to note is
that the root node for our tree is referenced by the rootNodeA variable.
All of the child nodes will follow from there.
function breadthFirstTraversal(root) {
if (!root) {
return;
}
if (current.right) {
discovered.enqueue(current.right);
}
}
return explored;
}
<script src="https://www.kirupa.com/js/queue_v1.js
Can you guess what we’ll see when we examine the output of this code? It
will be all of our tree’s nodes listed in the order it was explored when using
our breadth-first traversal (Figure 19-26).
Figure 19-26 Our code output
function depthFirstTraversal(root) {
p ( ) {
if (!root) {
return;
}
if (current.left) {
discovered.push(current.left);
}
}
return explored;
}
The output we see in our console when running this example is shown in
Figure 19-27.
Conclusion
Okay, that’s it. We’ve covered a lot of ground in our look at traversing a
tree using both breadth-first and depth-first approaches. There is a lot more
we need to cover here, but we’ll get those points later as we look into
searching. For now, here are the main things to keep in mind:
1. With breadth-first traversal, we visit all nodes at each level of the
tree before moving on to the next level.
2. With depth-first traversal, we visit nodes along a branch until we
reach a leaf node, then backtrack to visit other branches.
Some additional resources:
Ask a question: https://forum.kirupa.com
Errors/Known issues: https://bit.ly/algorithms_errata
Source repository: https://bit.ly/algorithms_source
20. Depth-First Search (DFS) and Breadth-
First Search (BFS)
When we look at graphs (or trees), we often see this nice collection of
nodes with the entire structure fully mapped out (Figure 20-1).
In real-world scenarios, this fully mapped-out view is the final result of our
code having fully explored the graph. When we encounter a graph for the
first time, Figure 20-2 is what our computers see.
Our goal is to start from our starting point and explore all of the places in
the graph. We’ use both a DFS approach and a BFS approach for our
exploration. By the time we’re done, we’ll be able to clearly see how these
two approaches differ!
It’s like taking one road and following it until we can’t go any further, then
going back and trying a different road. We keep doing this until we have
explored all possible paths.
Breadth-First Search Overview
In contrast, BFS is like exploring the map by starting at one location and
gradually moving outward (Figure 20-5).
We can keep track of our findings by using the same explored and
discovered terminology we used earlier when looking at how to traverse
binary trees (Figure 20-9).
Figure 20-9 Keeping track of what we explored
As of right now, our explored list contains only node A. The discovered list
contains nodes B, C, and D.
Next, we explore the first item in our discovered list, node B. When we
explore node B, we find that it has node A as a neighbor. Because we have
already explored node A, we don’t consider that to be a new node that
we have discovered.
Node B, then, has no new neighbors. We remove node B from our
discovered list and move it over to our explored list (Figure 20-10).
Figure 20-10 Continuing our exploration
Note that we didn’t add node E to the end of our discovered list. We added
it to the front, and this ensures that this is the node we explore next. This is
an important implementation detail of the DFS approach that we should
keep in mind.
When we explore node E, nodes D and C show up as neighbors. Node C is
one we have already explored, and node D is already in our list to be
discovered next. This puts us in the state shown in Figure 20-12.
Because we don’t have any new nodes to discover, we go ahead and explore
node D next. When we explore node D, we discover nodes F and G as new
nodes to explore in the future. Node E, since we already explored it, is
happily ignored (Figure 20-13).
Figure 20-13 Exploring node D and seeing what details it has hiding!
If we speed things up a bit and continue the logic we have employed so far,
we explore node F next. Node F has one unexplored node: node H (Figure
20-14).
Figure 20-14 We continue to have some nodes we haven’t explored.
After moving node F to our explored list, we add node H to the beginning
of our discovered list and go exploring it next (Figure 20-15).
Figure 20-15 Only node G remains.
Node A has three neighbors, and we catalog them in our discovered list for
exploration later. The next node we explore is the first node under our
discovered list: node B. Node B has no neighbors, so we move it from our
discovered list to our explored list (Figure 20-19).
Figure 20-19 Exploring node B
So far, what we’ve seen that our early exploration closely mimics what we
saw with DFS earlier. That all changes momentarily.
The next node we explore is node C. Node C has one neighbor: node E
(Figure 20-20).
Figure 20-20 Exploring node C
At this point, node D moves into the explored list, and our discovered list
now contains nodes E, F, and G.
The next node we explore is node E (Figure 20-22).
Figure 20-22 Time to explore node E
Node D has already been explored, but node H is new. Let’s add it to the
end of our discovered list and move on to node G (Figure 20-24).
Figure 20-24 Our graph is getting to be fully explored!
These two approaches for exploring nodes lead to the following state:
BFS, with its measured approach toward checking immediate
neighbors, guarantees that it will find the shortest path between two
nodes or help efficiently explore a graph level by level.
DFS is more suited for tasks such as finding a path between two
nodes, detecting cycles, or searching for a specific condition in a
graph, as it may exhaustively explore a single branch before
backtracking, potentially going deeper and exploring more nodes
overall.
Depending what our purpose is for exploring our graph, we’ll use either
DFS or BFS. If we are looking for just a way to explore all the nodes in a
graph, then pick between DFS or BFS randomly. You can’t go wrong with
either.
A JavaScript Implementation
Now that we have seen in great detail how DFS and BFS work to explore
the nodes in a graph, let’s shift gears and look at how both of these
exploration approaches are implemented. We are going to build on top of
the Graph class we looked at earlier when looking specifically at the graph
data structure, so a lot of code that is familiar. Some new code (which is
highlighted) implements what we need to have DFS and BFS working:
class Graph {
constructor() {
// Map to store nodes and their adjacent nodes
this.nodes = new Map();
getExploredNodes() {
return this.#explored;
}
//
// Depth First Search (DFS)
//
dfs(startingNode) {
// Reset to keep track of explored nodes
this.#explored = new Set();
#dfsHelper(node) {
// Mark the current node as explored
this.#explored.add(node);
//
// Breadth First Search (BFS)
//
bfs(startingNode) {
// Reset to keep track of explored nodes
this.#explored = new Set();
<script src="https://www.kirupa.com/js/queue_v1.js
We can certainly modify this code to avoid using a queue and work with
arrays directly, but the performance penalties may be quite high—especially
if we are dealing with a lot of nodes.
graph.addNode("A");
graph.addNode("B");
graph.addNode("C");
graph.addNode("D");
graph.addNode("E");
graph.addNode("F");
graph.addNode("G");
graph.addNode("H");
graph.addEdge("A", "B");
graph.addEdge("A", "C");
graph.addEdge("A", "D");
graph.addEdge("C", "E");
graph.addEdge("D", "E");
graph.addEdge("D", "F");
graph.addEdge("D", "G");
graph.addEdge("F", "H");
console.log("DFS:");
graph.dfs("A"); // Perform DFS starting from node
console.log(graph.getExploredNodes());
console.log("BFS:");
graph.bfs("A"); // Perform BFS starting from node
console.log(graph.getExploredNodes());
When you run this code, pay attention to the console output where we print
the final explored node for both our DFS and BFS approaches. Notice that
the output matches what we manually walked through in the previous
sections.
Implementation Detail
A key distinction between DFS and BFS is in where newly discovered
nodes get added to our discovered list.
For DFS, newly discovered items are added to the beginning of our
discovered list. This ensures DFS maintains its behavior of exploring deep
into a path. For BFS, newly discovered items are added to the end of our
discovered list. This behavior ensures BFS fully explores its immediate
neighbors before going to the next level.
Our implementations of DFS and BFS reflect the difference in how
discovered items are added to our discovered list. For DFS, we perform a
recursive call on the newly discovered node:
This allows us to efficiently add items to the end and remove items from the
beginning to explore more deeply.
Performance Details
There is one more thing before we wrap up here, and that has to do with
how efficient both DFS and BFS are when it comes to exploring a graph.
DFS:
Runtime Complexity: The runtime complexity of DFS depends on
the representation of the graph and the implementation. In the
worst-case scenario, where every node and edge is visited, DFS
has a time complexity of O(|N| + |E|), where |N| represents the
number of nodes and |E| represents the number of edges in the
graph.
Memory Complexity: The memory complexity of DFS is
determined by the maximum depth of recursion, which is the
approach our implementation here takes. In the worst-case scenario,
where the graph forms a long path, DFS may require O(|N|) space
for the call stack.
BFS:
Runtime Complexity: The runtime complexity of BFS, just like
with DFS, is also influenced by the graph representation and the
implementation. In the worst-case scenario, where every node and
edge is explored, BFS has a time complexity of O(|N| + |E|).
Memory Complexity: The memory complexity of BFS primarily
depends on the space required to store the visited nodes and the
queue used for traversal. In the worst-case scenario, where the
entire graph needs to be explored, BFS may require O(|N|) space.
In the grand scheme of things, if we had to summarize the performance, it is
safe to say that both DFS and BFS run in linear time and take up a linear
amount of space. That’s not too shabby.
Conclusion
Well, this was quite a blast! In the many preceding sections, we learned
how to explore all the nodes in a graph using both DFS and BFS! On the
surface, DFS and BFS are similar. You throw a starting node at them and a
graph to start exploring. Both approaches explore all the nodes that they
encounter. What sets them apart is how they go about doing their
exploration. DFS goes deep down the first path it finds and doesn’t stop
until it reaches a dead end, at which point it goes back to the last
unexplored node and continues diving deep again. BFS goes broad. It
explores all its immediate nodes first and then goes to the next level and
explores all of those nodes next. It takes a gradual outward approach.
In the end, by learning DFS and BFS, we gain valuable tools to tackle a
wide range of graph problems. As we find out shortly, DFS and BFS are
one of the more foundational pieces of path-finding algorithms and other
more interesting things we will want to do with graphs. Happy exploring!
Some additional resources:
Ask a question: https://forum.kirupa.com
Errors/Known issues: https://bit.ly/algorithms_errata
Source repository: https://bit.ly/algorithms_source
21. Quicksort
When it comes to sorting stuff, one of the most popular algorithms we have
is quicksort. It is popular because it is fast—really fast—when compared to
other algorithms for similar types of workloads. Key to its speed is that
quicksort is a divide-and-conquer algorithm. It is called that because of
how it breaks up its work. Instead of eating a giant chunk of data in one bite
and chewing it over a long period of time (kinda like an anaconda),
quicksort breaks up its data into smaller pieces and chews on each smaller
piece quickly.
In this in-depth chapter, we go a tad bit more precise and really understand
how quicksort works. By the end of the chapter, you’ll be able to regale
your friends and family (over dinner, preferably) with all the cool things
quicksort does. You may even improve your code with this knowledge.
Onward!
A Simple Look
To start things off, imagine that the grid of squares in Figure 21-1 represents
the numbers we want to sort.
Figure 21-1 Our unsorted list of numbers
We want to use quicksort to sort these numbers, and this is what quicksort
does:
1. Picks a midpoint value called the pivot
2. Reorders items based on how large they are relative to the pivot:
a. Items smaller than the pivot are moved to the left of the pivot
b. Items larger than the pivot are moved to the right of the pivot
3. Repeats steps 1 and 2 on the partially sorted values
At first glance, how these three steps help us sort some data may seem
bizarre, but we see shortly how all of this ties together.
Starting at the top, because this is our first step, the region of values we are
looking to sort is everything. The first thing we do is pick our pivot, the
value at the middle position, as shown in Figure 21-2.
This is the equivalent of throwing things over the fence to the other side
where the pivot value is the fence. When we do this rearranging, Figure 21-
4 shows what we will see.
Figure 21-4 Items less than our pivot and items greater than our pivot
There are a few things to notice here. First, notice that all items to the left of
the pivot are smaller than the pivot. All items to the right of the pivot are
larger than the pivot. Second, these items also aren’t ordered. They are just
smaller or larger relative to the pivot value, but they aren’t placed in any
ordered fashion. Once all of the values to the left and right of the pivot have
been properly placed, our pivot value is considered to be sorted. What we
just did is identify a single pivot and rearrange values to the left or right of
it. The end result is that we have one sorted value. There are many more
values to sort, so we repeat the steps we just saw on the unsorted regions.
At this point, we now have two sections of data on either side of our initial
pivot value that are partially sorted by whether they are less than or greater
than our pivot value. What we do next is repeat all of this pivot picking and
rearranging on each of these two unsorted sections, as illustrated by Figure
21-5.
Figure 21-5 Two pivots!
In each unsorted section, we pick our pivot value first. This will be the
value at the midpoint of the values in the section. Once we have picked our
pivot, it is time to do some rearranging, as shown in Figure 21-6.
We keep repeating all of this pivoting and sorting on each of the sections
until we get to the point where we don’t have enough values to pick a pivot
and divide from. Once we reach that point and can divide no further, guess
what? We are done! Our initial collection of unordered data is now sorted
from small to large, and we can see that if we read our pivot items from left
to right, as shown in Figure 21-8.
If we take many steps back, what we did here was pick a pivot and arrange
items around it based on whether the item is less than or greater than our
current pivot value. We repeated this process for every unsorted section that
came up, and we didn’t stop until we ran out of items to process.
The size (or magnitude) of the value is represented by the height of the bar.
A taller bar indicates a larger value. A smaller bar indicates a smaller value.
Let’s take what we learned in the previous section and see how quicksort
helps us sort this. Hopefully, much of this will be a review.
As always, the first thing is for us to pick a pivot value, and we pick one in
the middle (Figure 21-10).
Figure 21-10 Picking the pivot
Once the pivot has been picked, the next step is to move smaller items to
the left and larger items to the right of the pivot (Figure 21-11).
Figure 21-11 Rearranging between the two halves created by our pivot
At this point, your pivot value is considered to be sorted and in the proper
location. After all, it is right in the middle of all the items that will appear
before or after it. The next step is to sort the left half of the newly arranged
items (Figure 21-12).
Figure 21-12 Focusing on the smaller items
The end result is that our left half is now semi-ordered and we have a
smaller range of values left to arrange. Let’s jump over to the right half that
we left alone after the first round of reorderings and go mess with it (Figure
21-14).
Figure 21-14 Rinse and repeat on the right now.
Let’s rinse and repeat our pivot and reordering steps on this side of our
input (Figure 21-15).
Figure 21-15 Continuing to rearrange items
By now, we should be able to see the pattern more clearly. To save some
time (and ink!), let’s speed through the remaining steps for getting our
entire input properly ordered (Figure 21-16).
Figure 21-16 Fully sorting our bars
Yet again, the end result of the various pivotings and reorderings is that we
get a fully ordered set of numbers. Let’s now look at another example …
no, just kidding! We are good on examples for now. Instead, it’s time to
look at the coding implementation.
// Loop
while (i <= j) {
if (i <= j) {
let tempStore = arrayInput[i];
arrayInput[i] = arrayInput[j];
i++;
arrayInput[j] = tempStore;
j--;
}
// Swap
if (left < j) {
quickSortHelper(arrayInput, left, j);
}
}
if (i < right) {
quickSortHelper(arrayInput, i, right);
}
return arrayInput;
}
function quickSort(input) {
return quickSortHelper(input, 0, input.length
}
The code we see here is largely identical to the pseudocode we saw earlier.
The biggest change is that we have a quickSortHelper function to deal
with specifying the array, left, and right values. This makes the call to the
quickSort function very clean. You just specify the array.
Here is an example of how to use this code:
alert(myData);
Performance Characteristics
We have said a few times already that quicksort is really good at sorting
numbers quickly—hence its name. It is a divide-and-conquer sorting
algorithm that works by repeatedly partitioning the array into two smaller
subarrays, each of which is then sorted recursively. The performance of
quicksort is typically O(n log n), which is the best possible time complexity
for a sorting algorithm. Nothing faster has been invented/discovered yet.
However, the worst-case time complexity of quicksort is O(n^2), which can
occur if the array is already sorted or nearly sorted.
Table 21-1 highlights quicksort’s performance characteristics.
Time Complexity
Best Case: In the best-case scenario, the pivot chosen divides the
array into two roughly equal halves. Each recursive call partitions the
array into two halves, and there will be log(n) levels of recursion,
where n is the number of elements in the input array. At each level of
recursion, all n elements are compared once. Therefore, the best-case
time complexity is O(n log n).
Average Case: Quicksort’s average case performance is also O(n log
n). It occurs when the pivot selection is done randomly or in a way
that avoids consistently unbalanced partitions.
Worst Case: The worst-case scenario happens when the pivot chosen
is always the smallest or largest element, leading to highly
unbalanced partitions. In this case, the recursion depth reaches its
maximum, and the algorithm exhibits poor performance. The worst-
case time complexity is O(n^2).
Space Complexity
Quicksort is generally an in-place sorting algorithm, meaning it does not
require additional memory proportional to the input size. The space
complexity is O(log n) due to the recursion stack space required to maintain
the call stack during the sorting process.
Stability
Quicksort is not a stable sorting algorithm, which means the relative order
of equal elements might not be preserved after sorting.
Conclusion
Well, you have reached the end of this dive into one of the fastest sort
algorithms. Will all of this knowledge help you out in real (nonacademic)
life? I highly doubt it. Almost all popular programming languages have
their own built-in sort mechanism that you can use. Many are already based
on quicksort (or a highly optimized and specialized version of it), so the
performance gains you will see by using your own version of quicksort
compared to using a built-in sort approach will be zero.
In that case, why did we spend so much time on this? Besides the obvious
reasons of entertaining people with your newfound knowledge, one of the
reasons is that the built-in sort mechanisms will fail you at some point. You
may find yourself needing to sort a more complex set of data that goes
beyond what the built-in sorts support. At times like that, you may have to
implement your own sort algorithm. What you implement may be based on
quicksort or it may be something completely unrelated. Speaking of
unrelated, Table 21-2 compares various popular sorting algorithms on their
performance and memory characteristics.
Table 21-2 Sorting Algorithms and Their Performance and Memory
Characteristics
And with that, you are free to go and use your newfound knowledge to sort
all sorts of things really, REALLY quickly.
Some additional resources:
Ask a question: https://forum.kirupa.com
Errors/Known issues: https://bit.ly/algorithms_errata
Source repository: https://bit.ly/algorithms_source
22. Bubblesort
When it comes to sorting stuff, one of the worst algorithms you can use is
known affectionately as bubblesort. It is sooo terrible that people have
stopped naming kids after it. Despite its terribleness, it is important for you
to learn how bubblesort works. The main reason is to understand what it
does and avoid doing anything in your code that even remotely resembles it.
The other reason is purely for trivial purposes. You’ll never know when this
is belted out as a Final Jeopardy answer one day.
Onward!
The first two numbers are compared. Then the next two numbers are
compared. Then the next two, and the next two, and so on. You get the
picture. The comparison it performs is to see if the first number is smaller
than the second number. If the first number happens to be bigger, then the
first and second numbers are swapped. Let’s walk through this briefly.
In our example, the first comparison will be between the 6 and the 2 (Figure
22-3).
The 6 is not less than 0, so another swap takes place (Figure 22-6).
You repeat this process of sliding over by one, comparing the two numbers
in front of you, and swapping them if you need to until you have no more
numbers left (Figure 22-7).
Figure 22-7 The process for checking each pair of numbers
When you reach the last number, you go back to the beginning and repeat
this whole process again. This is because, as you can see, your numbers are
still not fully sorted. You repeat this painfully time-consuming process over
and over and over again until you get to the point where all of your numbers
are sorted perfectly (Figure 22-8).
Walkthrough
In the previous section, you learned a bit about how bubblesort works. In
this section, let’s clarify everything you’ve seen so far by doing a full
walkthrough. To avoid boring you to tears, I’m going to trim our example to
only four numbers (Figure 22-9).
Figure 22-9 Simplifying our example
Don’t worry. Because we are dealing with bubblesort, these four numbers
will ensure you do a lot of scrolling (or page turning or hand gesturing) to
get to the fully sorted version of things. You’ve already sorta (ha!) seen the
first few numbers, but we’ll go through them again with a little more detail
for the sake of completeness.
The first run of bubblesort through our four numbers looks as shown in
Figure 22-10.
Figure 22-10 The first round of sorting
Now, we start at the beginning and do our old song and dance again (Figure
22-11).
Figure 22-11 The second round of sorting
At this point, if you look at the results of the last step, our numbers are fully
sorted. To us humans, we would call it a night and take a break. Bubblesort
doesn’t know that the numbers are sorted just yet. It needs to run through
the numbers one more time to realize, when no swaps take place, that its job
is done (Figure 22-12).
Figure 22-12 Running through the remaining numbers
The Code
Now that you’ve seen how bubblesort operates, let’s take a look at one
implementation of it:
function bubbleSort(input) {
let swapSignal = true;
while (swapSignal) {
swapSignal = false;
swapSignal = true;
}
}
}
}
console.log(myData);
If you walk through this code, everything you see should map to what we
looked at in the previous sections. The main thing to call out is the
swapSignal variable that is used to indicate whether bubblesort has gone
through these numbers without swapping any values. Besides that one thing
of note, everything else is just simple array and for loop tomfoolery.
Conclusion
As you’ve seen from the walkthrough, bubblesort is not very efficient. For
sorting four numbers, it took about 18 steps if you count the shifting and
comparison as two individual operations. This was despite several numbers
requiring no further sorting.
Bubblesort is fastest when you are already dealing with a fully sorted list of
numbers. It goes through the numbers once, realizes that no numbers were
swapped, and then heads over to the local discotheque and does some …
discotheque-ing with all of the other cool kids. If you are dealing with a list
of numbers that are sorted in reverse, bubblesort takes the longest amount
of time. Every single number needs to be swapped and moved over to the
end. I feel bad for whatever computing device has to deal with that reversed
situation.
Before we call it a night (and join bubblesort at the local discotheque
ourselves), Table 22-1, repeated from Chapter 21 for convenience,
compares various popular sorting algorithms and their performance and
memory characteristics.
At the very beginning before anything happens, what we have right now is
a collection of unsorted values (Figure 23-3).
Figure 23-3 A collection of unsorted values
Our goal is to turn these unsorted values into sorted ones. We start with our
first item, and if we think of these bars as values in an array (or similar
collection), we start at the very left. We select our first item for processing.
When we select an item, it becomes known as the active item or value
(Figure 23-4).
Figure 23-4 Starting with our first item
When we have our active value, we ask ourselves the following question:
When I look at the sorted items, am I in the right spot?
For the very first item in our collection, this question doesn’t apply. There
are no items that are already sorted, so we go ahead and claim our first item
as already being sorted (Figure 23-5).
Figure 23-5 Our first item is assumed to be sorted … for now!
It’s time for us to go to our next item, so move one item to the right and
mark it as active (Figure 23-6). This also happens to be our first item in our
unsorted region, so that’s another way to refer to it.
Figure 23-6 Moving to the next item
We repeat the question we asked earlier: When I look at the sorted items,
am I in the right spot? When we look at our region of sorted items, we
have just a single entry. When we compare its size to our active item, our
active item is larger. That meets our goal of sorting from short to tall, so we
can answer yes to this question and mark our active item as sorted (Figure
23-7).
We move on to our next item and repeat the steps we have been following
so far. We mark our third item (or the current first unsorted item) as active
(Figure 23-8).
When we look at our sorted items, where should this active item go? This
requires that we go through each sorted item, compare heights, and continue
onward until we are at the right spot (Figure 23-9).
Figure 23-9 Moving it to the right location
In the case of this active item, we move it all the way to the beginning of
our sorted items region (Figure 23-10).
Figure 23-10 We have three sorta sorted items.
Figure 23-11 Ensuring our fourth item is placed into the right location
The number of items in our sorted region is gradually growing (Figure 23-
12).
Figure 23-12 Our sorted region is getting larger.
Moving on to our next active item and skipping a few steps, this is a
straightforward comparison where this active item is already in the right
spot given that it is now the largest sorted item (Figure 23-13).
Figure 23-13 Our next item happens to already be sorted.
We are going to look at one more item before closing the book on this
example. Our next active item is shown in Figure 23-14.
Figure 23-14 Moving the next item into the appropriate order
When we compare it with our other sorted items, as the previous image also
highlights, we’ll need to move it to the left by two spots to ensure it is in the
appropriate sorted location (Figure 23-15).
Figure 23-15 Our sorted region is starting to better take shape.
At this point, we aren’t going to continue this example further. The steps to
sort the remaining items are identical to the ones we’ve taken so far. At the
end, we’ll have a nicely sorted array, as shown in Figure 23-16.
Figure 23-16 All of our data is now sorted.
The approach we’ve seen here with our sorted region, unsorted region,
and active number are all core to how insertion sort works. Now that
we’ve seen all of this, let’s look into one more example that shows in
greater detail how insertion sort works.
Once we have our active number, we follow the “look left” approach that
insertion sort operates under. In this approach, it compares the active
number against each number to the left of it until it hits a number that is
smaller than itself.
We have only one number to the left, so let’s compare the 3 against the 5. Is
the active number (3) greater than 5? The answer is no, so we move left.
Because there is nothing more to the left, the active number gets inserted in
its new home at the beginning (Figure 23-19).
Next, we move right and pick a new active number (Figure 23-20).
That new active number is 1. Repeating the earlier process, let’s look left.
The 1 is not greater than 5. The 1 is not greater than 3. There is no other
number to the left, so the 1 now gets inserted as the first item in the list
(Figure 23-21).
So far, so good, right? Let’s speed this up even more by skipping a lot of the
explanations and just showing the results of our active number reaching its
new destination for the remaining numbers (Figure 23-22).
Figure 23-22 Going through and sorting the remaining items
And … we are done! All that remains now is to analyze how insertion
works, present a JavaScript implementation, and look at insertion sort’s
performance characteristics.
function insertionSort(input) {
// Variable to store the current element being
let activeNumber;
alert(myinput);
Performance Analysis
The performance characteristics of insertion sort are nothing to write home
about. It’s not very efficient for large data sets, and Table 23-1 highlights its
performance characteristics.
As we move from left to right, we take our active number and try to find the
correct place in our sorted region to move it to. This too takes up around n
operations on average (Figure 23-24).
Figure 23-24 We do almost n comparisons at each step.
We put all that together and get an average running time of n2, where the
linear time to go through our numbers combined with the linear look left
insertion at each point make it a pretty slow algorithm. If we are running
insertion sort on an already sorted list, the running time is O(n) because our
inner loop, which (on average) runs about n times, will run exactly once.
The first item it compares against will indicate that our active item is in the
right location already, so the bulk of the work is in just moving from left to
right. This is why insertion sort is not a terrible choice when sorting
partially sorted items, nor is it a terrible choice when dealing with small
lists of values.
Now, it isn’t all bad news for all you insertion sort afficionados, though! It
isn’t memory intensive at all. Insertion sort takes up a constant amount of
memory, so keep insertion sort at the top of your pile if you need to sort
numbers (slowly) but are memory constrained.
Conclusion
Insertion sort is not very efficient from a speed point of view. It is, however,
very efficient from a memory point of view. To see how insertion sort
compares with other sort algorithms, check out Table 23-2.
Table 23-2 Insertion Sort versus the Other Sort Algorithms by Speed
and Memory Characteristics
Overall, there are better sorting algorithms to use. Unless you are sorting a
small quantity of numbers, or you really need to take advantage of its sweet
constant memory usage, it’s best to stay as far away as possible from
insertion sort.
Some additional resources:
Ask a question: https://forum.kirupa.com
Errors/Known issues: https://bit.ly/algorithms_errata
Source repository: https://bit.ly/algorithms_source
24. Selection Sort
A slow but very easy-to-comprehend sort algorithm is selection sort. Its
approach is very simple. Let’s say we have a list of values that we want
sorted (Figure 24-1).
Selection sort works by finding (aka selecting) the smallest item in your
entire list. Once it finds that smallest item, it showers it with love and praise
and makes it the first sorted item in a region it carves out called the sorted
region. Our selection sort then goes out and finds the next smallest item in
the list. Once it finds that item, it places it directly after our earlier smallest
item in the sorted region. This whole process of finding the next smallest
item and shoving it in the sorted region repeats itself until there are no more
unsorted items left.
All of this probably doesn’t make a whole lot of sense. It is also boring, so
let’s fix that with some pictures and stuff in the next section.
Onward!
Selection Sort Walkthrough
Since I am extremely lazy, let’s just continue with the input of bars you saw
earlier. Our goal is to use selection sort to sort the bars from shortest to
tallest. Along the way, we also learn a thing or two about how selection sort
actually works. Hopefully.
First, putting on our selection sort mask, we are going to assume the first
item is the smallest item in the list (Figure 24-2).
Before you start jumping up and down about the absurdity of this, hold on a
second. The next step is to see if the first item is indeed the smallest item in
our list. To answer this, selection sort walks through the entire list and
compares the size of each item with our current smallest (and first) item
(Figure 24-3).
Figure 24-3 Checking the next value
Usually, the first item is rarely the smallest item for long. When selection
sort encounters an item that is smaller, this new item becomes the new
smallest item. As you can see, this happens immediately in our example,
for the next item is smaller than the first item (Figure 24-4).
Figure 24-4 Currently our smallest item
At this point, we have a new smallest item. Just like before, though, it is
premature to call it a day because we have examined only two values so far.
Our quest to find the smallest number needs to continue, so we compare our
newest smallest value with the remaining items in the list to see if another
item will take the smallest item crown.
During this trip, we’ll frequently encounter numbers that are larger than our
current smallest value (Figure 24-5).
Figure 24-5 Continuing to go down the list to find our smallest item
When that happens, we simply skip over it and move on to the next item
and repeat the comparison (Figure 24-6).
Figure 24-6 A new smallest item has emerged.
Selection sort goes through the entire list until it has selected the smallest
item. For this example, that’s the bar shown in Figure 24-7.
All that is left is to move this number to the mysterious sorted region that I
alluded to earlier, and let’s assume that we are going to carve our sorted
region from the beginning of our list. To do this, our next step is to swap
our smallest item with the first item we started with—the first item in our
unsorted region (Figure 24-8).
Figure 24-8 Swap the smallest item into the first place.
Once this happens, our list is partially sorted with the smallest item
leading the way. The rest of our list is still unsorted, though (Figure 24-9).
Just like before, the new smallest number is the first item. As selection sort
goes through the unsorted items to find the smallest item, that will change.
To be more precise and foreshadowy, it will change to the bar shown in
Figure 24-11 after all of the unsorted items are examined.
Figure 24-11 Finding the next smallest item
The next step is for this item to be swapped with our first unsorted item
with the sorted region of our list getting one more entry (Figure 24-12).
This process of finding a new smallest number and swapping it into the
sorted region repeats until we have no more unsorted items (Figure 24-13).
Figure 24-13 Fully sorted list
If we really want to be different, we can use an entirely new list to store our
sorted items (Figure 24-16).
function selectionSort(input) {
for (let i = 0; i < input.length; i++) {
alert(myinput);
The JavaScript doesn’t veer too far from the English description you saw in
the previous two sections. The outer loop represented by the i variable is
responsible for going through each item in the list, and its position marks
the dividing line between the sorted and unsorted regions of our input
(Figure 24-17).
Figure 24-17 The i variable divides the sorted and unsorted regions
of our input.
if (smallestPosition != i) {
var temp = input[smallestPosition];
input[smallestPosition] = input[i];
input[i] = temp;
}
Just because I like to optimize some small details for easy wins, I do a
check to do the swap only if our smallest item is indeed different than the
item we started off with. While that doesn’t happen often, it is worth adding
the check to avoid some unnecessary operations. You can safely skip that
if statement if you can sleep well at night without it.
Conclusion
Selection sort makes up the large number of sorts that is easy to understand
but not very fast. To see how selection sort compares with other sort
algorithms, check out Table 24-1.
Table 24-1 Selection Sort versus the Other Types of Sort Algorithms by
Speed and Memory Characteristics
If I were you and looking for a slow sort algorithm that is easy to
implement, I would probably choose insertion sort over selection sort any
day of the week.
Some additional resources:
Ask a question: https://forum.kirupa.com
Errors/Known issues: https://bit.ly/algorithms_errata
Source repository: https://bit.ly/algorithms_source
25. Mergesort
One of the most popular sorting algorithms you’ll bump into is mergesort.
It was designed in the 1940s when dinosaurs roamed the jungles (Figure 25-
1).
Our original input is now divided into two sections. Next, we continue
dividing by breaking our two sections into four sections (Figure 25-4).
Just like before, we combine the two sections into one section. The sorting
part is clearer this time around because the original arrangement wasn’t
already sorted. We start with a 4 and 1, and the merged arrangement is 1
and 4. Pretty simple so far, right?
Now, we keep repeating this merge and sort operation on each pair of
sections until we run out of sections (Figure 25-9).
Instead of the merged section containing two numbers, it now contains four
numbers, all in perfectly sorted bliss. We repeat this process for the
remaining sections as well (Figure 25-11).
Figure 25-11 Our sections keep getting merged.
The number 10 is still not quite in the right position to be sorted and
merged, so we drag it along for the next round (Figure 25-12).
Figure 25-12 The 10 value is special.
By now, you should start to see a pattern emerge. We are nearing the home
stretch here, so let’s continue merging and sorting with the next row (Figure
25-13).
Figure 25-13 More merging is happening.
This almost looks fully sorted! We have just one more round to go, and to
those of you deeply worried about the number 10 … it makes the cut this
time around (Figure 25-14).
Figure 25-14 Our final sorted list
Woohoo! We now have a sorted list of numbers. There are no more sections
to merge and sort, so we are done. As a quick recap (and to reminisce about
all the good times we had), take a look at the full list of steps we performed
to sort our initial collection of numbers (Figure 25-15).
Figure 25-15 The path mergesort took to sort our numbers
To put it in plain English, that is pretty fast and efficient for a sorting
algorithm. The depth of your typical mergesort implementation is log n, and
the number of operations at each level is n.
When it comes to how much space it takes, things get a little less rosy.
Common mergesort implementations take up 2n space in worst-case
scenarios—which is not terrible, but it is something to keep in mind if you
are dealing with sorting within a fixed region of limited memory.
The last detail is that mergesort is a stable sort. This means that the
relative order of items is maintained between the original input and the
sorted input. That’s a good thing if you care about things like this.
Looking at the Code
Now that you’ve learned how mergesort works and covered some boring
details about its complexity, it is time to look at how all of that translates
into JavaScript. Following is what my version of the JavaScript
implementation looks like:
function mergeSort(input) {
// Just a single lonely item
if (input.length < 2) {
return input;
}
// Divide
let mid = Math.ceil(input.length / 2);
let left = mergeSort(input.slice(0, mid));
let right = mergeSort(input.slice(mid));
If you want to see this code in action, just call the mergeSort function
with an array of numbers as the argument:
As you follow through the code, notice that there is absolutely nothing
interesting going on here. It’s just a lot of loop and array manipulations that
make up the divide and merge/sort conquer operations.
Conclusion
If you want to sort a large list of values, you can’t go wrong by using
mergesort. It is fast, uses up a reasonable amount of memory, and (unlike
quicksort) is stable. Now, before we call it a night and party it up with
mergesort at the local paintball range, Table 25-1 compares mergesort with
various other popular sorting algorithms and their performance and memory
characteristics: