Singly linked lists: fundamental algorithms

Table of Contents

“Image by Alina Grubnyak (https://unsplash.com/photos/black-and-silver-turntable-on-brown-wooden-table-GNyjCePVRs8)

Introduction

A singly linked list is a collection of nodes where each node is charcterized by a value val and a reference next, which is a pointer to the next node in the sequence. Implementing such a data structure is easy, as the following Python class ListNode demonstrates.

1class ListNode:
2    def __init__(self, val=0, next=None):
3        self.val = val
4        self.next = next

Fundamental operations on singly linked-lists:

In the following, we are going to review fundamental operations on linked lists. This includes reversing, sorting, or removing elements from a single list as well as merging different lists or determining their intersection.

Reversing a list

In a singly linked list, reversal is one of the fundamental operations.

0 0 1 1 2 2 3 3 4 4

Reversing a list follows a straightforward procedure that relies on three pointers: the previous node prev, the current node curr and the next node next.

 1def reverseList(self, head: ListNode) -> ListNode:
 2    prev = None
 3    curr = head
 4
 5    while curr is not None:
 6        next = curr.next
 7        curr.next = prev
 8
 9        prev = curr
10        curr = next

Removing all elements with value k from a linked list

The next fundamental operation removes all nodes with a given value from the linked list.

0 k 0 2 2 k 5 5

This operation is trivial, as we simpy skip the connection from the current node curr to curr.next, if the latter node has value k.

 1def removeElements(self, head: ListNode, k) -> ListNode:
 2    dummy = ListNode()
 3    dummy.next, curr = head, dummy
 4
 5    while curr.next is not None:
 6        if curr.next.val == k:
 7            curr.next = curr.next.next
 8        else:
 9            curr = curr.next
10
11    return dummy.next 

Remove duplicates from a sorted list

Removing duplicates is very similar to removing all nodes with a given value.

0 1 0 1 1 2 2 2

The key lies in the second while loop.

 1def deleteDuplicates(self, head: ListNode) -> ListNode:
 2
 3    cur = head
 4
 5    while cur:
 6        while cur.next and cur.next.val == cur.val:
 7            cur.next = cur.next.next
 8        cur = cur.next
 9
10    return head

Sorting all elements in a linked list

The next fundamental operation sorts a linked list.

5 1 3 2 1 3 4 4 2 5

This operation is more challenging and relies on a recursion. We begin by splitting our linked list recursively in a left and right half and merge the sorted result.

 1def sortList(self, head: Optional[ListNode]) -> Optional[ListNode]:
 2
 3    if not head or not head.next:
 4            return head
 5        
 6    left = head
 7    right = self.getMid(head)
 8    tmp = right.next
 9    right.next = None
10    right = tmp
11    
12    left = self.sortList(left)
13    right = self.sortList(right)
14    
15    return self.merge(left, right)
16    
17def getMid(self, head):
18    slow = head
19    fast = head.next
20    
21    while fast and fast.next:
22        slow = slow.next
23        fast = fast.next.next
24    return slow
25
26def merge(self, list1, list2):
27    newHead = tail = ListNode()
28    while list1 and list2:
29        if list1.val > list2.val:
30            tail.next = list2
31            list2 = list2.next
32        else:
33            tail.next = list1
34            list1 = list1.next
35        tail = tail.next
36    
37    tail.next = list1 or list2
38    
39    return newHead.next

Merge two sorted lists into a sorted list

As in the previous example, merging two sorted lists is trivial.

0 0 0 1 1 2 2 3 5 4 5

The code directly corresponds to the merge() function in the previous example.

 1def mergeTwoLists(self, list1, list2) -> ListNode:
 2
 3    dummy = cur = ListNode()
 4
 5    while list1 and list2:
 6        if list1.val < list2.val:
 7            cur.next = list1
 8            list1 = list1.next
 9        else:
10            cur.next = list2
11            list2 = list2.next
12        cur = cur.next
13        
14    cur.next = list1 or list2
15    return dummy.next

Determine the intersection of two linked lists

Assume that we are given the heads of two linked lists, headA and headB. If the linked lists intersect, we want to know at which node.

0 2 1 3 4 5 6

We can make the following key observation: Regardless of whether the linked lists intersect, moving from headA to the tail and then from headB to the tail requires the same number of steps as moving from headB to the tail and then from headA to the tail.

Assume that we have two pointers a and b to the headAand headB, respectively. Pointer a (b) starts at headA (headB) and moves to the tail, before starting from headB (headA) to move to the tail once again. If we move both pointers one step at a time, then they will either:

  • both be None (after starting from both heads of non-intersecting lists).
  • meet at the intersection after starting from the first head, if the number of nodes before the intersection is equal.
  • meet at the intersection after starting from the second head, if the number of nodes before the intersection is unequal.

This is exactly what the following code does:

 1def getIntersectionNode(self, headA, headB) -> ListNode:
 2
 3    if headA is None or headB is None:
 4        return None
 5
 6    a = headA
 7    b = headB
 8
 9    while a is not b:
10        a = headB if a is None else a.next
11        b = headA if b is None else b.next
12
13    return a

Remove the n-th element from the end of the list

0 0 n = 2 1 1 2 2 4 3 4

We use two pointers: slow and fast, where the latter is given a headstart of n

 1def removeNthFromEnd(self, head: ListNode, n: int) -> Optional[ListNode]:
 2
 3    fast, slow = head, head
 4
 5    for _ in range(n):
 6        fast = fast.next
 7    if not fast:
 8        return head.next
 9    
10    while fast.next is not None:
11        fast, slow = fast.next, slow.next
12    slow.next = slow.next.next
13
14    return head

Rotating the list to the right by k places

0 4 3 k k = = 1 0 4 1 2 , , 6 7 2 1 0 3 2 1 4 3 2

The idea is to first count the length of the list and introduce a cycle that links tail to head. As k may be larger than length, we update k = k % length. All that remains is to move length - k - 1 steps from the head to break the cycle.

 1def rotateRight(self, head: ListNode, k: int) -> ListNode:
 2
 3    if not head:
 4        return None
 5
 6    tail = head
 7    length = 1
 8    while tail.next is not None:
 9        tail = tail.next
10        length += 1
11
12    k = k % length
13    tail.next = head
14
15    temp = head
16    for _ in range(length - k - 1):
17        temp = temp.next
18
19    result = temp.next
20    temp.next = None
21
22
23    return result

Cycle detection in singly linked lists

Let head be the root element of singly linked list of finite size. We want to know if there is a cycle in the linked list and at which node (if any) it occurs.

0 1 9 2 8 3 7 5 6
0 1 2 8 3 7 5 6

This problem has a very elegant solution known as Floyd’s cycle detection algorithm (also known as tortoise and hare algorithm).

 1def detectCycle(self, head: ListNode) -> Optional[ListNode]:
 2
 3    slow, fast = head, head
 4
 5    while fast and fast.next:
 6        slow = slow.next
 7        fast = fast.next.next
 8        if slow == fast:
 9            slow = head
10            while slow != fast:
11                slow = slow.next
12                fast = fast.next
13            return slow
14    return None

This solution may seem counterintuituive. More precisely, a question may be why this algorithm is guaranteed to always work, irrespective of the number of nodes before the cycle, the duration of the cycle, or the position in the cycle where slow and fast meet?

Let $f$ and $s$ be the number of nodes traversed by fast and slow, respectively. We know that $f = 2s$ must hold when slow and fast meet for the first time. Additionally, let $n_{start}$ and $n_{cycle}$ be the number of nodes required to reach the start of the cycle and the duration of the cycle, respectively. Finally, define $n_{meet}$ as the number of nodes from the start necessary to reach the meeting point of slow and fast.

We can derive the following system of equations:

\[ \begin{aligned} f &= n_{start} + n_{cycle} + n_{meet} \\ s & = n_{start} + n_{meet} \end{aligned} \]

Inserting $f = 2s$ we receive:

\[ \begin{aligned} & n_{start} + n_{cycle} + n_{meet} = 2n_{start} + 2n_{meet} \\ \Rightarrow \quad & n_{cycle} = n_{start} + n_{meet} \\ \Leftrightarrow \quad & n_{start} = n_{cycle} - n_{meet} \end{aligned} \]

Therefore, the distance $n_{start}$ to travel from the head to the start of the cycle is identical to the distance required to complete the cycle when starting at $n_{meet}$.

Conclusion

Further challenges can easily be addressed by relying on the presented building blocks! 🚀