The most basic dynamic data structure: linked list

Posted by Muggin on Wed, 19 Jan 2022 01:45:42 +0100

What is a linked list?

Linked list is not only a linear structure, but also the most basic dynamic data structure. When we implement dynamic arrays, stacks and queues, the bottom layer relies on static arrays and resize s to solve the problem of fixed capacity, and the linked list is a real dynamic data structure. Learning the data structure of linked list can have a deeper understanding of reference (or pointer) and recursion. The linked list is divided into single linked list and double linked list. The single linked list introduced in this paper.

The data in the linked list is stored in nodes. This is the most basic node structure:

class Node {
	E e;
	Node next;  // Node holds a reference to the next node
}

We can think of the linked list as a train. Each carriage is a node. If passengers ride in the carriage of the train, it is equivalent to storing elements in the nodes of the linked list. Each car of the train is connected to the next car, just as the node in the linked list will hold the reference of the next node. The last carriage of the train is not connected to any carriage, just as the node at the end of the linked list points to null:

Advantages and disadvantages of linked list:

  • Advantages: the real dynamic structure does not need to deal with the problem of fixed capacity. It is convenient to insert and delete nodes from the middle, which is more flexible than arrays
  • Disadvantages: it loses the ability of random access and cannot be accessed directly through index like array

Needless to say, let's start to write the data structure of the linked list. First, let's implement the node structure in the linked list and some simple methods of the linked list. The code is as follows:

/**
 * Implementation of linked list data structure
 **/
public class LinkedList<E> {
    /**
     * Node structure in linked list
     */
    private class Node {
        E e;
        Node next;

        public Node() {
            this(null, null);
        }

        public Node(E e) {
            this(e, null);
        }

        public Node(E e, Node next) {
            this.e = e;
            this.next = next;
        }

        @Override
        public String toString() {
            return e.toString();
        }
    }
    
    /**
     * Head node
     */
    private Node head;

    /**
     * Number of elements in the linked list
     */
    private int size;

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

    /**
     * Gets the number of elements in the linked list
     *
     * @return Number of elements
     */
    public int getSize() {
        return size;
    }

    /**
     * Is the linked list empty
     *
     * @return If it is empty, return true; otherwise, return false
     */
    public boolean isEmpty() {
        return size == 0;
    }    
}

Add elements to the linked list

When we add elements to the array, the most convenient way is to add them from the back of the array. Because size always points to the position of + 1 of the last element of the array, we can easily add elements by using the size variable.

On the contrary, in the linked list, it is most convenient for us to add a new element in the head of the linked list, because a head variable is maintained in the linked list, that is, the head of the linked list. We only need to put the new element into a new node, then point the next variable in the new node to the head, and finally point the head to the new node to complete the addition of the element:

Let's implement the method of adding new elements to the linked list header. The code is as follows:

/**
 * Add a new element to the linked list header e
 *
 * @param e New element
 */
public void addFirst(E e) {
    Node node = new Node(e);
    node.next = head;
    head = node;

    // The above three codes can be directly completed by using the following code,
    // But in order to make the logic clearer, the code is specially decomposed here
    // head = new Node(e, head);
    size++;
}

Then let's see how to insert a new node at the specified position in the linked list. Although this is not a common operation in the linked list, some problems related to the linked list will involve this operation, so we still have to understand it. For example, we now want to insert a new node at the position with "index" 2. How to implement:

Although there is no real index in the linked list, in order to insert a new node at the specified location, we have to refer to the concept of index. As shown in the figure above, the chain header is regarded as index 0, the next node is regarded as index 1, and so on. Then we also need to have a prev variable. Move this variable circularly to find the position of the specified "index" - 1. After finding it, point the next of the new node to the next of prev, and then point to the new node to complete the logic of inserting the node. Therefore, the key point is to find the previous node of the node to be added:

The specific implementation code is as follows:

/**
 * Add a new element at the index(0-based) position of the linked list e
 *
 * @param index Where the element is added
 * @param e     New element
 */
public void add(int index, E e) {
	// Check whether the index is legal
    if (index < 0 || index > size) {
        throw new IllegalArgumentException("Add failed. Illegal index.");
    }

    // The addition of linked list header needs special treatment
    if (index == 0) {
        addFirst(e);
    } else {
        Node prev = head;
        // Move prev to index - 1
        for (int i = 0; i < index - 1; i++) {
            prev = prev.next;
        }

        Node node = new Node(e);
        node.next = prev.next;
        prev.next = node;

        // Similarly, the above three sentences of code can be completed in one sentence
        // prev.next = new Node(e, prev.next);

        size++;
    }
}

Based on the above method, we can easily add new elements at the end of the linked list:

/**
 * Add a new element at the end of the linked list e
 *
 * @param e New element
 */
public void addLast(E e) {
    add(size, e);
}

Virtual header node using linked list

In the previous section, in the code for inserting elements into the specified position, we need to deal with the position of the chain header, because there is no previous node in the chain header. Many times, those who use linked lists need similar special processing, which is not very elegant, so this section introduces how to solve this problem gracefully.

The main reason for special processing is that the head does not have a previous node. When initializing prev, it can only point to the head. In this case, we can add a node in front of it. This node does not store any data and is only used as a virtual node. This is also a skill often used when writing the linked list structure. Adding such a node can unify the operation logic of the linked list:

The modified code is as follows:

public class LinkedList<E> {

    /**
     * Virtual head node
     */
    private Node dummyHead;

    /**
     * Number of elements in the linked list
     */
    private int size;

    public LinkedList() {
        this.dummyHead = new Node(null, null);
        this.size = 0;
    }

    /**
     * Add a new element at the index(0-based) position of the linked list e
     *
     * @param index Where the element is added
     * @param e     New element
     */
    public void add(int index, E e) {
        if (index < 0 || index > size) {
            throw new IllegalArgumentException("Add failed. Illegal index.");
        }

        Node prev = dummyHead;
        // Move prev to the location of the previous node of index
        for (int i = 0; i < index; i++) {
            prev = prev.next;
        }

        Node node = new Node(e);
        node.next = prev.next;
        prev.next = node;

        // Similarly, the above three sentences of code can be completed in one sentence
        // prev.next = new Node(e, prev.next);

        size++;
    }

    /**
     * Add a new element to the linked list header e
     *
     * @param e New element
     */
    public void addFirst(E e) {
        add(0, e);
    }
    /**
     * Add a new element at the end of the linked list e
     *
     * @param e New element
     */
    public void addLast(E e) {
        add(size, e);
    }
}

Traversal, query and modification of linked list

With the foundation of the above sections, it is easy to traverse, query and modify the linked list. The code is as follows:

/**
 * Get the element at the index(0-based) position of the linked list
 *
 * @param index
 * @return
 */
public E get(int index) {
    if (index < 0 || index >= size) {
        throw new IllegalArgumentException("Get failed. Illegal index.");
    }

    Node cur = dummyHead.next;
    for (int i = 0; i < index; i++) {
        cur = cur.next;
    }

    return cur.e;
}

/**
 * Get the first element in the linked list
 *
 * @return
 */
public E getFirst() {
    return get(0);
}

/**
 * Gets the last element in the linked list
 *
 * @return
 */
public E getLast() {
    return get(size - 1);
}

/**
 * Modify the element at the index(0-based) position of the linked list to e
 *
 * @param index
 * @param e
 */
public void set(int index, E e) {
    if (index < 0 || index >= size) {
        throw new IllegalArgumentException("Set failed. Illegal index.");
    }

    Node cur = dummyHead.next;
    for (int i = 0; i < index; i++) {
        cur = cur.next;
    }

    cur.e = e;
}

/**
 * Find whether the linked list contains element e
 *
 * @param e
 * @return
 */
public boolean contain(E e) {
    Node cur = dummyHead.next;
    // The first way to traverse the linked list
    while (cur != null) {
        if (cur.e.equals(e)) {
            return true;
        }
        cur = cur.next;
    }

    return false;
}

@Override
public String toString() {
    if (isEmpty()) {
        return "[]";
    }

    StringBuilder sb = new StringBuilder();
    sb.append(String.format("LinkedList: size = %d\n", size));
    sb.append("[");
    Node cur = dummyHead.next;
    // The second way to traverse the linked list
    for (int i = 0; i < size; i++) {
        sb.append(cur.e).append(" -> ");
        cur = cur.next;
    }

    // The third way to traverse the linked list
    // for (Node cur = dummyHead.next; cur != null; cur = cur.next) {
    //     sb.append(cur.e).append(" -> ");
    // }

    return sb.append("NULL]").toString();
}

Delete element from linked list

Finally, the linked list operation we want to implement is to delete elements from the linked list. Deleting elements is equivalent to deleting nodes in the linked list. For example, if I want to delete a node with an index of 2, we also need to use a prev variable to move circularly to the previous node of the node to be deleted. At this time, take out the next of prev to be deleted. It is also very simple to delete the node. After taking out the node to be deleted, point the next of prev to the next of the node to be deleted:

Finally, point the node to be deleted to a null and make it out of the linked list, so that it can be garbage collected quickly. In this way, the node is deleted:

The specific implementation code is as follows:

/**
 * Delete the element at the index(0-based) position from the linked list and return the deleted element
 *
 * @param index
 * @return The element stored by the deleted node
 */
public E remove(int index) {
    if (index < 0 || index >= size) {
        throw new IllegalArgumentException("remove failed. Illegal index.");
    }

    Node prev = dummyHead;
    for (int i = 0; i < index; i++) {
        prev = prev.next;
    }

    Node delNode = prev.next;
    // Change the reference and delete it
    prev.next = delNode.next;
    delNode.next = null;
    size--;
    
    return delNode.e;
}

Based on the above method, we can simply implement the following two methods:

/**
 * Delete the first element in the linked list
 *
 * @return Deleted element
 */
public E removeFirst() {
    return remove(0);
}

/**
 * Delete the last element in the linked list
 *
 * @return Deleted element
 */
public E removeLast() {
    return remove(size - 1);
}

Finally, let's take a look at the time complexity of the linked list addition, deletion, query and modification operation:

addLast(e)         // O(n)
addFirst(e)        // O(1)
add(index, e)      // O(n)
removeLast()       // O(n)
removeFirst()      // O(1)
remove(index)      // O(n)
set(index, e)      // O(n)
get(index)         // O(n)
contain(e)         // O(n)

Using linked list to realize stack

From the time complexity of the addFirst and removeFirst methods of the linked list, we can see that if the complexity of adding and deleting only the chain header is O(1), the complexity of querying only the elements of the chain header is O(1). At this time, we can think of using the linked list to realize the stack. The operation time complexity of entering and leaving the stack of the stack realized by the linked list is also O(1). The specific implementation code is as follows:

/**
 * Implementation of stack data structure based on linked list
 **/
public class LinkedListStack<E> implements Stack<E> {
    private LinkedList<E> linkedList;

    public LinkedListStack() {
        this.linkedList = new LinkedList<>();
    }

    @Override
    public int getSize() {
        return linkedList.getSize();
    }

    @Override
    public boolean isEmpty() {
        return linkedList.isEmpty();
    }

    @Override
    public void push(E e) {
        linkedList.addFirst(e);
    }

    @Override
    public E pop() {
        return linkedList.removeFirst();
    }

    @Override
    public E peek() {
        return linkedList.getFirst();
    }

    @Override
    public String toString() {
        if (isEmpty()) {
            return "[]";
        }

        StringBuilder sb = new StringBuilder();
        sb.append(String.format("LinkedListStack: size = %d\n", getSize()));
        sb.append("top [");
        for (int i = 0; i < getSize(); i++) {
            sb.append(linkedList.get(i));
            if (i != getSize() - 1) {
                sb.append(", ");
            }
        }
        return sb.append("]").toString();
    }

    // test
    public static void main(String[] args) {
        Stack<Integer> stack = new LinkedListStack<>();

        for (int i = 0; i < 5; i++) {
            stack.push(i);
            System.out.println(stack);
        }

        stack.pop();
        System.out.println(stack);
    }
}

Linked list with tail pointer: use linked list to realize queue

In the previous section, we easily implemented a stack structure based on the linked list. In this section, we will see how to use the linked list to realize the queue structure and what improvements need to be made to the linked list.

Before writing the code, we need to consider a problem. In the previous implementation code of linked list structure, only one head variable points to the head node. If we directly use this linked list to implement the queue, the complexity is O(n) when we need to operate the elements at the end of the chain, because we need to traverse the whole linked list until the position of the end node. So how to avoid traversal and quickly find the tail node under the complexity of O(1)? The answer is to add a tail variable that always points to the tail node. In this way, the complexity of operating the tail node is O(1).

In addition, there is another problem to be considered when using the linked list to realize the queue, that is, from which side to enter the queue element and from which side to exit the queue element. In the linked list code we wrote before, adding elements at the head of the chain is O(1), which is also the most simple and convenient. So should we take the head of the chain as the end of the team? The answer is the opposite. We should take the head of the chain as the end of the team and the tail of the chain as the end of the team.

Because the linked list we implemented is a single chain structure, in this case, the head of the chain can be used as either the end of the team or the end of the team, but the tail of the chain can not, and the tail of the chain can only be used as the end of the team. If you take the tail of the chain as one end of the queue, the complexity of the queue will be O(n). You need to traverse the chain list to find the previous node of the tail node, and then point to null next of the node to complete the queue out operation. It doesn't matter if it is a double chain structure. You can get the previous node only through the tail variable, and you don't need to traverse the linked list to find it. Therefore, we need to take the head of the chain as the end of joining the team and the tail of the chain as the end of leaving the team, so that the time complexity of both leaving and joining the team is O(1).

The specific implementation code is as follows:

/**
 * Queue data structure based on linked list
 **/
public class LinkedListQueue<E> implements Queue<E> {
    private class Node {
        E e;
        Node next;

        public Node() {
            this(null, null);
        }

        public Node(E e) {
            this(e, null);
        }

        public Node(E e, Node next) {
            this.e = e;
            this.next = next;
        }

        @Override
        public String toString() {
            return e.toString();
        }
    }

    /**
     * Head node
     */
    private Node head;

    /**
     * Tail node
     */
    private Node tail;

    /**
     * Indicates the number of elements in the queue
     */
    private int size;

    @Override
    public void enqueue(E e) {
        if (tail == null) {
            // The linked list has no elements
            tail = new Node(e);
            head = tail;
        } else {
            // Chain tail join element
            tail.next = new Node(e);
            tail = tail.next;
        }
        size++;
    }

    @Override
    public E dequeue() {
        if (isEmpty()) {
            throw new IllegalArgumentException("Can't dequeue from an empty queue.");
        }

        // Chain first outgoing element
        Node retNode = head;
        head = head.next;
        retNode.next = null;
        if (head == null) {
            // If there are no elements in the queue, the tail node needs to be empty
            tail = null;
        }
        size--;

        return retNode.e;
    }

    @Override
    public E getFront() {
        if (isEmpty()) {
            throw new IllegalArgumentException("Queue is empty.");
        }

        return head.e;
    }

    @Override
    public int getSize() {
        return size;
    }

    @Override
    public boolean isEmpty() {
        return size == 0;
    }

    @Override
    public String toString() {
        if (isEmpty()) {
            return "[]";
        }

        StringBuilder sb = new StringBuilder();
        sb.append(String.format("LinkedListQueue: size = %d\n", getSize()));
        sb.append("front [");
        Node cur = head;
        while (cur != null) {
            sb.append(cur.e).append(", ");
            cur = cur.next;
        }

        return sb.append("NULL] tail").toString();
    }
}

Topics: data structure linked list