Grokking Algorithms: A Comprehensive Beginnerís Guide to Learn the Realms of Grokking Algorithms from A-Z

Discover the realms of Grokking algorithms Do you think algorithms are complicated? Does the mere thought of them make

604 93 4MB

English Pages [185]

Report DMCA / Copyright

DOWNLOAD FILE

Polecaj historie

Grokking Algorithms: A Comprehensive Beginnerís Guide to Learn the Realms of Grokking Algorithms from A-Z

Table of contents :
Introduction
Chapter 1: Let’s Start with Strategy
Code It
Chapter 2: Looking for Patterns
Multiple Pointers
Divide and Conquer
O(1) Lookup: Object/Dictionary/Hash
Chapter 3: Divide and Conquer
Divide and Conquer Algorithm:
DAC vs. DP
Chapter 4: All about Data Structures
Arrays
Linked Lists
Stacks
Queues
Hash Tables
Trees
Heaps
Graphs
Chapter 5: How Linked Lists Work
Navigation
Other Uses
Chapter 6: Stacks and Queues
What Is a Stack?
Representation of Stacks
What Is a Queue?
Representation of Queues
Chapter 7: Recursion and Iteration
Recursion and Iteration: A Simple Example
Chapter 8: Let’s Get Greedy
What Is a Greedy Algorithm?
Creating a Greedy Algorithm
Chapter 9: Dijkstra’s Algorithm
Brief Introduction to Graphs
Introducing Dijkstra’s Algorithm
Example of Dijkstra's Algorithm
Chapter 10: Getting Lost in Random Forests
An Introduction to Random Forests
Random Forests vs. Decision Trees
Important Random Forest Hyperparameters
Advantages and Disadvantages
Chapter 11: The Best Sorting Algorithms
Classification of a Sorting Algorithm
Popular Sorting Algorithms
Chapter 12: What Are Hash Tables and HashMaps?
How Hash Tables Work
Basic Hash Table Examples
Hash Table Use Cases
FAQ
Bonus Chapter: A Simple Look at Big O Notation
Conclusion
References

Citation preview

Grokking Algorithms A Comprehensive Beginner’s Guide to Learn the Realms of Grokking Algorithms from A-Z

© Copyright 2022 - All rights reserved. The content contained within this book may not be reproduced, duplicated, or transmitted without direct written permission from the author or the publisher. Under no circumstances will any blame or legal responsibility be held against the publisher, or author, for any damages, reparation, or monetary loss due to the information contained within this book, either directly or indirectly. Legal Notice: This book is copyright protected. It is only for personal use. You cannot amend, distribute, sell, use, quote or paraphrase any part, or the content within this book, without the consent of the author or publisher. Disclaimer Notice: Please note the information contained within this document is for educational and entertainment purposes only. All effort has been executed to present accurate, up-to-date, reliable, complete information. No warranties of any kind are declared or implied. Readers acknowledge that the author is not engaging in the rendering of legal, financial, medical, or professional advice. The content within this book has been derived from various sources. Please consult a licensed professional before attempting any techniques outlined in this book. By reading this document, the reader agrees that under no circumstances is the author responsible for any losses, direct or indirect, that are incurred as a result of the use of information contained within this document, including, but not limited to, errors, omissions, or inaccuracies.

Table of Contents Introduction Chapter 1: Let’s Start with Strategy Code It Chapter 2: Looking for Patterns Multiple Pointers Divide and Conquer O(1) Lookup: Object/Dictionary/Hash Chapter 3: Divide and Conquer Divide and Conquer Algorithm: DAC vs. DP Chapter 4: All about Data Structures Arrays Linked Lists Stacks Queues Hash Tables Trees Heaps Graphs Chapter 5: How Linked Lists Work Navigation Other Uses Chapter 6: Stacks and Queues What Is a Stack? Representation of Stacks What Is a Queue?

Representation of Queues Chapter 7: Recursion and Iteration Recursion and Iteration: A Simple Example Chapter 8: Let’s Get Greedy What Is a Greedy Algorithm? Creating a Greedy Algorithm Chapter 9: Dijkstra’s Algorithm Brief Introduction to Graphs Introducing Dijkstra’s Algorithm Example of Dijkstra's Algorithm Chapter 10: Getting Lost in Random Forests An Introduction to Random Forests Random Forests vs. Decision Trees Important Random Forest Hyperparameters Advantages and Disadvantages Chapter 11: The Best Sorting Algorithms Classification of a Sorting Algorithm Popular Sorting Algorithms Chapter 12: What Are Hash Tables and HashMaps? How Hash Tables Work Basic Hash Table Examples Hash Table Use Cases FAQ Bonus Chapter: A Simple Look at Big O Notation Conclusion References

Introduction Algorithms will always be the central point of efficient development, and you will use them everywhere. You'll need to use them when writing code, you'll come across them at every coding interview you attend, and they will definitely be part of your daily development work. Even more, they are part of life, and throughout this book, I'll show you some real-world examples of algorithms to help you understand them more easily. You could start by learning each algorithm individually – this is helpful, but learning how to think algorithmically is even more helpful. If your brain can be trained to understand algorithmic logic and follow it, you'll find it much easier to understand existing algorithms and write your own. What is algorithmic thinking, though? And how do you use it to help you work through a problem? How to Use Algorithmic Thinking For many people, algorithmic thinking is a complete change in their thinking. The concept isn't difficult – it's merely a way of systematically thinking through a problem and finding the solution, much the same way a computer runs. But this isn't as easy as you might think for the human brain. Over time, we create our own assumptions and shortcuts, our own little rules that we live by to help us solve problems without thinking too much about them. Take a simple problem – a list of 10 numbers that need to be sorted into ascending or descending order. Most of us would look at the list, see pretty quickly what order they should be in, then put them in the right order. We are not used to breaking down how we think about a problem into smaller steps and translating those steps into how a computer works. Take the issue of finding a specific word in the dictionary. We would go to the dictionary section starting with the first letter of that word, but a computer cannot do that – it needs to be told specifically how to find that word. What about finding a name in the phone book? Humans use the first letter of the name to start the search in the book – for example, if we are looking for Mitchel, we go to the 'M' section of the book and start looking. Again, the computer cannot do this.

For those just starting their development journey, breaking down their thought process into "computable" steps is tough because humans can make judgment calls on where to start – computers can't. So, how do you do this? How do you train your brain to think algorithmically? Believe it or not, it is a skill you can learn, just like any other; all it takes is practice. As a coder, think about how you learned object-oriented design and how you learned to organize your code into the right classes. You do what you think is right, then tweak the solution to get it right and strengthen any weaknesses you find. Like the object-oriented design, you can follow some guidelines to help you learn algorithmic thinking faster. The core of algorithmic thinking is nothing more than thinking systematically about how a problem should be solved. It's all about: Clearly defining the problem Breaking it down into smaller pieces Defining solutions for each smaller piece Implementing the solutions Learning how to make the solution more efficient What Is an Algorithm? And do you really care? Most people are somewhat intimidated by algorithms, so many choose not to learn them. They think they are too hard to learn, too complicated, and far too much mathematics is involved. In short, they are too complex and beyond most people's ability to understand. If the truth is known, most people realize that algorithms are important but don't know how to learn them. If you keep up with tech news, you'll hear, almost daily, about new algorithms; for example, Uber's latest algorithms for getting the best rides or Google's new and improved search algorithm. We hear about them in interviews and are constantly told that they are complicated but important – mostly by those who probably don't even know what one is, let alone what they are for. As for mathematics, many people assume algorithms are full of it because

they confuse the word "algorithms" with "logarithms," which are full of math and tough concepts. I'm here to tell you that they are not as difficult as you think to learn. I want you to feel comfortable about learning and understanding them, and I want you to push aside any doubt and uncertainty you might feel. The word "algorithm" has been blown up and weighted with far too much complexity. In all truthfulness, they are nothing more than a series of steps that tell us how something you should be done. An algorithm is nothing more than a process that solves problems, such as: Finding specific words in dictionaries Sorting numbers Generating the Fibonacci sequence of numbers Finding all the prime numbers in a list Doing the laundry Baking a pie Making a sandwich Notice those last three? Normal, daily tasks we do daily? They perfectly portray that an algorithm is nothing more than a set of steps to achieve something. Most of the algorithms you hear about or read about in the news are impressive. And many do require you to have a solid knowledge of machine learning, math, and computer science theory. I don't understand all of them, but just because these complicated algorithms exist doesn't mean they are all the same. Some are actually quite easy to understand – when you know how. To start learning algorithmic thinking, simply pick a problem and then choose one of the following two ways to think through how to solve it: Break it down Build it up Break It Down Breaking a problem down into smaller parts doesn't come naturally to everyone, but it is one of the most helpful methods. First, you learn to understand the problem better when you break it down into smaller pieces.

Second, you can easily see some solutions when you understand a problem. This is a great method to use when facing a problem you see as being outside your comfort zone. Let's consider the problem of finding a word in a dictionary – the word is 'discombobulate,' which means 'confused.' We'll assume that our dictionary is nothing more than a list of words, in the English-language sense of what a list is. To find a word, we need to know: 1. Where the search should be started 2. How to start 3. When the search should be stopped 4. How to stop 5. How two items in the list should be compared to work out which one comes before the other 6. How the search should be continued when the word isn't found The better the algorithm, the less time it takes to get from steps 1 to 3, and you won't need to do steps 5 and 6 so often. For our search, we can expand things by breaking the problem down into the following pieces: 1. The order we expect the words to be in, for example, alphabetical order 2. How two words should be compared so we can work out which one comes first in the list 3. How we will know when the right word is found 4. How to know if the word we want does not exist in the dictionary. We can easily assume that we can deal with the first two pieces by using the English alphabetical order for the words. By the same token, using alphabetical order should help us determine the correct word order by using the alphabetical order of the letters in each word. That leaves us with two points. We will know we have found the right word when our search word matches the one at the search position. Today's programming languages can more than determine if two words are identical or if one comes before the other

alphabetically. That deals with point three. For point four, if we complete the search without finding the word, we know it doesn't exist. However, this is based on the assumption that the first three points have been executed correctly. Proof by Induction The big question is, how do you start? Well, the easiest way to begin a problem is to think through it using a small data sample. Make sure it is big enough to give you the right answers but easy to think through. You should also be able to write it out if necessary. Mathematics introduces us to the concept of proof by induction. This is basically the idea that where you can prove a formula will work for 1 item, and assuming it's true for n-items, where n indicates an unknown quantity, you can then go on to prove it for n items. If it works for that, it will work for any number. If we use this for our word search, we can try to make it work for 1 word. Then we can try it with 10 words. If that works, we can use the formula to search any number of words. Build It Up This might be the most tempting method to start with, by using the proof of induction concept. Indeed, the point of any coding is to build the solution, so it makes perfect sense to want to get straight into it. Let me tell you something. In the past, I've dived straight into building up a solution to solve a problem and put hours and days of work into it before I realized I was stuck. I couldn't solve the problem the way I wanted because I failed to understand how the existing code affected the problem. Think of it as baking a pie; you get halfway through and suddenly realize you are missing some ingredients or don't have the right equipment. I learned my lesson the hard way – if I don't have a thorough understanding of the problem, I cannot build an efficient and effective solution. And if I can, it will take me much longer to get there. Once a problem has been broken down into smaller pieces, you can begin to solve it by building the solution. You will need to start by solving each piece individually or solving a couple of pieces simultaneously. At this stage,

things are flexible. I like to start with the easiest piece of the solution as it gives me a framework to use for the rest of it. Using our dictionary search as an example. I might use the following method to build my solution: Write a loop or a recursive function Write the code to help me exit the recursion or loop if the correct word is identified Write the code to help me exit the recursion or loop if the entire dictionary has been searched and the word is not found Write the code that helps me determine the next step should the word not be found, but there is more to the dictionary Fix any boundary conditions, edge cases, and any other issues that arise, such as if an empty list is passed The best places to start are sorting and searching algorithms because these are some of the easiest to learn, building in complexity as they go. Solving Real-World Problems When it comes to solving real-world problems, things aren't quite so obvious. Most coders find solutions for existing code or add new features, not starting from scratch. With real-world problems, you still need to break them down into smaller pieces, and you still need to build the solution piece by piece. However, the solution will also likely require you to design classes and their relevant methods and learn how to put them all together. As you build up slowly from small, simple problems to bigger, more complex ones, each step must be kept isolated and manageable. Changes must be introduced slowly so that when something goes wrong, which it inevitably will, you can easily find the potential cause. You should also consider that, to start with, you might not know how to produce a fix or a new feature and implement it. This is more true for those adding new features or fixing defective code than for those building a solution from scratch. Believe, there have been times when I've spent days, weeks, trying everything possible to work out how a fix or feature should be implemented,

but all I'm left with is something that may work but is inefficient and certainly not pretty. However, all that work allowed me to learn how to go back to the code and rewrite it more efficiently and cleanly. Allow yourself the time to experiment and accept that it will take several attempts to get something that works. Then accept that it will take more work to clean it up and make it more efficient. That is the nature of algorithms, and this book will teach you some of the most efficient ways to learn them.

Chapter 1: Let’s Start with Strategy A word of warning. If you are taking part in a coding interview, algorithmic puzzles are one of the commonest ways interviewers weed the candidates out. Still, if this scenario doesn't apply to you, then settle back and enjoy yourself – these puzzles are similar to crosswords or logic puzzles for coders.

When you try to solve these puzzles, you are faced with challenges you won't encounter anywhere else, not to mention unique concepts that you may not know of. Practicing these challenges helps broaden your problem-solving abilities and solidify a useful process in all walks of life, not just coding. Like any other type of puzzle you try to solve, some strategies can give you an easier way of breaking the problem down. Let's say you are doing a jigsaw puzzle. To make it easier, you might go through the pieces and separate them into groups – same or similar colors, edge pieces, corner pieces, similar features, etc. Then you start from the corners and edges and work your way into the center. If you play Minesweeper, you may make one random click and then work your way around the edges, calculating the clear areas and where the obvious mines are. You randomly click again, but only when you are sure all possibilities have been exhausted. The same thing applies to solving algorithmic puzzles, but while some

strategies will help you approach similar algorithms, I recommend you start from the bottom – learn a broader strategy that you can adopt as a habit. Instead of just diving straight into the whole problem, try approaching it in the following stages: Think First: 1. Analyze it 2. Restate it 3. Write out some input and output examples 4. Break it down into its natural components 5. Use pseudocode to outline it 6. Use the pseudocode to step through the example data Execute It 1. Code it 2. Test it against your sample data 3. Refactor it Let's walk through these one at a time: Analyze It When you first looked at the problem, did you get some kind of insight into how to solve it? If you did, it's your mind making a connection to a previous experience – hold onto it; don't let that insight fade away! Then take the time to look at the problem and look for places where your insight differs from the problem. No matter what a puzzle is, if it is well-written, it will contain everything you need to solve it. But just reading it doesn't mean you understand the problem. And when you don't understand it, you won't have any direction or will try to solve what you think the problem is, not what it actually is. Look at it properly, find the keywords that define the challenge Look for the input and work out the right output Find the keywords and phrases that matter For example, let's say you are given a sorted array of nums and are asked to remove the duplicates. This must be done in place, ensuring each element

only appears once and the new length returned. You cannot allocate additional space for a separate array – this must be done by in-place modification of the input array with [0(1) extra memory]. Input: The input is an array, which tells us iteration will be required An array of numbers – this isn't specifically stated, more implied, and isn't that important because the same conditionals set can be used Return: We need to return the new length of the array after alterations The side effect of this is a modified array Important Words/Phrases: sorted – you will find duplicate elements beside one another remove – the duplicates in-place – we need to modify the array ab destructively, and the inplace constraint tells us the array methods that can be used O(1) extra memory – space complexity is limited to O(1), meaning we can define the variables but not make a copy of our array. Restate It Restate the problem using your own words; that makes it mean something to you. So, if you were attending a coding interview, you would repeat the question to the interviewer using your own words – this tells the interviewer you understood the question and cements it in your mind. Write out some input and output examples All you are doing here is mapping the inputs to the outputs, and your challenge is to work out how to go from A to B. First, though, you need to know what A and B are. If you are provided with test cases, write your own. You understand much more if you write it yourself and do it, rather than just reading it. This is also a good time to ensure you understand a problem and learn how to spot quirks that might get in the way of your intended solution. Consider edge cases, such as empty inputs, arrays populated with duplicated values, vast data sets, etc. If anything is outside of the problem's constraints,

disregard it. Try to write a minimum of three examples: [] -> [], return 0 [1] -> [1], return 1 [1, 1, 2, 3, 4, 4, 4, 5] -> [1, 2, 3, 4, 5], return 5 [1, 1, 1, 1, 1] -> [1], return 1 Do the inputs provide you with sufficient information to map to the result? If not, stand back and look at the problem again. Try to find a simple process that you can apply consistently to get to the outcome, no matter the value. If you wind up with a long-winded series of exceptions and steps, you've taken it too far and overlooked something simpler. Break it down into its natural components Pick the simplest example you can and simplify the problem. Bring it down to nothing more than a puzzle and build on it. In our example, the simplest example is an array containing three elements, two of which are duplicates, for example [2, 2, 3]. When you reduce the problem to the smallest possible case, you will find it much easier to approach – it will also clarify your first step. From there, you need to develop a process to solve the simplest case that will also work for every other case in the problem. First, some things must be done: An array must be iterated through You must know where you are in the array at all times You must look at the adjacent values for equality After the first occurrence, all duplicate values must be destructively removed Return the final array length While this problem is relatively easy, be aware of the "gotcha." Many iteration methods don't like it when elements are removed from an array while it is being iterated through. This is because it changes the index values, and there is a higher chance of a duplicate being missed because the pointer incremented over it.

The "gotcha" in our example indicates that we should use an approach where explicit control of the iteration is given to us. Where a problem is more complicated, we might need some, if not all, the components to be placed into helper functions. This ensures our solution is more concise and clear and allows us to test our sub-parts for validity separately. Use pseudocode to outline it If we understand the problem, know the core tasks, and have identified any flaws in our assumptions, not to mention potential "gotchas," we can move on to the next step. Now we can write our approach in human-readable format, and, once that's done, we should be able to turn it into clean, working code. There's no set way of writing pseudocode; it's entirely up to you. It doesn't matter if your notation isn't quite grammatically correct as long as you have a readable version of your code that others can understand. Pseudocode is used to help provide a roadmap you can refer to if you get lost in the implementation of the actual code, so ensure that what you record is enough to help you later down the line. If you are in a coding interview, this is the time to tell the interviewer what you intend to do, and even if your time runs out, you still have something that shows how you approach a problem. Here are some recommendations: Your pseudocode should begin with a function signature: removeDuplicates :: (Array) -> number If you are whiteboarding your pseudocode, ensure you leave sufficient space for the actual code to be written. If you are using an IDE, ensure you use comments but separate them from your code in the correct way (as per the language you use) to make them easier to refer to later Write your code as a sequence of steps and make good use of bullet points to help you Our task is to find duplicates, so one thing we must do is perform a comparison. We have two choices – look ahead or behind the position, we are currently at in the array. Here's an example: // removeDuplicates :: (Array) -> number

// if the array is empty or only has one element, return the array length and exit // iterate through the array // compare each element to the next // // repeat until false: // if the next element is identical to the current element // remove the next element // // move to the next element in the array // when the second to last element has been reached, stop // return the array length If the array has 0 or 1 element, we exit immediately. This is due in part because the problem conditions are solved. There cannot be a duplicate in an array of 0 or 1 element. Another reason is that our code would break if we tried comparing a value with one that doesn't exist. Next, we need to define the condition upon which we will exit the iteration. We're using a look-ahead in this case, so we must make sure we stop before reaching the final element. Our pointer position is not moved until the duplicates are dealt with, so we should be able to avoid the issue of shifting indices. Use the pseudocode to step through the example data Stop for a minute and run some sample data mentally through the pseudocode: [] -> [], return 0 [1] -> [1], return 1 [1, 1, 2, 3, 4, 4, 4, 5] -> [1, 2, 3, 4, 5], return 5 [1, 1, 1, 1, 1] -> [1], return 1 Has anything been missed? Look at the last line – did you spot a potential issue with it? What would happen if all duplicates were removed and we went to the next

element without looking for any? You must ensure that your end condition is written to catch changes to the array's length.

Code It Now it's time for the real stuff. This is where all your assumptions will come right back into your face, even those you didn't realize you'd made. The better your plan, the less trouble you will have: function removeDuplicates(arr) { if (arr.length < 2) return arr.length return arr.length } I find it better if the return values are put in first; this allows me to see my goal clearly, and you'll also spot that I managed to capture the first case of a 0 or 1 element array. function removeDuplicates(arr) { if (arr.length < 2) return arr.length for(let i = 0; i < arr.length; arr++) {} return arr.length } We've chosen a standard for loop here, although I don't like using them if there is a cleaner alternative. However, our problem dictates that we need to have control over the iteration. function removeDuplicates(arr) { if (arr.length < 2) return arr.length for(let i = 0; i < arr.length; i++) { while (arr[i + 1] && arr[i] === arr[i + 1]) arr.splice(i + 1, 1) } return arr.length } And this works right off the bat, with the exception of: removeDuplicates([0,0,1,1,1,2,2,3,3,4]) //> 6, should be 5 As it happens, I put an existence check in the while loop, and when the array value is 0, the check resolves to falsy. That's JavaScript for you! So, we need

to do something about that; instead of a look-ahead, we'll go for a lookbehind, and you'll notice that this one simple change also cleans up the code: function removeDuplicates(arr) { if (arr.length < 2) return arr.length for(let i = 0; i < arr.length; i++) { while (arr[i] === arr[i - 1]) arr.splice(i, 1) } return arr.length } That works. This solution is memory-efficient because only one variable was defined with the array reference. However, its speed is average, and that could be improved. Mostly, this is nothing more than a simple process you can use for everything: 1. Analyze 2. Restate 3. Write some examples 4. Break the initial problem into small chunks 5. Outline your solution in pseudocode 6. Step through your pseudocode using sample data 7. Code 8. Test 9. Refactor

Chapter 2: Looking for Patterns Algorithm challenges aren't just about algorithms and data structures with standard approaches. They can also fall into categories where similar approaches are suggested for several problems. If you can learn those approaches, it gives you a head start in solving the problem.

Multiple Pointers You will usually start with a single pointer when you first learn about iterating through collections or arrays of items. Its index will point from the lowest to the highest value, and this typically works for a few operations and is easy to understand and code. However, where there are multiple elements, especially those with important positions in the array, using a single pointer to find a corresponding value would mean having to iterate through the entire array one or more times for every single value. This operation is O(n2) – you'll understand this later when we get on to Big O notation. If we used multiple pointers, we have the potential to bring that operation down to O(n). This involves two strategies: Two Pointer What could be better than having a pointer at each end, simultaneously working your way towards the center of the array from both sides. Or what if you could start at one or a pair of values and work your way outwards? These are both great approaches for finding the biggest sequence in an array. Because two points are being handled at the same time, a rule must be defined to ensure that they don’t cross one another: // Time complexity O(n) // Space complexity O(1) function sumZero(arr) { let left = 0; let right = array.length - 1; while (left < right) { let sum = arr[left] + arr[right]; if (sum === 0) return [arr[left], arr[right]]; else if (sum > 0) right--; else left++; } } Sliding Window

Rather than points being placed at the two outer bounds, we could go through the array sequentially, with two pointers moving in parallel. The window's width may expand or shrink, depending on the problem set at hand, but it will progress across the array, taking a snapshot of the best-fitting sequence for the outcome: function maxSubarraySum(array, n) { if (array.length < n) n = array.length; let sum = 0; for (let i = 0; i < n; i++) { sum = sum + array[i]; } let maxSum = sum; // shift the window across the array for (let i = n; i < array.length; i++) { sum = sum + array[i] - array[i - n]; if (sum > maxSum) maxSum = sum; } return maxSum; }

Divide and Conquer This approach often requires us to use recursion, which means the same rule is applied to divide the array until it is broken down into the smallest possible components and the solution is identified. We'll discuss divide and conquer later in the next chapter and recursion, but Merge Sort and Binary Search are two of the best algorithms to use.

O(1) Lookup: Object/Dictionary/Hash Depending on your programming language, dictionaries, hashes, or objects are great tools to store data when you need to find frequency, look for duplicates, or find an answer's complement. The value you find can be stored, or you could store the value you want to find. For example, if you search an array for zero-sum pairs, the complement could be stored rather than the actual value.

Chapter 3: Divide and Conquer This chapter will dive into the Divide and Conquer (DAC) technique and look at how helpful it is in solving certain problems. We'll also look at how it compares to Dynamic programming.

We can divide this technique into three parts: 1. Divide – the problem is divided into smaller problems 2. Conquer – recursion is used to solve the smaller problems 3. Combine – the solutions for all the smaller problems are combined to solve the whole problem. Before we look at the technique, here are some of the more common algorithms that use it: 1. Quicksort – a popular sorting algorithm. Quicksort chooses a pivot element and places the array elements in an order where those smaller than the pivot are to the left of it, while those larger are to the right of the pivot. The subarrays on either side of the pivot are recursively sorted.

2. Merge Sort – another sorting algorithm, this one splits the array in half. Each half is recursively sorted, and both sides are merged. 3. Closest Pair of Points – in this problem, a set of points in the x-y plane are examined to find the closest pair of points. This takes O(n^2) time to solve because the distance must be calculated for every pair of points and all distances compared to find the smallest. By using DAC, we can solve the problem in O(N log N). 4. Strassen's Algorithm – one of the most efficient algorithms for multiplying a pair of matrices. A simple technique takes three nested loops and runs in O(n^3), while Strassen's takes O(n^2.8974). 5. Cooley-Tukey FFT – this is the commonest Fast Fourier Transform algorithm, working in O(N log N) time. 6. Karatsuba – this algorithm is one of the best for fast multiplication. It multiplies two n-digit numbers in no more than 3nlog ≈ 3n1.585 singledigit multiplications, and when n is exactly the power of 2. That makes it faster than a classical algorithm that needs exactly n2 single-digit numbers. Specifically, where n = 210 = 1024, the counts are respectively 310 = 59, 049, and 210(2) = 10485876 What Doesn't Qualify as Divide and Conquer? Binary Search does not qualify as a divide and conquer technique, regardless of what many people think. It is a searching algorithm where the input element x is compared in each step with the array's middle element value. Where the values match, the middle element index is returned. If not, where x is lower than the middle element, Binary Search will recur on the middle element's left side. So, why isn't this DAC? Because each step has only a single sib-problem, where DAC requires at least two. That makes Binary Search a Decrease and Conquer algorithm instead.

Divide and Conquer Algorithm: DAC(a, i, j) { if(small(a, i, j)) return(Solution(a, i, j)) else m = divide(a, i, j) // f1(n) b = DAC(a, i, mid) // T(n/2) c = DAC(a, mid+1, j) // T(n/2) d = combine(b, c) // f2(n) return(d) } Recurrence Relation: This is for the program above: O(1) if n is small T(n) = f1(n) + 2T(n/2) + f2(n) Here's an example: In a given array, we want to find the maximum and minimum elements: The input is: { 70, 250, 50, 80, 140, 12, 14 } The output is the minimum number in the array: 12 And the maximum number: 250 How do we approach this problem? It's simple – the divide and conquer technique is the best way to find the array's minimum and maximum element. Here's how we do it: The Maximum: Finding the maximum element requires the use of recursion until only two

elements are remaining. At that point, the condition can be used to find the maximum, i.e.: if(a[index]>a[index+1].) In a coded program, using the condition: a[index] and a[index+1]) ensures that we are left with just two elements. if(index >= l-2) { if(a[index]>a[index+1]) { // (a[index] // We can now say that the final element is the maximum in the specified array } else { //(a[index+1] // We can now say that the final element is the maximum in the specified array } } In this condition, the left side was checked to find the maximum. Next, we do the same with the right side. We use a recursive function on the right side of the array’s current index: max = DAC_Max(a, index+1, l); // Recursive call Next, the condition is compared, and the right side is checked at the current index. This logic will be implemented to check the condition: // Right element will be the maximum.

if(a[index]>max) return a[index]; // max is the maximum element in the specified array. else return max; } The Minimum: The recursive approach will be used to find the minimum in the specified array: int DAC_Min(int a[], int index, int l) /a recursive call function to find the minimum number in the specified array if(index >= l-2) // this will check the condition to make sure there are two elements on the left So we can find the minimum easily in the specified array { // here the condition is checked if(a[index] '; currentNode = currentNode.getNextNode(); } output += ''; console.log(output); } } Take a little time to familiarize yourself with this code and ensure you understand it, especially if you have never worked in JavaScript. We have included several methods: Adding a new node to the list's head Removing a node from the head Adding a new node to the list's tail Adding a new direction after a specified node, for example, you might want to add a stop for fuel or lunch Printing the list Let's Go to Citi Field I used to travel from my home in Hoboken to Citi Field in Queens to watch the New York Mets play. That route was pretty long, requiring buses, trains, and the subway, and I would use Google Maps to get me there. When I input my starting location and destination, Google Maps goes to work. A complex algorithm is used to work out all potential routes, returning the best options in terms of time. Really though, all Google Maps does is

send a linked list to your GPS device. Let's see if we can build this journey: const LinkedList = require('./LinkedList'); const getToCiti = new LinkedList(); //a new Linked List is created for these directions getToCiti.printList() >> ' '//if the printList method is called now, the list is empty but defined getToCiti.addToHead('Take 126 Bus to the PATH'); getToCiti.printList(); >> Take 126 Bus to the PATH Station > getToCiti.addToTail('Take Path to 23rd'); getToCiti.addToTail('Take the M to 5th Ave'); getToCiti.addToTail('Take the 7 to Mets-Willets Point'); getToCiti.printList(); >> Take 126 Bus to the PATH Station > Take the M to 5th Ave > Take the 7 to Mets-Willets Point

That's quite simple but also cool. We wrote a function that can traverse the nodes in the right order and print them all out. Going Wrong or Adding a Stop We reach New York City and find, probably predictably, that one of the subways is shut. Or perhaps we decide we want some pizza before we get to the game. Either way, a detour is required. Google Maps can deal with this easily by adding a node to the list. Let's say we want to add a pizza stop after we exit the M-line. We can do this by using a method called .addAfter. getToCiti.printList();>> Take 126 Bus to the PATH Station > Take the M to 5th Ave > Take the 7 to Mets-Willets Point

getToCiti.addAfter("Take the M to 5th Ave", "Grab slice from Joe's")getToCiti.printList();>> Take 126 Bus to the PATH Station > Take the M to 5th Ave > Grab pizza from Joe's > Take the 7 to Mets-Willets Point

We pass in the new direction we need to go in to make a detour and the place we want to detour to, thus adding in a new stop to get pizza. Then, the next node for that new stop is assigned to the next step, taking the number 7 train. The print method continues to function as normal, and there is no disruption to the program flow.

Other Uses While navigation is one of the best examples of using linked lists, there are plenty of other uses. These include: Going through your computer’s file structure to find a specific folder Navigating websites and their pages on your computer browser Music players that offer you ‘previous’ and ‘next’ buttons Operations that allow you to undo or redo an action A deck of cards

Chapter 6: Stacks and Queues To get anywhere in software development, you must understand many different types of data structures to know how data is stored and processed. Stacks and queues are common, linear data structures you will frequently encounter.

But how do you know which one to use? To know that, you need to understand each structure and their differences.

What Is a Stack? In computer science, the stack is much like a stack of things, like plates, in the real world. It is linear in nature and is much like the linked list and array in that random element access is restricted. In a linked list or array, elements can be accessed using two methods – random indexing or traversal. However, in stacks, neither method is possible. At best, we can understand a stack by looking at it as a container full of pieces that you can only stack one on top of another and then remove them in the same direction. Think of a stack of books. You can stack books on top of one another but only from the top – you can't stack them from beneath (you can, but it would soon get difficult!) This represents sequential access to the books, which is the same as the computer science stack.

Representation of Stacks Stacks are LIFO data structures, which means Last In First Out. In simple terms, the element inserted into the stack last is the first to be removed from the top of the pile, and the element inserted first, at the bottom of the stack, is removed last. This is why stacks require only one pointer: only the top element must be remembered. Basic Operations

Stacks and queues can perform a set of basic operations, such as storing data elements and manipulating them. These are the functions that can be performed on a stack: Push(arg): During the push() process, elements are added to the top. If you wish to place an element into a stack, you must pass it to the push() method. Here's how the operation works: 1. First, push() checks if the stack is complete 2. If it is, the operation exits, and an overflow condition is produced 3. If it isn't, the top is incremented by one, and the operation points to the next space 4. The data element is added to the space

5. The operation is returned as a success. Here's the push() algorithm: begin :stack, data_element If the stack is full return null end if top rear) return true; else return false; } Enqueue() The Enqueue() operation is used to add or insert elements into the queue. This is always done using a rear pointer. There are two pointers in an Enqueue operation – FRONT and REAR. Here are the steps: 1. The operation checks whether the queue is full 2. If it is, the operation exits, and an overflow condition is produced 3. If it isn’t, the rear is incremented, and the operation points to the space 4. The data element is added where the rear pointer indicates 5. The operation is returned as a success Here’s the Enqueue() algorithm: Begin enqueue(data_element) If the queue is full Overflow end if rear > N >> T; for(int i = 0;i < N;++i) cin >> A[i]; sort(A, A + N); for(int i = 0;i < N;++i) { currentTime += A[i]; if(currentTime > T) break; numberOfThings++; } cout ( P[2] / T[2] ) > …. > ( P[N] / T[N] ) Assumption two ensures we have a greedy schedule of A = ( 1, 2, 3, …., N). A is not the optimal solution, as you know from above, and A and B are not equal – B is the optimal solution – so it's fair to say the following: "B must include (i, j), two consecutive jobs, such that the first of the two jobs has the biggest index – (i > j)." This is true, but why? Because A is the only schedule with the Property whose indices only increase – A = ( 1, 2, 3, …., N). That means B = ( 1, 2, …, i, j, …, N ) where i > j. You must also consider the impact in terms of profit/loss if the jobs were swapped. Consider this effect on the following completion times: Any work on k that isn't j and j Work done on i

Work done on j There are two cases for k: In B, k is to the left of i and j – if i and j were swapped, the completion time for k would not change In B, k is to the right of i and j – when i and j are swapped, k has a new completion time of k is C(k) = T[1] + T[2] + .. + T[j] + T[i] + .. T[k], where k stays the same In terms of i, before they were swapped, its completion time was C(i) = T[1] + T[2] + ... + T[i]. However, after the swap, it becomes C(i) = T[1] + T[2] + ... + T[j] + T[i] It's clear that i's completion time increase by T[j], while j's decreases by T[i]. The swap causes a loss of (P[i] * T[j] and a profit of (P[j] * T[i]). If we use the second assumption, i > j implies that ( P[i] / T[i]) , ( P[j] / T[j] ). That means that P[i] * T[j] ) < ( P[j] * T[i] ), which leads to Loss < Profit. So, while B is made better by the swap, it is a contradiction because we already assumed B was the optimal schedule. That finishes the Proof. Where Greedy Algorithms Are Used For greedy algorithms to be the best choice, the problem must have the following components: 1. Optimal Substructures – a problem's optimal solution will contain the sub-problems' optimal solutions 2. Greedy Property – you won't easily prove correctness. If the decision you make seems to be the best one at the time, and you can solve the rest of the sub-problems later, you can still achieve the optimal solution. It means never having to reconsider any earlier choices you made.

Chapter 9: Dijkstra’s Algorithm Time to look at a popular algorithm – Dijkstra's algorithm for the shortest path. This chapter will include a look at basic graphs, what we use Dijkstra's for, and how it all works. Let's get started.

Brief Introduction to Graphs Graphs are another data structure we use when we want connections between two elements represented. In a graph, the elements are nodes, and the connections between them are the edges. The nodes are used to represent real people or objects. Here is a representation:

The nodes are the circles, while the edges are the lines connecting each circle. That's all the detail you need here because we already covered some of this earlier in the book. Graph Applications We can easily apply graphs to many real-life scenarios. Take a transport network, for example. Each node is a facility where products are sent and received, while the edges are the roads/paths/routes connecting each facility. Graph Types There are two types of graphs: Undirected – each connected pair of nodes allows you to visit each node in either direction.

Directed – each connected pair of nodes allows you to go in only one direction. In these graphs, the edges are represented with arrows.

In this chapter, we'll use the first type, the undirected graph. Weighted Graphs Weighted graphs have costs or weights on the edges. An edge's weight may indicate anything that shows the connection between the nodes it connects, such as time or distance.

In the graph above, the weights are indicated by the numbers on each edge. These are critical in Dijkstra's, and you will see why now.

Introducing Dijkstra’s Algorithm With basic knowledge of graphs under your belt, we can look at one of the best-known and cleverest algorithms – Dijkstra's. This algorithm allows you to find the shortest path between two or more nodes. In particular, it allows you to find the shortest distance between one node and all others, giving you a shortest-path tree. Dijkstra's algorithm is commonly used in GPS devices, such as Maps apps on mobile devices that help you find the shortest route from your start point to your destination. History The man responsible for Dijkstra's algorithm is Dr. Edsger W. Dijkstra, a Dutch software engineer and computer scientist. His new algorithm was explained and presented in 1959 when he published a short article, "A note on two problems in connection with graphs." In 2001, he explained why he had designed his algorithm and how. The following is a direct quote from the interview: "What's the shortest way to travel from Rotterdam to Groningen? It is the algorithm for the shortest path, which I designed in about 20 minutes. One morning I was shopping in Amsterdam with my young fiancée, and tired, we sat down on the café terrace to drink a cup of coffee, and I was just thinking about whether I could do this, and I then designed the algorithm for the shortest path. As I said, it was a 20-minute invention. In fact, it was published in 1959, three years later. The publication is still quite nice. One of the reasons that it is so nice was that I designed it without pencil and paper. Without pencil and paper, you are almost forced to avoid all avoidable complexities. Eventually, that algorithm became, to my great amazement, one of the cornerstones of my fame." It took Dr. Dijkstra only 20 minutes to design one of computer science's most famous algorithms. Incredible! The Basics So, let's break down how Dijkstra's algorithm works: The algorithm begins at your chosen node, which is the source node. From there, it will analyze the graph, looking for the shortest path

between the source and every other node. The algorithm tracks the current shortest distance between the source and all other nodes, ensuring the values are updated if a shorter path is found. When the shortest path has been found, the algorithm marks the node as "visited" and adds it to the path This continues until every node is added. This way, the path connects every node to the source in the shortest possible path. Requirements Dijkstra's algorithm has certain requirements. It can only work on graphs with positive weights because finding the shortest path requires adding all the edge weights. The algorithm cannot work correctly if one or more edges have a negative weight. When a node is "visited," the current path leading to that node becomes the shortest path to it. Negative weights simply alter that, where the weight can be decreased after that step.

Example of Dijkstra's Algorithm So, how does Dijkstra's algorithm work? Let's look at it step by step:

2

5

15

6

6

6

8

10

2

Take the graph above. Here, the edge weights are assumed to be the distances between the nodes, and the algorithm will work out the shortest path between node 0 and all other nodes. The algorithm will tell us the shortest path between node 0 and node 1, node 0 and node 2, and so on until all the nodes are used. To start with, we will have the following list of distances:

0: 1: 2: 3: 4: 5: 6:

0 ∞ ∞ ∞ ∞ ∞ ∞ The distance between the source node and the source node is 0. We have not yet worked out the distances between the source node and the others

We also have another list that helps us track the unvisited nodes, i.e., those not yet included in the path: UNVISITED NODES: {0, 1, 2, 3, 4, 5, 6} Don't forget; once all the nodes are added to the path, the algorithm is finished. Because we have started at node 0, that one can be marked off – we've visited it. So, it gets crossed off the list and a border added to the corresponding graph node: UNVISITED NODES: {0, 1, 2, 3, 4, 5, 6}

2

5

15

6

6

6

8

10

2

The next step is to look at the distance between the source (0) and the nodes adjacent to it. You can see from our graph that those nodes are 1 and 2:

2

5

15

6

6

6

8

10

2

Don't think this means the adjacent nodes are immediately added to our shortest path. Before we can do that, the algorithm needs to make sure the shortest path to the node has been found. All we do at this stage is examine it to see what options are available.

The distances between the source node and node 1 and the source node and node 2 must now be updated with the connecting edge weights. Those are 2 (node 0 to node 1) and 6 (node 0 to node 2).

0: 1: 2: 3: 4: 5: 6:

0 ∞ ∞ ∞ ∞ ∞ ∞

2 6

Once the distances are updated, we need to do three more things: Choose the closest node to the source based on the distances we already know Mark the node as visited Add the node to the path Looking at our distances list, we can see that the shortest distance to the source node is node 1, with a distance of 2, which is added to the shortest path. We represent this by changing the color of the circle on our graphical representation:

2

5

15

6

6

6

8

10

2

And our distance list is updated to show that the node has been visited and we located its shortest path – we use a red circle to denote this:

0: 1: 2: 3: 4: 5: 6:

0 ∞ ∞ ∞ ∞ ∞ ∞

2 6

Then cross it off our unvisited nodes list: UNVISITED NODES: {0, 1, 2, 3, 4, 5, 6} Next, we look at the new adjacent nodes to see the next shortest path. We only need to analyze those adjacent to the nodes already added to the shortest path, i.e., those on the red path. We can see that the next nodes we need to analyze are 2 and 3 because node 2 is directly connected to node 0, and node 3 is directly connected to node 1. We already know the distance between nodes 0 and 2 because it's in our list. That means the distance doesn't need to be updated; we only need the new distance from 0 to 3 updated:

0: 1: 2: 3: 4: 5: 6:

0 ∞ ∞ ∞ ∞ ∞ ∞

2 6 7

We updated it to 7, but that number doesn't appear on our graph

representation. So, how did we get it? When we want to find the distance between node 0 to another one (node 3, in this case), the weights on every edge on the shortest path to the node are added: Node 3: the distance is 7 because the weights on the path's edges are added – 0 -> 1 -> 3. Edge 0 to 1 is 2, and edge 1 to 3 is 5, which equals 7. Now we have our distances, and we need to determine the node we will add to the path. The chosen node should be the unvisited node with the shortestknown distance back to 0. From our distances list, we can see that this is node 2, which has a distance of 6:

0: 1: 2: 3: 4: 5: 6:

0 ∞ ∞ ∞ ∞ ∞ ∞

2 6 7

So it is added to our graphical representation with a red border and edge:

2

5

15

6

6

6

8

10

2

Our distance list is updated with a red circle to indicate it has been visited, and the unvisited nodes list is also updated:

0: 1: 2: 3: 4: 5: 6:

0 ∞ ∞ ∞ ∞ ∞ ∞

2 6 7

UNVISITED NODES: {0, 1, 2, 3, 4, 5, 6} Now it's time to repeat all that to find the next shortest path, between node 0 and node 3, which is the next adjacent node. There are two possible paths: 0 -> 1 -> 3 0 -> 2 -> 3 How do we determine which of these is the shortest path? You can see that we have two possible paths 0 -> 1 -> 3 or 0 -> 2 -> 3. Let's see how we can decide which one is the shortest path.

2

5

15

6

6

6

8

10

2

We already have a distance for node 3 in our list – 7. We got this distance because of a previous step, where the edge weights 2 and 5 were added to get the path 0 -> 1 -> 3. Now we have another choice. If we follow 0 -> 2 -> 3, there are two edges. They are 0 -> 2 with a weight of 6 and 2 -> 3 with a weight of 8. If we add those weights, we get 14.

0: 1: 2: 3: 4: 5: 6:

0 ∞ ∞ ∞ ∞ ∞ ∞

2 6 7 this comes from (5+2) vs. 14 from (6+8)

It's clear that the existing distance is the shorter of the two, so that's the path we choose to keep. Remember, the distance only needs to be updated if the new path is shorter than the existing one. So, this node needs to be added to the path using the first calculation – 0 -> 1 -> 3

2

5

15

6

6

6

8

10

2

This node is now marked as visited in our distances list and removed from the unvisited nodes list:

0: 1: 2: 3: 4: 5: 6:

0 ∞ ∞ ∞

2 6 7

∞ ∞ ∞

UNVISITED NODES: {0, 1, 2, 3, 4, 5, 6} And rinse and repeat! Onto the next unvisited adjacent nodes – that would be node 4 and node 5 because they are both adjacent to node 3. The distances between the source node and these are updated, once again looking for that shortest path: Node 4 – distance is 17 – 0 -> 1 -> 3 -> 4 Node 5 – distance is 22 – o -> 1 -> 3 -> 5 Note that only the shortest path can come under consideration. Paths that run

through edges not added to the shortest path cannot be considered. Let’s add those distances to our list:

0: 1: 2: 3: 4: 5: 6:

0 ∞ ∞ ∞ ∞ ∞ ∞

2 6 7 17 – this comes from (2+5+10) 22 – this comes from (2+5+15)

We must determine the unvisited node to mark as visited; in our case, it will be node 4. This is because it has the shortest distance in our list, so we mark it on our representation:

2

5

15

6

6

6

8

10

And we update our distances list with a red circle:

0: 1:

0 ∞

2

2

2: 3: 4: 5: 6:

∞ ∞ ∞ ∞ ∞

6 7 17 22

And remove it from the unvisited nodes list: UNVISITED NODES: {0, 1, 2, 3, 4, 5, 6} And repeat again! Now we check nodes 5 and 6, analyzing all the possible paths to them from the nodes we visited and added to the shortest path. Node 5: First, we could follow 0 -> 1 -> 3 -> 5, with a distance of 22 – (2+5+10+6). We recorded this distance in our list previously. Second, we could follow 0 -> 1 -> 3 -> 4 -> 5, with a distance of 23 – (2+5+10+6) It's clear that the shortest path is the first one, so that's the one we choose for node 5. Node 6: The only available path is 0 -> 1 -> 3 -> 4 -> 6, with a distance of 19 – (2+5+10+2) On our distances list, we mark the shortest distance (node 6) as visited:

0: 1: 2: 3: 4: 5:

0 ∞ ∞ ∞ ∞ ∞

2 6 7 17 22

6:



19

And remove it from the unvisited nodes list: UNVISITED NODES: {0, 1, 2, 3, 4, 5, 6} And lastly mark the path in red:

2

5

15

6

6

6

8

10

2

We only have one unvisited node now, node 5. How do we get this included in our path? We can take three separate paths from the visited nodes to node 5: 1. 0 -> 1 -> 3 -> 5, a distance of 22 (2+5+15) 2. 0 -> 1 -> 3 -> 4 -> 5, a distance of 23 (2+5+10+6) 3. 0 -> 1 -> 3 -> 4 -> 6 -> 5, a distance of 25 (2+5+10+2+6) The shortest path is clearly the first one, a distance of 22.

2

5

15

6

6

6

8

10

2

Once again, the node is marked as visited and removed from the unvisited nodes list:

0: 1: 2: 3: 4: 5: 6:

0 ∞ ∞ ∞ ∞ ∞ ∞

2 6 7 17 22 19

UNVISITED NODES: {0, 1, 2, 3, 4, 5, 6} And that brings us to the end of the algorithm – we have our shortest path from the source node (0) to all other nodes.

2

5

15

6

6

6

8

10

2

We follow the red lines, which indicate the shortest path from 0 to the other nodes. For example, if you wanted to get to node 6, you would follow the red lines that take you there – 0 -> 1 -> 3 -> 4 -> 6. Let’s head to the forest for a while.

Chapter 10: Getting Lost in Random Forests Random forests are one of the most flexible algorithms, not to mention the easiest to use, and they will give you top results without tuning hyperparameters as you do with other algorithms. It is simple, diverse, and well-suited to regression and classification tasks.

An Introduction to Random Forests Random forest is classed as a supervised learning algorithm. The forest is simply a collection of decision trees trained using bagging – a method whereby the overall result is increased using several learning models. In simple terms, that means several decision trees are built and merged to give a stable, more accurate prediction. One of the biggest advantages of using the random forest is that it works for regression and classification, which covers most machine learning systems. We'll start by looking at how it works in classification. This is what a random forest looks like with four trees:

Random forests, decision trees, and bagging classifiers all have nearly identical hyperparameters. Thankfully, we do not need to combine decision trees and bagging classifiers because the random forest has a neat classifier class we can make easy use of. If you want to use random forest on a regression task, simply use the regressor provided by the algorithm. Using a random forest gives the model extra randomness when the trees are growing. Rather than hunting down important features while the nodes are

being split, it looks at a random feature subset to find the best features. This gives us the diversity needed for a more efficient model. Real-Life Example Bob wants to take a 12-month vacation and needs to decide where he should go. To help him make his decision, he speaks to people he knows and asks for suggestions. The first person he speaks to asks Bob what he did and didn't like during his previous vacations. Based on how Bib answers, the friend will give him a few suggestions. This is how a typical decision tree works. Based on Bob's answers, the friend came up with some rules to ensure his decision was guided on what to recommend to Bob. Next, Bob speaks to other people, asking for advice, each asking him a different question to help them give him the best recommendations. Lastly, Bob picks the most recommended places – that's how a random forest works. The Importance of the Random Forest Algorithm Another great, random forest quality is that the algorithm makes measuring each feature's relative importance on the prediction. One of the best tools for this is Sklearn, helping you measure importance by examining how much impurity is reduced across the entire forest by the tree nodes using a specific feature. The score is determined for every feature automatically once training is complete and the result is scaled, ensuring the sum of all importance equals one. For those that don't know how decision trees work or what a node or leaf is, Wikipedia explains it like this: "In a decision tree, each internal node represents a 'test' on an attribute (e.g., whether a coin flip comes up heads or tails), each branch represents the outcome of the test, and each leaf node represents a class label (decision taken after computing all attributes). A node that has no children is a leaf." When you examine the importance of the features, you can determine which ones could be dropped. You might decide to drop a feature because it offers little or, in some cases, nothing to the prediction. This is critical – one of the most important rules in machine learning is that the more features you include, the more likely it is your model will overfit. Conversely, too few

features and your model will likely underfit.

Random Forests vs. Decision Trees In theory, a random forest is simply a series of decision trees, so they are much the same, right? Well, no, they are not; there are some differences.

If you pass a decision tree a training dataset containing labels and features, the tree will come up with a set of rules which are then used in making a prediction. Let’s say we want to predict the likelihood of a person clicking an advert online. In that case, you might gather in the ads they previously clicked on and a few features that tie in with their decisions on clicking those ads. If those features and labels were fed to a decision tree, rules would be generated to help predict if the person will click the ad. In contrast, a random forest chooses random features and observations, builds multiple trees, and takes an average of the results from all of them. The second main difference is that overfitting is common in deep decision trees. Random forests create random feature subsets to prevent this from happening, and they use those subsets to build small trees and then combine them. Do note that this won't be the case all the time and will slow down computation; that will depend on how many trees are built.

Important Random Forest Hyperparameters Random forest hyperparameters serve one of two purposes – to increase the model's predictive power or to speed up the mode. Here are the important hyperparameters you will find in the random forest function in Sklearn: 1. Increase Predictive Power The first hyperparameter for this is n_estimators, indicating how many trees the random forest builds before the maximum vote or prediction average can be taken. Generally, the more trees, the higher the performance and the more stable the predictions. However, the downside is that the computation is slower. The next important hyperparameter is max_features, indicating the maximum number of features considered by the random forest before the node is split. Finally, we have min_sample_leaf, which determines the least amount of leafs required for an internal node to be split. 2. Making the Model Faster Another hyperparameter called n_jobs lets the engine know the number of processors it can use. If the value is 1, the engine can only use one processor, but if it is -1, there are no limits. A hyperparameter called random_state ensures the output from the model is replicable. When the model's random_state value is definite, the same results will be produced, so long as it has the same training data and hyperparameters. The last hyperparameter is a random forest cross-validation method called oob_score, otherwise known as oob sampling. In this, two-thirds of the data are used in training the model, leaving a third to evaluate the model's performance. These are known as out-of-the-bag samples, and the method is similar to a cross-validation method called leave-one-out but without extra computational burdens.

Advantages and Disadvantages The random forest algorithm has several advantages and disadvantages: Advantages: Perhaps the biggest advantage is the algorithm's versatility – the fact that you can use it in both classification and regression problems. You can also easily see the importance the algorithm assigns to input features. The random forest algorithm is good because it uses default hyperparameters that produce accurate predictions. It is easy to understand these hyperparameters, and there are few of them, making it even easier. In machine learning, one of the biggest issues we face is overfitting, but the random forest classifier prevents this on almost all occasions. Provided the model has a decent number of trees, overfitting cannot happen. Disadvantages: If the algorithm has one important limitation, it's that more trees equals a slower speed and ensures the random forest is not so efficient or effective at making real-time predictions. Generally, random forest algorithms are relatively quick to train but once trained, they take a while to produce predictions. If you want more accuracy, you need more trees, which results in an even slower model. It is fast enough for most of its real-world applications, but there may be times when its performance in run-time is more important, which means another approach should be used. Random forest is also classed as a predictive and not descriptive modeling tool, meaning you need to look elsewhere if you want your data relationships described. Use Cases Random forest is used in many fields, such as medicine, banking, ecommerce, and the stock market. For example, in finance, the algorithm helps detect customers who are likely to pay debts on time or those who are likely to use more of the bank's services. It is also used to determine fraudsters. In the trading field, it is used to predict a stock's future behavior. In healthcare, it helps identify the right combination of components in medicine and helps predict diseases based on a patient's medical record and history. Lastly, ecommerce helps predict whether a customer will buy a certain product.

The random forest algorithm is a good one for early training in the process of developing a model to see its performance. Because it is such a simple algorithm, it means that you'll find it hard to build anything other than a good forest. It's also the best choice for developers who need a model developed quickly and also tells you the importance assigned to features.

Chapter 11: The Best Sorting Algorithms Sorting algorithms are among the most commonly used in computer sciences. They are, quite simply, a series of instructions that a list or array as the input and place the elements in a specified order. Typically, this will be in alphabetical, lexicographical, or numerical order and may be in ascending or descending order.

They are among the most important algorithms because they can help reduce a problem's complexity. They are directly used in many other algorithms, such as divide and conquer, database, searching, data structure, and more. When you want to use a sorting algorithm, you must ask yourself some questions to determine the right one. You need to know the size of the collection you need to be sorted, the available memory, and whether the collection needs room to grow. When you can answer these, you can choose the right sorting algorithm for the problem. Bear in mind that some, including Merge Sort, need more memory or space than others, while others, like Insertion Sort, are slower but need fewer resources.

Before determining the sorting algorithm to use, work out your requirements and your system's limitation. Some of the commonest algorithms are: Bubble Sort Bucket Sort Counting Sort Heap Sort Insertion Sort Merge Sort Quick Sort Radix Sort Selection Sort Before we look at these, we need to understand how sorting algorithms are classified.

Classification of a Sorting Algorithm Five parameters are used to classify sorting algorithms: 1. How many inversions or swaps are needed: this indicates how often elements need to be swapped for the input to be sorted. The algorithm requiring the least swaps is Selection Sort. 2. How many comparisons are needed: this indicates how often elements need to be compared for the input to be sorted. In the bestcase scenario, the algorithms mentioned above need a minimum of O(nlogn) comparisons, while the worst case requires O(n^2) for most inputs. 3. Whether recursion is used: some algorithms sort the inputs using recursion, such as Quick Sort. However, others don't use recursion, such as Insertion Sort and Selection Sort. Lastly, Merge Sort and other similar algorithms use a combination of recursive techniques with nonrecursive. 4. Whether the algorithm is stable: if an algorithm is stable, it can easily maintain order in a series of elements with equal keys or values. If it isn't stable, it cannot maintain that order. Let's say, for example, that your input array is [1, 2, 3, 2, 4]; there are two equal values, and we want to differentiate between them, so we update them, renaming them 2a and 2b. The array is now [1, 2a, 3, 2b, 4]. If the algorithm is stable, the order of 2a and 2b are maintained. If it isn't, the algorithm might change the order to [1, 2b, 2a, 3, 4]. Examples of stable algorithms are Merge Sort, Insertion Sort, and Bubble Sort, while examples of unstable algorithms are Quick Sort and Heap Sort. 5. How much additional space is needed: some algorithms can sort lists without creating a new array, so they don't need extra space. This process is called in-place sorting, and the algorithms need a constant O(1) additional space. In contrast, out-of-place sorting requires the algorithm to create another list. Two examples of in-place sorting algorithms are Quick Sort and Insertion Sort, as both use a pivot point to move the elements around, and neither created new arrays. Merge Sort is one of the most common out-of-place algorithms where the input size must be set at the beginning, allocating it for the output to be

stored throughout the sorting process. This means additional memory is required.

Popular Sorting Algorithms Let's dive into some of these algorithms and see how they work: Bucket Sort

This popular comparison sort algorithm divides elements into buckets and sorts each bucket individually. A separate algorithm is used for each algorithm, for example, Insertion Sort, depending on what needs to be done or by recursively applying Bucket Sort. Think of sorting a pack of cards into suits and then by rank. First, you divide the pack into the four suits, and then each pile is sorted into rank order – or any other order you might want. Bucket Sort is best used when the input has been distributed uniformly. For example, let's say you have an array filled with floating-point integers, all uniformly distributed between upper and lower bounds. You could use Bucket Sort to sort this array in O(n) time. Here’s the pseudocode: void bucketSort(float[] a,int n) { for(each floating integer 'x' in n) { insert x into bucket[n*x]; } for(each bucket) { sort(bucket); }

} Counting Sort

How does it work? This algorithm starts by creating a list containing each occurrence or count of every unique value from another list. Then it will create a sorted list based on those counts. You can only use the counting sort algorithm when you already know the input's possible values. Here's an example Let's say you start with a list containing integers from 0 to 5: input = [2, 5, 3, 1, 4, 2] The first step is to create a new list containing a count of every unique value in the list. We already know that our inputs range from 0 to 5, so you can go ahead and create your list with 6 placeholders, one for each value: count = [0, 0, 0, 0, 0, 0] # val: 0 1 2 3 4 5 Next, you must iterate the index for each individual value in the input list. For example, in your original input list, the first index is value 2. So, a 1 is added at index 2 in the count list – don't forget, indexing starts from 0: count = [0, 0, 1, 0, 0, 0] # val: 0 1 2 3 4 5 Our next value is 5, so 1 is added to the final index in the new count list: count = [0, 0, 1, 0, 0, 1] # val: 0 1 2 3 4 5 Repeat until every value has a total count: count = [0, 1, 2, 1, 1, 1] # val: 0 1 2 3 4 5

In this case, we already know the number of values the input list contains, so creating a sorted list is simple. The count list is looped through, and the corresponding value for each count is added to the output array the number of times it appears. For example, our input list did not have any 0s, but 1 appeared once. So you add a value of 1 to the output array: output = [1] We also saw that 2 appeared twice, so these values are added to the output list: output = [1, 2, 2] Repeat until you have created a final list containing the outputs. output = [1, 2, 2, 3, 4, 5] Properties The counting sort algorithm has these performance and other properties: Space complexity: O(k) Performance – best-case: O(n+k) Performance – average-case: O(n+k) Performance – worst-case: O(n+k) Stable: Yes , because k is in the array’s element range. JavaScript Implementation let numbers = [1, 4, 1, 2, 7, 5, 2]; let count = []; let output =[]; let i = 0; let max = Math.max(...numbers); // initialize counter for (i = 0; i = 0; i--) { output[--count[numbers[i]]] = numbers[i]; } // output sorted array for (i = 0; i < output.length; i++) { console.log(output[i]); } Insertion Sort This is one of the simplest sorting algorithms when you only need to sort a few elements.

A real-world example of insertion sort is holding several playing cards you want to put in order. You remove a card from the hand, move all the other cards and insert the extracted card in the right place. This is repeated until all the cards are in the right order. In algorithmic terms, the key element is compared with previous elements. If they are greater, the previous element is moved to the next position. We begin at index 1 to get the size of the array: [835142] Here's how to do it: Step One key = 3 //start from the 1st index. We compare 'key' with all the previous elements; in our case, it is compared with 8. Because 8 > 3, 8 is moved to the next position, and 'key' is inserted in the previous position. The result of this is: 385142] Step Two key = 5 //2nd index Because 8 > 5, 8 is moved to index 2, and 5 is inserted in the first index. [358142] Step Three key = 1 //3rd index 8 > 1 => [ 3 5 1 8 4 2 ] 5 > 1 => [ 3 1 5 8 4 2 ] 3 > 1 => [ 1 3 5 8 4 2 ] The result is: [135842] Step 4 : key = 4 //4th index 8 > 4 => [ 1 3 5 4 8 2 ]

5 > 4 => [ 1 3 4 5 8 2 ] 3 > 4 ≠> stop The result is: [134582] Step Five key = 2 //5th index 8 > 2 => [ 1 3 4 5 2 8 ] 5 > 2 => [ 1 3 4 2 5 8 ] 4 > 2 => [ 1 3 2 4 5 8 ] 3 > 2 => [ 1 2 3 4 5 8 ] 1 > 2 ≠> stop And the result is: [1 2 3 4 5 8] Below, you can see a slightly optimized algorithm designed so the key element doesn't need to be swapped in every iteration. Instead, it is swapped when the iteration ends: InsertionSort(arr[]) for j = 1 to arr.length key = arr[j] i=j-1 while i > 0 and arr[i] > key arr[i+1] = arr[i] i=i-1 arr[i+1] = key And this is a full JavaScript implementation ith more detail: function insertion_sort(A) { var len = array_length(A); var i = 1; while (i < len) {

var x = A[i]; var j = i - 1; while (j >= 0 && A[j] > x) { A[j + 1] = A[j]; j = j - 1; } A[j+1] = x; i = i + 1; } } Properties: The insertion sort algorithm has the following properties: Space Complexity: O(1) Time Complexity - best case: O(n) Time Complexity – average case: O(n* n) Time Complexity – worst case: O(n* n) Best Case: when the array starts in a sorted order Average Case: when the array is in a randomly sorted order Worst Case: when the array is sorted in reverse. Sorting In Place: Yes Stable: Yes Heapsort The heapsort algorithm is incredibly efficient and uses max/min heaps. Heap structures are tree-based, satisfying the heap property. For example, in a max heap, a node's key is equal to or less than its parent key, where the node has a parent. We can use this property to gain access to the heap's maximum element in O(logn) time – this is done using a method called maxHeapify. The operation is performed n times; the heap's maximum element is moved to the top of the heap on each operation, extracted, and placed into a sorted array. Once n

iterations have taken place, we have a new version of the input array in sorted order. Heapsort is not an in-place algorithm. This means a heap data structure needs to be created first. It is also classed as an unstable algorithm, so it will not retain the original order when objects with the same key are compared. The time complexity is O(nlogn), and space complexity is O(1) with additional O(n) for the input data. The time complexity is the same for the best, average, and worst cases, and, although it is better than Quicksort in its worst case, it still better to use a properly implemented Quicksort algorithm. Because Heapsort is comparison-based, you can use it for data sets not containing numbers so long as you can define a heap property over all the elements. Here's a Java implementation: import java.util.Arrays; public class Heapsort { public static void main(String[] args) { //test array Integer[] arr = {1, 4, 3, 2, 64, 3, 2, 4, 5, 5, 2, 12, 14, 5, 3, 0, -1}; String[] strarr = {"hope this is helpful!", "wef", "rg", "q2rq2r", "avs", "erhijer0g", "ewofij", "gwe", "q", "random"}; arr = heapsort(arr); strarr = heapsort(strarr); System.out.println(Arrays.toString(arr)); System.out.println(Arrays.toString(strarr)); } //O(nlogn) TIME, O(1) SPACE, NOT STABLE public static E[] heapsort(E[] arr){ int heaplength = arr.length; for(int i = arr.length/2; i>0;i--){ arr = maxheapify(arr, i, heaplength);

} for(int i=arr.length-1;i>=0;i--){ E max = arr[0]; arr[0] = arr[i]; arr[i] = max; heaplength--; arr = maxheapify(arr, 1, heaplength); } return arr; } //Creates maxheap from array public static E[] maxheapify(E[] arr, Integer node, Integer heaplength){ Integer left = node*2; Integer right = node*2+1; Integer largest = node; if(left.compareTo(heaplength) = 0){ largest = left; } if(right.compareTo(heaplength) = 0){ largest = right; } if(largest != node){ E temp = arr[node-1]; arr[node-1] = arr[largest-1];

arr[largest-1] = temp; maxheapify(arr, largest, heaplength); } return arr; } } Radix Sort To understand and use radix sort, you first need to understand counting sort, so make sure you do before you continue. While Merge Sort, Quick Sort, and Heap Sort are comparison-based, Counting Sort and Radix Sort are not. Count Sort has a O(n+k) complexity, where k indicates the input array's maximum element. Where k is O(n), Count Sort is a linear sorting algorithm, which works much better than comparison algorithms with time complexity of O(nlogn). The idea is that the Count Sort algorithm is extended to improve the time complexity where k is O(n2). That's where Radix sort comes in.

The Algorithm You have a series of digits, i, where they vary from the least to the most significant digit of a number. In this case, the input array should be sorted using Count Sort as per the ith digit – Count Sort is a stable sorting algorithm, which is why we chose it for this example. Here's an example. Let's assume we have an input array of: 10, 21, 17, 34, 44, 11, 654, 123 The input array is sorted per the least significant digit, the one's digit.

0: 10 1: 21 11 2: 3: 123 4: 34 44 654 5: 6: 7: 17 8: 9: After sorting, the array is 10, 21, 11, 123, 24, 44, 654, 17. Next, we use the next least significant, the ten's digit, to sort the array: 0: 1: 10 11 17 2: 21 123 3: 34 4: 44 5: 654 6: 7: 8: 9: Our array is now: 10, 11, 17, 21, 123, 34, 44, 654. Lastly, we use the most significant digit, the 100's digit, to sort the array: 0: 010 011 017 021 034 044 1: 123 2: 3: 4: 5: 6: 654 7:

8: 9: We now have a sorted array indicating how the algorithm works: 10, 11, 17, 21, 34, 44, 123, 654 Here is the algorithm implementation in C: void countsort(int arr[],int n,int place){ int i,freq[range]={0}; //the range for integers is 10 as the digits range from 0-9 int output[n]; for(i=0;i [2, 4, 3, 6, 9] [2, 4, 3, 6, 9] => [2, 4, 3, 6, 9] [2, 4, 3, 6, 9] => [2, 4, 3, 6, 9] [2, 4, 3, 6, 9] => [2, 4, 3, 6, 9] It isn’t an efficient way of sorting a list but it is simple to grasp. Properties Bubble Sort has the following properties: Space complexity: O(1) Performance – best case: O(n) Performance – average case: O(n*n) Performance – worst case: O(n*n) Stable: Yes Here’s a Java implementation: public class BubbleSort { static void sort(int[] arr) { int n = arr.length;

int temp = 0; for(int i=0; i < n; i++){ for(int x=1; x < (n-i); x++){ if(arr[x-1] > arr[x]){ temp = arr[x-1]; arr[x-1] = arr[x]; arr[x] = temp; } } } } public static void main(String[] args) { for(int i=0; i < 15; i++){ int arr[i] = (int)(Math.random() * 100 + 1); } System.out.println("array before sorting\n"); for(int i=0; i < arr.length; i++){ System.out.print(arr[i] + " "); } bubbleSort(arr); System.out.println("\n array after sorting\n"); for(int i=0; i < arr.length; i++){ System.out.print(arr[i] + " "); } } } And in Python: def bubbleSort(arr): n = len(arr)

for i in range(n): for j in range(0, n-i-1): if arr[j] > arr[j+1] : arr[j], arr[j+1] = arr[j+1], arr[j] print(arr) Quick Sort Quick Sort is one of the more efficient sorting algorithms, especially for divide and conquer problems. It has O(nlog(n)) average-case time complexity and O(n^2) worst case, but that depends on the pivot chosen to divide the array into two almost identically-sized arrays.

For example, if the pivot divides the array into roughly equal-sized arrays, the time complexity is O(nlog(n)), but where the algorithm chooses a pivot that consistently provides sub-arrays with big size differences, it could be O(n^2), which is the worst case. Here are the steps the algorithm takes: It picks an element as the pivot. In our example, the final array element is chosen The array is partitioned, i.e., sorted in a way that all elements lower than the pivot are placed on the left, and the elements larger than the pivot are placed on the right. Quick Sort is recursively called, considering the previous pivot to ensure the right and left arrays are subdivided. Here's a Python implementation: import random

z=[random.randint(0,100) for i in range(0,20)] def quicksort(z): if(len(z)>1): piv=int(len(z)/2) val=z[piv] lft=[i for i in z if ival] res=quicksort(lft)+mid+quicksort(rgt) return res else: return z ans1=quicksort(z) print(ans1) Quick Sort has O(n) space complexity, better than many divide and conquer algorithms which tend to have space complexity of O(nlog(n)). This is managed because Quick Sort changes the element order in the array, whereas the Merge Sort algorithms will create two arrays in each of the function calls, each array being length n/2. However, Quick Sort does have one problem – if the chosen pivot is always in the center, the time complexity comes out at a poor O(n*n). We can overcome this by using random pivots. Quick Sort is not classed as stable and is an in-place algorithm with a stack space of O(log(n)). It has a best-case complexity of n log(n)n, an average case of log(n)m, and a worst-case of 2log(n). Space complexity is better than other divide and conquer algorithms at O(n), whereas others are O(n log(n)). Timsort Timsort is one of the fastest of all sorting algorithms with a stable complexity of O(N log(N)). The algorithm combines Merge Sort and Insertion Sort and is implemented in

Arrays.sort() in Java and in sort() and sorted() in Python. Insertion Sort is used to sort the smaller bits and Merge Sort is used later to merge them all. Here's a quick Python implementation: def binary_search(the_array, item, start, end): if start == end: if the_array[start] > item: return start else: return start + 1 if start > end: return start mid = round((start + end)/ 2) if the_array[mid] < item: return binary_search(the_array, item, mid + 1, end) elif the_array[mid] > item: return binary_search(the_array, item, start, mid - 1) else: return mid """ Insertion sort is used by timsort if the array size is small or if the size of the "run" is small """ def insertion_sort(the_array): l = len(the_array) for index in range(1, l): value = the_array[index] pos = binary_search(the_array, value, 0, index - 1) the_array = the_array[:pos] + [value] + the_array[pos:index] + the_array[index+1:]

return the_array def merge(left, right): """Takes a pair of sorted lists and returns a single sorted list by comparing the elements one at a time. [1, 2, 3, 4, 5, 6] """ if not left: return right if not right: return left if left[0] < right[0]: return [left[0]] + merge(left[1:], right) return [right[0]] + merge(left, right[1:]) def timsort(the_array): runs, sorted_runs = [], [] length = len(the_array) new_run = [the_array[0]] # for every i in the range of 1 to length of array for i in range(1, length): # if i is at the end of the list if i == length - 1: new_run.append(the_array[i]) runs.append(new_run) break # if the i'th element of the array is less than the previous one if the_array[i] < the_array[i-1]: # if new_run is set to None (NULL) if not new_run:

runs.append([the_array[i]]) new_run.append(the_array[i]) else: runs.append(new_run) new_run = [] # else if its equal to or more than else: new_run.append(the_array[i]) # every item in runs is appended using insertion sort for item in runs: sorted_runs.append(insertion_sort(item)) # for every run in sorted_runs, merge them sorted_array = [] for run in sorted_runs: sorted_array = merge(sorted_array, run) print(sorted_array) timsort([2, 3, 1, 5, 6, 7]) Merge Sort Merge Sort is another one for divide and conquer problems, dividing an input array in half, calling itself for each sub-array, and then merging the two sorted subarrays. The biggest part of the algorithm has an input of a pair of sorted arrays and must then merge them into one fully sorted array. We can summarize this process in just three steps: 1. The array is divided into two halves 2. The left and right halves are both sorted with a recurring algorithm 3. The two sorted halves are merged The Two Finger algorithm helps with merging the two sorted arrays. Using the algorithm (subroutine) and the merge sort function recursively on both sub-arrays, we get the result we want – a fully sorted single array.

Because Merge Sort is based on recursion, there is a recurrence relation – this is nothing more than a way of using subproblems to represent a problem: T(n) = 2 * T(n / 2) + O(n) In clear terms, the subproblem is broken in half at each step, leaving us with a small amount of linear work to merge both sorted subproblems at each step. Complexity Merge Sort’s biggest advantage is a time complexity of n*log(n) to sort a whole array. Before we get on with the code, here’s a diagram to show you how Merge Sort works:

The original array has six integers in unsorted order: Arr(5, 1, 8, 3, 9, 2) The array is split in half: Arr1 = (5, 1, 8) Arr2 = (3, 9, 2) Each of these is divided into two: Arr3 = (5, 1) and Arr4 = (8) Arr5 = (3, 9) and Arr6 = (2) And repeat: Arr7 = (5), Arr8 = (1), Arr9 = (3), Arr10 = (9) Arr6 = (2) The elements in each sub array are then compared to ensure they are merged correctly Properties:

Merge Sort has the following properties: Space Complexity: O(n) Time Complexity: O(n*log(n)). This might not be obvious at first look, but it comes from the recurrence relation mentioned above. Sorting In Place: No, not in typical implementations Stable: Yes Here’s an implementation in JavaScript: function mergeSort (arr) { if (arr.length < 2) return arr; var mid = Math.floor(arr.length /2); var subLeft = mergeSort(arr.slice(0,mid)); var subRight = mergeSort(arr.slice(mid)); return merge(subLeft, subRight); } First, the array length is checked. If it is a length of one, the array is returned – it cannot be split and it becomes the base case. If it is more than one, the middle value is found and the array divided in two. Now we can recursively call the Merge Sort function to sort the two subarrays. function merge (a,b) { var result = []; while (a.length >0 && b.length >0) result.push(a[0] < b[0]? a.shift() : b.shift()); return result.concat(a.length? a : b); } When the two subarrays are merged, an auxiliary array is used to store the result. The starting elements of the left and right arrays are compared, and the lesser one is pushed into the auxiliary results array. The shift operator is used to move it from its respective array. If we still have one or more values in either subarray, they are concatenated to the end of the result. var test = [5,6,7,3,1,3,15]; console.log(mergeSort(test));

>> [1, 3, 3, 5, 6, 7, 15] Here’s a Python implementation: def merge(left,right,compare): result = [] i,j = 0,0 while (i < len(left) and j < len(right)): if compare(left[i],right[j]): result.append(left[i]) i += 1 else: result.append(right[j]) j += 1 while (i < len(left)): result.append(left[i]) i += 1 while (j < len(right)): result.append(right[j]) j += 1 return result def merge_sort(arr, compare = lambda x, y: x < y): #Use the lambda function to sort the array in increasing and decreasing orders. #By default, the array is sorted in increasing order if len(arr) < 2: return arr[:] else: middle = len(arr) // 2 left = merge_sort(arr[:middle], compare) right = merge_sort(arr[middle:], compare)

return merge(left, right, compare) arr = [2,1,4,5,3] print(merge_sort(arr))

Chapter 12: What Are Hash Tables and HashMaps? This chapter will discuss one of the most critical data structures – hash tables. Hash tables are used to store data in the format of keys and values, with constant time access to all elements in the table. They are commonly known as associative, which means that data occurs almost instantly for every key.

We can use hash tables when we want to implement dictionaries, phone books, and other similar structures. The association between keys and values is stored in these tables. For example, the word "table" is the key, and the value is the dictionary definition of "table." We can use unique keys to store and retrieve and delete data. Why Hash Tables? The hash table's most valuable aspect, which puts it above other abstract data structures, is how fast it is to perform operations such as insertion, search, and deletion. All these operations are done in constant time, which is the fastest time complexity possible. Except for the list method, a hash table used O(1) time to perform virtually all its methods – this is fast.

Hash tables are so useful that they are implemented in many ways, such as databases, libraries used to fetch data, caches, and much more.

How Hash Tables Work In learning how hash tables work, you need to consider four separate aspects: Storage Key-value pairs Hashing function Table operations Let's discuss each of these: Storage Hash tables are abstract data types that store data using primitive data types, such as objects or arrays – implications in your implementation will determine which you use. We'll delve into that a little later. Key-Value Pairs No matter which primitive structure you use, data can only be stored as a value when you have a unique way of identifying it – that's where the key comes in. Some data may have individual properties that are ideal to use as keys. Let's say we have a user with an email address and userID. In that case, you could use the userID or email address as the key, so long as they are unique. { email: '[email protected]', // highlight name: ABC 123r', userId: '1', // or use this phone number: '666-321-9876' } You may also work with data with ambiguous uniqueness. Let's take a dictionary as an example; the dictionary definition of 'table' looks something like this: { word: 'table', definition: 'a piece of furniture with a flat top and at least one leg. '

} What could we choose to use as uniqueness? If we wanted this stored in a hash table in such a way that we had quick access to the dictionary definition, the word 'table' would need to be used as the key. However, we have a problem with uniqueness because there is more than one definition of the word 'table.' { word: 'table,' definition: 'facts and figures displayed systematically in columns. ' } You can see the problem, can't you? If the word 'table' was used as the key, we have no guarantee of uniqueness, which is the most important factor in retrieving the definition by way of providing the key. If one word has two definitions, it will have an influence on how the data is shaped. Solving this requires that we change how the data is modeled: { word: 'table', definition: [ 'a piece of furniture with a flat top and at least one leg.', 'facts and figures displayed systematically in columns' ] } We could also improve this a bit more if we wanted to. We could provide a type and example property for each definition, but this would mean the data stored in the definition must become an object. { word: 'table', // the key to this data definition: [ { type: 'noun', example: 'no one was using the table, so I ate my dinner there,' value: 'a piece of furniture with a flat top and at least one leg.'

}, { type: 'noun', example: 'a data structure used to display information, value: 'facts and figures displayed systematically in columns' ).` } ] } That gives us data we can uniquely identify and store data that needs to be accessed quickly. Hash Functions The key-value pair is then passed to the hash table so the data can be stored for retrieval at a later time. For a hash table to determine how the data is stored in it, it will need a hash function, which is a standard operation on hash tables. Hash functions need two pieces of information – the key and its associated value. The key has the logic in it to work out the index where the value will go in the object, array, or whatever underlying data structure you use. When objects are used, the index will more than likely be an integer or string version of the key When a fixed-size array is used, a hash code must be generated. This value is used to map the key to an index in the array. Once the index is decided, the hash function will then merge or insert the value where the index specifies it – this will depend on the implementation. Let's say the hash table for our dictionary only had one definition for 'table.' That would mean the hash function could then be configured, so it merges additional definitions, so long as they are unique, to the definition of the 'table' value when we attempt to hash it: {

'tab': [...], 'tabl': [...], - 'table': { - type: 'noun', - example: 'no one was using the table, so I ate my dinner there,' - value: 'a piece of furniture with a flat top and at least one leg.' - } + 'table': [ + { + type: 'noun', + example: 'no one was using the table, so I ate my dinner there,' + value: 'a piece of furniture with a flat top and + at least one leg.', + }, + { + type: 'noun', + example: 'a data structure used to display information, + value: 'facts and figures displayed systematically in columns' + } + ] } We could do it an alternative way, by configuring the hash function to throw an error if it finds a duplicate. This is known as a collision in implementations where data is not being merged. Table Operations We already know that a hash table can and should hash items, which means creating an index based on the key. But what else can a hash table do? At the absolute minimum, all hah tables must be able to do the following: add – add key-value pairs get – get values by using their key

remove – remove values using their keys list – get every key or key-value pair in the list count – count how many items a table has Choosing Keys in a Hash Table Deciding what should be the key in the pair means the item must be: Unique - for example, 'green' is unique, and, while it may have more than one definition, each definition can be stored at the index the 'green' key creates Known in the real world – so we can retrieve the data. In simple terms, when we store a value, we need to remember what key is used to retrieve the value in constant time.

Basic Hash Table Examples We will use JavaScript and TypeScript to implement some simple hash tables. Object Implementation Object implementation of hash tables is nothing more than using a standard JavaScript object: let table = {}; The API lets us add key-value pairs to the hash table in several ways, the first being the dot property: let table = {}; table.green = 'color between orange and blue'; console.log(table); // { green: 'color between orange and blue' } We could also use object brackets, one of the more useful methods when dealing with numeric keys: let table = {}; let pairOne = { key: 'green', value: 'color between orange and blue ' } let pairTwo = { key: 2, value: { name: 'Charlie' } } // Text key table[pairOne.key] = pairOne.value; console.log(table); // { green: 'color between orange and blue' } // Numeric key table[pairTwo.key] = pairTwo.value console.log(table); // { // green: 'color between orange and blue', // 2: { name: 'Charlie' } // } The hasOwnProperty method is useful when you want to see if a specific value is there – this method is present in every JavaScript method: let table = {}; table.green = 'color between orange and blue';

table.hasOwnProperty('green'); // true If we want to obtain an item's value, we use the key inside the object dot notation or the object brackets to call on the item: let table = {}; table.green = 'color between orange and blue'; console.log(table['green']); // 'color between orange and blue' console.log(table.green); // 'color between orange and blue' If you need to solve a one-off problem, the required functionality can quickly be retrieved from the table using objects. However, this isn't the best way to make your hash tables easier; one of the best ways is to use encapsulation. Encapsulated Hash Table Encapsulating your table with related functionality within a collection of functions or a cohesive object is a good idea. In the following hash table, a plain object is used for storage, and all the methods required for the hash table are exposed: class HashTable { constructor() { this.storage = {}; } // Hash function add(key, value) { if (this.exists(key)) { throw new Error(`${key} already exists`); } this.storage[key] = value; } get(key) { if (!this.exists(key)) { throw new Error(`${key} not found`); }

return this.storage[key]; } exists(key) { return this.storage.hasOwnProperty(key); } remove(key) { delete this.storage[key]; } list() { return Object.keys(this.storage).map((key) => ({ key, value: this.storage[key], })); } count() { return Object.keys(this.storage).length; } } let hashTable = new HashTable(); hashTable.add(1, "Bobby"); hashTable.add(2, "Maria"); hashTable.add(3, "Simon"); console.log(`Our hashtable contains ${hashTable.count()} elements`); console.log(`This is ${hashTable.get(2)}`); hashTable.remove(1); console.log(`Our hashtable contains ${hashTable.count()} elements`); console.log(`Theses are the elements: ${hashTable.list()}`); On your console, you would see this: Our hashtable contains 3 elements

This is Maria Our hashtable contains 2 elements These are the elements: [object Object],[object Object] Similar Data Structures Most computer programming languages already include defined data structures similar to hash tables, including: Dictionaries - Python Hashmaps – Java Maps Sets These are all pretty much the same as a hash table. Associative Array Example Let’s move on to something more advanced – using arrays instead of objects to create a hash table. First, the HashTable class is created, and a fixed-size array is initialized with 200 elements: class HashTable { constructor() { this.table = new Array(200); this.size = 0; } } Hash Code Next, the hash method is implemented. We’ll assume that this method is private and is used by all public methods; in that case, it needs to take a key and create a hash code from it. A hash code is nothing more than a value that the key computes, dictating the index in the array where the value should be saved. In our implementation, we need to give our hash function a string key so it can give us a numeric hash code back. Considering all the string elements, the character codes are added so that the hash code can be created:

// Assume the key is a 'string' _hash(key) { let hash = 0; for (let i = 0; i < key.length; i++) { hash += key.charCodeAt(i); } return hash; } The question now is, how do we ensure the hash value remains within the array’s bounds? One way we can do that is with the modulo operator; the number will just continue rolling around and never going outside the length of the table: _ hash(key) { let hash = 0; for (let i = 0; i < key.length; i++) { hash += key.charCodeAt(i); } return hash % this.table.length; } Implement the Operations A hash code is created in the add method, and this will be used as the index; the key and the value are then stored in the array. The array size is incremented, allowing us to see whether the array storage has been utilized fully. Later, we may want it resized, should we reach a good utilization of the size. add(key, value) { const index = this._hash(key); this.table[index] = [key, value]; this.size++; }

The hash function is once again relied on to obtain a value’s index, this time for the get method: get(key) { const index = this._hash(key); return this.table[index]; } If we want an item removed, we take it out of the array and the size counter is subsequently decremented: remove(key) { const index = this._hash(key); const itemExists = this.table[index] && this.table[index].length if (itemExists) { this.table[index] = undefined; this.size--; } } A complete example of an associative array implementation looks something like this: class HashTable { constructor() { this.table = new Array(200); this.size = 0; } _hash(key) { let hash = 0; for (let i = 0; i < key.length; i++) { hash += key.charCodeAt(i); } return hash % this.table.length; }

set(key, value) { const index = this._hash(key); this.table[index] = [key, value]; this.size++; } get(key) { const target = this._hash(key); return this.table[target]; } remove(key) { const index = this._hash(key); const itemExists = this.table[index] && this.table[index].length; if (itemExists) { this.table[index] = undefined; this.size--; } } } And if we test it: const ht = new HashTable(); ht.set("Bobby", '519-429-2212'); ht.set("Kieran", '647-232-1246'); ht.set("Sharon", '764-545-3589'); console.log(ht.get("Bobby")); // [ 'Bobby', '519-429-2212' ] console.log(ht.get("Kieran")); // [ 'Kieran', '647-232-1246' ] console.log(ht.get("Sharon")); // [ 'Sharon', '764-545-3589' ] console.log(ht.remove("Kieran")); console.log(ht.get("Kieran")); // undefined That looks great. On your console, you might see this:

console.log(ht) HashTable {table: Array(200), size: 2} size: 2 table: Array(200) 96: undefined 122: (2) [‘Sharon’, ‘764-545-3589’] 187: (2) [‘Bobby’, '519-429-2212' length: 200 [Prototype]]: Array(0) [[Prototype]]: Object Collisions When a hash function maps more than one key-value pair to the same index in the storage, it is called a collision. As the array is gradually populated, we will come across this. For example, if the following were run, it would result in a collision: const ht = new HashTable(); ht.set("Sharon", '764-545-3589'); ht.set("z", '597-359-4342'); console.log(ht.get("Sharon")); // ['z', '597-359-4342'] console.log(ht.get("z")); // ['z', '597-359-4342'] From the looks of it, the ‘Sharon’ key has been overridden with the ‘z.’ This is not good because it means data has been lost and that is the last thing we want. How does this happen? In this case, it comes down to ASCII characters – the one used for 'z' is the same as all the ASCII characters in 'Sharon' added together - 122. Fixing this requires logic to detect collisions, and in some cases, you could add array resizing logic too. Collision Detection and Resizing Dynamic Array Resizing This is not the easiest of problems to solve. While some trivial approaches could be used, like storing identical data in identical index positions in the array and traversing through it, this would only increase time complexity,

making it O(n). When you write your own hash tables using associative arrays, you will discover that the perfect approach does not exist where collision detection and resizing are concerned. However, we can do one quite easy thing – when a prime number, such as 257, is used for the array, the chances of collision are significantly reduced. If the array size had been set as 257 and not 200, this is what we would get: const ht = new HashTable(); ht.set("Sharon", '764-545-3589'); ht.set("z", '597-359-4342'); console.log(ht.get("Sharon")); // ['Sharon', '764-545-3589'] console.log(ht.get("z")); // ['z', '597-359-4342'] And this is what you would see on the console: console.log(ht) HashTable {table: Array(251), size: 2} size: 2 table: Array(251) 96: undefined 122: (2) [‘Sharon’, ‘764-545-3589’] 187: (2) [‘z, '519-429-2212' length: 251 [Prototype]]: Array(0) [[Prototype]]: Object So, which should you use? To simplify things, it's best to use a hash table provided by your chosen programming language or choose the object encapsulation method rather than associative arrays. It gets messy when you have to deal with collisions and array resizing; your time is best spent on other things.

Hash Table Use Cases If you were in a coding interview, the hash table is probably the first data structure you should consider using when asked to solve a problem. But, because their retrieval time is so fast, they are also great for other things. These are the commonest uses: Databases/indexing – this is the most common use case for a hash table. When we want data retrieved from a database and incremented through, we need a unique way of identifying it and a simple way to index it. Databases use indexes to allow for fast data location without having to search every row – not an issue if your database is small, but when you have thousands of rows, it gets difficult. Let's say your database contains records from 0 to 150, and we need to find 103. The database knows it doesn't need to start searching from the beginning; instead, it can choose a nearer index. Finding Unique Elements – hash tables make it easy to find unique elements in unorganized data or lists. Using the key or another common element, we ensure that an item can only be added once. When done, a simple iteration through the keys will tell us all the unique items in the hash table. Storing Transitions – think about a game of strategy where you need to track every move made – chess, for example. Using a transposition table, we can work out the previous moves of any given move in constant O(1) time. Let's say your chess piece is at [1, 3]. How could you tell what moves it made previously? If moves such as [xPosition, yPosition, moveNumber, player] were stored, we could use it as a key mapping as a value to a previous position. Here's an example: [1, 3, 5, 1] -> [1, 4, 4, 1] That tells us that the player's 5th move takes them to position [1, 3], and it also tells us that they were at position [1, 4] before that. If we want the previous move retrieved, the hash function could use the current position to find the index and turn number.

FAQ To finish this chapter, here are two of the commonly asked questions: 1. What is the difference between a hashtable and a hashmap Not much – they are pretty much the same. What determines the difference is your chosen methods and backing data structure. Many people tend to equate hash tables with associative arrays and HashMaps to objects. They do the same; they typically have identical time complexity, but your storage and methods determine which one you use. 2. How is a HashMap created in JavaScript? Technically, we did this when we showed you the object implementation earlier in the chapter. HashMaps are specific to Java and JavaScript, but without using a HashMap abstraction, we could get behavior similar to the following: function createHashCode (object){ return obj.someUniqueKey; // Here’s an example }; let dict = {}; dict[key(obj1)] = obj1; dict[key(obj2)] = obj2; However, Java also has Map abstraction: let map = new Map(); let obj1 = { value: 1 } let obj2 = { value: 2 } map.set(1, obj1); map.set(2, obj2); console.log(map.get(1)); // expected output: { value: 1 } map.set(3, { value: 'billy' }); console.log(map.get(3)); // expected output: { value: 'billy' }

console.log(map.size); // expected output: 3 map.delete(2); console.log(map.size); // expected output: 2

Bonus Chapter: A Simple Look at Big O Notation Finally, we come to Big O notation. I've mentioned time and space complexity throughout, so now it's time to explain what it's all about. I won't be going into vast amounts of detail because it isn't something you need to spend too much time on. So what is it?

Big O indicates an algorithm's time complexity (how long it takes to run) and/or its space complexity (how much memory it uses). Big O can tell us the best, average, and worst-case running time, and that's what we will focus on here – time complexity. The most important thing to understand is that running time is nothing to do with seconds, milliseconds, etc. Time complexity analysis also does not consider processor speed, runtime environment, or programming language. Instead, time indicates how many steps or operations a program of n size takes to complete. In other words, Big O is all about the speed the runtime grows at it relative to the input's size. In terms of the worst case, we need to know the maximum number of steps that might be needed for an input of size

n. Let's look at the most common times. O(1) – Constant Time O(1) indicates an algorithm runs in constant time, no matter how large or small. A real-world example is the humble bookmark – these allow you to quickly find the last page you read in a book, whether it has 10 pages or 1000. So long as your bookmark is used, it only takes one step to find the page. Many programming operations are constant: Insert() and remove() queue operations Mathematical operations Pop() and push() stack operations Returning values from functions Using a key to access a hash Using an index to access an array Below is an example of firstFindIndex. We get the same O(1) runtime by passing giganticCollection or smallCollection when the 0 index is accessed. And returning firstIndex is also in o(1) time. const smallCollection = [1, 2, 3, 4]; const giganticCollection = [1, 2, 3, …, 1000000000]; function findFirstIndex(n) { const firstIndex = n{0}]; return firstIndex; O(n) – Linear Time Linear time indicates that runtime and input increase at the same speed. A good real-world example of this is reading a book. Let's say you can read a page in one minute. The book has 300 pages, so it will take 300 minutes to read. In the same way, a book with 2000 pages will take you 2000 minutes to read. You might start a book you aren't enjoying, so you know you won't finish it. Before you start, you know that your worst case is 2000 minutes for a 2000-page book.

One of the commonest linear-time operations is array traversal. JavaScript includes methods to help you run through an array, including reduce, forEach, and map. Below you can see a printAllValues function. The number of steps needed to loop through n directly relates to n's size. Typically, loops are a good sign that your code has O(n) runtime. However, this won't always be the case. const smallCollection = [1, 2, 3, 4]; const giganticCollection = [1, 2, 3, …, 1000000000]; function printAllValues (n) { for (let i = 0; i < n.length; i++) { firstIndex = n{0}]; console.log(n[i]); } } What about the find method? Can it be classed as linear, even though it doesn't always go through a whole array? The next example shows that the first value lower than 3 is placed at index 0. Why isn't this constant time? const numbers = [2, 3, 4, 6, 3, 6, 7]; const lessThanThree = numbers.find(number => number < 3); Remember that we want to find the worst-case, so we should assume that we won't always have an ideal input and that the value or element we want may be the last one. You will see that in the next example. To find the number lower than three, we would need to iterate the whole array in the least ideal scenario. const numbers = [3, 4, 6, 3, 6, 7, 2]; const lessThanThree = numbers.find(number => number < 3); O(n2) – Quadratic Time Quadratic time indicates the input data's squared size. Some basic sorting algorithms run in O(N2) as their worst-case, including Insertion Sort, Bubble Sort, and Selection Sort. In the following countOperations example below, two nested loops are used to increment the variable called operations at the end of each iteration. If smallCollection is n, we get 16 operations, but if n were giganticCollections,

that number would run to a billion billion – a lot of operations. Even arrays with small numbers of elements would create large numbers of operations: const smallCollection = [1, 2, 3, 4]; const giganticCollection = [1, 2, 3, …, 1000000000]; function countOperations(n) { let operations = 0; for (let i = 0; i < 9; i++) { for (let j = 0; j < 9; j++;){ operations++; } } Return operations; } Again, although it isn't guaranteed, when you see a pair of nested loops usually tells you the code has O(N2) runtime. O(Log n) – Logarithmic Time Logarithmic time indicates that the runtime grows proportionately to the input size logarithm, which means as the input is exponentially increased, the runtime doesn't increase much. A good real-world example is looking for a word in a dictionary by cutting the sample size in half. For example, if you were looking for the word "ridden," you can start from the middle of the dictionary and then determine where your word will be found. Once you know that "r" is in the second part of the dictionary, you can dismiss everything in the first half. Then you repeat the process. By following this all the way, you can cut the pages you need to look at in half each time until you find the word. In terms of programming, this kind of searching is known as a binary search, most commonly used for logarithmic terms. Let's look at another example; the countOperations example has been modified, and now n is a number. n may be an array's length ( the input size) or a number (the input). const smallNumber = 1000;

const biggerNumber = 10,000; function countOperations(n) { let operations = 0; let i = 1; while (i < n) { i = i * 2; operations++; } Return operations; } In this example, if n=2000, we get 11 operations; if n=4000, we get 12. No matter how often n is doubled, we only get an increase in operations of 1. Algorithms running in logarithmic time offer significant implications for big inputs. Using the above example, O(log(7)) would give us three operations, and O(log)10000000)) would return just 20. As I said, you don't really need to understand Big O to write your code. You should also consider that it isn't exact. What you calculate as a runtime upfront may not happen when the algorithm runs in production because performance can be affected by many different things that Big O doesn't consider – runtime, environment, programming language, etc. A basic understanding is good for now but leave the in-depth look at Bit O until you have more experience.

Conclusion First, I want to thank you for reading my guide. I hope you now have a much better understanding of algorithms and realize there are easier ways to learn them. However, learning them is the tip of the iceberg; to truly understand them, you must go further. You need to know how to do them better. How do you do this? Like anything, it all comes to practice. It comes down to challenging yourself to go further. Here are some of the best ways to help you improve: Study and Practice Existing Algorithms It goes without saying that you should look at existing algorithms before trying to write your own. These algorithms are the basis for most of programming's fundamental concepts so make sure you understand exactly what each algorithm does and, more importantly, why. Try reimplementing the algorithms to learn them better. When you read books about algorithms or join a class online or at college, you'll find that they mostly discuss some of the more popular sorting and searching algorithms and not much else. Yes, we have discussed these in this book, but we also discussed a lot more. Sorting and searching are good places to start because virtually all developers will need to sort and/or search at some time or another. These algorithms also provide you with a sound basis to help you understand algorithmic edge cases and efficiency. When you start with sorting and searching algorithms, you learn to think about the following: How to create new algorithms How to improve algorithms How to work out the efficiency of an algorithm How to choose the right algorithm Sorting and searching are related; typically, when you do one, you do the other, and the different algorithms tend to build on one another. This allows you to see how, to begin with a simple algorithm and improve on it gradually as you learn how and where it isn't efficient. Some of the most important of these algorithms are:

Search Algorithms: Binary search Linear search Sorting Algorithms: Bubble sort Heapsort Insertion sort Merge sort Quicksort Selection sort In all likelihood, you will never really have to implement these algorithms because, in modern programming languages, the standard libraries already contain efficient sort and search algorithms. That said, even if you never have to write your own version of a binary search or bubble sort algorithm, you must still understand them and learn how to write them. Algorithms like these are the foundation of every other algorithm. They show you the real-world effects of algorithmic efficiency and how to go on to build ever-more complex, more efficient algorithms. Practice, Practice, and Practice Some More Honestly, practice cannot make perfect but can make you better; the same applies to algorithms. However, improving these skills is somewhat different from improving a physical skill that you can continuously practice until you get it right. Think about it. How many times can you write one algorithm and improve on it? I'd say it's not many times. Eventually, it becomes the best it can be and cannot be improved anymore Simple repetition is not helpful with algorithms. Sure, you can gain some benefits, but it's not about knowing a single algorithm inside out; it's about being able to create new ones, implement them and evaluate them as necessary. Like all coding skills, you need to challenge yourself constantly, and those challenges should surround solving problems. But there's one more thing you need to do to benefit – evaluate how well you

are doing. Is it useful to run through practice tests without having the answer readily available? One of the biggest challenges to learning coding and algorithms is understanding that one problem can be solved in multiple ways. How can you possibly improve if you don't understand how to improve your solution? Accomplishing this requires that you can evaluate yourself and how you do it must be objective. A couple of ways to do this are: Time your solution – when you change the solution, monitor the time to see if it improves. You'll find built-in methods in most programming languages to help you with this Ask an experienced developer – get someone more experienced to look at your solution and ask for their input Challenge yourself – there are plenty of online sites where you can find code challenges. Some will provide you with the right answer to compare with your solution, while others will take your solution, evaluate it and provide you with advice or help. Some of the best places to find these are: CoderByte GeekstoGeeks HackerRank Project Euler These will help you get started and give you plenty of opportunities to improve your algorithmic thinking skills. Optimization and the Big O Notation Many online sites and books will make a big thing about Big O and optimization. The further you get in your learning journey, the more intuitive you will become about how an algorithm should perform. In time, you will know if an algorithm is performing well and which ones provide the best performance, and most of the time, this will be based on how quickly and efficiently the algorithm runs on a given problem. Most developers use Big O to compare the performance of the algorithms, and most books or articles will briefly explain a particular algorithm's performance and how it should be calculated. I've done the same, but it isn't

something you should focus too much on now. At the start of your journey, your focus should be on learning how to solve problems by breaking them down and building them up. When you are comfortable with that, you can consider performance. Right now, all that's left for me to do is wish you the best of luck in your coding journey. Thank you for buying and reading/listening to our book. If you found this book useful/helpful please take a few minutes and leave a review on Amazon.com or Audible.com (if you bought the audio version).

References “A Beginner’s Guide to Algorithmic Thinking.” 2020. Learn to Code with Me. January 15, 2020. https://learntocodewith.me/posts/algorithmic-thinking/. “Algorithm Problem Solving Strategies.” n.d. DEV Community. Accessed July 2, 2022. https://dev.to/moresaltmorelemon/algorithm-problem-solving-strategies-21cp. “Basics of Greedy Algorithms Tutorials & Notes | Algorithms | HackerEarth.” 2016. HackerEarth. 2016. https://www.hackerearth.com/practice/algorithms/greedy/basics-ofgreedy-algorithms/tutorial/. “Big O Notation | Interview Cake.” n.d. Interview Cake: Programming Interview Questions and Tips. Accessed July 2, 2022. https://www.interviewcake.com/article/javascript/big-onotation-time-and-space-complexity?. “Difference between Recursion and Iteration - Interview Kickstart.” n.d. Www.interviewkickstart.com. https://www.interviewkickstart.com/learn/differencebetween-recursion-and-iteration#:~:text=Recursion%20is%20when%20a%20function. “Divide and Conquer Algorithm with Applications.” 2021. TechVidvan. June 18, 2021. https://techvidvan.com/tutorials/divide-and-conquer-algorithm-with-applications/. Donges, Niklas. 2021. “A Complete Guide to the Random Forest Algorithm.” Built In. July 22, 2021. https://builtin.com/data-science/random-forest-algorithm. “Hash Tables | What, Why & How to Use Them | Khalil Stemmler.” n.d. Khalilstemmler.com. https://khalilstemmler.com/blogs/data-structures-algorithms/hashtables/. LaFlam, Sean. 2021. “Real-Life Example of a Linked List.” The Startup. February 27, 2021. https://medium.com/swlh/real-life-example-of-a-linked-list-8f787b660b3f. Mallawaarachchi, Vijini. 2020. “8 Common Data Structures Every Programmer Must Know.” Medium. March 8, 2020. https://towardsdatascience.com/8-common-datastructures-every-programmer-must-know-171acf6a1a42. “Multiple Pointers.” n.d. DEV Community. Accessed July 2, 2022. https://dev.to/clouded_knight/multiple-pointers-592h. Navone, Estefania Cassingena. 2020. “Dijkstra’s Shortest Path Algorithm - a Detailed and Visual Introduction.” FreeCodeCamp.org. September 28, 2020. https://www.freecodecamp.org/news/dijkstras-shortest-path-algorithm-visual-introduction/. “Sorting Algorithms Explained with Examples in Python, Java, and C++.” 2019. FreeCodeCamp.org. December 4, 2019. https://www.freecodecamp.org/news/sortingalgorithms-explained-with-examples-in-python-java-and-c/. “The Ultimate Guide to Stacks and Queues Data Structures.” n.d. Simplilearn.com. https://www.simplilearn.com/tutorials/data-structure-tutorial/stacks-and-queues.