Data Structures

Linked Lists

Last updated
Reading time
6 min read

Getting started

Linked lists create an ordered structure of values by storing a current value and pointing to the next. Folks typically start out with these by learning how to reverse them which I solved here. There are two classes involved: a ListNode class and the LinkedList class itself. The node helps as a constructor to define the links between nodes while the list details all of the basic functionality like adding, removing, counting the size, and printing. Additionally, there may be methods that allow for more granular updates like insertion or removal from a specific index.

Functionality

I'll start with the basics of constructing each node, then the entire linked list class.

ListNode

class ListNode<T> {
  value: T
  next: ListNode<T> | null
  constructor(value: T, next?: ListNode<T> | null) {
    this.value = value
    this.next = next === undefined ? null : next
  }
}

With this class, a linked list can already be constructed as shown below. Note that the list is built in this way from the deepest part outward so the array must be iterated over in reverse to make the list retain the array's order. 3 is added first, 1 is added last.

let arr = [1, 2, 3]
let list = null

for (let i = arr.length - 1; i >= 0; i--) {
  list = new ListNode(val, list)
}

console.log(list)
// Output:
// {
//   value: 1,
//   next: {
//     value: 2,
//     next: {
//       value: 3
//       next: null
//     }
//   }
// }

LinkedList

Up next is the list with some common list operations as methods. These include finding values, printing values, returning the list's length, and insert/delete operations. Note that references to new ListNode() are referring to the class presented previously.

Doubly Linked List

When I got to the LRU cache problem, I realized I would need to use a doubly linked list for all of the operations to be O(1) which is required. For each node's access, O(1) is achieved by mapping all nodes which is possible via the constraint that there can only be one key in the cache at any given time. However, the operation that necessitates a doubly linked list is that, if you only had a singly linked list, you would need to traverse the list in worst case O(n) time to find the previous node whenever that node needs to be changed. Previous nodes would be changed when a node is inserted or deleted because the previous node's next pointer must be updated.

Realizing this, I went through the process of learning the ins and outs of doubly linked lists for the first time by creating one for Leetcode's 707. Design Linked List question. It wasn't too difficult, as the only additional things to handel are prev and keeping track of the tail.

class LinkedList<T> {
  head: ListNode<T> | null
  size: number
  constructor() {
    this.head = null
    this.size = 0
  }

  length = (): void => console.log(this.size)

  print = (): void => {
    let listString = ''
    let curr = this.head
    while (curr) {
      listString += curr.value + ' '
      curr = curr.next
    }
    console.log(listString)
  }

  find = (value: T): number => {
    let index = 0
    let curr = this.head
    while (curr) {
      if (curr.value === value) {
        return index
      }
      index++
      curr = curr.next
    }
    return -1
  }

  append = (newValue: T) => {
    let newNode = new ListNode(newValue)
    if (this.head === null) {
      this.head = newNode
    } else {
      let curr: ListNode<T> | null = this.head
      while (curr.next) {
        curr = curr.next
      }
      curr.next = newNode
    }
    this.size++
  }

  insert = (value: T, index: number): void => {
    if (index < 0 || index > this.size) {
      console.error('Index not in range')
    } else {
      let newNode = new ListNode(value)
      if (index === 0) {
        newNode.next = this.head
        this.head = newNode
      } else {
        let prev
        let curr = this.head
        let i = 0
        while (i < index) {
          prev = curr
          curr = curr?.next || null
          i++
        }
        newNode.next = curr
        prev!.next = newNode
      }
      this.size++
    }
  }

  deleteAt = (index: number) => {
    if (index < 0 || index > this.size) {
      console.error('Index not in range')
    } else {
      if (index === 0) {
        this.head = this.head!.next
      } else {
        let prev
        let curr = this.head
        let i = 0
        while (i < index) {
          prev = curr
          curr = curr!.next
          i++
        }
        prev!.next = curr!.next
        this.size--
        return curr!.value
      }
    }
  }

  // Makes the most sense when values are simple like numbers or strings.
  // Comparing objects would be trickier, but a use-case dependent example
  // is if objects represent people in line and it is known ahead of time
  // which key is to be used for the search such as the person's name.
  deleteValue = (value: T): T | -1 => {
    let prev
    let curr = this.head
    while (curr) {
      if (curr.value === value) {
        if (prev === null) {
          this.head = curr.next
        } else {
          prev!.next = curr.next
        }
        this.size--
        return curr.value
      }
      prev = curr
      curr = curr.next
    }
    return -1
  }
}

DoubleListNode

class DoubleListNode<T> {
  val?: number
  next?: DoubleListNode<T> | null
  prev?: DoubleListNode<T> | null
  constructor(val?: number, next?: DoubleListNode<T> | null, prev?: DoubleListNode<T> | null) {
    this.val = val ? val : 0
    this.next = next ? next : null
    this.prev = prev ? prev : null
  }
}

DoublyLinkedList

class DoublyLinkedList<T> {
  head: DoubleListNode<T>
  tail: DoubleListNode<T>
  length: number
  constructor() {
    this.head = new DoubleListNode()
    this.tail = this.head
    this.length = 0
  }

  get(index: number): number {
    if (index >= this.length) return -1

    let curr = this.head.next
    for (let i = 0; i < index; i++) curr = curr.next

    return curr.val
  }

  addAtHead(val: number): void {
    const newNode = new DoubleListNode(val, this.head.next, this.head)
    if (this.head.next) {
      this.head.next.prev = newNode
    } else this.tail = newNode
    this.head.next = newNode
    this.length++
  }

  addAtTail(val: number): void {
    const newNode = new DoubleListNode(val)
    if (!this.head.next) {
      this.head.next = newNode
      newNode.prev = this.head
    } else {
      this.tail.next = newNode
      newNode.prev = this.tail
    }

    this.tail = newNode
    this.length++
  }

  addAtIndex(index: number, val: number): void {
    if (index > this.length) return

    let curr = this.head
    for (let i = 0; i < index; i++) curr = curr.next

    const newNode = new DoubleListNode(val, curr.next, curr)
    if (curr.next) curr.next.prev = newNode
    curr.next = newNode
    if (!newNode.next) this.tail = newNode

    this.length++
  }

  deleteAtIndex(index: number): void {
    if (index >= this.length || !this.length) return

    let curr = this.head
    for (let i = 0; i <= index; i++) curr = curr.next

    curr.prev.next = curr.next
    if (curr.next) curr.next.prev = curr.prev

    if (!curr.next) this.tail = curr.prev

    this.length--
  }
}