DS&A Summary


JavaScript

Updated Sep 22nd, 2022

Data Structures and Algorithms

Table of Contents

Section Details

Section 1: Introduction – 11min

The animations are what set the course apart. Viewer doesn’t have to watch the author type out the code.

Two takeaways from this course. 1.) Which data structure and/or algorithm to use in a given situation. 2.) Write the code, and hour a day for 30 days.

Code Editor: Developer Tools, Three Dots to undock and click on sources tab. Top left hit double arrows icon to view Snippets. Click the play button or Ctrl + Enter on windows to run the snippet.

Ctrl + L clears the dev tools console.

Section 2: Big O – 31min

Will come up in an interview and make you a better coder. Mathematically figure out which code is better.

Time and Space complexity. Time complexity is measured in the number of operations.

Three Greek Letters: Theta (average), Omega (best), and Omicron (worst). Omicron always measures the worst case.

O(n) is the easiest to get your head around. Basic for loop. Pass the function a number, n, and it runs, n times. Linear Line on graph.

Simplify with a concept known as “drop constants.” For loop followed by another for loop is n + n which equals 2n but this simplifies to O(n).

For loop nested inside of another for loop is n * n which equals O(n^2)

function logItems(n) {
  for (let i = 0; i < n; i++) {
    for (let j = 0; j < n, j++) {
      console.log(i, j)
    }
  }
}

logItems(10) // outputs 100 items

For loop nested inside of another and a third for loop nested inside that is n * n * N which equals n ^ 3 but this simplifies to O(n^2) as it doesn’t mater if it’s O(n^x) it’s always simplified to n^2.

O(n) is more efficient than O(n^2).

Drop non-dominants is another way we simplify Big O notation. Another for loop comes after a nested for loop. The nested for loop runs O(n^2) and the second for loop runs O(n) times and if you combine these together you get O(n^2 + n) which simplifies down to O(n^2)

O(1) is often referred to as constant time and is the most efficient. Flat line across the bottom of the chart.

// O(1)

function addItems(n) {
  return n + n + n + n
}

// equals O(3) operations = O(1)

O(log n) is described with an example of a sorted 8-item array to find a number using divide and conquer. How many steps? Log sub 2 of 8 equals 3. How many times to divide by 2 to get to one item. Amazingly powerful when you get very large arrays. Horizontal-ish on the chart above O(1).

O(nlog n) is another Big-O for some sorting algorithms, most efficient you can make a sorting algorithm unless you are sorting only numbers. On the chart this is to the right of O (n^2).

Different terms for inputs. Trick interview question. Passing multiple arguments to a function that has two for loops, (not nested). These arguments are the variables for each for loop. The novice may thing the first loop is O(n) and the second loop is O(n) so that is O(2n) and we’ll drop the constants to be O(n). But you cannot have both variables be equal to n. The first for loop is O(a), or whatever the variable may be and the second for loop is O(b) which simplifies to O(a + b).

// Different terms for inputs

function logItems(a, b) {
  for (let i = 0; i < a; i++) {
    console.log(i)
  }
  for (let j = 0; j < b; j++) {
    console.log(j)
  }
}

// Big O equals O(a + b)
// Nested for loop would be O(a * b)

Big O of arrays

Push/pop are O(1) but it’s different on the other end of the array because we need to re-index, (shift and unshift are O(n)). Splice is O(n) since you need to re-index everything after the insert. Removing is also O(n). Finding by value is O(n) but finding by index is O(1).

Summary of the simplifications shown: Drop constants, drop non-dominants.

Words and phrases used with the different types of Big O

O(n^2) is a loop with a loop, O(n) is proportional, O(log n) is proportional, and O(1) is constant.

BigOCheatsheet.com


Section 3: Classes & Pointers – 10min

Classes: Cookie Cutter Analogy

class Cookie {
  constructor(color) {
    this.color = color
  }
  getColor() {
    return this.color
  }
  setColor(color) {
    this.color = color
  }
  push(value) {...}
  insert(index, value) {...}
  unshift(value) {...}
  remove(index) {...}
  pop() {...}
  shift() {...}
}

let cookieOne = new Cookie("green")
let cookieTwo = newCookie("blue")

Pointers:

Example of not using a pointer:

// doesn't use pointers
let num1 = 5
let num2 = num1
// if we later change num1 it does not change num2

Example of using a pointer:

let obj1 = {
  value: 11
}

let obj2 = obj1

// if we later change obj1 it does change obj2 !!!

obj1.value = 22
console.log(obj2.value)
// result 22

The code above says make “obj2” point to the exact same object in memory as “obj1.” These pointers can be moved to a different place. If this results in an object that is not being pointed to it will just be sitting in memory since it is not being used. JS will use “garbage collection” to periodically clean up things like this.


Section 4: Data Structures: Linked Lists – 1hr 13min

Like arrays but no index. Are also anywhere in memory which is another difference from arrays. Arrays are in a continuous place in memory where linked lists can be all over the place in memory. Has a head and a tail. May be heard referenced as a “null terminated list” since the last node points to null.

Linked Lists Big O push: O(1)

Linked Lists Big O pop: O(n)

Linked Lists Big O unshift: O(1)

Linked Lists Big O shift: O(1)

Linked Lists Big O insert: O(n)

Linked Lists Big O remove: O(n)

Linked Lists Big O find (by value or by index): O(n)

Comparing the above with arrays there are some differences. These differences may make you choose the use of one data structure over another. For example, “pop” for an array is O(1) and lookup by index for an array is also O(1). Shift and unshift are better for Linked Lists O(1) whereas for arrays these are O(n).

Big O Cheatsheet is Awesome

Under the Hood: Node made up of value and the pointer. An object with “value” and “next” keys.

{
  head: {
    value: 11,
    next: {
      value: 3,
      next: {
        value: 23,
        next: {
          value: 7,
          next: {
            value: 4,
            next: null
          }
        }
      }
    }
  }
}

Before we build out the Linked List we realize that the class itself and other methods will need to create a Node. So instead of repeating this code over and over, we will just create a Node class of its own and then call that when we need a new node object.

class Node {
  constructor(value) {
    this.value = value
    this.next = null
  }
}
// to call this use const newNode = new Node(4)
class LinkedList {
  constructor(value) {
    const newNode = new Node(value)
    this.head = newNode
    this.tail = this.head
    this.length = 1
  }  
}
const myLinkedList = new LinkedList(4)

Note: When you call the “new” keyword it is specifically calling the constructor function.

The edge case for “push” is adding a node to a Linked List that doesn’t have any nodes yet.

push(value) {
  const newNode = new Node(value)
  if(!this.head) {
    this.head = newNode
    this.tail = newNode
  } else {
    this.tail.next = newNode
    this.tail = newNode
  }
  this.length++
  return this
} // end push

Need to code “pop” to handle the three cases of no nodes, one node, and multiple nodes. How do we keep track of the node before the end? With “pre” and “temp” variables.

pop() {
  if(!this.head) return undefined
  
  let temp = this.head
  let pre = this.head
  while(temp.next) {
    pre = temp
    temp = temp.next
  }
  this.tail = pre
  this.tail.next = null
  this.length--
  
  if(this.length === 0) {
    this.head = null
    this.tail = null
  }
  return temp
} // end pop 

The “unshift” method is pretty straightforward.

unshift(value) {

  const newNode = new Node(value)

  if(!this.head) {
    this.head = newNode
    this.tail = newNode
  } else {
    newNode.next = this.head
    this.head = newNode
  }

  this.length++
  return this
}

The “shift” method needs to account for having no items, having multiple items, and having one item. We will need the help of a “temp” variable.

shift() {
  if(!this.head) return undefined
  let temp = this.head
  let head = this.head.next
  temp.next = null
  this.length--
  if(this.length === 0) {
    this.tail = null
  }
  return temp
}
get(index) {
  if(index < 0 || index >= this.length) {
    return undefined
  }
  let temp = this.head
  for(let i = 0; i < index; i++) {
    temp = temp.next
  }
  return temp
}

Before coding the “set” method we get a hint that we can use other methods inside of a method.

set(index, value) {
  if(index < 0 || index >= this.length) {
    return undefined
  }
  let temp = this.get(index)
  if(temp) {
    temp.value = value
    return true
  }
  return false
}
insert(index, value) {
  if(index < 0 || index > this.length) return false
  if(index === this.length) return this.push(value)
  if(index === 0) return this.unshift(value)
  const newNode = newNode(value)
  const temp = this.get(index - 1)
  newNode.next = temp.next
  temp.next = newNode
  this.length++
  return true
}
remove(index) {
  if(index < 0 || index >= this.length) return undefined
  if(index === this.length - 1) return this.pop()
  if(index === 0) return this.shift()
  const before = this.get(index - 1)
  const temp = before.next
  before.next = temp.next
  temp.next = null
  this.length--
  return temp
}

Note: In the code above, we could set “temp” to “this.get(index)” but that is O(n) and there is a more efficient operation. So we set “temp” to “before.next”

Reverse a Linked List

Being able to reverse a Linked List is a very common interview question. And this is complicated.

Take head and tail and switch them. And then one by one, take the arrows and point them the other way. Notice that there is a gap when we are switching each arrow, and this is the hard part, keeping track of where everything is while introducing all of these gaps, all the way down the Linked List and continue to reverse until it is completely reversed.

We use a “temp” variable that we can set equal to “head” so we can get “head” over to the “tail” and the “tail” over to “temp.”

We will also need something after “temp” called “next” that should point to the next node after “temp” set equal to “temp.next.” We will also have a variable “prev” that we will have before “temp” and initially set it to null. Then we use a “for loop” to move these through the Linked List.

Need “next” variable to help cross the opening and handle an opening being on either side of “temp.”

reverse() {
  let temp = this.head
  this.head = this.tail
  this.tail = temp
  let next = temp.next
  let prev = null
  for(let i = 0; i < this.length; i++) {
    next = temp.next
    temp.next = prev // what flips the arrow
    prev = temp
    temp = next
  }
  return this
}

Section 5: Data Structures: Doubly Linked Lists – 40min

Doubly Linked Lists are the same as Linked Lists but they have an additional arrow that goes the other way. This is represented in the object by adding a “prev” key that is initially set to null in the constructor in the Node class.

Nothing changes in the constructor for the Doubly Linked List compared to the constructor in the Linked List, (the constructor in the Node class is what changes as mentioned above).

class DoublyLinkedList {
  constructor(value) {
    const newNode = new Node(value)
    this.head = newNode
    this.tail = this.head
    this.length = 1
  }
}
push(value) {
  const newNode = new Node(value)
  if(!this.head) {
  this.head = newNode
  this.tail = newNode
  } else {
    this.tail.next = newNode
    newNode.prev = this.tail
    this.tail = newNode   
  }
  this.length++
  return this
}

DLL: Pop: The “pop” method becomes more efficient with the Doubly Linked List

pop() {
  if(this.length === 0) return undefined
  let temp = this.tail
  this.tail = this.tail.prev
  this.tail.next = null
  temp.prev = null
  this.length--
  if(this.length === 0) {
    this.head = null
    this.tail = null
  }
  return temp
}

The code above isn’t all that readable and we can accomplish the same thing, in a more readable way with some slight changes

pop() {
  if(this.length === 0) return undefined
  
  let temp = this.tail
  if(this.length === 1) {
    this.head = null
    this.tail = null
  } else {
    this.tail = this.tail.prev
    this.tail.next = null
    temp.prev = null
  }
  this.length--
  return temp
}

DLL: Unshift: Write code for no items and items.

unshift(value) {
  const newNode = new Node(value)
  if(this.lengthv === 0) {
    this.head = newNode
    this.tail = newNode
  } else {
    newNode.next = this.head
    this.head.prev = newNode
    this.head = newNode 
  }
  this.length++
  return this
}

DLL: Shift: Write code for no items, q item, and two or more items

shift() {
  if(this.length === 0) return undefined
  let temp = this.head
  if(this.length === 1) {
    this.head = null
    this.tail = null
  } else {
    this.head = this.head.next
    this.head.prev = null
    temp.next = null
  }
  this.length--
  return temp
}

DLL: Get: Similar to the “get” method for Linked List but since we have “prev” and “next” we can start at the “tail” if we can get to the index in less steps by doing so. So we put a “for loop” that starts at the “head” inside and “if check” that test whether the index is less than half of the length minus one. In an “else block” we can have a second “for loop” that starts with the “tail.”

get(index) {
  if(index < 0 || index >= this.length) return undefined
  let temp = this.head
  if(index < this.length / 2) {
    for(let i = 0; i < index; i++) {
      temp = temp.next
    }
  } else {
    temp = this.tail
    for(let i = this.length - 1; i > index; i--) {
      temp = temp.prev
    }
  }
  return temp
}

DLL: Set:

set(index, value) {
  let temp = this.get(index)
  if(temp) {
    temp.value = value
    return  true
  }
  return false
}

Note: This is exactly the same method as in the Linked List. The only difference is that the “get method” it leverages is written differently. Because the “get method” is more efficient, this “set method” is now more efficient as well.

Insert: Need “before” and “after” variables. Rember trcik to keep “after” O(1) instead of O(n)

insert(index, value) {
  if(index < 0 || index >= this.length) return undefined
  if(index === 0) return this.unshift(value)
  if(index === this.length) return this.push(value)
  const newNode = new node(value)
  const before = this.get(index - 1) 
  const after = before.next
  before.next = newNode
  newNode.next = after
  newNode.prev = before
  after.prev = newNode
  this.length++
  return this
}

Remove: Since we have a Doubly Linked List, we don’t need a “prev” variable at all, one “temp” variable will do the trick.

remove(index) {
  if(index < 0 || index >= this.length) return undefined
  if(index === 0) return this.shift()
  if(index === this.length - 1) return this.pop()
  
  let temp = this.get(index)
  temp.previous.next = temp.next
  temp.next.previous = temp.prev
  temp.next = null
  temp.prev = null
  this.length--
  return temp
}


Section 6: Data Structures: Stacks & Queues – 26min

Stacks

A Stack is just like a Linked List and a good example of a Stack is a can of tennis balls. Stacks are LIFO. We add and remove from the same end.

A common way to implement a Stack is with an Array. With a Stack we add and remove from the same end and it doesn’t matter which end you choose as long as it is the same end. We won’t use an Array because it is built in to JavaScript and that would be too easy.

If you do implement a stack with an Array it does matter which end you use. When you add or remove to an end of an Array this is O(1). If you do it from the other end, you have to re-index all items when you add or remove making it a less efficient, O(n).

So if you are implementing a stack with an array you always want to do it with the end and not the beginning. An easy way to remember this is that you “push on and pop off” of a stack and these are the methods used with the end of an Array.

We will implement a stack by altering a Linked List. Unlike an Array, when you create a stack with a Linked List, we always use the left side as the top. This keeps the “null-terminated” end at the bottom.

The reason for this is when you remove an item from the end/tail of a Linked List this is O(n) and when you add an item to the end/tail this is O(1). On the left/head/begining of the Linked List, when you add and remove items these are both O(1) operations. So we can use the “shift” and “unshift” methods for a Linked List to help us create the methods of “push” and “pop,” with slight alterations, for our stack.

We also rename the “head” and “tail” to be “top” and “bottom” and we actually don’t even need the “bottom.”

The “Node” class from the Linked List is exactly the same.

We can also take some other things from the Linekd List.

class Node {
  constructor(value) {
    this.value = value
    this.next = null
  }
}
class Stack {
  constructor(value) {
    const newNode = new Node(value)
    this.top = null
    this.length = 1
  }
}

Stack: Push

push(value) {
  const newNode = new Node(value)
  if(this.length === 0) {
    this.top = newNode
  } else {
    newNode.next = this.top
    this.top = newNode
  }
  this.length++
  return this
}

Stack: Pop

pop() {
  if(this.length === 0) return undefined
  let temp = this.top
  this.top = this.top.next
  temp.next = null
  this.length--
  return temp
}

Queue

Queues are like standing in line and are FIFO.

Can use an Array for a Queue. It doesn’t matter which end you use as long as the enqueue/dequeue operations happen at opposite ends. One end will be O(n) and one end will be O(1).

When creating a Queue with Linked Lists you want to enqueue/dequeue from a specific end. This is because adding or “pushing” an item onto the “tail” end is O(1) and removing or “popping” an item from the “tail” end is O(n). On the “left/head” end both of the “shift/unshift” methods are O(1).

So when we create a Queue from a Linked List we don’t want to “dequeue” from the tail end since that is O(n). Instead we want to “dequeue” from the left “head” end and enqueue from the “right/tail” end. This will keep both operations at O(1).

We also rename “head” and “tail” to “first” and “last.”

class Node () {
  constructor(value) {
    this.value = value
    this.next = null
  }
}
class Queue {
  constructor(value) {
    const newNode = new Node(value)
    this.first = newNode
    this.last = newNode
    this.length = 1
  }
  enqueue(value) {
    const newNode = new Node(value)
    if (this.length === 0) {
      this.first = newNode
      this.last = newNode
    } else {
        this.last.next = newNode
        this.last = newNode
    }
    this.length++
    return this
  }
  dequeue() {
    if (this.length === 0) return undefined
    let temp = this.first
    if (this.length === 1) {
      this.first = null
      this.last = null
    } else {
        this.first = this.first.next
        temp.next = null
    }
    this.length--
    return temp
  }
}      // Closes Queue Class


Section 7: Data Structures: Trees – 42min

Terminology for Trees: full, perfect, complete, parent, children, leafs.

Binary Search Tree example

class Node {
  constructor(value) {
    this.value = value
    this.left = null
    this.right = null
  }
}
class BST {
  constructor() {
    this.root = null
  }
 
  insert(value) {
    const newNode = new Node(value)
    if (this.root === null) {
      this.root = newNode     
      return this
    }
    let temp = this.root
    while(true) {
      if (newNode.value === temp.value) return undefined
      if (newNode.value < temp.value) {
        if (temp.left === null) {
          temp.left = newNode
          return this
        }
        temp = temp.left
      } else {
          if (temp.right === null) {
            temp.right = newNode
            return this
          }
          temp = temp.right
        }
  
    }     // Closes while loop
  }     // Closes insert method
  contains(value) {
    if (this.root === null) return false
    let temp = this.root
    while (temp) {
      if(value < temp.value) {
        temp = temp.left
      } else if (value > temp.value) {
        temp = temp.right
      } else {
          return true
        }
    }
    return false
  }     // Closes contains method
}     // Closes BST class


Section 8: Data Structures: Hash Tables – 33min

Example of building an inventory for a hardware store using “key/value pairs.”

class HashTable {
  constructor(size = 7) {
    this.dataMap = new Array(size)
  }
  _hash(key) {
    let hash = 0
    for (let i = 0; i < key.length; i++) {
      hash = (hash + keycharCodeAt(i) * 23) % this.dataMap.length
    }
    return hash
  }     // end _hash method
  set(key, value) {
    let index = this._hash(key)
    if(!this.dataMap[index]) {
      this.dataMap[index] = []
    }
    this.data[index].push([key, value])
    return this
  }     // end set method
  get(key) {
    let index = this._hash(key)
    if(this.dataMap[index]) {
      for(let i = 0; i < this.dataMap[index].length; i++) {
        if(this.dataMap[index][i][0] === key) {
          return this.dataMap[index][i][1]
        }
      }
    }
    return undefined
  }     // end get method
  keys() {
    let allKeys = []
    for(let i = 0; i < this.dataMap.length; i++) {
      if(this.dataMap[i]) {
        for(let j = 0; j < this.dataMap[i]; j++) {
          allKeys.push(this.dataMap[i][j][0])
        }
      }
    }
    return allKeys
  }     // end keys method
}     // end HashTable class

Common interview question for hash tables: Some variation of “write a formula to see if two given arrays have any items in common.”

Could use “for loops” and have nested “for loops” but nested “for loops” are O(n^2), hence very inefficient:

function itemInCommon(arr1, arr2) {
  for(let i = 0; i < arr1.length; i++) {
    for(let j = 0; j < arr2.length; j++) {
      if(arr1[i] === arr2[j]) return true
    }
  }
  return false
}
let array1 = [1, 3, 5]
let array2 = [2, 4, 5]
itemInCommon(array1, array2)

So the above code works but it’s not very efficient. Instead we will use an object to loop through that first “for loop” and for each of the numbers, add to an object as a key with a value of true. Then just loop through the second array and say “is 2 in this object as a key?” and this is an O(1) operation. Now the number of operations is far less and we went through each array only one time.

function itemInCommon(arr1, arr2) {
  let obj = {}
  for(let i = 0; i < arr1.length; i++) {
    obj.[arr1[i]] = true
  }
  for(let j = 0; j < arr2.length; j++) {
    if (obj[arr2[j]]) return true
  }
  return false
}

Note: in the code above we are setting and assessing a key/property on an object with a variable, so bracket notation must be used for this to work.

Section 9: Data Structures: Graphs – 32min

Terminology: Vertex (although will hear called a node sometimes). Connect two vertex with an “edge” but you will also here it called a “connection.”

We can “weight” edges, (maybe to represent traffic).

Can have “bi-directional” edges (represented without edges). An example would be you and a friend following each other on a social network.

Can have “uni-directional” edges (represented with arrows). An example would be you following a celebrity.

“Linked Lists” and “Trees” are types of “Graphs”

Adjacency Matrix: X axis is vertex and Y Axis is what it points to or not represented by a 1 or 0. 45 degree line of zeroes in adjacency matrix. if the graph has all “bi-directional” connections the matrix will be symmetrical across that 45 degree line. If the edges are weighted we can store the weights in the graph instead of a 1.

With an Adjacency Matrix, you store the zeroes along with the 1 so this gets very inefficient as the numbers become very large, (example of a company with billions of users).

Adjacency List: Stored in object. Represent vertex as “key” with “value” set to an Array of edges set as Strings. More efficient and simpler to code.

Big O of Graphs:

Space Complexity with Adjacency Matrix very inefficient, O( |v|^2)

Space Complexity with Adjacency List more efficient, O( |V|+|E| )

Time Complexity with Adjacency Matrix adding a vertex, O(|v|^2)

Time Complexity with Adjacency List adding a vertex, O(1)

Time Complexity with Adjacency Matrix adding an edge, O(1)

Time Complexity with Adjacency List adding an edge, O(1)

Time Complexity with Adjacency Matrix removing an edge, O(1)

Time Complexity with Adjacency List removing an edge, O( |E| )

Time Complexity with Adjacency Matrix removing a vertex, O( |V|^2 )

Time Complexity with Adjacency List removing a vertex, O( |V|+|E| )

class Graph {
  constructor() {
    this.adjacencyList = {}
  }
  addVertex(vertex) {
    if(!this.adjacencyList[vertex]) {
      this.adjacencyList[vertex] = []
      return true
    }
    return false
  }
  addEdge(vertex1, vertex2) {
    if(this.adjacencyList[vertex1] &&
       this.adjacencyList[vertex2]
    ) {
      this.adjacencyList[vertex1].push(vertex2)
      this.adjacencyList[vertex2].push(vertex1)
      return true
    }
    return false
  }                // end addEdge method
  removeEdge(vertex1, vertex2) {
    if(this.adjacencyList[vertex1] &&
       this.adjacencyList[vertex2]
    ) {
      this.adjacencyList[vertex1] = this.adjacencyList[vertex1]
        .filter(v => v !== vertex2)
      this.adjacencyList[vertex2] = this.adjacencyList[vertex2]
        .filter(v => v !== vertex2)
      return true
    }
    return false
  }               // end removeEdge method
  removeVertex(vertex) {
    if(!this.adjacencyList[vertex]) return undefined
    while(this.adjacencyList[vertex].length) {
      let temp = this.adjacencyList[vertex].pop()
      this.removeEdge(vertex, temp)
    }
    delete this.adjacencyList[vertex]
    return this
  }
}     // end Graph

Section 10: Algorithms: Recursion – 21min

A function that calls itself…until it doesn’t

Gift box example: Get a present or another gift box

// pseudo code only; do not run code
function openGiftBox() {
  if (isBall) return ball
  openGiftBox()
}

Two keys rules: 1.) Have to have a base case. The process of whatever you are doing has to be the same, (opening each new box is the same), and 2.) each time we call a function on itself, it needs to be working on a smaller and smaller problem, open a box we make the problem smaller.

Have to have an “if statement,” or some conditional, that when some condition is true, the code will stop running. If not you will face an infinite loop or a “stack overflow.”

Also have to have a “return” statement! A “console.log” will not cut it.

Some key mistakes include having an if statement that is never true.

The call stack:

We used a tennis ball example

Example with non-recursive code first: Three functions that console log the number of the function.

function funcThree() {
  console.log('Three')
}
function funcTwo() {
  funcThree()
  console.log('Two')
}
function funcOne() {
  funcTwo()
  console.log('One')
}
funcOne()

The code above logs three, then two, then one. We add function one two and three to the call stack but we need to wait for three run before we can remove from the call stack and run function two and the same thing happens. So we see the log in the console three two and then one.

Click the left arrow in the top right corner of the dev tools to see the debugger. Collapse everything but the Call Stack and Breakpoints tab. Clicking the number to the left of the code creates a breakpoint. Now when you hit the “play” button to “run snippet” the line will be highlighted green and now we can step through the code line by line (by hitting right arrow with dot).

factorial: Any course that talks about recursion will use factorial as an example

4! = 4 * 3 * 2 * 1

function factorial(n) {
  if(n === 1) return 1
  return n * factorial(n-1)
}

Can check out in the chrome dev tools debugger and see only the word factorial on the call stack but if you click the word in the call stack you will see n = 3 or whichever number corresponds.

Factorials grow incredibly fast so it’s a fun function to play around with.

Section 11: Algorithms: Basic Sorts – 20min

Bubble Sort: The easiest of the sorting algorithms covered.

Since the highest value is bubbled to the top, the array you are sorting gets smaller and smaller in size and this is represented by having the outer loop decrement.

function bubbleSort(array) {
  for(let i = array.length -1; i > 0; i--) {
    for(let j = 0; j < i; j++) {
      if(array[j] > array[j+1]) {
        let temp = array[j]
        array[j] = array[j+1]
        array[j+1] = temp
      }
    }
  }
  return array 
}

Selection Sort: Have to keep track of the indexes. Store in a “min” variable, the index of the lowest item in the array. After looping through the array comparing items, updating the min variable as necessary, use this “min” variable to switch with the comparison item.

function selectionSort(array) {
  let min
  for (let i = 0; i < array.length -1; i++) {
    min = i
    for (let j = i+1; j < array.length; j++) {
      if(array[j] < array[min]) min = j
    }
    if(i !== min) {
      let temp = array[i]
      array[i] = array[min]
      array[min] = temp
    }
  }
  return array
}

Insertion Sort: Always starts with the second item and you compare it to the item before it. If it is less than the item before it we switch them and then compare it to the item before that.

function insertionSort(array) {
  let temp
  for(let i = 1; i < array.length; i++) {
    temp = array[i]
    for(var j = i - 1; array[j] > temp && temp && j > -1; j--) {
      array[j+1] = array[j]
    }
    array[j+1] = temp
  }
  return array
}

Notice that the variable “i” starts at the second item via the index of one.

Note: We use the “var” keyword because we need to use the “j” variable outside of the “for loop.”

Also note: another example of decrement used in the loop.

I am always confused with code like “array[j+1] = array[j]” as my brain thinks it is overriding/overwriting what is already there. And that’s because it is. And I get that that’s why we have this “temp” variable, to keep a a copy of the working item, array[i], so when it it overwritten we still have it’s value. But whereas the author refers to it as “dropping in an open spot” I don’t see an open spot. And that’s because there isn’t one. It is still whatever the value of array[j] was. Then j is decremented, and the value of array[j] pre-decrement is then overwritten by the line “array[j+1] = temp.” The “open spot” verbiage was causing confusion for me.

Big O for Insertion Sort

A “for loop inside of a for loop” so its O(n^2). With “almost sorted” data you may only loop through once, making this O(n), and faster than even merge sort and quick sort.

Section 12: Algorithms: Merge Sort – 15min

Merge sort is our first sorting algorithm that leverages recursion. Leverages the idea that “merging” two sorted arrays is fairly easy.

Merge Sort cuts arrays in half, and then cuts them in half again, and then again, until we have “single item arrays.” Then we take two of them and combine them into one larger sorted array. And then we do it again and again until we have one sorted array.

It looks inefficient but it is actually incredible efficient; in fact, as efficient as you can make a sorting algorithm.

We use a function “merge” that is a helper function we will use in the “Merge Sort” function. It basically says look at both arrays, assuming they have items and are already sorted, and add the appropriate item to a new sorted and combined array.

function merge(array1, array2) {
  let combined = []
  let i = 0
  let j = 0
  while(i < array1.length && j < array2.length) {
    if(array1[i] < array2[j]) {
      combined.push(array1[i])
      i++
    } else {
        combined.push(array2[j])
        j++
    }
  }
  while(i < array1.length) {
    combined.push(array1[i])
    i++
  }
  while(j < array2.length) {
    combined.push(array2[j])
    j++
  }
  return combined
}

The big thing Merge Sort does it break things in half. The “putting it back together” is what the helper “merge” function code above is for.

Two things required in recursion is a smaller and smaller problem, (here we have arrays being broken in half). Also need a base case (here that is when array.length is 1). Then we will use merge() to put arrays together.

function mergeSort(array) {
   if(array.length === 1) return array
   let mid = Math.floor(array.length/2)
   let left = array.slice(0, mid)
   let right = array.slice(mid)
   return merge(mergeSort(left), mergeSort(right))
}

Note: What about when there is an odd-numbered array? A non-issue, “Math.floor” rounds down, the extra item will be in the second array. For example, 7/2 = 3.5 = 3 so the first array has three items and the second array has four items.

Merge Sort Big O

Big O for Space Complexity: As you break down the original array in half you actually created two new arrays doubling the memory usage. If you originally had 8 items in an array, and broke down to 8 single-item-arrays, that is O(n).

Big O Time Complexity: When you break them down you have (say 8 items broken down in 3 steps) you have a O(log n) operation. But when you put them back together that is a O(n) operation. So these steps together we have O(n log n).

On the Big O notation graph O(n log n) looks similar to O(n^2) but it is a little to the right, so better thank O(n^2).

When it comes to sorting algorithms, O(n^2) and O(n log n) are pretty much your two options. There are some exceptions with sorting algorithms that sort only numbers because there are some efficiencies with just numbers.

Section 13: Algorithms: Quick Sort – 17min

We make the first item a “pivot point” and we compare each item to the pivot point. If we find an item that is less than the pivot we exchange places, not with the pivot, but the first item after the pivot that is greater than the pivot. We continue this until all items in the array have ben compared to pivot. At this point all items lower than pivot are near the front of the array (but still to the right of the pivot). We then swap pivot with the last of those smaller items. The pivot is now sorted (likely into the middle) at this point. We then run the “Quick Sort” function again just for the items on the left of the pivot. And then we run the “Quick Sort” function again just for the items on the right. We continue with this until all items in the array are sorted.

We define “swap” and “pivot” as helper functions for a “Quick Sort” function.

function swap(array, firstIndex, secondIndex) {
  let temp = array[firstIndex]
  array[firstIndex] = array[secondIndex]
  array[secondIndex] = temp
}

function pivot(array, pivotIndex=0, endIndex=array.length-1) {
  let swapIndex = pivotIndex
  for(let i = pivotIndex + 1; i <= endIndex; i++) {
    if(array[i] < array[pivotIndex]) {
      swapIndex++
      swap(array, swapIndex, i)
    }
  }
  swap(array, pivotIndex, swapIndex)
  return swapIndex 
}

Note: only need to pass the “pivot” function an array since the other two parameters have defaults. They are needed because we are going to run a function on a subset of the array and will need the first and last index of the subset.

function quickSort(array, left=0, right=array.length-1) {
  if(left < right) {
    let pivotIndex = pivot(array, left, right)
    quickSort(array, left, pivotIndex-1)
    quickSort(array, pivotIndex+1, right)
  }
  return array
}

Note: similar to pivot, you only need to pass the “quickSort” function an array.

Big O of Quick Sort for Space Complexity: We don’t create any new items here so we are not duplicating any data so we are O(1). This is an advantage over Merge Sort which has a space complexity of O(n). Note that the Big-O Cheatsheet has the space complexity of Quick Sort at O(log n) and not O(1). Still better than merge sort.

Big O of Quick Sort for Time Complexity: In order to move all of these items around, this is O(n) because we have a “for loop” that goes all the way through to the end. But we have to do this multiple times, O(log n). So when we combine them together we have O(n log n), which is better than O(n^2), but this is only the best case for Quick Sort.

The worst case, which is what big O measures, is not O(n log n) but O(n^2). The worst case happens if you have an “already-sorted” data. Ironically, if you have sorted, or almost sorted data, this is not a good algorithm. It is an excellent sorting algorithm for random data.

Section 14: Algorithms: Tree Traversal – 32min

Traversing is going through each item. Two broad categories: Breadth First Search and Depth First Search.

Breadth First Search:

Steps: Start at the top and fill across each row, move lower until you have completed. We use two arrays (queue and results) to accomplish our task. The “queue” array gets the entire node where the “results” array gets just the value.

BFS() {
  let currentNode = this.root
  let queue = []
  let results = []
  queue.push(currentNode)
  
  while(queue.length) {
    currentNode = queue.shift()
    results.push(currentNode.value)
    if(currentNode.left) queue.push(currentNode.left)
    if(currentNdoe.right) {queue.push(currentNode.right)}
  }
  return results
}

Note: The BFS function is a method that should be part of the Binary Search Tree (BST) class as seen in Section 7.

Depth First Search:

Three types of Depth First Search: PreOrder, PostOrder, InOrder

DFS PreOrder:

We write the root to the array first.

DFSPreOrder() {
  let results = []
  function traverse(currentNode) {
    results.push(currentNode.value)
    if(currentNode.left) traverse(currentNode.left)
    if(currentNode.right) traverse(currentNode.right)
  }
  traverse(this.root)
  return results
}

DFS PostOrder:

We write the root to the array last.

DFSPostOrder() {
  let results = []
  function traverse(currentNode) {
    if(currentNode.left) traverse(currentNode.left)
    if(currentNode.right) traverse(currentNode.right)
    results.push(currentNode.value)
  }
  traverse(this.root)
  return results
}

When you compare post order to pre order, in pre order we wrote the root to the array first while in post order we write the root to the array last.

DFS InOrder:

We write the lowest (bottom-left-most) to the array first and goes from lowest to highest.

Always go to the left first and when it cannot go left we write it to the array. And the we try to go right. If we can’t then we go up and write that value, and then try to go right.

DFSInOrder() {
  let results = []
  function traverse(currentNode) {
    if(currentNode.left) traverse(currentNode.left)
    results.push(currentNode.value)
    if(currentNode.right) traverse(currentNode.right)
  }
  traverse(this.root)
  return results
}

Summary Note for DFS Methods:

It is really only the order of the three lines in the traverse function inside of each of these three methods that sets them apart.