Linked Lists

September 17, 2024·Johnathan Jacobs

A link list is a special case of a tree, where each node is connected to at most one other node (singly-linked) or two other nodes (doubly-linked). Nodes can only ever be linked to the next node, or the next and the previous node.

graph TB;
  subgraph "Doubly-Linked List"
    A<-->B<-->C<-->D;
  end
  subgraph "Singly-Linked List"
    a-->b-->c-->d;
  end

Singly-Linked List

Usage

  • Efficient insertion/deletion at the front: O(1) for push_front and pop_front, which makes it ideal for a stack-like structure.
  • Simple traversal: If you only need forward traversal, a singly-linked list is simpler and more space-efficient than a doubly-linked list.
  • When space is a concern: Singly-linked lists are more memory-efficient than doubly-linked lists due to the single pointer per node.
  • Stack: A simple stack can be implemented with a singly-linked list, where push and pop operations happen at the front.
  • FIFO Queue: A first-in-first-out data structure like a queue is implementable as a singly-linked list. Where you push to the back, and pop from the front. Note, this only makes sense if you also keep track of the tail.

Complexity

  • Insertion at the front (push_front): $O(1)$
  • Insertion at the back (push_back): $O(n)$, since we must traverse the entire list to append.
  • Deletion from the front (pop_front): $O(1)$
  • Traversal (print_list): $O(n)$
  • Space complexity: $O(n)$ due to the $n$ elements and pointers.

Implementation

Typically, implemented as a node structure where each node holds a pointer to the next node. Optionally, a pointer to the tail can be kept track of to be able to more efficiently insert at the back.

#include <cassert>
#include <memory>

template <typename T>
class SinglyLinkedList {
 private:
  struct Node {
    T value;
    std::unique_ptr<Node> next;
    explicit Node(const T& val) : value(val) {}
  };

  std::unique_ptr<Node> head = nullptr;
  Node* tail = nullptr;

 public:
  void push_front(const T& value) {
    auto newNode = std::make_unique<Node>(value);
    if (!head) {
      tail = newNode.get();
    } else {
      newNode->next = std::move(head);
    }
    head = std::move(newNode);
  }

  void push_back(const T& value) {
    auto newNode = std::make_unique<Node>(value);
    if (!tail) {
      head = std::move(newNode);
      tail = head.get();
    } else {
      tail->next = std::move(newNode);
      tail = tail->next.get();
    }
  }

  void pop_front() {
    if (head) {
      head = std::move(head->next);
      if (!head) {
        tail = nullptr;
      }
    }
  }

  [[nodiscard]] const Node* back() const { return tail; }
  [[nodiscard]] const Node* front() const { return head.get(); }
  [[nodiscard]] bool is_empty() const noexcept { return head == nullptr; }
};

int main() {
  SinglyLinkedList<int> l;
  l.push_front(1);
  l.push_front(2);
  l.push_front(3);
  l.push_back(0);
  l.push_back(-1);
  l.push_back(-2);
  assert(l.back()->value == -2);
  assert(l.front()->value == 3);
  l.pop_front();
  assert(l.back()->value == -2);
  assert(l.front()->value == 2);
}

Doubly-Linked List

Usage

  • Doubly-linked lists are often used when efficient insertions and deletions are necessary from both ends, such as the case of a Deque (Double-ended queue).
  • Not used when frequent random access is required.
  • Used for efficient bidirectional traversal.

Complexity

  • Insertion at the front (push_front): $O(1)$
  • Insertion at the back (push_back): $O(1)$
  • Deletion from the front (pop_front): $O(1)$
  • Deletion from the back (pop_back): $O(1)$
  • Traversal (both forward and backward): $O(n)$
  • Space complexity: $O(n)$ due to the need to store $n$ elements and their pointers.

Implementation

Typically, implemented as a node structure where each node holds a pointer to the next node and the previous node.

In this C++ implementation we use a smart pointer to allocate the node, and a plain pointer as a weak reference to the previous node.

#include <cassert>
#include <memory>

template <typename T>
class DoublyLinkedList {
 private:
  struct Node {
    T value;
    std::unique_ptr<Node> next;
    Node* prev = nullptr;
    explicit Node(const T& val) : value(val) {}
  };

  std::unique_ptr<Node> head = nullptr;
  Node* tail = nullptr;

 public:
  void push_front(const T& value) {
    auto newNode = std::make_unique<Node>(value);
    if (!head) {
      tail = newNode.get();
    } else {
      newNode->next = std::move(head);
      newNode->next->prev = newNode.get();
    }
    head = std::move(newNode);
  }

  void push_back(const T& value) {
    auto newNode = std::make_unique<Node>(value);
    if (!tail) {
      head = std::move(newNode);
      tail = head.get();
    } else {
      tail->next = std::move(newNode);
      tail->next->prev = tail;
      tail = tail->next.get();
    }
  }

  void pop_front() {
    if (head) {
      head = std::move(head->next);
      if (head) {
        head->prev = nullptr;
      } else {
        tail = nullptr;
      }
    }
  }

  void pop_back() {
    if (tail) {
      if (tail->prev) {
        tail = tail->prev;
        tail->next.reset();
      } else {
        head.reset();
        tail = nullptr;
      }
    }
  }

  [[nodiscard]] const Node* back() const { return tail; }
  [[nodiscard]] const Node* front() const { return head.get(); }
  [[nodiscard]] bool is_empty() const noexcept { return head == nullptr; }
};

int main() {
  DoublyLinkedList<int> l;
  l.push_front(1);
  l.push_front(2);
  l.push_front(3);
  l.push_back(0);
  l.push_back(-1);
  l.push_back(-2);
  assert(l.back()->value == -2);
  assert(l.front()->value == 3);
  l.pop_back();
  l.pop_front();
  assert(l.back()->value == -1);
  assert(l.front()->value == 2);
}