Mastering Linked Lists in JavaScript: A Comprehensive Guide

Linked lists are a fundamental data structure that every programmer must have in their toolkit, especially JavaScript developers. Many of us learn about the basics of linked lists in computer science courses, but it‘s not until you actually implement them in real-world projects that you truly appreciate their power and understand their nuances.

In this in-depth guide, I‘ll share my knowledge about linked lists from my experience as a full-stack developer and professional coder. We‘ll dive deep into what linked lists are, how to implement them in JavaScript, their real-world applications, performance considerations, and much more. Let‘s get started!

Understanding the internals of a linked list

A linked list is a data structure that represents a sequence of nodes. In contrast to an array which is a contiguous block of memory, the nodes of a linked list can be scattered in memory. Each node contains a value and a pointer to the next node in the sequence.

Here‘s a simplified representation of a linked list:

[1|*] -> [2|*] -> [3|*] -> [4|/]

The * represents a pointer to the next node and the / represents a null pointer terminating the list.

So instead of having the values laid out contiguously like [1,2,3,4] in an array, a linked list just has each value pointing to the next one. This means you don‘t need a big block of memory to store a linked list, you can just scatter the nodes anywhere in memory.

The first node is called the head and the last node points to null. To traverse a linked list, you start at the head and follow the pointers until you hit null.

One thing to keep in mind is that you always need to keep track of the head node. If you lose the head, you lose your entire list! This is why it‘s good practice to define a LinkedList class that always maintains a reference to the head node.

Implementing a linked list in JavaScript

Now that we understand how a linked list works internally, let‘s see how to actually code one up in JavaScript. We‘ll break this down into two parts:

  1. Implementing a Node class to represent each node
  2. Implementing a LinkedList class to tie the nodes together

The Node class

Here‘s a basic Node class:

class Node {
  constructor(value) {
    this.value = value;
    this.next = null;
  }
}

Each node has a value property to store the actual data and a next property to store a reference to the next node. When we create a new node, its next pointer is initially set to null.

The LinkedList class

Now let‘s define a LinkedList class that will manage our nodes:

class LinkedList {
  constructor() {
    this.head = null;
    this.size = 0;
  }

  // methods will go here
}

We start with a constructor that initializes the head to null (since a new list is empty) and size to 0.

The main methods we need to implement are:

  • insertAtHead(value) – insert a new node at the beginning of the list
  • insertAtTail(value) – insert a new node at the end of the list
  • insertAtIndex(value, index) – insert a new node at the given index
  • deleteAtHead() – delete the first node
  • deleteAtTail() – delete the last node
  • deleteAtIndex(index) – delete the node at the given index
  • search(value) – find the index of the first node with the given value

Here‘s the code for each of these methods:

  insertAtHead(value) {
    const newNode = new Node(value);
    newNode.next = this.head;
    this.head = newNode;
    this.size++;
  }

  insertAtTail(value) {
    const newNode = new Node(value);

    if (!this.head) {
      this.head = newNode;
    } else {
      let curr = this.head;
      while (curr.next) {
        curr = curr.next;
      }
      curr.next = newNode;
    }

    this.size++;
  }

  insertAtIndex(value, index) {
    if (index < 0 || index > this.size) {
      throw new Error(‘Invalid index‘);
    }

    if (index === 0) {
      this.insertAtHead(value);
    } else {
      const newNode = new Node(value);
      let prev = this.head;
      for (let i = 0; i < index - 1; i++) {
        prev = prev.next;
      }
      newNode.next = prev.next;
      prev.next = newNode;
      this.size++;
    }
  }

  deleteAtHead() {
    if (!this.head) {
      throw new Error(‘List is empty‘);
    }

    const value = this.head.value;
    this.head = this.head.next;
    this.size--;
    return value;
  }

  deleteAtTail() {
    if (!this.head) {
      throw new Error(‘List is empty‘);
    }

    let value;
    if (!this.head.next) {
      value = this.head.value;
      this.head = null;
    } else {
      let prev = this.head;
      while (prev.next.next) {
        prev = prev.next;
      }
      value = prev.next.value;
      prev.next = null;
    }

    this.size--;
    return value;
  }

  deleteAtIndex(index) {
    if (index < 0 || index >= this.size) {
      throw new Error(‘Invalid index‘);
    }

    let value;
    if (index === 0) {
      value = this.deleteAtHead();
    } else {
      let prev = this.head;
      for (let i = 0; i < index - 1; i++) {
        prev = prev.next;
      }
      value = prev.next.value;
      prev.next = prev.next.next;
      this.size--;
    }

    return value;
  }  

  search(value) {
    if (!this.head) {
      return -1;
    }

    let curr = this.head;
    let index = 0;
    while (curr) {
      if (curr.value === value) {
        return index;
      }
      curr = curr.next;
      index++;
    }

    return -1;
  }

The key things to notice here are:

  1. To insert/delete at the head, we adjust the head pointer. This takes O(1) time.

  2. To insert/delete at the tail, we have to traverse to the last node. This takes O(n) time.

  3. To insert/delete in the middle, we have to traverse to the node just before the desired index and then adjust pointers. This also takes O(n) time.

  4. To search for a value, we traverse the entire list in the worst case. This takes O(n) time.

Here‘s a visualization of what happens when you insert a new node at the head:

[1|*] -> [2|*] -> [3|/]

insertAtHead(0)

[0|*] -> [1|*] -> [2|*] -> [3|/]

And here‘s what happens when you delete at the tail:

[1|*] -> [2|*] -> [3|/]

deleteAtTail()

[1|*] -> [2|/]

See how the pointers are adjusted to maintain the correct sequence?

Time and space complexity analysis

Let‘s recap the time and space complexity of our linked list operations:

Operation Time Complexity Space Complexity
insertAtHead O(1) O(1)
insertAtTail O(n) O(1)
insertAtIndex O(n) O(1)
deleteAtHead O(1) O(1)
deleteAtTail O(n) O(1)
deleteAtIndex O(n) O(1)
search O(n) O(1)

The space complexity is always O(1) because we‘re only allocating a single new node at a time, regardless of the size of the list.

Compare this to an array where insertion/deletion at the beginning or middle is O(n) because you have to shift all the subsequent elements over. However, array lookups are O(1) vs O(n) for a linked list.

So in general, linked lists are great when you need to frequently add/remove from the ends and don‘t need random access. Arrays are better when you need indexed lookups and less insertion/deletion.

Real-world applications

Linked lists have a variety of applications in the real world:

  1. Implementing stacks and queues: The natural insertion and deletion order of a linked list makes it a perfect fit for stacks (LIFO) and queues (FIFO).

  2. Representing graphs: Graphs can be represented using an adjacency list which is essentially a linked list of neighbors for each node.

  3. Hash table collisions: Some hash table implementations use separate chaining (linked lists) to handle collisions.

  4. Tokenizing strings: When you need to parse a string into tokens (e.g. a compiler), you can use a linked list to efficiently build the token stream.

  5. Memory management: Many memory allocators and garbage collectors use linked lists under the hood to keep track of free blocks of memory.

Here‘s a concrete example of using a linked list to implement an LRU (least recently used) cache:

class Node {
  constructor(key, value) {
    this.key = key;
    this.value = value;
    this.prev = null;
    this.next = null;
  }
}

class LRUCache {
  constructor(capacity) {
    this.capacity = capacity;
    this.size = 0;
    this.head = new Node(‘head‘, null);
    this.tail = new Node(‘tail‘, null);
    this.head.next = this.tail;
    this.tail.prev = this.head;
    this.map = new Map();
  }

  get(key) {
    if (this.map.has(key)) {
      const node = this.map.get(key);
      this.remove(node);
      this.append(node);
      return node.value;
    }
    return null;
  }

  put(key, value) {
    if (this.map.has(key)) {
      const node = this.map.get(key);
      this.remove(node);
      this.size--;
    }

    if (this.size === this.capacity) {
      const lru = this.head.next;
      this.remove(lru);
      this.map.delete(lru.key);
      this.size--;
    }

    const newNode = new Node(key, value);
    this.append(newNode);
    this.map.set(key, newNode);
    this.size++;
  }

  remove(node) {
    const prev = node.prev;
    const next = node.next;
    prev.next = next;
    next.prev = prev;
  }

  append(node) {
    const prev = this.tail.prev;
    const next = this.tail;
    prev.next = node;
    node.prev = prev;
    node.next = next;
    next.prev = node;
  }
}

This uses a doubly linked list (where each node has a pointer to the previous node as well) combined with a hash map for efficient cache operations. The linked list keeps track of the usage order of the keys.

Advanced topics and algorithms

There are many fascinating algorithmic problems and techniques associated with linked lists. Here are a couple of examples:

  1. Floyd‘s cycle-finding algorithm (tortoise and hare): This is a clever algorithm to determine if a linked list contains a cycle. You have two pointers, a "slow" one that moves one step at a time and a "fast" one that moves two steps at a time. If there‘s a cycle, the fast one will eventually catch up to the slow one.
function hasCycle(head) {
  if (!head) return false;

  let slow = head;
  let fast = head.next;
  while (slow !== fast) {
    if (!fast || !fast.next) return false;
    slow = slow.next;
    fast = fast.next.next;
  }
  return true;
}
  1. Reversing a linked list: This is a common interview problem. The trick is to keep track of the previous node as you traverse the list.
function reverseList(head) {
  let prev = null;
  let curr = head;

  while (curr) {
    let nextTemp = curr.next;
    curr.next = prev;
    prev = curr;
    curr = nextTemp;
  }

  return prev;
}

These are just a couple of examples, but they demonstrate the kind of creative problem-solving that‘s possible with linked lists.

Conclusion

Wow, we‘ve covered a lot of ground! We started with the basics of what a linked list is, then dove into the implementation details in JavaScript, analyzed the time and space complexity, explored some real-world applications, and even touched on some advanced algorithms.

I hope this guide has given you a comprehensive understanding of linked lists and the confidence to use them in your own projects. Remember, the best way to truly understand a data structure is to implement it yourself, so I encourage you to take the code we‘ve written here and extend it further.

Try adding more utility methods like toArray, fromArray, reverse, slice, removeValue, etc. Experiment with different types of linked lists like doubly and circular linked lists. And most importantly, have fun!

Linked lists are just the beginning. As you progress in your journey as a developer, you‘ll encounter many more fascinating data structures and algorithms, each with their own unique properties and use cases. But the problem-solving skills and intuition you develop from mastering linked lists will serve you well no matter what you encounter.

So keep coding, keep learning, and don‘t be afraid to dive deep into the fundamentals. It‘s the foundation upon which all great software is built. Happy coding!

Similar Posts