Linked List

CSCI 1913 – Introduction to Algorithms, Data Structures, and Program Development
Adriana Picoral

Array-based List

Array-based List

Elements are stored in one contiguous block of memory – to grow, we must allocate a bigger block and copy everything over

Front of List Middle of List End of List
get O(1) O(1) O(1)
set O(1) O(1) O(1)
insert O(n) O(n) O(1)*
pop O(n) O(n) O(1)

* amortized

  • random access (access through an index) is fast
  • insert/remove something – shift everything else

Linked lists

A linked list is a chain of nodes, where each node contains:

  • data - the actual value you’re storing (like arrays)
  • a pointer/reference - the address of the next node in the chain

No need for contiguous memory (nodes can be scattered anywhere in memory)

Linked List

  • dynamic structure, can grow and shrink as needed – each new item gets its own node, so there is no resizing or copying
  • elements are arranged in a linear sequence
  • implemented through nodes – each node has a reference to the next node

Nodes

To create a linked list:

  • a class of self-referential objects
  • each object of the class contains a reference to an object of the same class
  • convention is to name the class Node

Write a generic class in Java called Node that has two instance variables: data (any type), and a reference to another node.

Write the constructor method, and a set and get method for each instance variable.

Solution

public class Node<T> {
    private T data;
    private Node<T> next;

    // constructor
    public Node(T data) {
        this.data = data;
        next = null;
    }

    // setters
    public void setNext(Node<T> next) {
        this.next = next;
    }

    public void setData(T data) {
        this.data = data;
    }

    // getters
    public Node<T> getNext() {
        return next;
    }

    public T getData() {
        return data;
    }

}

LinkedList class

  • What instance variables do you need?

LinkedList class

  • head – reference to the first Node
  • tail – reference to the last Node
  • length – number of nodes in the list

Strictly speaking, only head is required – tail and length save us from walking the whole list

LinkedList set up and constructor

public class LinkedList<T> {
    private Node<T> head;
    private Node<T> tail;
    private int length;

    // constructor
    public LinkedList() {
        head = null;
        tail = null;
        length = 0;
    }

}

Methods

What methods do you need to implement?

Here’s our generic interface we used to implement our ArrayList:

public interface List<T> {
    void append(T value);
    void prepend(T value);
    void insert(int index, T value);
    int length();
    T get(int index);
    void set(int index, T value);
    T pop(int index);
}

Implement append, prepend and length in your LinkedList<T> class + a toString() method to test your code. Submit your List.java, Node.java and LinkedList.java files to gradescope.

Edge cases checklist

Linked list bugs almost always hide in the edge cases. For every method, ask:

  • What if the list is empty? (head == null)
  • What if the list has one node? (head == tail)
  • What if we change the head? (do we update head?)
  • What if we change the tail? (do we update tail?)
  • What if the index is out of range?
  • Did we update length?

prepend – O(1)

before:              head → [A] → [B] → [C] → null
1. create node:      [X]
2. X.next = head:    [X] → [A] → [B] → [C] → null
3. head = X:         head → [X] → [A] → [B] → [C] → null

What happens if we do step 3 before step 2?

prepend – O(1)

public void prepend(T value) {
  Node<T> nodeToAdd = new Node<>(value);
  if (head == null) {
    head = nodeToAdd;
    tail = nodeToAdd;
  } else {
    nodeToAdd.setNext(head);
    head = nodeToAdd;
  }
  length++;
}

append – O(n)

If we only had head, we would need to walk to the end of the list:

public void append(T value) {
  Node<T> nodeToAdd = new Node<>(value);
  if (head == null) head = nodeToAdd;
  else {
    Node<T> currNode = head;
    while (currNode.getNext() != null) {
      currNode = currNode.getNext();
    }
    currNode.setNext(nodeToAdd);
  }
  length++;
}

append – O(1)

Keep a tail instance variable.

public void append(T value) {
  Node<T> nodeToAdd = new Node<>(value);
  if (head == null) {
    head = nodeToAdd;
    tail = nodeToAdd;
  } else {
    tail.setNext(nodeToAdd);
    tail = nodeToAdd;
  }
  length++;
}

toString() method

Let’s create a toString() method to test our list insertion.

Write a toString() method that traverses the entire list

  • Concatenate values

toString() solution

@Override
public String toString() {
  String result = "";
  Node<T> currNode = head;
  while (currNode != null) {
    result += currNode.getData() + " ";
    currNode = currNode.getNext();
  }
  return result.trim();
}

Same test, different implementation

Our UseList test from the ArrayList lecture – change one word:

public class UseList {
    public static void main(String[] args) {
        List<String> fruitList = new LinkedList<>(); // was: new ArrayList<>()
        fruitList.append("apple");
        fruitList.append("pear");
        fruitList.append("banana");
        fruitList.prepend("kiwi");
        fruitList.prepend("tomato");

        System.out.println(fruitList); // tomato kiwi apple pear banana
    }
}

This is the payoff of writing the List interface first: code that uses a List doesn’t care how it is implemented.

search() method

Write a search() method that traverses the entire list trying to find a node that matches the argument value – if found, return true, otherwise return false. Add the boolean search(T data) signature to the List.java interface.

  • Implementation of search() is similar to toString() (write a while loop)
  • Adding a method to the interface means every class that implements List must implement it – including ArrayList!

Submit your List.java, Node.java and LinkedList.java with the search() method to gradescope.

search() solution

public boolean search(T data) {
  Node<T> currNode = head;
  while (currNode != null) {
    if (currNode.getData().equals(data)) {
      return true;
    }
    currNode = currNode.getNext();
  }
  return false;
}

Linear search – complexity

What we have implemented is an unsorted linked list – how many comparisons does it require to find a target element?

In the worst case, each node must be examined once, resulting in \(O(n)\) complexity – \(n\) is the number of elements in the list.

Delete node

Removing a node takes a few more steps:

  • Find the node to remove (by index or by matching data), keeping track of the previous node
  • Make the previous node skip over it: previous.setNext(cur.getNext())

Delete node – head and tail

Removing the head – there is no previous node!

before:    head → [A] → [B] → [C] → null
pop(0):    head = head.getNext()
after:     head → [B] → [C] → null

Removing the tail – previous becomes the new tail

before:    [A] → [B] → [C] → null      tail → [C]
pop(2):    previous.setNext(null); tail = previous
after:     [A] → [B] → null            tail → [B]

implement pop(int index)

Implement pop(int index), get(int index), and set(int index, T value) in your LinkedList.java and submit it alongside with List.java and Node.java to gradescope.

Go back to the edge cases checklist!

Find index and remove

public T pop(int index) {
  if (index < 0 || index >= length) throw new IndexOutOfBoundsException("index out of range");
  int count = 0;
  Node<T> previous = null;
  Node<T> cur = head;
  while (count < index) {
    previous = cur;
    cur = cur.getNext();
    count++;
  }
  if (previous == null) head = cur.getNext(); // removing the head
  else previous.setNext(cur.getNext());
  if (cur == tail) tail = previous;          // removing the tail
  length--;
  return cur.getData();
}

Complete LinkedList solution

public class LinkedList<T> implements List<T> {
    private Node<T> head;
    private Node<T> tail;
    private int length;

    public LinkedList() {
        head = null;
        tail = null;
        length = 0;
    }

    public void append(T value) {
        Node<T> nodeToAdd = new Node<>(value);
        if (head == null) {
            head = nodeToAdd;
            tail = nodeToAdd;
        } else {
            tail.setNext(nodeToAdd);
            tail = nodeToAdd;
        }
        length++;
    }

    public void prepend(T value) {
        Node<T> nodeToAdd = new Node<>(value);
        if (head == null) {
            head = nodeToAdd;
            tail = nodeToAdd;
        } else {
            nodeToAdd.setNext(head);
            head = nodeToAdd;
        }
        length++;
    }

    public void insert(int index, T value) {
        if (index < 0 || index > length) throw new IndexOutOfBoundsException("index out of range");
        if (index == 0) {
            prepend(value);
            return;
        }
        if (index == length) {
            append(value);
            return;
        }
        Node<T> nodeToAdd = new Node<>(value);
        int count = 0;
        Node<T> previous = head;
        while (count < index - 1) {
            previous = previous.getNext();
            count++;
        }
        nodeToAdd.setNext(previous.getNext());
        previous.setNext(nodeToAdd);
        length++;
    }

    public int length() {return length; }

    public T get(int index) {
        if (index < 0 || index >= length) throw new IndexOutOfBoundsException("index out of range");
        int count = 0;
        Node<T> cur = head;
        while (count < index) {
            cur = cur.getNext();
            count++;
        }
        return cur.getData();
    }

    public void set(int index, T value) {
        if (index < 0 || index >= length) throw new IndexOutOfBoundsException("index out of range");
        int count = 0;
        Node<T> cur = head;
        while (count < index) {
            cur = cur.getNext();
            count++;
        }
        cur.setData(value);
    }

    public T pop(int index) {
        if (index < 0 || index >= length) throw new IndexOutOfBoundsException("index out of range");
        int count = 0;
        Node<T> previous = null;
        Node<T> cur = head;
        while (count < index) {
            previous = cur;
            cur = cur.getNext();
            count++;
        }
        if (previous == null) head = cur.getNext(); // removing the head
        else previous.setNext(cur.getNext());
        if (cur == tail) tail = previous;          // removing the tail
        length--;
        return cur.getData();
    }

    public String toString() {
        String out = "";
        Node<T> cur = head;
        while (cur != null) {
            out += cur.getData() + " ";
            cur = cur.getNext();
        }
        return out.trim();
    }

    public boolean search(T data) {
        Node<T> currNode = head;
        while (currNode != null) {
            if (currNode.getData().equals(data)) {
                return true;
            }
            currNode = currNode.getNext();
        }
        return false;
    }

}

Runtime of LinkedList

Front of List Middle of List End of List
get O(1) O(n) O(n)*
set O(1) O(n) O(n)*
insert O(1) O(n) O(1)
pop O(1) O(n) O(n)

* could be O(1) if we check for the last index and use tail – our implementation walks the list

Why is pop at the end \(O(n)\) even though we have tail? (Hint: what does tail need to become?)

ArrayList vs. LinkedList

ArrayList LinkedList
get/set by index O(1) O(n)
insert/pop at front O(n) O(1)
append O(1) amortized O(1) (with tail)
pop at end O(1) O(n)
insert/pop in middle O(n) O(n)
memory one block, may have unused slots one node (data + reference) per item

Other types of linked list

We just implemented a singly linked list

Doubly linked list – each node also points to the previous node, so pop at the end becomes O(1):

Other types of linked list

Circular linked list: