Python LinkedList practice reverse sort search insertion programs FP2

Python LinkedList practice — 3 real programs that consolidate the structure

In the previous article we built the LinkedList from scratch. Now it’s time to extend it with real algorithms. In this article we implement three programs that go beyond basic insert and delete — searching with conditions, insertion sort and reversing a linked list. Each one deepens your understanding of how to traverse and manipulate the node chain.

We start from the complete LinkedList class built in the theory article. All three programs add methods to it.

The base class

class LinkedList:
    class Node:
        def __init__(self, value, next_node=None):
            self.value = value
            self.next_node = next_node

    def __init__(self):
        self.__first = None
        self.__length = 0

    def insert_front(self, value):
        self.__first = self.Node(value, self.__first)
        self.__length += 1

    def insert_back(self, value):
        new = self.Node(value)
        if self.__first is None:
            self.__first = new
        else:
            current = self.__first
            while current.next_node is not None:
                current = current.next_node
            current.next_node = new
        self.__length += 1

    def delete(self, value):
        if self.__first is None:
            raise ValueError(f"{value} not in list")
        if self.__first.value == value:
            self.__first = self.__first.next_node
            self.__length -= 1
            return
        previous = self.__first
        current = self.__first.next_node
        while current is not None:
            if current.value == value:
                previous.next_node = current.next_node
                self.__length -= 1
                return
            previous = current
            current = current.next_node
        raise ValueError(f"{value} not in list")

    def is_empty(self):
        return self.__first is None

    def __len__(self):
        return self.__length

    def __iter__(self):
        current = self.__first
        while current is not None:
            value = current.value
            current = current.next_node
            yield value

    def __str__(self):
        return ' → '.join(str(v) for v in self) + ' → None'

    def __contains__(self, value):
        return any(v == value for v in self)

Python LinkedList Practice — Program 1: Search and count with conditions

This program adds a rich set of search and statistical methods to the LinkedList. All of them follow the same traversal pattern — they just do different things at each node.

class LinkedList(LinkedList):    # extend the base class

    def find_first(self, condition):
        """
        Return the first value satisfying condition(value).
        Returns None if not found.
        """
        current = self.__first
        while current is not None:
            if condition(current.value):
                return current.value
            current = current.next_node
        return None

    def find_all(self, condition):
        """Return list of all values satisfying condition(value)."""
        return [v for v in self if condition(v)]

    def count_if(self, condition):
        """Count values satisfying condition(value)."""
        return sum(1 for v in self if condition(v))

    def minimum(self):
        """Return the minimum value. Raises ValueError if empty."""
        if self.is_empty():
            raise ValueError('Cannot find minimum of empty list')
        current = self.__first
        min_val = current.value
        current = current.next_node
        while current is not None:
            if current.value < min_val:
                min_val = current.value
            current = current.next_node
        return min_val

    def maximum(self):
        """Return the maximum value. Raises ValueError if empty."""
        if self.is_empty():
            raise ValueError('Cannot find maximum of empty list')
        current = self.__first
        max_val = current.value
        current = current.next_node
        while current is not None:
            if current.value > max_val:
                max_val = current.value
            current = current.next_node
        return max_val

    def average(self):
        """Return average of all values. Raises ValueError if empty."""
        if self.is_empty():
            raise ValueError('Cannot compute average of empty list')
        total = sum(self)
        return total / len(self)

    def index_of(self, value):
        """Return 0-based index of first occurrence. Returns -1 if not found."""
        current = self.__first
        index = 0
        while current is not None:
            if current.value == value:
                return index
            current = current.next_node
            index += 1
        return -1

    def get(self, index):
        """Return value at position index. Raises IndexError if out of range."""
        if index < 0 or index >= len(self):
            raise IndexError(f'Index {index} out of range (length {len(self)})')
        current = self.__first
        for _ in range(index):
            current = current.next_node
        return current.value

    def to_list(self):
        """Convert to Python list."""
        return list(self)

Using the search methods

# Build a list of grades
ll = LinkedList()
for grade in [7.5, 3.0, 8.5, 5.0, 9.5, 4.5, 6.0, 2.0, 8.0]:
    ll.insert_back(grade)

print(f'List:    {ll}')
print(f'Length:  {len(ll)}')

# Basic search
print(f'\nMinimum: {ll.minimum()}')
print(f'Maximum: {ll.maximum()}')
print(f'Average: {ll.average():.2f}')

# Search with condition (lambda)
passing = ll.find_all(lambda g: g >= 5.0)
print(f'\nPassing grades:  {passing}')
print(f'Count passing:   {ll.count_if(lambda g: g >= 5.0)}')
print(f'Count failing:   {ll.count_if(lambda g: g < 5.0)}')

# Find first outstanding grade
first_outstanding = ll.find_first(lambda g: g >= 9.0)
print(f'First outstanding: {first_outstanding}')

# Index operations
print(f'\nIndex of 8.5: {ll.index_of(8.5)}')
print(f'Index of 1.0: {ll.index_of(1.0)}  (not found → -1)')
print(f'Element at [2]: {ll.get(2)}')

# Statistical breakdown
print(f'\n--- Grade breakdown ---')
categories = [
    ('Outstanding (9-10)', lambda g: g >= 9.0),
    ('Merit (7-8.9)',       lambda g: 7.0 <= g < 9.0),
    ('Passed (5-6.9)',      lambda g: 5.0 <= g < 7.0),
    ('Failed (0-4.9)',      lambda g: g < 5.0),
]
for label, condition in categories:
    count = ll.count_if(condition)
    grades = ll.find_all(condition)
    print(f'  {label}: {count} → {grades}')

Output:

List:    7.5 → 3.0 → 8.5 → 5.0 → 9.5 → 4.5 → 6.0 → 2.0 → 8.0 → None
Length:  9

Minimum: 2.0
Maximum: 9.5
Average: 5.94

Passing grades:  [7.5, 8.5, 5.0, 9.5, 6.0, 8.0]
Count passing:   6
Count failing:   3
First outstanding: 9.5

Index of 8.5: 2
Index of 1.0: -1  (not found → -1)
Element at [2]: 8.5

--- Grade breakdown ---
  Outstanding (9-10): 1 → [9.5]
  Merit (7-8.9): 3 → [7.5, 8.5, 8.0]
  Passed (5-6.9): 2 → [5.0, 6.0]
  Failed (0-4.9): 3 → [3.0, 4.5, 2.0]

The lambda functions passed to find_first, find_all and count_if are the key technique — they let you write generalised traversal code once and apply any criterion you want. find_all(lambda g: g >= 5.0) and find_all(lambda g: g >= 9.0) use the same traversal logic but different filtering conditions. This is the equivalent of Python’s built-in filter() applied to a linked structure.

Python LinkedList Practice — Program 2: Insertion sort

Insertion sort on a linked list works differently from insertion sort on a Python list. Instead of shifting elements to make space, we remove the node and reinsert it in the correct position. The algorithm traverses the unsorted portion, picks each element and inserts it at the right position in the sorted portion.

def insertion_sort(self):
    """
    Sort the list in ascending order using insertion sort.
    Modifies the list in place.
    Returns self for chaining.
    """
    if self.__first is None or self.__first.next_node is None:
        return self    # empty or single element — already sorted

    # Strategy: rebuild sorted list from scratch
    # Take each node from original, insert in correct position in new list
    sorted_first = None
    current = self.__first

    while current is not None:
        next_node = current.next_node    # save next before modifying current

        # Find correct position in sorted portion
        # Case 1: insert before sorted_first (new minimum)
        if sorted_first is None or current.value <= sorted_first.value:
            current.next_node = sorted_first
            sorted_first = current
        else:
            # Case 2: find position in sorted portion
            search = sorted_first
            while (search.next_node is not None and
                   search.next_node.value < current.value):
                search = search.next_node
            # Insert current after search
            current.next_node = search.next_node
            search.next_node = current

        current = next_node    # advance to next unsorted node

    self.__first = sorted_first
    return self


def sort(self, reverse=False):
    """
    Sort the list. Returns self for chaining.
    Uses insertion sort.
    """
    self.insertion_sort()
    if reverse:
        self.reverse()    # we'll implement this in Program 3
    return self

Step by step — sorting [5, 3, 8, 1, 4]

Start:   5 → 3 → 8 → 1 → 4 → None
sorted:  (empty)

Take 5:  sorted = 5 → None
Take 3:  3 < 5 → insert before 5
         sorted = 3 → 5 → None
Take 8:  8 > 5 → insert after 5
         sorted = 3 → 5 → 8 → None
Take 1:  1 < 3 → insert before 3
         sorted = 1 → 3 → 5 → 8 → None
Take 4:  4 > 3, 4 < 5 → insert between 3 and 5
         sorted = 1 → 3 → 4 → 5 → 8 → None
# Demo
ll = LinkedList()
for v in [5, 3, 8, 1, 9, 2, 7, 4, 6]:
    ll.insert_back(v)

print(f'Unsorted: {ll}')
ll.insertion_sort()
print(f'Sorted:   {ll}')

# Verify sorted property
values = ll.to_list()
assert values == sorted(values), "List not properly sorted!"
print('Verification: ✓ correctly sorted')

# Sort in reverse
ll2 = LinkedList()
for v in [3, 1, 4, 1, 5, 9, 2, 6]:
    ll2.insert_back(v)

print(f'\nUnsorted:       {ll2}')
ll2.sort(reverse=True)
print(f'Sorted reverse: {ll2}')

# Performance note
import time

ll3 = LinkedList()
import random
values = list(range(1000))
random.shuffle(values)
for v in values:
    ll3.insert_back(v)

start = time.time()
ll3.insertion_sort()
elapsed = time.time() - start
print(f'\n1000 elements sorted in {elapsed:.4f}s')
print(f'First 5: {ll3.to_list()[:5]}')
print(f'Last 5:  {ll3.to_list()[-5:]}')

Output:

Unsorted: 5 → 3 → 8 → 1 → 9 → 2 → 7 → 4 → 6 → None
Sorted:   1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → None
Verification: ✓ correctly sorted

Unsorted:       3 → 1 → 4 → 1 → 5 → 9 → 2 → 6 → None
Sorted reverse: 9 → 6 → 5 → 4 → 3 → 2 → 1 → 1 → None

1000 elements sorted in 0.0312s
First 5: [0, 1, 2, 3, 4]
Last 5:  [995, 996, 997, 998, 999]

The key line is next_node = current.next_node saved before modifying current. When you change current.next_node to point somewhere else (the insertion step), you’d lose track of where to continue in the original unsorted list if you hadn’t saved it first. This is the most common mistake in node manipulation — always save what you need before modifying anything.

Python LinkedList Practice — Program 3: Reverse the list

Reversing a linked list has two approaches. The iterative approach is more efficient and easier to understand with a diagram. The recursive approach is shorter but uses call stack space proportional to the list length.

Iterative approach

The idea: traverse the list and flip each arrow — make every node point backwards instead of forwards.

Before: None ← [1] ← [2] ← [3] → None
         first                    last

After:  None ← [3] ← [2] ← [1] → None
         first                    last

We need three pointers: previous (starts as None), current (starts at first) and next_node (saved before modification).

Step 0: previous=None, current=[1], next=?

Step 1: save next=[2], flip [1].next=None, advance:
        previous=[1], current=[2]

Step 2: save next=[3], flip [2].next=[1], advance:
        previous=[2], current=[3]

Step 3: save next=None, flip [3].next=[2], advance:
        previous=[3], current=None → STOP

first = previous = [3]
Result: [3] → [2] → [1] → None
def reverse(self):
    """
    Reverse the list in place.
    Returns self for chaining.
    """
    previous = None
    current = self.__first

    while current is not None:
        next_node = current.next_node    # 1. save next
        current.next_node = previous     # 2. flip the arrow
        previous = current               # 3. advance previous
        current = next_node              # 4. advance current

    self.__first = previous    # previous is now the new first (old last)
    return self


def reverse_recursive(self):
    """
    Reverse the list in place using recursion.
    Note: uses O(n) stack space — prefer iterative for long lists.
    """
    def _reverse(node):
        if node is None or node.next_node is None:
            return node    # base case: empty or single node — new head

        new_head = _reverse(node.next_node)    # recurse to end
        node.next_node.next_node = node        # flip arrow
        node.next_node = None                  # current's next is now None
        return new_head

    self.__first = _reverse(self.__first)
    return self


def is_palindrome(self):
    """Return True if the list reads the same forwards and backwards."""
    values = self.to_list()
    return values == values[::-1]


def get_middle(self):
    """
    Return the value at the middle position using two-pointer technique.
    For even length, returns the second middle element.
    """
    if self.is_empty():
        raise ValueError('Empty list has no middle')

    slow = self.__first    # moves 1 step at a time
    fast = self.__first    # moves 2 steps at a time

    while fast is not None and fast.next_node is not None:
        slow = slow.next_node
        fast = fast.next_node.next_node

    return slow.value

The two-pointer technique in get_middle is a classic linked list trick. When fast reaches the end, slow is at the middle — because fast moves twice as fast. For a list of 9 elements, fast takes 4 steps (reaching element 8 or None) while slow takes 4 steps (reaching element 4 — the middle).

Demo

# Reverse
ll = LinkedList()
for v in [1, 2, 3, 4, 5]:
    ll.insert_back(v)

print(f'Original:   {ll}')
ll.reverse()
print(f'Reversed:   {ll}')
ll.reverse()
print(f'Re-reversed: {ll}')

# Palindrome detection
palindromes = [
    ([1, 2, 3, 2, 1], True),
    ([1, 2, 3, 4, 5], False),
    ([5], True),
    ([1, 1], True),
    ([1, 2, 1], True),
]

print(f'\n--- Palindrome detection ---')
for values, expected in palindromes:
    ll = LinkedList()
    for v in values: ll.insert_back(v)
    result = ll.is_palindrome()
    status = '✓' if result == expected else '✗'
    print(f'  {status} {values} → {result}')

# Middle element
print(f'\n--- Middle element (two-pointer technique) ---')
for size in [1, 3, 5, 6, 9]:
    ll = LinkedList()
    for v in range(1, size + 1): ll.insert_back(v)
    middle = ll.get_middle()
    print(f'  {list(range(1, size + 1))} → middle: {middle}')

# Chain: sort + reverse
ll = LinkedList()
for v in [5, 3, 8, 1, 9, 2, 7]:
    ll.insert_back(v)
print(f'\nUnsorted:          {ll}')
ll.sort()
print(f'Sorted ascending:  {ll}')
ll.reverse()
print(f'Sorted descending: {ll}')

Output:

Original:    1 → 2 → 3 → 4 → 5 → None
Reversed:    5 → 4 → 3 → 2 → 1 → None
Re-reversed: 1 → 2 → 3 → 4 → 5 → None

--- Palindrome detection ---
  ✓ [1, 2, 3, 2, 1] → True
  ✓ [1, 2, 3, 4, 5] → False
  ✓ [5] → True
  ✓ [1, 1] → True
  ✓ [1, 2, 1] → True

--- Middle element (two-pointer technique) ---
  [1] → middle: 1
  [1, 2, 3] → middle: 2
  [1, 2, 3, 4, 5] → middle: 3
  [1, 2, 3, 4, 5, 6] → middle: 4
  [1, 2, 3, 4, 5, 6, 7, 8, 9] → middle: 5

Unsorted:          5 → 3 → 8 → 1 → 9 → 2 → 7 → None
Sorted ascending:  1 → 2 → 3 → 5 → 7 → 8 → 9 → None
Sorted descending: 9 → 8 → 7 → 5 → 3 → 2 → 1 → None

Visualise with Python Tutor

Copy this code into pythontutor.com and step through it:

class Node:
    def __init__(self, value, next_node=None):
        self.value = value
        self.next_node = next_node

# Build list: 1 → 2 → 3 → None
n1 = Node(1)
n2 = Node(2)
n3 = Node(3)
n1.next_node = n2
n2.next_node = n3
first = n1

# Reverse iteratively
previous = None
current = first

while current is not None:
    next_node = current.next_node    # save
    current.next_node = previous     # flip
    previous = current               # advance
    current = next_node              # advance

first = previous    # new head

# Print reversed list
current = first
while current is not None:
    print(current.value)
    current = current.next_node

Step through the reverse algorithm and watch each arrow flip direction. Before the loop: 1 → 2 → 3 → None. After step 1 (processing node 1): node 1’s arrow flips to point at previous=NoneNone ← 1, and previous now points to node 1. After step 2 (processing node 2): node 2’s arrow flips to point at previous=node1None ← 1 ← 2. After step 3 (processing node 3): node 3’s arrow flips to None ← 1 ← 2 ← 3. When current=None, the loop ends and first = previous = node3. The visual is worth ten minutes of reading. Pay attention to the order: always save next_node first, then flip, then advance both pointers.

Summary and next step

In this article you extended the LinkedList with three families of algorithms. The search methods showed how lambda conditions generalise traversal without rewriting the loop. Insertion sort showed the key pattern for in-place node rearrangement — save next_node before any modification. The reversal showed the three-pointer technique that flips every arrow in a single pass, plus the two-pointer technique that finds the middle in O(n) without knowing the length.

In the next article you’ll find exercises to solve on your own — including cycle detection, merging two sorted lists and a doubly linked list.

Similar Posts

One Comment

Leave a Reply

Your email address will not be published. Required fields are marked *