Jc-alt logo
jonathancamberos
data structures and algorithms

LeetCode: Linked List

LeetCode: Linked List
61 min read
#data structures and algorithms
Table Of Content

Linked List Intro:

Leetcode problems with elegant solutions using a linked list.

What is a Linked List

A linked list is a linear data structure where each element (node) points to the next. It does not offer direct index access like arrays, as all traversal is pointer based.

Structure of a Linked List

Every linked list falls into one of two structural forms:

  1. Linear (acyclic)
  • Nodes form a straight path that ends at None
  • No loops or repeated visits
    A → B → C → D → None
  1. Path + Cycle (Cyclic)
  • Initial path of nodes eventually linked back to an earlier node, forming a cycle.
  • Structure: Path (μ nodes) → Cycle (λ nodes repeating forever)
    A → B → C → D → E  
            ↑       ↓  
            H ← G ← F

Overall: Linked lists are either just a path or a path that leads to a cycle

Why Use a Linked List

Linked lists are ideal for:

  • Memory efficient manipulation (no resizing like arrays)
  • Representing dynamic data structures (stacks, queues, etc)
  • Insertions and deletions in constant O(1) time (if the node is known)

Linked List Application: Linear Traversal

We can traverse a linked list node by node using a single pointer. Commonly used for printing, searching, summing values, etc.

Ex: Count number of nodes in the list

    def countNodes(head):
        count = 0
        curr = head
        
        while curr: 
            count += 1
            curr = curr.next

        return count

Linked List Application: Dummy Head Trick

Using a dummy (sentinel) node simplifies edge cases in linked list operations, especially when manipulating the head node. Dummy node precedes the head, providing the uniform way to handle deletions, insertions, or merges without adding logic to handle the head.

Ex: Remove nth node from end of a list using dummy head

def removeNthFromEnd(head: Optional[ListNode], n: int) -> Optional[ListNode]:
    
    # dummy (sentinel) node used to point to the head
    dummy = ListNode(0, head)
    fast = slow = dummy

    # Move fast pointer n+1 steps ahead to keep gap
    for _ in range(n + 1):
        fast = fast.next

    # Move both pointers until fast reaches end
    while fast:
        fast = fast.next
        slow = slow.next

    # Remove the nth node (slow.next)
    slow.next = slow.next.next

    # Return head dummy is pointing to (may be different from original)
    return dummy.next 

Linked List Application: Tortoise and Hare

We can represent fast and slow pointers to traverse the list. Useful for splitting lists in half for sorting or palindrome checks and for checking for cycles.

Ex: Return middle node in list

    def findMiddle(head):
        slow = fast = head

        while fast and fast.next:
            slow = slow.next
            fast = fast.next.next

        return slow

    # If F == None: Even length list
    # S points to start of right half
    # Left and right halves have equal length

    # If F != None: Odd length list
    # S points to middle node (belongs to left half) 
    # Left half is longer by 1 element

    # None                              S: None
    # S^F^                              F: None

    # 0     None                        S: 0
    # S^F^                              F: 0

    # 0   1   None                      S: 1
    #     S^  F^                        F: None

    # 0   1   2   None                  S: 1
    #     S^  F^                        F: 2

    # 0   1   2   3   None              S: 2
    #         S^      F^                F: None

    # 0   1   2   3   4   None          S: 2
    #         S^      F^                F: 4

    # 0   1   2   3   4   5   None      S: 3
    #             S^          F^        F: None

    # 0   1   2   3   4   5   6   None  S: 3
    #             S^          F^        F: 6

Linked List Application: In Place Modification

Iteratively manipulate linked list during traversal for O(1) time and space.

Ex: Reverse the entire list and return a new head

    def reverseList(head):
        prev = None
        curr = head

        while curr:
            next_node = curr.next
            curr.next = prev
            prev = curr
            curr = next_node

        return prev

206. Reverse Linked List ::4:: - Easy

Topics: Linked List, Recursion

Intro

Given the head of a singly linked list, reverse the list, and return the reversed list. A linked list can be reversed either iteratively or recursively. Could you implement both?

Example InputOutput
head = [1,2,3,4,5][5,4,3,2,1]
head = [1,2][2,1]
head = [][]

Constraints:

The number of nodes in the list is the range [0, 5000].

-5000 ≤ Node.val ≤ 5000

Abstraction

Given a linked list, reverse it iteratively and recursively.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: Recursive - Linked List/Simple Traversal

    def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
        
        # Note:
        # With recursive Linked Lists:
        # A good way to think about the problem, is that you have access
        # to the list in reverse order, so what actions do you need to
        # take in order to reverse the list, while traversing the list
        # from back to front

        # Base case: 
        # If current list is empty or has only one node -> this node becomes the new head
        if head == None or head.next == None:
            return head

        # Recursion: reverse the rest of the list (head.next to end)
        # After Recursion: new_head is new head of reverse list (tail of original list)
        new_head = self.reverseList(head.next)

        # Recursion Part 1: Reverse Flow of next element (by adding head <- head.next)
        # After recursion: Point second Element to Original Head
        head.next.next = head

        # Recursion Part 2: Reverse Flow of curr element (by adding None <- head), as current head may be first element in original list
        # After recursion: point first element in original list to None
        head.next = None

        # Continue passing back new head (last element from original list), from the deepest recursive call

        # overall: time complexity O(n)
        # overall: space complexity O(n)
        return new_head 
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Solution 2: Recursive Clean (Learning Not) - Linked List/Simple Traversal

    def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
        
        if not head or not head.next:
            return head

        new_head = self.reverseList(head.next)
        
        head.next.next = head
        head.next = None

        # overall: time complexity O(n)
        # overall: space complexity O(n)
        return new_head 
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Solution 3: Iterative - Linked List/Simple Traversal

    def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
        
        # Setting prev pointing to None (end of reversed list)
        prev = None

        # Starting at head
        curr = head

        # while _ is not None
        # time complexity: iterate over list of n length O(n)
        while curr != None:

            # Grab next node, before disconnecting
            next_node = curr.next  

            # Disconnect and Reverse Flow: 
            # <- prev    curr -> next 
            # <- prev <- curr    next 
            curr.next = prev      

            # Advance both prev and curr
            prev = curr            
            curr = next_node       


        # Loop exits: 
        # val: (val)  (None)
        # node: prev -> curr

        # overall: time complexity O(n)
        # overall: space complexity O(1)
        return prev  
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Solution 4: Iterative Clean (Learning Not) - Linked List/Simple Traversal

    def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
        
        prev = None
        curr = head

        while curr:

            next_node = curr.next  

            curr.next = prev      

            prev = curr            
            curr = next_node       

        # overall: time complexity O(n)
        # overall: space complexity O(1)
        return prev  
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

21. Merge Two Sorted Lists ::2:: - Easy

Topics: Linked List, Recursion

Intro

You are given the heads of two sorted linked lists list1 and list2. Merge the two lists into one sorted list. The list should be made by splicing together the nodes of the first two lists. Return the head of the merged linked list.

Example InputOutput
list1 = [1,2,4], list2 = [1,3,4][1,1,2,3,4,4]
list1 = [], list2 = [][]
list1 = [], list2 = [0][0]

Constraints:

The number of nodes in the list is the range [0, 50].

-100 ≤ Node.val ≤ 100

Both list1 and list2 are sorted in non-decreasing order.

Abstraction

Given two sorted linked lists, merge and return head of merged list.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: Recursive - Linked List/Simple Traversal

    def mergeTwoLists(self, list1: Optional[ListNode], list2: Optional[ListNode]) -> Optional[ListNode]:
        
        # Note:
        # Building merged list from right to left,
        # connecting current node and passing back as we go
        
        # Base case: 
        # One of the lists is empty,
        # return second list for the .next
        if not list1:
            return list2
        if not list2:
            return list1

        # Recursive Call:
        # Point smaller val -> rest of list
        if list1.val < list2.val:
            list1.next = self.mergeTwoLists(list1.next, list2)
            return list1
        else:
            list2.next = self.mergeTwoLists(list1, list2.next)
            return list2

        # overall: time complexity O(n)
        # overall: space complexity O(n)
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Solution 2: Iterative Sentinel Anchor - Linked List/Simple Traversal

    def mergeTwoLists(self, list1: Optional[ListNode], list2: Optional[ListNode]) -> Optional[ListNode]:
        
        # Note:
        # DummyHead is used to enter the iterative loop cleanly
        # without handling first node assigning case
        
        # sentinel anchor for real head
        dummyHead = ListNode(-1)  

        # curr node of list
        tail = dummy          

        # Traverse both lists while both have nodes
        # time complexity: iterate over both lists O(m + n)
        while list1 and list2:

            # grab smaller value between list1 and list2
            if list1.val < list2.val:
                
                # append and iterate list1
                tail.next = list1     
                list1 = list1.next   
            else:
                # append and iterate list2
                tail.next = list2
                list2 = list2.next    

            # 1st iteration: dummyHead/tail no longer point to same obj
            # nth iteration: iterate tail to whatever the next_node is
            tail = tail.next          

        # At least one list is non empty at this point:
        # point tail to rest of list
        if list1:
            tail.next = list1
        else 
            tail.next = list2

        # Jump to merged head using dummyHead

        # overall: time complexity O(m + n)
        # overall: space complexity O(1)
        return dummyHead.next
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

141. Linked List Cycle ::2:: - Easy

Topics: Hash Table, Linked List, Two Pointers

Intro

Given head, the head of a linked list, determine if the linked list has a cycle in it. There is a cycle in a linked list if there is some node in the list that can be reached again by continuously following the next pointer. Internally, pos is used to denote the index of the node that tail's next pointer is connected to. Note that pos is not passed as a parameter. Return true if there is a cycle in the linked list. Otherwise, return false. Follow up: Can you solve it using O(1) (i.e. constant) memory?

Example InputOutput
head = [1,2,3,4,5][5,4,3,2,1]
head = [1,2][2,1]
head = [][]

Constraints:

The number of nodes in the list is the range [0, 5000].

-5000 ≤ Node.val ≤ 5000

Abstraction

Given a linked list, reverse it iteratively and recursively.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: Track Visited Notes - Linked List/Simple Traversal

    def hasCycle(self, head: Optional[ListNode]) -> bool:
        
        # Note:
        # Track visited nodes to check for repeats (cycles)
        
        # Nodes are stored by obj reference,
        # perfect for cycle detection
        visited = set()

        # start of iteration
        curr = head
        
        # time complexity: iterate over list of n length O(n)
        while curr:

            # repeated node: (cycle detected)
            if curr in visited:
                return True

            # add and continue iteration
            visited.add(curr)
            curr = curr.next
        
        # no repeat nodes (no cycles)

        # overall: time complexity O(n)
        # overall: space complexity O(n)
        return False
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Solution 2: Floyd Cycle Detection Tortoise and Hare - Linked List/Simple Traversal

    def hasCycle(self, head: Optional[ListNode]) -> bool:
        
        # Note:
        # If no cycle: fast pointer will hit None (end of list)
        # If cycle: once both fast and slow pointer enter cycle of size k,
        # fast pointer will lap and hit slow pointer within k steps

        # set pointers
        slow, fast = head, head
        
        # time complexity: iterate over list 
        while fast and fast.next:

            # move both slow and fast pointers
            slow = slow.next
            fast = fast.next.next
            
            # check if match (cycle detected)
            if slow == fast:
                return True

        # reached end of list (no cycle detected)
        
        # overall: time complexity O(n)
        # overall: space complexity O(1)
        return False
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

143. Reorder List ::4:: - Medium

Topics: Linked List, Two Pointers, Stack, Recursion

Intro

You are given the head of a singly linked-list. The list can be represented as: There is a cycle in a linked list if there is some node in the list that can be L0 → L1 → … → Ln - 1 → Ln Reorder the list to be on the following form: L0 → Ln → L1 → Ln - 1 → L2 → Ln - 2 → … You may not modify the values in the list's nodes. Only nodes themselves may be changed.

Example InputOutput
head = [1,2,3,4][1,4,2,3]
head = [1,2,3,4,5][1,5,2,4,3]

Constraints:

The number of nodes in the list is the range [1, 5 * 104].

1 ≤ Node.val ≤ 1000

Abstraction

Given a linked list, order it in values tending towards middle.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: Slow.next Explicit Set Tail of Final List Two Way Disconnect Iteration Reversal - Linked List/Simple Traversal

    def reorderList(self, head: Optional[ListNode]) -> None:

        # Note:
        # 1. Use Tortoise Hare to find the middle
        # 2. Reverse second half of list in place
        # 3. Merges first half and reverse second half alternating
        # Result: Fully in place reordering

        slow = fast = head
        while fast and fast.next:
            # slow travels half distance vs fast pointer
            slow = slow.next
            fast = fast.next.next

        # Slow result:
        # Odd List: slow -> odd mid
        # Even List: slow -> mid2

        # prev for reverse tail
        prev = None
        
        # Set Curr:

        # Odd List:
        # curr = slow.next, (mid.next)
        # left will be 1 element longer (keeps mid element), right does not

        # Even List:
        # curr = slow.next, (mid2.next)
        # left will be 2 elements longer (keeps mid1 and mid2), right does not

        curr = slow.next

        # Explicit Disconnect:
        # Set slow (belonging solely to left list)
        # to slow -> None
        slow.next = None 

        # Reverse second half of list
        while curr:
            # grab next
            next_node = curr.next
            # reverse flow
            curr.next = prev
            # iterate 
            prev = curr
            curr = next_node

        # iterate over left half and right half
        left, right = head, prev

        # Explicit Termination:
        # right half is always than the left half (by 1 or 2 nodes)
        # when we terminate right,
        # right.next = next_left guarantees that 
        # the rest of the left list was connected,
        # and since we already terminated the left list 'Explicit Disconnect'
        # our merged list will terminate
        while right:
            # grab next before disconnect
            next_left, next_right = left.next, right.next
            # alternate flow
            left.next = right
            right.next = next_left
            # iterate left and right half -> connect next_nodes
            left, right = next_left, next_right
        
        # overall: time complexity O(n)
        # overall: space complexity O(1)
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Solution 2: Shared Slow Between Left and Right List Implicit Disconnect - Linked List/Simple Traversal

    def reorderList(self, head: Optional[ListNode]) -> None:

        # Note
        # 1. Use Tortoise and Hare to find midpoint.
        # 2. Reverse the second half in place (without disconnecting manually)
        # 3. Merge first and reversed second half alternating nodes
        # Result: Fully in place reordering

        slow = fast = head
        while fast and fast.next:
            # slow travels half distance vs fast pointer: iterate references in memory
            fast = fast.next.next
            slow = slow.next
        
        # Slow result:
        # Odd List: slow -> odd mid
        # Even List: slow -> mid2

        # prev for reverse tail
        prev = None

        # Set Curr:

        # Odd List:
        # curr = slow, (mid)
        # left and right will be even (both keep mid element)

        # Even List:
        # curr = slow, (mid2)
        # left will be 1 element longer (keeps mid1 and mid2), (right keeps mid2)

        curr = slow

        # Implicit Disconnect:
        # Both left and right contain slow (either odd mid or mid2) in their list
        # during the first modification of slow, we point it to prev (None)
        # now, both lists have a slow.next -> None

        # Reverse second half of list:
        while curr:
            # grab next before disconnect
            next_node = curr.next
            # reverse flow
            curr.next = prev
            # iterate reference in memory
            prev = curr
            curr = next_node

        # iterate over left and right lists
        left, right = head, prev

        # Implicit Termination:
        # Now since:
        # left equal or longer by 1,
        # slow -> None is contained by both lists,
        # we will reach right's copy of slow
        # at the same time or earlier by 1 iteration.
        # However, when we reach right's copy,
        # right.next = next_left guarantees that
        # the rest of the left list was connected,
        # and since we already terminated the left list 'Implicit Disconnect'
        # our merged list will terminate
        while right != slow:
            next_left, next_right = left.next, right.next
            left.next = right
            right.next = next_left
            left, right = next_left, next_right

        # brain teaser:
        # why does this work:
        right.next = None
        # but right.next.next = None 
        # does not work

        # overall: time complexity O(n)
        # overall: space complexity O(1)
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Solution 3: Stack - Linked List/Simple Traversal

    def reorderList(self, head: Optional[ListNode]) -> None:

        # Note:
        # 1. Use Tortoise Hare to find the middle
        # 2. Iterate over right half in order and store elements in stack
        # 3. Pop all elements (in reverse order) and place in between left elements:
        # left -> right -> left.next
        # Result: Fully reordered

        slow = fast = head
        while fast and fast.next:
            slow = slow.next
            fast = fast.next.next

        # Slow result:
        # Odd List: slow -> odd mid
        # Even List: slow -> mid2
        
        # store right half list in order
        # space complexity: store half list of n length O(n)
        stack = []

        # Set Curr:

        # Odd List:
        # curr = slow.next, (mid.next)
        # left will be 1 element longer (keeps mid element), right does not

        # Even List:
        # curr = slow.next, (mid2.next)
        # left will be 2 elements longer (keeps mid1 and mid2), right does not

        curr = slow.next 

        # Explicit Disconnect:
        # Set slow (belonging solely to left list)
        # to slow -> None      
        slow.next = None  

        # time complexity: iterate over list of n length O(n)
        while curr:
            stack.append(curr)
            curr = curr.next

        # forward iteration over list 
        left = head

        # Odd List Len: left will be 1 element longer (keeps mid element)
        # Even List Len: left is 2 elements longer (keeps 1st and 2nd mid elements)

        # Explicit Termination:
        # right half stack is always than the left half (by 1 or 2 nodes)
        # when we terminate right,
        # right.next = next_left guarantees that 
        # the rest of the left list was connected,
        # and since we already terminated the left list 'Explicit Disconnect'
        # our merged list will terminate
        # time complexity: iterate over stack O(n)
        while stack:

            # iterate over right half
            right = stack.pop()

            # set: left -> right -> left.next 
            right.next = left.next
            left.next = right

            # iterate left list
            left = right.next

        # overall: time complexity O(n)
        # overall: space complexity O(n)
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Solution 4: Brain Teaser slow longer or equal Right list Stack - Linked List/Simple Traversal

    def reorderList(self, head: Optional[ListNode]) -> None:

        # Note:
        # 1. Use Tortoise Hare to find the middle
        # 2. Iterate over right half in order and store elements in stack
        # 3. Pop all elements (in reverse order) and place in between left elements:
        # left -> right -> left.next
        # Result: Fully reordered

        slow = fast = head
        while fast and fast.next:
            slow = slow.next
            fast = fast.next.next

        # Slow result:
        # Odd List: slow -> odd mid
        # Even List: slow -> mid2

        # store right half list in order
        # space complexity: store list of n length O(n)
        stack = []

        # Set Curr:

        # Odd List:
        # curr = slow, (mid)
        # left and right will be even (both keep mid element)

        # Even List:
        # curr = slow, (mid2)
        # left will be 1 element longer (keeps mid1 and mid2), (right keeps mid2)
    
        curr = slow 

        # No Implicit Disconnect:
        # (deal with that later)
        
        # time complexity: iterate over list of n length O(n)
        while curr:
            stack.append(curr)
            curr = curr.next

        # iterate over left list 
        left = head

        # time complexity: iterate over stack O(n)
        while stack:

            # grab right half node element
            right = stack.pop()

            # place right half node in between left half curr and curr.next: 
            # curr -> tail -> curr.next 
            right.next = left.next
            left.next = right

            # iterate left half of list
            left = right.next

        # brain teaser:
        # why does this work:
        # Odd/Even Case:
        # left is equal or longer by 1 node
        # Odd: 
        # left and right are equal in length, both share 'slow'
        # so at the last iteration, left -> right -> left.next
        # but we never disconnected, so left slow -> right slow
        # and since they share the same object in memory
        # this does not matter since it is pointing to itself
        # Even:
        # left is longer by 1, both share 'slow'
        # last iteration, right slow -> left slow
        # but we never disconnected, so left slow -> right slow
        # and since they share the same object in memory
        # this does not matter since it is pointing to itself
        right.next.next.next.next.next = None

        # overall: time complexity O(n)
        # overall: space complexity O(n)
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

19. Remove Nth Node From End of List ::2:: - Medium

Topics: Linked List, Two Pointers

Intro

Given the head of a linked list, remove the nth node from the end of the list and return its head. Follow up: Could you do this in one pass?

Example InputOutput
head = [1,2,3,4,5], n = 2[1,2,3,5]
head = [1], n = 1[]
head = [1,2], n = 1[1]

Constraints:

The number of nodes in the list is sz.

1 ≤ sz ≤ 30

0 ≤ Node.val ≤ 100

1 ≤ n ≤ sz

Abstraction

Given a linked list, remove nth node and return head of list.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: Two Pass Get Length and Stop Prev to Removal Index - Linked List/Simple Traversal

    def removeNthFromEnd(self, head: Optional[ListNode], n: int) -> Optional[ListNode]:
        
        # Note:
        # 1. Traverse list once to calculate total length
        # 2. Use a dummy node to handle edge cases (like removing head)
        # 3. Traverse again, stop at node prior to removal index
        # 4. point prior.next = prior.next.next, disconnecting removal node
        # Result: removed node at distance n from end of list

        # First pass: get length of list
        listLen = 0
        curr = head
        while curr:
            listLen += 1
            curr = curr.next

        # Second pass: find the node behind target index

        remove = listLen - n

        # we adding an element to the list
        dummy = ListNode(0, head)
        curr = dummy

        # this stops at the Node before the target remove index
        # thus we can disconnect it
        index = 0
        while index != remove:
            index += 1
            curr = curr.next
        
        # remove nth node from end by skipping it
        curr.next = curr.next.next

        # Return head dummy is pointing to (may be different from original)

        # overall: time complexity O(n)
        # overall: space complexity O(1)
        return dummy.next
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Solution 2: One Pass Fast Slow with HeadStart of N+1 - Linked List/Simple Traversal

    def removeNthFromEnd(self, head: Optional[ListNode], n: int) -> Optional[ListNode]:
        
        # Note:
        # 1. Use two pointers spaced n+1 apart (fast and slow)
        # 2. Move both pointers until fast hits end
        # 3. Slow pointer will be right before the node to remove
        # 4. Remove node by skipping it
       
        dummy = ListNode(-1, head)
        slow = fast = dummy
        
        # Set fast pointer: 
        # head start of n + 1 step,
        # in order to create a gap of n between fast and slow
        # (taking dummy node into account)
        index = 0
        while index != n+1:
            index += 1
            fast = fast.next
        
        # When fast reaches end:
        # n + 1 gap between slow (within list) and fast (end of list)
        while fast:
            fast = fast.next
            slow = slow.next
        
        # skip nth node
        slow.next = slow.next.next

        # Return head dummy is pointing to (may be different from original)

        # overall: time complexity O(n)
        # overall: space complexity O(1)
        return dummy.next 
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

138. Copy List with Random Pointer ::3:: - Medium

Topics: Hash Table, Linked List

Intro

A linked list of length n is given such that each node contains an additional random pointer, which could point to any node in the list, or null. Construct a deep copy of the list. The deep copy should consist of exactly n brand new nodes, where each new node has its value set to
the value of its corresponding original node. Both the next and random pointer of the new nodes should point to new nodes in the copied list such that the pointers in the original list and copied list represent the same list state. None of the pointers in the new list should point to nodes in the original list. For example, if there are two nodes X and Y in the original list, where X.random --> Y, then for the corresponding two nodes x and y in the copied list, x.random --> y. Return the head of the copied linked list. The linked list is represented in the input/output as a list of n nodes. Each node is represented as a pair of [val, random_index] where: val: an integer representing Node.val random_index: the index of the node (range from 0 to n-1) that the random pointer points to, or null if it does not point to any node. Your code will only be given the head of the original linked list.

Example InputOutput
head = [[7,null],[13,0],[11,4],[10,2],[1,0]][[7,null],[13,0],[11,4],[10,2],[1,0]]
head = [[1,1],[2,1]][[1,1],[2,1]]
head = [[3,null],[3,0],[3,null]][[3,null],[3,0],[3,null]]

Constraints:

0 ≤ n ≤ 1000

-104 ≤ Node.val ≤ 104

Node.random is null or is pointing to some node in the linked list.

Abstraction

Given a linked list where each node has an additional random node pointers, create a deep copy.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: Two Pass Hashmap Create Nodes and Set Next and Random Deep References - Linked List/Simple Traversal

    def copyRandomList(self, head: 'Optional[Node]') -> 'Optional[Node]':
        
        # Note:
        # .get() returns 'None' if nothing exists,
        # so perfect for our use case

        # Note:
        # 1. Traverse once: create all nodes (no wiring of next/random yet)
        # 2. Use a dictionary to map old nodes -> new nodes
        # 3. Traverse Twice: wire up both next and random using the map
        # 4. Return deep copy of head

        # Empty Check
        if not head:
            return None

        # Orig node -> deep node copy
        orig_to_deepCopy = {}

        # Iteration 1: 
        # Copy all nodes values (no next/random pointers yet)
        curr = head
        while curr:
            # create
            copy = Node(curr.val)
            # insert
            orig_to_deepCopy[curr] = copy
            # iterate
            curr = curr.next

        # Iteration 2:
        # All nodes created, now assign next and random pointers
        curr = head
        while curr:
            # grab curr deepCopy
            copy = orig_to_deepCopy[curr]
            # grab and set next and random deepCopy (value could be None, .get() returns None by default if no answer exists)
            copy.next = orig_to_deepCopy.get(curr.next)
            copy.random = orig_to_deepCopy.get(curr.random)
            # iterate
            curr = curr.next

        # Return deep copy of the head node

        # overall: time complexity O(n)
        # overall: space complexity O(n)
        return orig_to_deepCopy[head]

Solution 2: Memoization Lazy Construction One Pass Hashmap - Linked List/Simple Traversal

    def copyRandomList(self, head: 'Optional[Node]') -> 'Optional[Node]':
        
        # Note:
        # 1. Maintain a memo dictionary to store mapping on the fly
        # 2. Iterate once through original list while building the copy
        # 3. Create each copy node only whe first encountered (lazy construction)
        # 4. Maintain a pointer to build out the next chain

        # Empty check
        if not head:
            return None
        
        # dummy node head trick
        dummy = Node(-1)

        # prev allows deepCopy to connect once we create them lazily
        # for the entire deepCopy list as we go
        prev = dummy
        curr = head

        # orig -> deepCopy
        memo = {}
        
        # time complexity: iterate over list of n length
        while curr:
            
            # grab or create and grab curr deepCopy
            if curr not in memo:
                memo[curr] = Node(curr.val)
            currDeepCopy = memo[curr]
            
            # validate curr.random
            if curr.random:
                # grab or create and grab curr.random deepCopy
                if curr.random not in memo:
                    memo[curr.random] = Node(curr.random.val)
                currDeepCopy.random = memo[curr.random]
            
            # First iteration: dummy points to deepCopy head
            # next iteration: previous deepCopy node points to current deepCopy node 
            # after termination: entire deepCopy list is now connected
            prev.next = currDeepCopy

            # iterate to current deepCopy node connect next node
            # iterate over original list
            prev = prev.next
            curr = curr.next
        
        # Return deepCopy head
        
        # overall: time complexity O(n)
        # overall: space complexity O(n)

        return memo[head]
        # or
        # return dummy.next

Solution 3: Three Pass In Place Interleaving Technique - Linked List/Simple Traversal

    def copyRandomList(self, head: 'Optional[Node]') -> 'Optional[Node]':
        
        # Note:
        # 1. First interleave copy and paste nodes between original nodes O(1) space
        #    A -> B -> C ===> A -> A' -> B -> B' -> C -> C'
        
        # 2. Assign random pointers to the copy nodes using original's .random

        # 3. Detach copy list from original (restoring original list) 
        #    and setting valid .next for deepCopy list
        #    A -> A' -> B -> B' -> C -> C' ===> A' -> B' -> C'
        
        # 4. Return deepCopy head
        # Result: constant space within original list, but requires 3 passes 
        
        # Empty Check
        if not head:
            return None

        # Interleave deepCopy nodes
        # A -> A' -> B -> B' -> C -> C'
        curr = head
        while curr:
            # create and interweave
            deepCopy = Node(curr.val)
            deepCopy.next = curr.next

            # link A -> A' and iterate to next orig
            curr.next = deepCopy
            curr = deepCopy.next

        # Assign random pointers
        curr = head
        while curr:
            # grab intertwined deepCopy
            deepCopy = curr.next

            # assign random from orig if non None
            if curr.random:
                deepCopy.random = curr.random.next

            # iterate to next orig
            curr = deepCopy.next

        # Split orig and deepCopy list
        # (A) orig head: A -> A' -> B -> ...
        curr = head
        # (A') deepCopy head: A -> A' -> B -> ...
        deepCopyHead = head.next

        # time complexity: iterate over list of 2n length O(n)
        while curr:
            # grab deepCopy
            deepCopy = curr.next
            
            # link orig to next orig node, iterate to next orig
            # A -> A' -> B -> ...
            # A -> B
            curr.next = deepCopy.next
            curr = curr.next

            # if new orig is non None 
            # (B' -> C -> ...) vs (B' -> None)
            # there will exists a new deepCopy
            # iterate to deepCopy
            if curr:
                deepCopy.next = curr.next

        # return deepCopy head

        # overall: time complexity O(n)
        # overall: space complexity O(1)
        return deepCopyHead
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

2. Add Two Numbers ::2:: - Medium

Topics: Linked List, Math, Recursion

Intro

You are given two non-empty linked lists representing two non-negative integers. The digits are stored in reverse order, and each of their nodes contains a single digit. Add the two numbers and return the sum as a linked list. You may assume the two numbers do not contain any leading zero, except the number 0 itself.

Example InputOutput
l1 = [2,4,3], l2 = [5,6,4][7,0,8]
l1 = [0], l2 = [0][0]
l1 = [9,9,9,9,9,9,9], l2 = [9,9,9,9][8,9,9,9,0,0,0,1]

Constraints:

The number of nodes in each linked list is in the range [1, 100].

0 ≤ Node.val ≤ 9

It is guaranteed that the list represents a number that does not have leading zeros.

Abstraction

Given a two linked lists representing numbers, return the sum.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: Recursive - Linked List/Simple Traversal

    def addTwoNumbers(self, l1: ListNode, l2: ListNode) -> ListNode:
        
        # Note:
        # 1. Recursion to simulate digit addition with carry
        # 2. Add Corresponding digits with carry passed to recursive call
        # 3. Current node created with .next -> recursively created node
        # 4. Base case: both lists empty and carry is 0
        # Result: new sum list created recursively
        
        # time complexity: iterate over two lists of m and n length O(max(m, n))
        # space complexity: recursive call over two lists of m and n length O(max(m, n))
        def recursiveSum(l1, l2, carry):
            
            # Base Case:
            # Both lists and carry are empty
            if not l1 and not l2 and carry == 0:
                return None

            # grab values if exist
            val1 = l1.val if l1 else 0
            val2 = l2.val if l2 else 0

            # sum
            sum = val1 + val2 + carry
            carry = sum//10

            # iterate lists if exist
            l1 = l1.next if l1 else None
            l2 = l2.next if l2 else None

            # new node and attach
            newNode = ListNode(sum % 10)
            newNode.next = recursiveSum(l1, l2, carry)

            # return: curr node -> rest of list
            return newNode

        # calculate new sum list over two lists

        # overall: time complexity O(max(m, n))
        # overall: space complexity O(max(m, n))
        return recursiveSum(l1, l2, 0)
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Solution 2: Iterative - Linked List/Simple Traversal

    def addTwoNumbers(self, l1: ListNode, l2: ListNode) -> ListNode:
        
        # Note:
        # 1. Dummy node to create a sum list
        # 2. Iterate over both lists until complete
        # 3. Added values node by node, and pass the carry
        # Result: sum of two lists

        # dummy trick to return curr head
        dummy = ListNode(-1)
        prev = dummy
        carry = 0

        # Traverse while either list or carry is non empty
        while l1 or l2 or carry:
            
            # grab if value exists
            v1 = l1.val if l1 else 0
            v2 = l2.val if l2 else 0

            # add values and carry
            sum = v1 + v2 + carry
            carry = sum // 10

            # create new Node for new value  
            newNode = ListNode(sum % 10)
            prev.next = newNode
            prev = prev.next

            # iterate list
            l1 = l1.next if l1 else None
            l2 = l2.next if l2 else None


        # return new head of sum list

        # overall: time complexity O(max(m, n))
        # overall: space complexity O(1)
        return dummy.next
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

287. Find the Duplicate Number ::3:: - Medium

Topics: Array, Two Pointers, Binary Search, Bit Manipulation

Intro

Given an array of integers nums containing n + 1 integers where each integer is in the range [1, n] inclusive. There is only one repeated number in nums, return
this repeated number. You must solve the problem without modifying the array nums and using only constant extra space.

Example InputOutput
nums = [1,3,4,2,2]2
nums = [3,1,3,4,2]3
nums = [3,3,3,3,3]3

Constraints:

1 ≤ n ≤ 105

nums.length == n + 1

1 ≤ nums[i] ≤ n

All the integers in nums appear only once except for precisely one integer which appears two or more times.

Abstraction

Given list, return which number is a duplicate.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug: While True instead of While slow != fast

    def findDuplicate(self, nums: List[int]) -> int:

        slow = nums[0]
        fast = nums[0]

        while True:
            slow = nums[slow]
            fast = nums[nums[fast]]
            if slow == fast:
                break

        slow = nums[0]

        # INCORRECT:
        # we are taking a step before the while loop check
        # (which in some edge cases will get messy)
        # Instead:
        # while True:
        #    if slow == fast:
        #        break
        #    slow = nums[slow]
        #    fast = nums[fast]
        while True:
            slow = nums[slow]
            fast = nums[fast]
            if slow == fast:
                break

        return slow

Solution 1: Max Bit Length Duplicate Flips - Linked List/Simple Traversal

    def findDuplicate(self, nums: List[int]) -> int:
        
        # Note:
        # 1. Idea: Compare bit counts between full nums list and range [1..n]
        # 2. Duplicate number will cause extra set bits in some positions
        # 3. Identify bits where counts mismatch, reconstruct duplicate from bits

        n = len(nums) - 1
        bit_reconstruction = 0

        # max_bits = how many binary bits needed to represent the largest number
        # determines how many bit positions we need to check
        # [1, 3, 4, 2, 2] -> 4 bit_length is 3 bits (100 binary) 
        # we will check bit positions 0, 1, 2
        max_bit = max(num.bit_length() for num in nums)

        # building duplicate number by iterating over each available bit position
        # bit = 0 -> mask = 1 (binary 001)
        # bit = 1 -> mask = 2 (binary 010)
        # bit = 2 -> mask = 3 (binary 100)
        for bit in range(max_bit):

            # current mask
            # 0 -> 1 << 0 = 0001
            # 1 -> 1 << 1 = 0010
            # 2 -> 1 << 2 = 0100
            # 3 -> 1 << 3 = 1000
            mask = 1 << bit

            # for each num in array, count if bit is set
            # &: bitwise AND
            # sets current bit to 1 if both mask and bit are set to 1
            # else sets current bit to 0,
            # determines if current num has the bit set
            curr_bit_set_count = sum((num & mask) > 0 for num in nums)

            # across expected range [1..n], sum if bit is expected to be set
            # &: bitwise AND
            # checks how many numbers within range [1..n]
            # are expected to have current bit set.
            expected_bit_set_count = sum((i & mask) > 0 for i in range(1, n + 1))

            # compare bit set to expected bit set,
            # if bit is set more times than expected
            # duplicate number must have that bit set:
            # add it to bit reconstruction
            if curr_bit_set_count > expected_bit_set_count:
                bit_reconstruction |= mask
        
        # overall: time complexity O(n log n)
        # overall: space complexity O(1)
        return bit_reconstruction
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
Bit CountingO(n log n)O(1)log n bit positions, O(n) scan per bitOnly counters and bitmask
Max Bit CalcO(n)O(1)Iterate to determine max bitsNo additional memory allocation for iteration O(1)
OverallO(n log n)O(1)Iteration for log n each with n dominates, leading to O(n log n)No additional memory allocation, leading to constant O(1)

Solution 2: Pigeonhole Over Range 1 to N Then Count Array Binary Search - Linked List/Simple Traversal

    def findDuplicate(self, nums: List[int]) -> int:

        # Note:
        # Idea: Duplicate number must satisfy pigeonhole principle:
        #   With n+1 numbers range [1..n], at least one value is duplicated
        #  (n containers, n+1 pigeons -> at least one container has two pigeons)
        
        # 1. Binary search on value range [1..n]
        # 2. For mid value:
        #    mid = number of containers in lower half (including mid)
        #    count = numbers of pigeons (nums <= mid) in lower half
        # 3. Decision:
        #   If count <= mid -> equal or lesser pigeons to containers -> duplicate is in upper half
        #   Else count > mid -> more pigeons than containers -> duplicate is in lower half

        # smallest value 1
        # largest value n (but we have n+1) n+1 - 1 -> n (the largest value)
        left, right = 1, len(nums) - 1

        # [ 1 4 3 5 2 ], for some n,
        # there must be n-1 numbers below:
        # for 2, there is 1 number below
        # for 5, there are 4 numbers below
        # Binary optimization search: '<'
        while left < right:
            
            mid = (left + right) // 2

            # count how many numbers <= mid
            curr_count = sum(num <= mid for num in nums)

            # if count <= mid, there are no extra numbers in lower half:
            # duplicate must be in upper half
            if curr_count <= mid:
                left = mid + 1

            # if count > mid, there are too many numbers in lower half:
            # duplicate must be in lower half
            else:
                right = mid

        # Loop exit: left == right
        # left and right are pointing at duplicate number

        # overall: time complexity O(n log n)
        # overall: space complexity O(1)
        return left
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
Binary SearchO(log n)O(1)Binary search in O(log n)No additional memory allocation for binary search
CountingO(n)O(1)Iterate over list of n lengthNo additional memory allocation for iteration
OverallO(n log n)O(1)Binary search O(log n) each with iteration of O(n) dominates, leading to O(n log n)No additional memory allocation O(1)

Solution 3: Head to Start of Cycle Proof Floyd Tortoise and Hare - Linked List/Simple Traversal

    def findDuplicate(self, nums: List[int]) -> int:

        # Note:
        # 1. definitions
        # L = distance from head to start of cycle
        # C = length of the cycle (number of nodes in the cycle)
        # x = distance from the start of the cycle to the meeting point inside the cycle
        # k = number of full cycles the fast pointer has completed by the time of the first meeting

        # 2. setup
        # When slow and fast pointers first meet inside the cycle:
        
        # Slow pointer has traveled: L + x steps:
        #    L steps to reach the start of the cycle
        #    x steps inside the cycle to the meeting pointer

        # Fast pointer has traveled L + x + (k * C) steps:
        #    L steps to reach the start of the cycle
        #    x steps inside the cycle to the meeting point
        #    k full extra loops around the cycle (each length C)

        # Fast Substitute:
        # By definition, fast pointer speed is double slow pointer, so substitute
        #     L + x + (k * C) = 2(L + x)
        
        # Solve for L:
        #     L + x + kC = 2L + 2x
        #     kX = L + x
        #     L = kC - x 

        # Rewrite Rule Context:
        # Traveling x steps inside cycle of length C =
        # to traveling C - x steps backwards, due to wrap around
        # x steps forward = C - x backwards

        # 3. Rearrange
        #    L = kC - x
        #    L = kC + (C - x)

        #    L = kC + (C - x)   <-- this proves moving slow to right will cover

        # once we move slow to the head:
        # L -> distance from head to start of cycle

        # fast will stay at the meeting point:
        # kC + (C - x)

        # kC -> number of cycles (we can ignore this)
        # (C - x) -> steps remaining to get to the start (since we have traveled x steps already)

        # L = (C - x)
        # so both slow and fast will travel the same amount of steps 
        # to get to the start of the cycle


        # The above math proves the below algorithm:

        # Note:
        # Idea: Treat array as linked list: index -> nums[index]
        #    Problem set up with duplicate guarantees cycle in linked list representation
        
        # 1. Detect cycle (slow = nums[slow], fast = nums[nums[fast]])
        # 2. Move slow to head, advance both one step at a time to find cycle start (duplicate)

        # Find meeting point in cycle
        slow = nums[0]
        fast = nums[0]
        while True:
            slow = nums[slow]
            fast = nums[nums[fast]]
            if slow == fast:
                break

        # Move slow to head, move fast and slow one step at time,
        # fast and slow will meet at start of cycle
        slow = nums[0]
        while True:
            if slow == fast:
                break
            slow = nums[slow]
            fast = nums[fast]

        # overall: time complexity O(n)
        # overall: space complexity O(1)
        return slow
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
Cycle DetectionO(n)O(1)Each pointer moves less than or equal to 2n O(n)No additional memory allocation for iteration O(1)
Finding EntryO(n)O(1)Both pointers move less than or equal to n steps O(n)No additional memory allocation for iteration O(1)
OverallO(n)O(1)Two iterations over list of n length dominate, leading to O(n)No additional memory allocation, leading to O(1)

146. LRU Cache ::2:: - Medium

Topics: Hash Table, Linked List, Design, Doubly Linked List

Intro

Design a data structure that follows the constraints of a Least Recently Used (LRU) cache. Implement the LRUCache class: LRUCache(int capacity) Initialize the LRU cache with positive size capacity. int get(int key) Return the value of the key if the key exists, otherwise return -1. void put(int key, int value) Update the value of the key if the key exists. Otherwise, add the key-value pair to the cache. If the number of keys exceeds the capacity from this operation, evict the least recently used key. The functions get and put must each run in O(1) average time complexity.

Example InputOutput
nums = [1,3,4,2,2]2
nums = [3,1,3,4,2]3
nums = [3,3,3,3,3]3

Constraints:

1 ≤ capacity ≤ 3000

0 ≤ key ≤ 104

0 ≤ value ≤ 105

At most 2 * 105 calls will be made to get and put.

Abstraction

Design an efficient LRU cache using linked lists.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug: Did not add to cache when adding to linked list put()

class Node:
    def __init__(self, key : int, val : int):
        self.key = key
        self.val = val
        self.prev = None
        self.next = None

class LRUCache:
    def __init__(self, capacity : int):
        self.capacity = capacity
        self.cache = {}
        self.head = Node(0, 0)
        self.tail = Node(0, 0)
        self.head.next = self.tail
        self.tail.prev = self.head

    def _remove_node(self, node:Node):
        prev, next = node.prev, node.next
        prev.next = next
        next.prev = prev

    def _push_MRU(self, node:Node):
        twoMRU = self.head.next
        node.next = twoMRU
        node.prev = self.head
        self.head.next = node
        twoMRU.prev = node

    def get(self, key:int):
        if key not in self.cache:
            return -1

        node = self.cache[key]
        self._remove_node(node)
        self._push_MRU(node)
        return node.val

    def put(self, key:int, val:int):
        if key in self.cache:
            node = self.cache[key]
            self._remove_node(node)
            node.val = val
        else:

            # INCORRECT:
            # forgot to add new node to cache
            # Instead:
            # node = self.cache[key] = node
            node = Node(key, val)

        self._push_MRU(node)

        if len(self.cache) > self.capacity:
            lruNode = self.tail.prev
            self._remove_node(lruNode)
            self.cache.pop(lruNode.key)

Solution 1: Doubly Linked List with Hashmap - Linked List/Simple Traversal

class Node:
    def __init__(self, key: int, val: int):

        # key -> value node
        self.key = key
        self.val = val
        # doubly linked prev and next
        self.prev = None
        self.next = None

class LRUCache:
    def __init__(self, capacity: int):

        # list capacity
        self.capacity = capacity

        # key -> Node
        self.cache = {} 

        # MRU head
        self.head = Node(0, 0)
        # LRU tail
        self.tail = Node(0, 0)

        # set dummy head and tail
        self.head.next = self.tail
        self.tail.prev = self.head

    # time complexity: O(1)
    # space complexity: O(1)
    def _remove_node(self, node: Node):

        # Remove node from doubly linked list by linked neighbors
        prev, next = node.prev, node.next
        prev.next = next
        next.prev = prev

    # time complexity: O(1)
    # space complexity: O(1)
    def _push_MRU(self, node: Node):

        # Insert node between dummy head and MRU 
        node.next = self.head.next
        node.prev = self.head

        # Update head and 2nd MRU
        self.head.next = node
        node.next.prev = node

    # time complexity: O(1)
    # space complexity: O(1)
    def get(self, key: int) -> int:       

        # if node does not exist, return -1
        if key not in self.cache:
            return -1

        # grab node
        node = self.cache[key]

        # Move node and update MRU
        self._remove_node(node)
        self._push_MRU(node)

        # return val
        return node.val

    # time complexity: O(1)
    # space complexity: O(1)
    def put(self, key: int, value: int) -> None:

        # If key exists, remove to avoid duplicates,
        # need to grab node to update prev and next nodes
        if key in self.cache:
            node = self.cache[key]
            self._remove_node(node)

            # update existing node
            node.val = value

        # If key does not exist, create new node set to new value
        else:
            node = Node(key, value)
            self.cache[key] = node

        # push updates node to top of MRU
        self._push_MRU(node)

        # Remove LRU if capacity broken
        if len(self.cache) > self.capacity:
            lruNode = self.tail.prev
            self._remove_node(lruNode)
            self.cache.pop(lruNode.key)

    # overall: time complexity O(1)
    # overall: space complexity O(1)
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
removeO(1)O(1)Linking prev and next of removed node in constant O(1)No additional memory allocation for relinking nodes O(1)
insert_at_frontO(1)O(1)Linking new head in between dummy head and current head in constant O(1)No additional memory allocation for relinking nodes O(1)
getO(1)O(1)Hashmap lookup O(1) with DLL remove O(1) and DLL insert_at_front O(1) all in constant O(1)No additional memory allocation for either sub step O(1)
putO(1)O(1)Hashmap lookup O(1 ) with DLL remove O(1) and DLL insert_at_front O(1) all in constant O(1)No additional memory allocation for either sub step O(1)
overallO(1)O(capacity)All operations in constant O(1)Hashmap and DLL will store up to capacity O(capacity)

Solution 2: Python Implementation OrderedDict a Doubly Linked List with Hashmap - Linked List/Simple Traversal

class LRUCache:
    def __init__(self, capacity: int):
        
        # OrderedDict in Python is implemented internally
        # as a doubly linked list + hashmap (same as our solution):
        self.cache = OrderedDict()
        self.capacity = capacity

    # time complexity: O(1) 
    # space complexity: O(1)
    def get(self, key: int) -> int:

        # Check if key exists, else return -1
        if key not in self.cache:
            return -1

        # Update MRU
        self.cache.move_to_end(key)

        # return value
        return self.cache[key]

    # time complexity: O(1)
    # space complexity: O(1)
    def put(self, key: int, value: int) -> None:

        # If key exists, remove to avoid duplicates
        if key in self.cache:
            self.cache.pop(key)
        
        # Insert new value, automatically updates MRU,
        # automatically updates doubly linked list
        self.cache[key] = value
        
        # capacity exceeded, remove LRU
        if len(self.cache) > self.capacity:
            
            # last=True -> pops MRU
            # last=False -> pops LRU
            self.cache.popitem(last=False)

    # overall: time complexity O(1)
    # overall: space complexity O(1)
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
getO(1)O(1)DLL move to end and lookup in constant O(1)No additional memory allocation O(1)
putO(1)O(1)DLL move to end and lookup in constant O(1)No additional memory allocation O(1)
overallO(1)O(capacity)All operations in constant O(1)Hashmap and DLL store up to capacity O(capacity)

23. Merge k Sorted Lists ::2:: - Hard

Topics: Linked List, Divide and Conquer, Heap (Priority Queue), Merge Sort

Intro

You are given an array of k linked-lists lists, each linked-list is sorted in ascending order. Merge all the linked-lists into one sorted linked-list and return it.

Example InputOutput
lists = [[1,4,5],[1,3,4],[2,6]][1,1,2,3,4,4,5,6]
lists = [][]
lists = [[]][]

Constraints:

k == lists.length

0 ≤ k ≤ 104

0 ≤ lists[i].length ≤ 500

-104 ≤ lists[i][j] ≤ 104

lists[i] is sorted in ascending order.

The sum of lists[i].length will not exceed 104

Abstraction

Given a list of sorted linked lists, combine into one sorted linked list.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: Min Heap Priority Queue - Linked List/Simple Traversal

    def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]:
        
        # Note:
        # 1. minHeap stores current head for each sorted list
        # 2. minHeap sorted by value, list_index breaking ties: 
        #    (value, list_index, node)
        # 3. smallest value across heads added to list
        # 4. push to minHeap to replaced added node
        # Result: merge list in O(n log k)

        # minHeap of: (val, list index, node)
        min_heap = []

        # Push the head of each non-empty list into the heap
        for i_list, head_list in enumerate(lists):
            if head_list:
                # minHeap sorted by node.val,
                # i_list index used to break ties
                heappush(min_heap, (head_list.val, i_list, head_list))  

        dummy = ListNode(-1)
        prev = dummy

        # Pop smallest node, attach to merged list, push from same list if min.next
        while min_heap:

            # grab min value
            minVal, i_list, node = heappop(min_heap)
            prev.next = node
            prev = prev.next

            # valid node.next for current i_list
            if node.next:
                # grab next node from i_list
                heappush(min_heap, (node.next.val, i_list, node.next))

        # minHeap empty -> all lists empty
        # return head of merged list

        # overall: time complexity O(n log k)
        # overall: space complexity O(k)
        return dummy.next
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Solution 2: Merge Pair of List at a Time with Standard Merge Two Sorted Linked List - Linked List/Simple Traversal

    def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]:
        
        # Note:
        # 1. Merge lists in pairs with standard merge two sorted lists
        # 2. Each round halves the total number of lists -> O(log k) rounds
        # 3. Each round processes all nodes (across all merges) -> O(n) work per round
        # Result: Merging k lists in place
        
        # Empty check
        if not lists: 
            return None

        # Merge two sorted linked lists
        # time complexity: iterate over lists of n length O(n)
        def mergeTwo(l1, l2):

            # dummy node trick
            dummy = ListNode(-1)
            prev = dummy

            # merge while both lists non empty
            while l1 and l2:
                # grab smaller head
                if l1.val < l2.val:
                    prev.next = l1
                    l1 = l1.next
                else:
                    prev.next = l2
                    l2 = l2.next
                # iterate
                prev = prev.next

            # attach remaining list 
            if not l1:
                prev.next = l2 
            else:
                prev.next = l1

            # return merged new merged list
            return dummy.next

        # iteratively merge lists in pairs until one list remains
        # time complexity: log(len(list)) total merges
        while len(lists) > 1:
            merged = []
            for i in range(0, len(lists), 2):
                l1 = lists[i]
                l2 = lists[i + 1] if i + 1 < len(lists) else None
                merged.append(mergeTwo(l1, l2))
            lists = merged

        # overall: time complexity O(n log k)
        # overall: space complexity O(1)
        return lists[0]
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

25. Reverse Nodes in k-Group ::2:: - Hard

Topics: Linked List, Recursion

Intro

Given the head of a linked list, reverse the nodes of the list k at a time, and return the modified list. k is a positive integer and is less than or equal to the length of the linked list. If the number of nodes is not a multiple of k then left- out nodes, in the end, should remain as it is. You may not alter the values in the list's nodes, only nodes themselves may be changed. Follow-up: Can you solve the problem in O(1) extra memory space?

Example InputOutput
head = [1,2,3,4,5], k = 2[2,1,4,3,5]
head = [1,2,3,4,5], k = 3[3,2,1,4,5]

Constraints:

The number of nodes in the list is n. 1 ≤ k ≤ n ≤ 5000

0 ≤ Node.val ≤ 1000

-104 ≤ lists[i][j] ≤ 104

lists[i] is sorted in ascending order.

The sum of lists[i].length will not exceed 104

Abstraction

Given a linked lists, and group size k, reverse as many groups of k length as possible.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: Recursive - Linked List/Simple Traversal

    def reverseKGroup(self, head: Optional[ListNode], k: int) -> Optional[ListNode]:
        
        # Note:
        # 1. Check if there are at least k nodes ahead (otherwise return head)
        # 2. Reverse the first k nodes
        # 3. Recurse and process sub list
        # 4. Connect reversed head with resulting sub list
        # Result: original lists with reversed groups of k length

        # Base case: check if at least k nodes remain
        kLen = 0
        node = head
        while node and kLen < k:
            kLen += 1
            node = node.next

        # Base case: less than k nodes, return head
        if kLen < k:
            return head  

        # ----
        # standard reverse linked list

        # Reverse current sub list up to k
        prev = None
        curr = head
        for _ in range(k):
            # grab next
            next = curr.next
            # reverse flow
            curr.next = prev
            # iterate
            prev = curr
            curr = next
        
        # curr -> start of next sub list
        # prev -> new head of curr sub list
        # head -> new tail of curr sub list
        # connect head (new tail) via head.next to next reversed sub list
        head.next = self.reverseKGroup(curr, k)
        
        # return prev (new head) of curr sub list
        
        # overall: time complexity O(n)
        # overall: space complexity O(n/k) ~ O(n) recursive stack
        return prev
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Solution 2: Iterative - Linked List/Simple Traversal

    def reverseKGroup(self, head: Optional[ListNode], k: int) -> Optional[ListNode]:
        
        # Note:
        # 1. Dummy node handles head swaps cleanly
        # 2. For each sub list:
        #    Find the kth node
        #    Reverse the group in place
        #    Connect previous reverse group's tail to head of new reversed group
        # 3. Continue until remaining nodes < k, then return

        # dummy node trick for head of overall list
        dummy = ListNode(0, head)

        # group_prev_tail points to tail of the previous reverse group,
        # where group.next points to head of next list
        group_prev_tail = dummy

        # while remaining sub list is greater than k
        while True:

            # last item in previous reversed sub list
            kth_elem = group_prev_tail
            # check if k more elements exist
            for _ in range(k):
                kth_elem = kth_elem.next

                # less than k nodes remain, return head of entire list
                if not kth_elem:
                    return dummy.next 

            # next sub list start
            group_next = kth_elem.next

            # ----
            # standard reverse linked list

            # Reverse group [group_prev_tail.next, kth]
            prev = group_next
            curr = group_prev_tail.next

            # Reverse group in place for nodes [0, k):
            for _ in range(k):
                # grab next
                next = curr.next
                # reverse flow
                curr.next = prev
                # iterate
                prev = curr
                curr = next

            # group_prev_tail -> (new tail) of prev sub list
            # curr -> head of next sub list
            # prev -> (new head) of curr sub list
            # group_prev_tail.next -> (new tail) of curr sub list

            # ----
            # connect new tail of previous sub list 
            # to new head of curr sub list

            # last node of previous group, still pointing to original head,
            # which is now the tail 
            curr_tail = group_prev_tail.next
            
            # point last node of previous group, to new head of curr sub list
            group_prev_tail.next = prev
            # update last node of previous group, to last node of curr sub list
            group_prev_tail = curr_tail

        # overall: time complexity O(n)
        # overall: space complexity O(1)