Jc-alt logo
jc

LeetCode: Two Pointers I

LeetCode: Two Pointers I
107 min read
data structures and algorithms

Two Pointers Intro

LeetCode problems with solutions using two pointers.

What are Two Pointers

Two Pointers is the strategy of using a left and right pointer to iterate over a data structure, usually an array, to solve a problem.

Two Pointers Application: One Pointer with Auxiliary State

We can use a single pointer to iterate linearly and have a second variable to keep track of some state or action.

Ex: Find the maximum consecutive ones in an array

    def max_consecutive_ones(nums: list[int]) -> int:

        # Aux state: track current streak
        count = 0

        # Aux state: track max streak
        max_count = 0

        # Left Pointer: iterate array
        for right in range(len(nums)):
            
            # Condition: while consecutive 1's is true
            if nums[right] == 1:
                # Aux state: add to streak
                count += 1
                max_count = max(max_count, count)
            else:
                # Aux state: reset streak
                count = 0

        return max_count

    # max_consecutive_ones([1,1,0,1,1,1]) -> 3

Two Pointers Application: Opposite Ends

We can have two pointers, left and right, starting at opposite ends of a list and move them inward while validating some sort of logic, stopping when their indexes hit left == right at the middle of the array

Ex: Determine if a string is a palindrome

    def is_palindrome(s: str) -> bool:

        # Left: start of array
        # Right: end of array
        left, right = 0, len(s) - 1

        # Break when left and right pointers match 
        # when the middle of the array is hit
        while left < right:

            # if palindrome invariant is broken
            if s[left] != s[right]:
                return False

            # shrink left and right pointers towards middle
            left += 1
            right -= 1

        # valid palindrome
        return True

    # is_palindrome("radar") -> True
    # is_palindrome("hello") -> False

Two Pointers Application: Sliding Window

We can have two pointers represent a imaginary window, [Left, Right], over a sequence that expands or shrinks while iterating or checking if a condition is satisfied.

Ex: Find the length of the longest substring without repeating characters.

    def longest_unique_substring(s: str) -> int:

        # Left: start of window
        left = 0

        # Window data: stores unique chars within window range
        char_set = set()

        # Window data: stores max window found up to now
        maxLength = 0

        # Right: end of window, expand window range as we iterate
        for right in range(len(s)):

            # Invariant: window holds list of unique chars
            # Broken: if condition is broken, shrink window from the 
            # left side until the unique char condition is true again
            while s[right] in char_set:

                # Window data: remove char on left boundary of window
                char_set.remove(s[left])
                
                # Left: start of window, shrink window range
                left += 1
            
            # Invariant: window holds list of unique chars

            # Window data: add char unique list, guaranteed to be unique
            char_set.add(s[right])
  
            # Window data: check global max
            maxLength = max(maxLength, right - left + 1)

        return maxLength

    # longest_unique_substring("abcabcbb") -> 3

Two Pointers Application: Fast & Slow Pointers

We can traverse linked lists using pointers. In this case two pointers moving at different speeds x1 and x2 can detect cycles or find midpoints in linked lists or arrays.

Ex: Detect a cycle in a linked list.


    # linked list node definition
    class ListNode:
        def __init__(self, value=0, next=None):
            self.value = value
            self.next = next

    def has_cycle(head: ListNode) -> bool:
        
        # Tortoise, hare pointers: same starting index
        slow, fast = head, head

        while fast and fast.next:

            # Tortoise pointer: x1 steps
            # Hare pointer: x2 steps
            slow = slow.next
            fast = fast.next.next

            # if pointers match, cycle exists
            # will be hit a n/2 iterations
            if slow == fast:
                return True

        # reached end of list, no cycles
        return False

    # LinkedList: 1 -> 2 -> 3 -> 4 -> 2
    # has_cycle(head) -> True

Two Pointers Application: Read and Write Pointers (Lomuto Quicksort Partition)

We can have two pointers in the same array moving inward/outward to rearrange elements based on a condition.

Ex: Lomuto partition scheme in quicksort

    def partition(nums, pivot):

        # Left: partition flip slot
        left = 0

        # Right: iterate array checking condition
        for right in range(len(nums)):

            # Condition: if curr element value is less than pivot val, 
            # flip element to left side of array in place
            if nums[right] < pivot:

                # Flip: swap element with flip slot
                nums[left], nums[right] = nums[right], nums[left]

                # Left: iterate flip slot by 1 step
                left += 1

        # Left: ends up pointing to first index where all elements 
        # are greater than the pivot value
        return (nums, left)

    # partition([9, 3, 5, 2, 8, 1, 6], 5) -> ([3, 2, 1, 5, 8, 9, 6], 5)

Two Pointers Application: Parallel Array Pointer Traversal

We can expand our previous application cards to use k pointers traversing separate k arrays in parallel to merge, compare, find intersections, or other patterns.

Ex: Merge two sorted arrays into one sorted array

    def merge_sorted_arrays(arr1, arr2):

        result = []

        # i / j: 2 pointers
        i, j = 0, 0

        # i / j: parallel iterate array, while elements remain in both lists 
        while i < len(arr1) and j < len(arr2):

            # Merge: append smaller element between arrays 
            if arr1[i] < arr2[j]:
                result.append(arr1[i])
                i += 1
            else:
                result.append(arr2[j])
                j += 1
        
        # Merge: one list has run out of elements, 
        # append list with remaining elements as its already sorted
        result.extend(arr1[i:])
        result.extend(arr2[j:])

        # merged sorted array
        return result

    # merge_sorted_arrays([1, 3, 5], [2, 4, 6]) -> [1, 2, 3, 4, 5, 6]

Two Pointers Application: Catchup Pointer

We can have two pointers traversing an array. The left pointer can be responsible for being frozen until the right pointer hits a delimiter, at which point some logic executes, then left jumps 'catches up' but jumping to right+1 to mark the start of the next section/iteration.

Ex: Split string by spaces

    def split_words(s: str, delim: str = ' ') -> list[str]:

        words = []

        # Left: frozen until right hits delim
        left = 0

        # Right: iterate list checking for delim
        right = 0

        # Right: iterate list
        while right < len(s):

            # Right condition: delimiter found
            if s[right] == delim:

                # Logic: check if non-empty word, 
                # then splice word and add to array
                if left != right:
                    words.append(s[left:right])

                # Left: Catch up, move to right+1, to 1 index
                # after the delimiter, to restart scanning for delim
                left = right + 1

            # Right: iterate pointer, either after delim
            # or to next index
            right += 1

        # Right: hit end of string, check if last word exists
        if left < len(s):
            words.append(s[left:])

        return words


    # split_words("catch up pointers example") -> ['catch', 'up', 'pointers', 'example']

Two Pointers Application: K Pointer Variants

We can extend the two pointers to k pointers. These pointers could follow any of the pointer applications, traverse the same list, different lists, freeze while moving others, etc.

Ex: Given array, return unique triplets [nums[i], nums[j], nums[k]] that sum to 0.

    def threeSum(nums):

        result = []
        nums.sort()

        # k: 3 pointers, i, left, right

        # i: iterate pointer as 'frozen' pointer
        for i in range(len(nums)):

            # i: Avoid duplicates
            if i > 0 and nums[i] == nums[i - 1]:
                continue

            # Inner two pointer approach:

            # Left: 1 index after frozen pointer
            # Right: right end of array
            left, right = i + 1, len(nums) - 1
            while left < right:

                # Condition: check if triplet sum == 0
                current_sum = nums[i] + nums[left] + nums[right]                
                if current_sum == 0:

                    # Match: add triplet
                    result.append([nums[i], nums[left], nums[right]])

                    # Left / Right: Iterate to avoid duplicates
                    left += 1
                    while left < right and nums[left] == nums[left - 1]:
                        left += 1
                    right -= 1
                    while left < right and nums[right] == nums[right + 1]:
                        right -= 1

                # Search: 
                # left end has lowest numbers, right end has highest numbers,
                # shift towards whichever gets triplet sum closer to 0

                # Left: shift towards higher numbers
                elif current_sum < 0:
                    left += 1 

                # Right: shift towards lower numbers 
                else:
                    right -= 1

        return result

    # threeSum([-1, 0, 1, 2, -1, -4]) -> [[-1, -1, 2], [-1, 0, 1]]

Two Pointers Application: Algorithm

We can have cases where problems that seems to require two pointers have an algorithm specifically made for that problem.

Ex: Manacher's Algorithm, longest palindromic substring

    def longestPalindrome(s: str) -> str:

        # Preprocess the string to handle even length palindromes
        t = "#".join(f"^{s}$")
        n = len(t)
        p = [0] * n
        center = right = 0

        
        for i in range(1, n - 1):

            # Mirror of `i` with respect to `center`
            mirror = 2 * center - i

            # If within bounds of the current right boundary, use mirror head start 
            if i < right:
                p[i] = min(right - i, p[mirror])

            # Expand around 'i' while palindrome condition true
            while t[i + p[i] + 1] == t[i - p[i] - 1]:
                p[i] += 1

            # Update the center and right boundary if the palindrome is expanded
            if i + p[i] > right:
                center = i
                right = i + p[i]

        # Find the maximum length palindrome
        maxLen, centerIndex = max((n, i) for i, n in enumerate(p))

        # Convert index back to original string
        start = (centerIndex - maxLen) // 2
        
        # Grab palindrome substring
        return s[start: start + maxLen]

344. Reverse String ::2:: - Easy

Topics: Two Pointers, String

Intro

Write a function that reverses a string.
The input string is given as an array of characters s. You must do this by modifying the input array in-place with O(1) extra memory.

InputOutput
s = ["h","e","l","l","o"]["o","l","l","e","h"]
s = ["H","a","n","n","a","h"]["h","a","n","n","a","H"]

Constraints:

1 ≤ s.length ≤ 10^5

s[i] is a printable ascii character

Abstraction

Given a string, reverse it

Pseudocode

Sol 2: Iterative In Place
1. (left, right = start, end)
2. while left < right:
    a. s[left], s[right] = s[right], s[left]
    b. left += 1 
    c. right -= 1
3. return

Test Cases

if __name__ == "__main__":

    sol = Solution()

    testCases = [

        # Regular strings
        "hello",
        "aaa",
        "abc",

        # Edge cases
        "",
        "a",

        # Palindrome patterns
        "racecar",
        "abba",
        "abca",
    ]

    for s in testCases:

        chars = list(s)
        sol.reverseString(chars)

        # !r calls repr() on the value before inserting it into the f-string,
        # for strings that means it wraps the output in quotes
        # w/ : 'hello' -> 'olleh'
        # w/o: hello -> hello
        print(f"{s!r} -> {''.join(chars)!r}")

Solution 1: [Two Pointer] Recursive In Place Reversal - Two Pointers/Opposite Ends

    def reverseString(self, s: List[str]) -> None:
        
        # Two Pointer Approach (In-Place)
        
        # Substring Representation:
        #   - Maintain window [left, right] representing characters to swap
        #   - Goal: Swap characters until window meets in the middle
        
        # Idea:
        #   - Initialize two pointers at the ends of the array
        #   - Swap s[left] and s[right]
        #   - Move pointers inward
        #   - Stop when left >= right

        # Yes, this is a dumb way to do recursion, just a test for syntax

        def helper(left, right):
            if left >= right:
                return
            
            # Swap characters at the current ends
            s[left], s[right] = s[right], s[left]
            
            # Recurse inward
            helper(left+1, right-1)
        
        helper(0, len(s) - 1)

        # overall: tc O(n)
        # overall: sc O(n)
class Solution {
public:
    void reverseString(vector<char>& s) {

        // Two Pointer Approach (In-Place)

        // Substring Representation:
        //   - Maintain window [left, right] representing characters to swap
        //   - Goal: Swap characters until window meets in the middle

        // Idea:
        //   - Initialize two pointers at the ends of the array
        //   - Swap s[left] and s[right]
        //   - Move pointers inward
        //   - Stop when left >= right

        // Yes, this is a dumb way to do recursion, just a test for syntax

        helper(s, 0, s.size() - 1);

        // overall: tc O(n)
        // overall: sc O(n)
    }

private:
    void helper(vector<char>& s, int left, int right) {
        if (left >= right) {
            return;
        }

        // Swap characters at the current ends
        swap(s[left], s[right]);

        // Recurse inward
        helper(s, left + 1, right - 1);
    }
};

Solution 2: [Two Pointer] Iterative In Place Reversal - Two Pointers/Opposite Ends

    def reverseString(self, s: List[str]) -> None:
        
        # Two Pointer Approach (In-Place)
        
        # Substring Representation:
        #   - Maintain window [left, right] representing characters to swap
        #   - Goal: Swap characters until window meets in the middle
        
        # Idea:
        #   - Initialize two pointers at the ends of the array
        #   - Swap s[left] and s[right]
        #   - Move pointers inward
        #   - Stop when left >= right

        left = 0
        right = len(s) - 1

        # tc: iterate over half the array O(n)
        while left < right:

            # Swap characters at left and right
            s[left], s[right] = s[right], s[left]

            # Shrink window from both ends
            left += 1
            right -= 1
         
        # overall: tc O(n)
        # overall: sc O(1)
class Solution {
public:
    void reverseString(vector<char>& s) {

        // Two Pointer Approach (In-Place)

        // Substring Representation:
        //   - Maintain window [left, right] representing characters to swap
        //   - Goal: Swap characters until window meets in the middle

        // Idea:
        //   - Initialize two pointers at the ends of the array
        //   - Swap s[left] and s[right]
        //   - Move pointers inward
        //   - Stop when left >= right

        int left = 0;
        int right = s.size() - 1;

        // tc: iterate over half the array O(n)
        while (left < right) {

            // Swap characters at left and right
            swap(s[left], s[right]);

            // Shrink window from both ends
            left++;
            right--;
        }

        // overall: tc O(n)
        // overall: sc O(1)
    }
};

125. Valid Palindrome ::2:: - Easy

Topics: Two Pointers, String

Intro

A phrase is a palindrome if, after converting all uppercase letters into lowercase letters and removing all non-alphanumeric characters, it reads the same forward and backward. Alphanumeric characters include letters and numbers. Given a string s, return true if it is a palindrome, or false otherwise.

InputOutput
"A man, a plan, a canal: Panama"true
"race a car"false
" "true

Constraints:

string s consists only of printable ASCII characters.

Abstraction

Given a string, determine if its a valid palindrome

Pseudocode

Sol 2: Shrink While Cleaning In Place:
1. def isAlphaNum(c):
    a. if ord('a') <= ord(c) <= ord('z') or
          etc
          return True

2. def toLower(c):
    a. if ord('A') <= ord(c) <= ord('Z'):
        return(chr(ord(c) + 32))
    b.   return c

3. (left, right = start, end)
4. while left < right:
    a. while left < right and not isAlphaNum(s[left]):
      left += 1
    b. while left < right and not isAlphaNum(s[right]):
      right -= 1
    c. leftChar = toLower(s[left])
    d. rightChar = toLower(s[right])
    e. if leftChar != rightChar:
        return False
    f. left += 1
    g. right -= 1
5. return True

Test Cases

if __name__ == "__main__":
    sol = Solution()

    categories = {
        "palindrome": ["racecar", "abcba", "aaaaaaaa"],
        "not palindrome": ["ksjdnfjgnsdkf", "ab"]
    }

    for (category, tests) in categories.items():

        for case in tests:

            res = sol.isPalindrome(case)

            print(f"{'category':<10} -> {category}")
            print(f"{'case':<10} -> {case}")
            print(f"{'result':<10} -> {res}")
            print("\n--------------------------\n")

Solution 1: [Two Pointers] Clean Then Reverse Slicing [::-1] Comparison - Two Pointers/Algorithm

    def isPalindrome(self, s: str) -> bool:
        
        # Note:
        # Appending to a list and joining once is more efficient than repeatedly
        # appending to a string. Strings are immutable, so each concatenation
        # creates a new string and copies all existing characters.
        # tc: list append + join: O(m), repeated string concat: O(m^2)

        # Helper: to skip over non alphaNum chars
        # tc: O(1)
        def isAlphaNum(c):
            if (ord('a') <= ord(c) <= ord('z') or
                ord('A') <= ord(c) <= ord('Z') or
                ord('0') <= ord(c) <= ord('9')):
                return True
            return False

        # Helper: to turn uppercase into lowercase
        # tc: O(1)
        def upperClean(c):
            if (ord('A') <= ord(c) <= ord('Z')):
                return chr(ord(c)+32)
            return c

        # sc: cleaned version of string O(n)
        cleaned = []

        # tc: iterate string O(n)
        for c in s:

            # only grab alphaNum chars
            if isAlphaNum(c):
                cleaned.append(upperClean(c))
        
        # tc: join alphaNum list O(n)
        phrase = "".join(cleaned)

        # Note:
        # Slicing: [start:stop:step]
        # if start and stop are omitted, slice includes the entire sequence
        # if step is -1, indicates to traverse in reverse

        # tc: single iteration over the two strings
        # sc: creates new reversed string
        res = phrase == phrase[::-1]

        # overall: tc O(n)
        # overall: sc O(n)
        return res
    bool isPalindrome(string s) {

        // Note:
        // - std::string is mutable, its a contiguous buffer with capacity (allocated size)
        // - avoids a separate list to ''.join() since Python strings are not mutable

        // Helper: to skip over non alphaNum chars
        // tc: O(1)
        auto isAlphaNum = [](char c) -> bool {
        //   ^^^^          ^^^^^^^^^^^^^^^^^^^
        //   variable      lambda (actual function)
            return (('a' <= c && c <= 'z') ||
                    ('A' <= c && c <= 'Z') ||
                    ('0' <= c && c <= '9'));
        };
        
        // Helper: turn uppercase into lowercase
        // tc: O(1)
        auto upperClean = [](char c) -> char {
            if ('A' <= c && c <= 'Z') {
                return c + 32;
            }
            return c;
        };

        // Reserve():
        // - std:string stores chars in a contiguous buffer with capacity (allocated size)
        //   which bounds the actual length (curr char stored)
        // - push_back() only reallocates() once length has exceeded capacity
        // - reallocates() must allocate new memory with a new capacity
        //   and copy every char over, which is expensive if done repeatedly
        // - reserve() simply preallocates the correct length to avoid unnecessary reallocates()
        // sc: O(n)
        string cleaned;
        cleaned.reserve(s.size());

        // tc: O(n)
        for (char c : s) {

           // only grab alphaNum chars
            if (isAlphaNum(c)) {
                //  ^^^^^^^
                // variable holds a callable oject (the lambda),
                // c++ lets you use () on any variable that is callable,
                // and triggers 'invoke whatever this thing is'
                // () is the call operator and lambdas overload it under the good
                cleaned.push_back(upperClean(c));
            }
        }

        // Replace [::-1]:
        // c++ std::string has no built in slice reverse like [::-1],
        // so we build a reversed copy explicitly via reverse iterators

        // C++ Iterators:
        // There are 3 ways to iterate a string in c++:

        // 1. Index Iteration: 
        //    - when we need the index
        //    - container types must support indexing

        //      for (int i = 0; i < s.size(); i++) {
        //         char c = s[i];
        //      }

        // 2. Range Based For Each Loop:
        //    - need each value, no need for index
        //    - for both containers that do and do not support indexing

        //      int i = 0;
        //      for (char c : s) {
        //          // can use both c and i
        //          i ++;
        //      }

        // 3. Iterators: 
        //    - we have forward iterators and reverse iterators
        //    - when we need more than just the value: a movable position/handle,
        //      partial ranges, two walkers at once, reversed views, or to
        //      pass into STL algorithms (sort, find, count, etc.)
        //    - works on all containers, indexable or not
        //    - only indexable containers (vector, string, array, deque) support
        //      iterator arithmetic (it + n) -- list/set/map iterators can only
        //      move one step at a time (++it), and have no numeric index at all
        
        //      - Simple forward iteration:
        //          string forward;
        //          int i = cleaned.size() - 1
        //          for (auto it = cleaned.begin(); it != cleaned.end(); ++it) {
        //              forward.push_back(*it);
        //              i --;
        //          }

        //      - Forward iteration halfway, with a manually tracked index:
        //          auto mid = s.begin() + s.size() / 2;
        //
        //          string firstHalf;
        //          int i = 0;
        //          for (auto it = s.begin(); it != mid; ++it) {
        //              firstHalf.push_back(*it);
        //              i++;
        //          }

        //          string secondHalf;
        //          int j = s.size() / 2;
        //          for (auto it = mid; it != s.end(); ++it) {
        //              secondHalf.push_back(*it);
        //              j++;
        //          }

        //      - Reverse iteration, with a manually tracked index:
        //          string reversed;
        //          int i = 0;
        //          for (auto it = cleaned.rbegin(); it != cleaned.rend(); ++it) {
        //              reversed.push_back(*it);
        //              i++;
        //          }


        // Range Constructor Iterator For std::string:
        // - same as s[::-1]
        // tc: O(n)
        // sc: O(n)
        
        string reversed;
        for (auto it = cleaned.rbegin(); it != cleaned.rend(); ++it) {
            reversed.push_back(*it);
        }
        
        
        //string reversed(cleaned.rbegin(), cleaned.rend());
        //     ^^^^^^^^  ^^^^^^^^^^^^^^^  ^^^^^^^^^^^^^^^
        //     new       reverse iterator  reverse iterator
        //     string    to cleaned's      to cleaned's
        //     variable  LAST char         position BEFORE
        //                                 the first char


        // this is the "range constructor" for std::string --
        // it takes [start_iterator, end_iterator) and copies every
        // character walked over into a new string
        //
        // rbegin() and rend() are REVERSE iterators: incrementing them
        // walks backward through the original string instead of forward,
        // so walking from rbegin() -> rend() visits cleaned's characters
        // last-to-first, and the constructor copies them in that order --
        // producing a new string that is cleaned, reversed

        bool res = (cleaned == reversed);

        // overall: tc O(n)
        // overall: sc O(n)
        return res;
    }

Solution 2: [Two Pointers] Cleaning String In Place - Two Pointers/Opposite Ends

    def isPalindrome(self, s: str) -> bool:
        
        # Helper: to skip over non alphaNum chars
        def isAlphaNum(c):
            if (ord('a') <= ord(c) <= ord('z') or
                ord('A') <= ord(c) <= ord('Z') or
                ord('0') <= ord(c) <= ord('9')):
                return True
            return False

        # Helper: to turn uppercase into lowercase
        def UpperClean(c):
            if (ord('A') <= ord(c) <= ord('Z')):
                return chr(ord(c)+32)
            return c

        # outer ends pointers
        # sc: O(1)
        left = 0
        right = len(s)-1

        # tc: O(1)
        while left < right:

            # skip non-alphaNum, while within bounds
            # tc: O(n)
            while left < right and not isAlphaNum(s[left]):
                left += 1

            # tc: O(n)
            while left < right and not isAlphaNum(s[right]):
                right -= 1

            # grab pointer values
            # sc: O(1)
            leftChar = s[left]
            rightChar = s[right]

            # Convert to lowercase
            leftClean = UpperClean(leftChar)
            rightClean = UpperClean(rightChar)

            # Check: if chars match
            # tc: O(1)
            if leftClean != rightClean:
                return False

            # Shrink pointers towards center
            # tc: O(1)
            left += 1
            right -= 1

        # overall: tc O(n)
        # overall: tc O(1)
        return True
    bool isPalindrome(string s) {

        // Helper: to skip over non alphaNum chars
        // tc: O(1)
        auto isAlphaNum = [](char c) -> bool {
            return (('a' <= c && c <= 'z') ||
                    ('A' <= c && c <= 'Z') ||
                    ('0' <= c && c <= '9'));
        };

        // Helper: to turn uppercase into lowercase
        // tc: O(1)
        auto upperClean = [](char c) -> char {
            if ('A' <= c && c <= 'Z') {
                return c + 32;
            }
            return c;
        };

        // outer ends pointers
        // sc: O(1)
        int left = 0;
        int right = s.size() - 1;

        // tc: O(n)
        while (left < right) {

            // skip non-alphaNum, while within bounds
            // tc: O(n)
            while (left < right && !isAlphaNum(s[left])) {
                left++;
            }

            // tc: O(n)
            while (left < right && !isAlphaNum(s[right])) {
                right--;
            }

            // grab pointer values
            // sc: O(1)
            char leftChar = s[left];
            char rightChar = s[right];

            // Convert to lowercase
            char leftClean = upperClean(leftChar);
            char rightClean = upperClean(rightChar);

            // Check: if chars match
            // tc: O(1)
            if (leftClean != rightClean) {
                return false;
            }

            // Shrink pointers towards center
            // tc: O(1)
            left++;
            right--;
        }

        // overall: tc O(n)
        // overall: sc O(1)
        return true;
    }

680. Valid Palindrome II ::2:: - Easy

Topics: Two Pointers, String, Greedy

Intro

Given a string s, return true if the s can be palindrome after deleting at most one character from it.

InputOutput
s = "aba"true
s = "abca"true
s = "abc"false

Constraints:

1 ≤ s.length ≤ 10^5

s consists of lowercase English letters

Abstraction

Given a string, determine if its a valid palindrome. You are given an "extra life" which means a single char in the string is allowed to not be part of the valid palindrome.

Pseudocode

Sol 1: Skip Char Failsafe Try Skip Slicing
1. def isPalindrome(sub):
    a. (n = len(s))
    b. (left, right = 0, n-1)
    c. return sub == sub[::-1]

2. (left, right = start, end) 
3. while left < right:
   a. if left == right:
        shrink
   b. else +1 extra life:
        return s[left+1:right+1] or s[left:right]  
4. return True

Sol 2: Outer Shrink With Reverse Fa Inwards Greedy Shrink:
def isPalindrome(i, j):
1. (left, right = start, end)
2. while left < right:
   a. if left == right:
        shrink
   b. else +1 extra life:
        return isPalindrome(left+1, right) or isPalindrome(left, right-1)
3. return True

Test Cases

if __name__ == "__main__":

    sol = Solution()

    sections = {
        "palindromes":        ["racecar", "aaa", "a", ""],
        "one deletion":       ["abca", "baabx", "deeee", "raceecar"],
        "near palindrome":    ["abba", "abbba"],
        "non palindromes":    ["abc", "abcdef", "leetcode"],
        "boundary patterns":  ["ab", "ac", "ba", "aaab"],
    }

    testCases = []

    # for each section
    for (label, cases) in sections.items():
        
        # for each testcase in that section
        for s in cases:
        
            # add to our test list
            testCases.append((label, s))

    # print
    for (label, s) in testCases:

        # No shallow copy needed:
        # strings are immutable in Python, validPalindrome cannot mutate s
        res = sol.validPalindrome(s)

        print(f"{'case':<18} -> {label}")
        print(f"{'input':<18} -> {s!r}")
        print(f"{'isPalindrome':<18} -> {res}")
        print("\n------------------------\n")

Solution 1: [Two Pointers] Slicing Skipping Char At Fail [TC Opt] - Two Pointers/Opposite Ends

    def validPalindrome(self, s: str) -> bool:
        
        # Two Pointers + Slicing

        # Substring Representation:
        #   - Opposite ends over  [left, right] over string s
        #   - On first mismatch, try removing and continue:
        #         1) left character
        #         2) right character
        #   - if failure, false
        #   - Use simple reverse [::-1] slicing to verify palindrome

        n = len(s)

        # sc: O(1)
        left = 0
        right = n - 1


        # Simple reverse slicing check
        # tc: O(n)
        # sc: O(n)
        def isPalindrome(sub) -> bool:

            # CPython Interpreter:
            #   Slicing: [::-1] is implemented at the interpreter level in CPython
            #   Copy:    sub[::-1] creates a reversed string copy using fast C memory operations
            #   Equal:   sub == sub[::-1] compares strings at C level with fast memory comparison
            
            # Interpreter level is faster than a python loop which adds multiple steps for simple ops
            
            return sub == sub[::-1]


        # tc: O(n)
        while left < right:

            # Failsafe hit:
            # chars fails palindrome check,
            # check if we can skip char and pass palindrome check
            if s[left] != s[right]:

                # Greedy Skip:
                # Two chars are mismatching,
                # this gives us 2 candidates substrings
                # by skipping either of the chars

                # Slicing Exclusive:
                # [0:2] = [0, 2) => [0, 1]

                # Skip left, include right
                skipLeft = s[left+1:right+1]

                # Include left, skip right
                skipRight = s[left:right]         

                # If either passes, then failsafe succeeded
                return isPalindrome(skipLeft) or isPalindrome(skipRight)

            # Shrink towards center
            left += 1
            right -= 1

        # String is complete palindrome

        # overall: tc O(n)
        # overall: sc O(n)
        return True

Solution 2: [Greedy] [Two Pointers] Opposite Ends With Greedy Shrink [SC Opt] - Two Pointers/Opposite Ends

    def validPalindrome(self, s: str) -> bool:
        
        # Two Pointers (Opposite Ends) Greedy Decision at First Mismatch

        # Substring Representation:
        #   - Maintain window [left, right] over string s
        #   - Move inward while characters match
        #   - On first mismatch, we are allowed to delete at most ONE character
        
        # Greedy Insight:
        #   - If s[left] != s[right], only two deletions can fix the mismatch:
        #         1) Delete s[left]
        #         2) Delete s[right]
        #   - Deleting any other character will NOT fix this mismatch.
        #   - Therefore, trying these two options is sufficient and exhaustive.
        #   - This makes the solution greedy: we resolve the first conflict locally
        #     and never revisit earlier decisions.

        # Simple shrink inwards palindrome:
        # tc: O(n)
        # sc: O(n)
        def isPalindrome(i, j) -> bool:

            # tc: O(n)
            while i < j:

                # substring failed failsafe
                if s[i] != s[j]:
                    return False

                # Continue to shrink
                i += 1
                j -= 1

            # substring passed failsafe
            return True


        # original string length
        n = len(s)

        # Opposite Ends Variables
        # sc: O(1)
        left = 0
        right = n - 1

        # tc: O(n)
        while left < right:

            # Safety hit:
            # current mismatch chars fails palindrome check,
            # check if passes by skipping mismatch chars
            if s[left] != s[right]:

                # Greedy Skip:
                # Create two candidate substrings, 
                # each by skipping one of the 2 mismatched characters
                return isPalindrome(left+1, right) or isPalindrome(left, right-1)

            left += 1
            right -= 1

        # String is a palindrome

        # overall: tc O(n)
        # overall: sc O(1)
        return isPalindromeSafety(s)

1768. Merge Strings Alternately ::1:: - Easy

Topics: Two Pointers, String

Intro

You are given two strings word1 and word2. Merge the strings by adding letters in alternating order, starting with word1. If a string is longer than the other, append the additional letters onto the end of the merged string. Return the merged string.

InputOutput
word1 = "abc", word2 = "pqr""apbqcr"
word1 = "ab", word2 = "pqrs""apbqrs"
word1 = "abcd", word2 = "pq""apbqcd"

Constraints:

1 ≤ word1.length, word2.length ≤ 100

word1 and word2 consist of lowercase English letters

Abstraction

Given 2 strings, assign pointer to each, L and R, and alternate appending from each of the strings

Pseudocode

Sol 1: K Pointer Parallel Traversal With Loop Clean Up
1. (i, j = start1, start2)
2. while i < len1 and j < len2:
   a. res.append(word1[i])
   b. res.append(word2[j])
   c. i += 1, j += 1
3. while i < len1: res.append(word1[i])
4. while j < len2: res.append(word2[j])
5. return "".join(res)

Sol 2: K Pointer Parallel Traversal w/ Slicing Cleanup
1. (i, j = start1, start2)
2. while i < len1 and j < len2:
   a. res.append(word1[i])
   b. res.append(word2[j])
   c. i += 1, j += 1
3. res.append(word1[i::])
4. res.append(word2[j::])
5. return "".join(res)

Test Cases

if __name__ == "__main__":

    sol = Solution()

    sections = {
        "same length":  [
                        ("abc", "xyz")],

        "word1 longer": [
                        ("abcd", "xy"), 
                        ("abcde", "x")],

        "word2 longer": [
                        ("ab", "xyzw"), 
                        ("a", "xyzw")],

        "single char":  [
                        ("a", "b")],

        "one empty":    [
                        ("abc", ""), 
                        ("", "xyz")],

        "both empty":   [
                        ("", "")],
    }

    testCases = []

    # for each section
    for label, pairs in sections.items():

        # for each testcase in that section
        for pair in pairs:

            # add to our test list
            testCases.append((label, pair))

    # for each tuple in each section
    for label, (word1, word2) in testCases:

        # No shallow copy needed:
        # strings are immutable in Python,
        # mergeAlternately cannot mutate word1 or word2
        res = sol.mergeAlternately(word1, word2)

        print(f"{'case':<10} -> {label}")
        print(f"{'word1':<10} -> {word1!r}")
        print(f"{'word2':<10} -> {word2!r}")
        print(f"{'merged':<10} -> {res!r}")
        print("\n------------------------\n")

Solution 1: [Two Pointers] Parallel Merge Traversal With Loop Clean Up - Two Pointers/Parallel Array Pointer Traversal

    def mergeAlternately(self, word1: str, word2: str) -> str:
        
        # Two Pointers (Same Direction Traversal)

        # Substring Representation:
        #   - Traverse both strings from left to right
        #   - Append characters alternately from word1 and word2
        #   - If one string finishes first, append the remaining characters
        #
        # Goal:
        #   - Build merged string by alternating characters
        #
        # Pattern:
        #   - Two pointers moving forward independently

        n1 = len(word1)
        n2 = len(word2)

        # Two Pointer Variables
        # sc: O(n1 + n2) for result storage
        i = 0
        j = 0

        # Result list
        # sc: O(n1 + n2)
        merged = []

        # tc: iterate over lists O(min(n1, n2))
        while i < n1 and j < n2:

            # Alternate Appending
            merged.append(word1[i])
            merged.append(word2[j])

            # iterate both lists
            i += 1
            j += 1


        # Word1 has remaining letters
        # tc: O(n1)
        while i < n1:
            merged.append(word1[i])
            i += 1

        # Word2 has remaining letters
        # tc: O(n2)
        while j < n2:
            merged.append(word2[j])
            j += 1

        # Join iterates over list and creates a new string
        # tc: O(n1 + n2)
        # sc: O(n1 + n2)
        res = "".join(merged)

        # overall: tc O(n1 + n2)
        # overall: sc O(n1 + n2)
        return res

Solution 2: [Two Pointers] Parallel Merge Traversal With Slicing Clean Up - Two Pointers/Parallel Array Pointer Traversal

    def mergeAlternately(self, word1: str, word2: str) -> str:
        
        # Two Pointers (Same Direction Traversal)

        # Substring Representation:
        #   - Traverse both strings from left to right
        #   - Append characters alternately from word1 and word2
        #   - If one string finishes first, append the remaining characters
        #
        # Goal:
        #   - Build merged string by alternating characters
        #
        # Pattern:
        #   - Two pointers moving forward independently

        n1 = len(word1)
        n2 = len(word2)

        # Two Pointer Variables
        # sc: O(n1 + n2) for result storage
        i = 0
        j = 0

        # Result list
        # sc: O(n1 + n2)
        merged = []

        # tc: iterate over lists O(min(n1, n2))
        while i < n1 and j < n2:

            # Alternate Appending
            merged.append(word1[i])
            merged.append(word2[j])

            i += 1
            j += 1

        #[i::] [start:end:step], 
        # when end:step are omitted, it says "from index i to end"

        # Append remaining characters via slicing
        # Returns "" if index is out of bounds, 
        # so both are always safe to append
        # tc: O(n1 - i) ~= O(n1) or O(n2 - j) ~= O(n2)
        merged.append(word1[i::])
        merged.append(word2[j::])

        # Join iterates over list and creates a new string
        # tc: O(n1 + n2)
        # sc: O(n1 + n2)
        res = "".join(merged)

        # overall: tc O(n1 + n2)
        # overall: sc O(n1 + n2)
        return res

88. Merge Sorted Array ::2:: - Easy

Topics: Two Pointers, Sorting

Intro

You are given two integer arrays nums1 and nums2, sorted in non-decreasing order, and two integers m and n, representing the number of elements in nums1 and nums2 respectively. Merge nums1 and nums2 into a single array sorted in non-decreasing order. The final sorted array should not be returned by the function, but instead be stored inside the array nums1. To accommodate this, nums1 has a length of m + n, where the first m elements denote the elements that should be merged, and the last n elements are set to 0 and should be ignored. nums2 has a length of n. Follow up: Can you come up with an algorithm that runs in O(m + n) time?

InputOutput
nums1 = [1,2,3,0,0,0], m = 3, nums2 = [2,5,6], n = 3[1,2,2,3,5,6]
nums1 = [1], m = 1, nums2 = [], n = 0[1]
nums1 = [0], m = 0, nums2 = [1], n = 1[1]

Constraints:

nums1.length == m + N

nums2.length == n

0 ≤ m, n ≤ 200

1 ≤ m + n ≤ 200

-10^9 ≤ nums1[i], nums2[i] ≤ 10^9

Abstraction

Given 2 arrays, assign pointer to each, L and R, and append which ever char has a lower value until one list is empty, then append the non empty list

Pseudocode

Sol 1: Reverse Direction Fill Two Separate While Loops:
1. (i, j = end1, end2)
2. (write = end1)
3. while 0 <= i and 0 <= j:
   a. nums1[write] = max(nums1[i], nums2[j])
   b. advance pointer with value written
   c. write -= 1
4. while 0 <= j:
    a. nums1[write] = nums2[j]
    b. j -= 1
5. return

Sol 2: Reverse Direction Fill Single While Loop Based On Nums2 Exhaustion:
1. (i, j = end1, end2)
2. (write = end1)
3. while 0 <= j
    a. if nums1 != exhausted and nums2 != exhausted
        nums1[write] = max(nums1[i], nums2[j])
        advance pointer with value written
    b. else
        nums1[write] = nums2[j]
        advance j
    c. advance write
4. return

Test Cases:

if __name__ == "__main__":
    sol = Solution()

    categories = {
        "equal": (([1,2,3,0,0,0], 3), ([1,2,3], 3)),
        "longer": (([1,2,3,4,5,6,7,0,0], 7), ([4,9],2)),
        "empty": (([0,0,0,0,0,0], 0), ([2,4,5,6,7,8], 6))
    }

    for (category, ((a, ax), (b, bx))) in categories.items():

        cpy = a[:]
        sol.merge(a, ax, b, bx)

        print(f"{'original left':<10} -> {cpy}")
        print(f"{'original right':<10} -> {b}")
        print(f"{'merged':<10} -> {a}")
        print("\n------------------------\n")

Solution 1: [Two Pointers] Reverse Direction Fill Simple Two Separate While Loops - Two Pointers/Opposite Ends

    def merge(self, nums1: List[int], m: int, nums2: List[int], n: int) -> None:

        # Two Pointers (Reverse Traversal)

        # Write Array:
        #   - nums1 is larger than nums2
        #   - nums1's first m elements are valid,
        #     the rest are placeholders so we can place nums2 values
        #   - [1, 2, 3, 4, 0, 0, 0]
        #   - [7, 8, 9]

        # Note:
        #   - Iterate from right to left across both lists (larger to lower num)
        #   - Avoid a holder for storing overwritten values
        #     by writing to the placeholder side of nums1 (from right to left)

        # Reverse Traversal
        # sc: O(1)
        L = m - 1
        R = n - 1

        # Placeholder Reverse Write Index
        W = m + n - 1

        # When either array is exhausted, we hit our 2 cases
        # tc: O(m + n)
        while 0 <= L and 0 <= R:

            # Case 2: grab element from list2
            #   - nums2 has the larger value
            if nums1[L] < nums2[R]:
                nums1[W] = nums2[R]

                # Iterate nums2 pointer
                R -= 1

            # Case 1: grab element from list1
            #   - nums1 has the larger or equal value
            else:
                nums1[W] = nums1[L]

                # Iterate nums1 pointer
                L -= 1

            # Always iterate write pointer
            W -= 1


        # Case 1: nums1 still has elements:
        #   - nothing to do as nums1 was already in order:
        #   - [1, 2, 3, 4, 0, 0, 0]

        # Case 2: nums2 still has elements elements
        #   - we can overwrite the elements in nums1 as all have been put in placeholder positions
        #   - [1, 2, 3, 4, 2, 3, 4]
        # tc: O(m)
        while 0 <= R:
            
            # Overwrite nums1 elements
            nums1[W] = nums2[R]
            R -= 1
            W -= 1

        
        # 2 arrays have been merged into nums1

        # overall: tc O(m + n)
        # overall: sc O(1)
        return

Solution 2: [Two Pointers] Reverse Direction Fill Elegant Single While Loop - Two Pointers/Opposite Ends

    def merge(self, nums1: List[int], m: int, nums2: List[int], n: int) -> None:
        

        # Two Pointers (Reverse Traversal)

        # Write Array:
        #   - nums1 is larger than nums2
        #   - nums1's first m elements are valid,
        #     the rest are placeholders so we can place nums2 values
        #   - [1, 2, 3, 4, 0, 0, 0]
        #   - [7, 8, 9]

        # Note:
        #   - Iterate from right to left across both lists (larger to lower num)
        #   - Avoid a holder for storing overwritten values
        #     by writing to the placeholder side of nums1 (from right to left)

        # Reverse Traversal
        # sc: O(1)
        L = m - 1
        R = n - 1

        # Placeholder Reverse Write Index
        W = m + n - 1

        # Once nums2 exhausted, we know nums1 will already be in sorted order
        #   - [1, 2, 3, 4, 0, 0, 0]
        #   - so we can exit based on nums2 exhaustion
        # tc: O(m + n)
        while 0 <= R:

            # Case 1:
            #   - nums2 still has elements
            #   - nums1 still has elements
            #   - compare 2 elements to find the larger
            if 0 <= L and nums1[L] > nums2[R]:

                # move num1 element 
                nums1[W] = nums1[L]

                # iterate nums1 pointer
                L -= 1

            # Case 2:
            #   - nums2 still has elements
            #   - nums1 exhausted
            #   - overwrite num1 elements which have been moved to placeholder slots already
            else:

                # Overwrite nums1 elements
                nums1[W] = nums2[R]
                R -= 1

            # Iterate write pointer
            W -= 1


        # 2 arrays have been merged into nums1

        # overall: tc O(m + n)
        # overall: sc O(1)
        return

696. Count Binary Substrings ::1:: - Easy

Topics: Two Pointers, String

Intro

Given a binary string s, return the number of non-empty substrings that have the same number of 0's and 1's, and all the 0's and all the 1's in these substrings are grouped consecutively. Substrings that occur multiple times are counted the number of times they occur.

heightOutput
s = "00110011"6
s = "10101"4

Constraints:

1 ≤ s.length ≤ 10^5

s[i] is either '0' or '1'

Abstraction

Have a sliding L R pointer tracking groups of 0's and 1's

Pseudocode

Sol 1: Track Group Lengths Side By Side To Find Min Shared 0's and 1's
1. (i = 1)
2. (prev, curr = 0, 1)
3. (groups = 0)
4. while scan <= len:
   a. if arr[i] == arr[i-1]:
      curr += 1
   b. else:
      groups += min(prev, curr)
      prev = curr
      curr = 1
   c. i += 1
5. group += min(prev, curr)
6. return groups

Test Cases

if __name__ == "__main__":

    sol = Solution()

    testCases = [

        # Edge cases
        "",
        "a",
        "0",
        "1",

        # Minimal valid cases
        "01",
        "10",

        # Simple repeating groups
        "0011",      # 2
        "000111",    # 3
        "00001111",  # 4

        # Uneven groups
        "00011",     # 2
        "001",       # 1
        "1100",      # 2

        # Alternating pattern
        "010101",    # 5
        "101010",    # 5

        # Long same-character blocks
        "000000",    # 0
        "111111",    # 0

        # Mixed patterns
        "00110011",  # 6
        "1011000",   # 3
        "00110",     # 3

        # Realistic patterns
        "0001110011", # 6
        "100111001",  # 4

        # Random patterns
        "110001110",  # 5
        "010011",     # 3
    ]

    for s in testCases:
        print(s, "=>", sol.countBinarySubstrings(s))

Solution 1: [Two Pointers] Count Groups Inner To Outer So 000111 Makes 3 Individual Groups - Two Pointers/K Pointer Variants

    def countBinarySubstrings(self, s: str) -> int:
        
        # Consecutive Group Lengths Approach (Two Pointers)

        # Idea:
        #   - Track consecutive characters as groups using two pointers.
        #   - prevGroupLength stores length of previous group
        #   - currGroupLength stores current group we scan as we match characters
        #
        #   - The number of groups found, depends on the shorter group:
        
        # Inner Outer Group Calculation:
        #
        #    0000110 =>  ["01", "0011"]
        #      00110 =>  ["01", "0011"]
        #   
        #   We add the min to the total as the length is the number of groups
        #    0001110 => ["01", "0011", "000111"]  
        #   makes 3 individual groups

        n = len(s) - 1

        # Empty Check:
        if n == 0:
            return 0

        # Initialize pointers
        # tc: O(1)

        # Start scanner at index 1, we have labeled index 0 as a group
        right = 1 

        # Start prev group at 0, no group exists before index 0
        prevGroupLen = 0
        
        # Start curr group at 1, we have labeled index 0 as a group
        currGroupLen = 1
        
        # Total number of groups
        res = 0

        # tc: O(n)
        while right < n:

            # Extend current group
            if s[right] == s[right-1]:
                currGroupLen += 1

            # Form new group:
            else:
                
                # We add the min to the total as the length is the number of groups
                #   000111 => "01", "0011", "000111"
                res += min(prevGroupLen, currGroupLen)
                
                # Make current group, the new previous group
                prevGroupLen = currGroupLen

                # Start new group length
                currGroupLen = 1


            # Iterate group finder
            right += 1

        # Nothing will trigger the last group to match
        # as no nums will follow the last group
        res += min(prevGroupLen, currGroupLen)

        # overall: tc O(n)
        # overall: sc O(1)
        return res

271. String Encode and Decode ::1:: - Medium

Topics: Two Pointers, Design

Intro

Design an algorithm to encode a list of strings to a single string and a decode algorithm to decode the single string back to the original list of strings, strs[i] contains only UTF-8 characters.

InputOutput
["leet", "code", "love", "you"]["leet", "code", "love", "you"]
["we", "say", ":", "yes"]["we", "say", ":", "yes"]

Constraints:

string s contains only UTF-8 characters

Abstraction

Create an encode and decode function to encode a list to a string, and a string back to the original list.

Have a sliding L R pointer encoding the words and their length into a single string

Pseudocode

Sol 1, 2: Encode and Decode With Delim "{length}#{string}"
Encode:
1. list = []
2. For each string:
   a. list.append("{length}#")
   b. list.append(string)
3. return "".join(list)

Sol 1: Decode Two Catch-Up Pointers
Decode:
1. list = []
2. (left = start)
3. while left <= len:
   a. (right = left)
   b. While right != '#':
        len = int(s[left:right])
   c. right += 1
   d. list.append(s[right:right+len])
   e. left = right + len
3. return list

Sol 2: Decode One Pointer + Slice String By Length
Decode:
1. list = []
2. (left = start)
3. while left <= encoded_len:
   a. len = 0
   a. While left != '#'
        len = (len * 10) + int(encoded[left])
   b. left += 1, Skip '#'
   c. s = []
   d. for _ in range(len)
        s.append(encoded[left])
   e. list.append(''.join(s))
3. return list

Test Cases

if __name__ == "__main__":

    sol = Solution()

    testCases = [

        # Edge cases with custom delim
        [],
        [""],
        ["a"],
        ["#"],
        ["##"],

        # Mixed length strings
        ["hello"],
        ["leet", "code"],
        ["abc", "defg", "h"],
        ["racecar", "level", "radar"],
    ]

    # Test encoding + decoding round trip
    for strs in testCases:

        print("Original:", strs)

        encoded = sol.encode(strs)
        decoded = sol.decode(encoded)

        print("Encoded:", encoded)
        print("Decoded:", decoded)
        print("***")

Solution 1: [Two Pointers] Slice Length And Splice String [TC Opt] - Two Pointers/Catchup

    def encode(self, strs: List[str]) -> str:

        # Note:
        # Appending to a list and joining once is more efficient than repeatedly
        # appending to a string. Strings are immutable, so each concatenation
        # creates a new string and copies all existing characters.
        # tc: list append + join: O(m), repeated string concat: O(m^2)

        # sc: n strings with m chars O(n * m)
        encoded = []

        # tc: iterate list O(n)
        for s in strs:
            
            # Note:
            # custom delimiter to mark start of string "{length}#" -> "5#""

            # tc: delimiter length proportional to log10(m) ~= O(1)
            encoded.append(str(len(s)) )
            encoded.append("#")
            encoded.append(s)
    
        # overall: tc O(n * m)
        # overall: sc O(n * m)
        return ''.join(encoded)


    def decode(self, encoded: str) -> List[str]:

        # sc: n strings with m chars O(n * m)
        decoded = []
        left = 0

        # tc: iterate over encoded O(n * m)
        while left < len(encoded):

            # set right to start of length prefix
            right = left

            # tc: log 10 (m) ~= O(1)
            # shift right until pointing to delimiter
            while encoded[right] != "#":
                right += 1

            # after:
            # [ 2 # h i ... ]
            #   ^ ^
            #   l r 

            # slice out string length
            length = int(encoded[left:right])

            # skip delimiter, point to start of string
            right += 1

            # after:
            # [ 2 # h i ... ]
            #   ^   ^
            #   l   r 

            # tc: slice out substring of length m 
            decoded.append(encoded[right:right + length])
           
            # set left to start of next custom delimiter
            left = right + length
            
            # after:
            # [ 2 # h i 3 # b y e ...]
            # [ 0 1 2 3 4 5 6 7 8 ...]
            #       ^   ^
            #       r   l

        # overall: tc O(n * m)
        # overall: sc O(n * m)
        return decoded

Solution 2: [Two Pointers] Elementary Math Carry 10 Rule For Length And Manual For Loop String Read [TC Opt] - Two Pointers/One Pointer with Auxiliary State

    def encode(self, strs: List[str]) -> str:

        # Note:
        # Appending to a list and joining once is more efficient than repeatedly
        # appending to a string. Strings are immutable, so each concatenation
        # creates a new string and copies all existing characters.
        # tc: list append + join: O(m), repeated string concat: O(m^2)

        # sc: n strings each n length O(n^2)
        encoded = []

        # tc: O(n)
        for s in strs:
            
            # Note:
            # custom delimiter to mark start of string "{length}#"

            # tc: delimiter log10(m) ~= O(1)
            encoded.append(str(len(s)) )
            encoded.append("#")
            encoded.append(s)
        
        # overall: tc O(n^2)
        # overall: sc O(n^2)
        return ''.join(encoded)


    def decode(self, encoded: str) -> List[str]:

        # sc: n strings each n length O(n^2)
        decoded = []
        left = 0

        # tc: n strings each n length O(n^2)
        while left < len(encoded):

            # Grab len from delim "{len}#"
            currLen = 0
            while encoded[left] != "#":

                # Elementary Math Carry 10:
                # add zero to right side of prev,
                # then add new number
                currLen = currLen * 10 + int(encoded[left]) 
                left += 1

            # skip delimiter '#'
            left += 1  

            # left is now pointing to start of string
            # [ 2 # h i ... ]
            #       ^
            #       l 

            # tc: O(n)
            substring = []
            for _ in range(currLen):

                # Grab char from string
                substring.append(encoded[left])
                left += 1
            
            # left is now pointing to start of next length
            # [ 2 # h i 3 # b y e ...]
            # [ 0 1 2 3 4 5 6 7 8 ...]
            #           ^
            #           l

            # Add string to list
            decoded.append(''.join(substring))

        # Return full list of decoded strings

        # overall: tc O(n^2)
        # overall: sc O(n^2)
        return decoded

953. Verifying an Alien Dictionary ::1:: - Easy

Topics: Array, Hash Table, String

Intro

You are given row x col grid representing a map where grid[i][j] = 1 represents land and grid[i][j] = 0 represents water. Grid cells are connected horizontally/vertically (not diagonally). The grid is completely surrounded by water, and there is exactly one island (i.e., one or more connected land cells). The island doesn't have "lakes", meaning the water inside isn't connected to the water around the island. One cell is a square with side length 1. The grid is rectangular, width and height don't exceed 100. Determine the perimeter of the island.

Example InputOutput
words = ["hello","leetcode"], order = "hlabcdefgijkmnopqrstuvwxyz"true
words = ["word","world","row"], order = "worldabcefghijkmnpqstuvxyz"false
words = ["apple","app"], order = "abcdefghijklmnopqrstuvwxyz"false

Constraints:

1 ≤ words.length ≤ 100

1 ≤ words[i].length ≤ 20

order.length == 26

All characters in words[i] and order and English lowercase letters.

Abstraction

Given a list of words and a string representing a custom alphabet order, determine whether the words are sorted according to that alien order.

Pseudocode

Sol 1: Char Order Rank And Compare Word By Pairs
1. rank = {}
2. for (i, char) in enumerate(order):
        a. rank[char] = i
3. (i = start)
4. for i in range (words_len-1)
        a. (word1, word2 = words[i], words[i+1])
        b. minLen = min(word1_len, word2_len)
        c. for j in range minLen:
                differenceFound = False
                if word1[j] != word2[j]:
                    if word1[j] > word[2]:
                        Return False
                    else:
                        differenceFound = True
                        break;
            
        d. if not differenceFound and len(word1) > len(word2):
            return False
3. return True

Solution 1: [Two Pointer] Adjacent Pairwise Lockstep Comparison With Custom Rank Mapping - Two Pointer/String Verifying an Alien Dictionary

    def isAlienSorted(self, words: List[str], order: str) -> bool:

        # Two Pointer (Lockstep Across Sequences):
        # Given an alphabet ordering and a list of words,
        # verify that the word list is sorted according to the ordering.

        # Strategy:
        # 1. Build a rank map: char -> alien alphabet order number
        # 2. Grab pairs of words and find the first differing character
        # 3. Differing character determines pairs relative order
        # 4. If one word is a prefix of the other (no differing char found), 
        #    then the shorter word must come first

        # Rank Mapping: 
        # char -> alien alphabet order number
        # sc: O(1)
        rank = {}
        for i, char in enumerate(order):
            rank[char] = i

        # tc: O(n * c)
        for i in range(len(words) - 1):

            pair1 = words[i]
            pair2 = words[i+1]

            # Lockstep Scan:
            # walk both words with j, 
            # only compare up to the shorter words length
            minLen = min(len(pair1), len(pair2))

            # Track whether we found a differing char
            foundDifference = False

            for j in range(minLen):

                # Char Mismatch:
                # there exists a rank difference
                if pair1[j] != pair2[j]:

                    # Incorrect Rank Order:
                    # this pair is in reverse order, invalid order
                    if rank[pair1[j]] > rank[pair2[j]]:
                        return False

                    # Correct Rank Order:
                    # this pair is in the correct order,
                    # no need to continue comparing
                    foundDifference = True
                    break

            # No Rank Difference Found:
            # one is a prefix of the other,
            # validate that the shorter word comes first
            if not foundDifference and len(pair1) > len(pair2):

                # Shorter word is second, invalid order
                return False

        # Every pair of words is in the correct order,
        # entire list is in the correct order

        # overall: tc O(n * l)
        # overall: sc O(1)
        return True

189. Rotate Array ::3:: - Medium

Topics: Array, Math, Two Pointers, Graph Theory

Intro

Given an integer array nums, rotate the array to the right by k steps, where k is non-negative. Follow up: Try to come up with as many solutions as you can. There are at least three different ways to solve this problem. Could you do it in-place with O(1) extra space?

numsOutput
nums = [1,2,3,4,5,6,7], k = 3[5,6,7,1,2,3,4]
nums = [-1,-100,3,99], k = 2[3,99,-1,-100]

Constraints:

1 ≤ nums.length ≤ 10^5

-2^31 ≤ nums[i] ≤ 2^31 - 1

0 ≤ k ≤ 10^5

Abstraction

Calculate the new index for a char after rotating k steps for each char which turns this into a cycle problem, or doing the 3x reversal trick.

Pseudocode

Sol 1: Extra Array For Re Indexing Then Overwrite Original
1. (k = k % n)
2. rotatedCopy = [0] * n
3. For each i in nums:
        a. newIndex = (i + k) % n
        b. rotatedCopy[newIndex] = nums[i]
4. For i in range nums_len:
        a. nums[i] = rotatedCopy[i]
5. return nums

Sol 2: Modular Cycle Traversal With Direct Re Index Into Original:
1. (k = k % n)
2. (numsMoved = 0)
3. (cycleEntryPoint = 0)
3. while numsMoved != n:
   a. indexToShift = cycleEntryPoint
   a. carry = nums[indexToShift]
   b. While True
        shiftedIndex = indexToShift + k
        inBoundsIndex = shiftedIndex % n
        swap(nums[inBoundsIndex], nums[carry])
        indexToShift = inBoundsIndex
        numsMoved += 1
        if indexToShift == cycleEntryPoint:
            break;
   c. cycleEntryPoint += 1
4. return nums

Sol 3: Array Reversal Trick
1. revArr(left, right):
        a. while left < right:
            swap(nums[left], nums[right])
            left += 1, rights -= 1
1. (k = k % n)
2. revArr(0, nums_len-1)
3. revArr(0, k-1)
4. revArr(k, nums_len-1)
5. return arr

Test Cases

if __name__ == "__main__":
    sol = Solution()

    categories = {
        "1": ([1,2,3,4,5], 1),
        "2": ([1,2,3,4,5], 2),
        "3": ([1,2,3,4,5], 3),
        "4": ([1,2,3,4,5], 4),
        "5": ([1,2,3,4,5], 5)
    }

    for (category, (nums, k)) in categories.items():

        # shallow item copy
        cpy = nums[:]

        sol.rotate(nums, k)

        print(f"{'category':<10} -> {category}")
        print(f"{'orig':<10} -> {cpy}")
        print(f"{'k':<10} -> {k}")
        print(f"{'shifted':<10} -> {nums}")
        print("\n----------------------\n")

Solution 1: [Two Pointer] Extra Array With Direct ReIndexing - Two Pointers/K Pointer Variants

    def rotate(self, nums: List[int], k: int) -> None:
        
        # Array Re Indexing On Extra Array

        # Substring Representation:
        #   - Each element moves to (i + k) % n
        #   - We avoid overwriting the original array
        #     before mapping is complete by moving nums to extra array

        n = len(nums)

        # If k is larger than n,
        # just mod it so its relative to the actual array size
        k = k % n

        # sc: O(n) for extra array
        rotatedCopy = [0] * n

        # Calculate all the new indexes and move nums
        # tc: O(n)
        for indexCandidate in range(n):

            # Shift index i to its new index
            newIndex = (indexCandidate + k) % n

            # Put num in corresponding new index
            rotatedCopy[newIndex] = nums[indexCandidate]

        # Overwrite original array with shifted extra array
        # tc: O(n)
        for i in range(n):
            nums[i] = rotatedCopy[i]

        # overall: tc O(n)
        # overall: sc O(n)

Solution 2: [Two Pointer] Modular Cycle Traversal Array With Direct ReIndexing - Two Pointers/K Pointer Variants

    def rotate(self, nums: List[int], k: int) -> None:
        
        # Cyclic Replacements (Modular Cycle Traversal)

        # Idea:
        #   - Each element moves to (i + k) % n
        #   - This movement creates a cycle that we can follow as 
        #     we rotate elements 

        # Cycle:
        #   - Think of the cycle as a circle with nodes
        #   - We can step through the cycle, going from node to node
        #     as we place nums in their new nodes
        
        #   - The circle can have multiple cycles:
        #      0 => 2 => 0
        #      1 => 3 => 1
        #     or have a single cycle:
        #      0 => 2 => 4 => 1 => 3 => 0
        #     which is why we need the inner outer while loop to make sure:
        #        - complete the current cycle
        #        - complete all cycles
        
        n = len(nums)

        # If k is larger than n,
        # just mod it so its relative to the actual array size
        k = k % n

        # Total num of elements we have moved to new indexes
        numsMoved = 0  

        # Start of the first cycle
        currCycleEntryPoint = 0

        # tc: shift n elements O(n)
        while numsMoved < n:

            # First shifted index will be the index at the cycle entry point
            indexToShift = currCycleEntryPoint

            # First carried value will be the value at the cycle entry point
            carriedValue = nums[indexToShift]

            # While we have not reached the beginning of the current cycle
            while True:

                # Cycle Formula:

                # shift the index 
                shiftedIndex = indexToShift + k
                
                # make sure it does go out of bounds
                inBoundsIndex = shiftedIndex % n

                # Placed num we are carrying into its new rotated position
                # The displaced number becomes our new carry
                nums[inBoundsIndex], carriedValue = carriedValue, nums[inBoundsIndex]

                # Set new index, as next index we are calculating the new position for
                indexToShift = inBoundsIndex

                # Number of numbers we have replaced
                numsMoved += 1

                # If we returned to the current cycle entry point, cycle is complete
                if currCycleEntryPoint == indexToShift:
                    break

            # Move to the next potential cycle entry point
            currCycleEntryPoint += 1

        # overall: tc O(n)
        # overall: sc O(1)
        return

Solution 3: [Two Pointer] Reversal Trick - Two Pointers/Algorithm

    def rotate(self, nums: List[int], k: int) -> None:

        # Two Pointers (Reversal Trick Technique)

        # Key Insight:
        #   Reverse entire array
        #         [7,6,5,4,3,2,1]
        #   Reverse first k elements
        #         [5,6,7,4,3,2,1]
        #   Reverse remaining n-k elements
        #         [5,6,7,1,2,3,4]

        # This achieves rotation in-place.

        n = len(nums)

        # If k is larger than n,
        # just mod it so its relative to the actual array size
        k = k % n
        
        # Helper: reverses an array
        def reverse(left, right) -> None:

            # tc: O(n)
            while left < right:

                # swap elements at index
                nums[left], nums[right] = nums[right], nums[left]

                # shrink towards middle
                left += 1
                right -= 1

        # Reverse entire array
        reverse(0, n - 1)

        # Reverse first [0, k] elements
        reverse(0, k - 1)

        # Reverse second [k, n] elements
        reverse(k, n - 1)

        # overall: tc O(n)
        # overall: sc O(1)

881. Boats to Save People ::1:: - Medium

Topics: Array, Two Pointers, Greedy, Sorting

Intro

You are given an array people where people[i] is the weight of the ith person, and an infinite number of boats where each boat can carry a maximum weight of limit. Each boat carries at most two people at the same time, provided the sum of the weight of those people is at most limit. Return the minimum number of boats to carry every given person.

numsOutput
people = [1,2], limit = 31
people = [3,2,2,1], limit = 33
people = [3,5,3,4], limit = 54

Constraints:

1 ≤ people.length ≤ 5 * 10^4

1 ≤ people[i] ≤ limit ≤ 3 * 10^4

Abstract

Compare the two extreme values at any given moment, the largest and smallest. If the smallest and largest can both fit into the boat, fit both. If only one can fit, put the largest so that there is more potential room for fitting both with a smaller max.

Pseudocode

Sol 1: Sort By Height To Start End Pointers Check If Both People Fit
1. people.Sort()
2. (left, right = start, end)
3. boats = 0
4. while left < right:
    a. if people[left] + people[right] <= weight_limit:
        left += 1
    b. right -= 1
    c. boats += 1
5. return boats

Test Cases

if __name__ == "__main__":

    sol = Solution()

    testCases = [

        # Edge cases with custom delim
        [],
        [""],
        ["a"],
        ["#"],
        ["##"],

        # Mixed length strings
        ["hello"],
        ["leet", "code"],
        ["abc", "defg", "h"],
        ["racecar", "level", "radar"],
    ]

    # Test encoding + decoding round trip
    for strs in testCases:

        print("Original:", strs)

        encoded = sol.encode(strs)
        decoded = sol.decode(encoded)

        print("Encoded:", encoded)
        print("Decoded:", decoded)
        print("***")

Solution 1: [Two Pointers] Greedy Pairing Lightest Heaviest Opposite Ends After Sorting - Two Pointers/Opposite Ends

    def numRescueBoats(self, people: List[int], limit: int) -> int:
        
        # Greedy + Two Pointers (Opposite Direction)

        # Substring Representation:
        #   - Sort people by weight
        #   - left:  lightest remaining person
        #   - right: heaviest remaining person
        #
        # Greedy Insight:
        #   - If the heaviest person cannot pair with the lightest,
        #     they cannot pair with anyone.
        #   - So the heaviest must go alone.
        #   - Otherwise, pair them together.
        #
        # Goal:
        #   - Minimize number of boats


        # tc: O(n log n) due to sorting
        people.sort()

        # Two Pointer Variables
        # sc: O(1) (ignoring sort space)
        left = 0
        right = len(people) - 1

        # total boats we need
        boats = 0

        # tc: O(n)
        while left <= right:

            # Check the combined weight
            combinedWeight = people[left] + people[right]

            # Greedy:
            # Check: if the lightest person fits with the heaviest person
            # Implies: then the lightest can pair with the heaviest
            if people[left] + people[right] <= limit:

                # Board the lightest person
                left += 1

            # The heaviest person will always board,
            # it just depends whether the lightest person will board with them
            right -= 1

            # Increase number of boats
            boats += 1


        # overall: tc O(n log n)
        # overall: sc O(1)
        return boats

11. Container With Most Water ::1:: - Medium

Topics: Array, Two Pointers, Greedy

Intro

You are given an integer array height of length n. There are n vertical lines drawn such that the two endpoints of the ith line are (i, 0) and (i, height[i]). Find two lines that together with the x-axis form a container, such that the container contains the most water. Return the maximum amount of water a container can store

numsOutput
[1,8,6,2,5,4,8,3,7]49
[1,1]1

Constraints:

trapped water involves the space between and including two walls (bars). width = (right - left)

Abstract

We need to calculate the container with the most water.

The integer value represents the height of a side of a container, and the distance between two sides is calculated using the index of the array

We can iterate over the array calculating the sides of the container that will give us the most water.

Pseudocode

Sol 1: Opposite Ends Greedy Shift Binary Search Modification
1. (left, right = start, end)
2. (maxWater = 0)
3. while left < right:
    smallerHeight = min(height[left], height[right])
    currWater = smallerHeight * (right-left)
    maxWater = max(maxWater, currWater)
    if height[left] < height[right]:
        left += 1
        while height[left] < smallerHeight:
            left += 1
    else:
        right -= 1
        while height[right] < smallerHeight:
            right -= 1
4. return maxWater

Test Cases

if __name__ == "__main__":

    sol = Solution()

    sections = {
        "two elements":     [[1, 1], [1, 8]],
        "ascending":        [[1, 2, 3, 4, 5]],
        "descending":       [[5, 4, 3, 2, 1]],
        "equal height":     [[5, 5, 5, 5, 5]],
        "valley":           [[5, 1, 1, 1, 5]],
        "peak":             [[1, 5, 5, 5, 1]],
        "general":          [[1, 8, 6, 2, 5, 4, 8, 3, 7], [4, 3, 2, 1, 4]],
    }

    testCases = []

    # for each section
    for label, cases in sections.items():
        # for each testcase in that section
        for height in cases:
            # add to our test list
            testCases.append((label, height))

    for label, height in testCases:

        # No shallow copy needed:
        # maxArea only reads height, it does not mutate it
        res = sol.maxArea(height)

        print(f"{'case':<12} -> {label}")
        print(f"{'Input':<12} -> {height}")
        print(f"{'Output':<12} -> {res}")
        print("\n----------------------\n")

Solution 1: [Greedy] Opposite Ends Pointer With Greedy Shift by BinarySearch Modification [TC Opt] - Two Pointers/Opposite Ends

    def maxArea(self, height: List[int]) -> int:
        
        # boundaries
        left, right = 0, len(height)-1
        maxWater = 0

        # tc: iteration n O(n)
        while left < right:

            # grab smaller height between outside pointers
            smallerHeight = min(height[left], height[right])

            # Width includes walls 
            # According to test case: [1, 1] is 1 water 
            # Thus, width = index 1 - index 0 = 1
            # Or, width = rightIndex - leftIndex
            width = (right - left)

            # Water is limiting height * width
            currWater = smallerHeight * width
            
            # Compare to global max water
            maxWater = max(maxWater, currWater)

            # Greedy Shift:
            # As we move pointers inwards, width is guaranteed to shrink
            # Thus, the only way to beat our currWater is with a taller height
            # So we can continue to move our pointers until we hit a bigger height,
            # as we do not care about smaller or equal heights
            # In other words, we only need to stop and check the max water
            # at a taller heights

            # tc: iteration n list O(n)
            if height[left] < height[right]:
                
                # Iterate past current left/right wall combination
                left += 1
                
                # Greedy Shift:
                # We only need to stop at taller heights 
                while left < right and height[left] < smallerHeight:
                    left += 1 

            else:

                # Iterate past current left/right wall combination
                right -= 1

                # Greedy Shift:
                # We only need to stop at taller heights 
                while left < right and height[right] < smallerHeight:
                    right -= 1

        # overall: tc O(n)
        # overall: sc O(1)
        return maxWater

42. Trapping Rain Water ::3:: - Hard

Topics: Array, Two Pointers, Dynamic Programming, Stack, Monotonic Stack

Intro

Given n non-negative integers representing an elevation map where the width of each bar is 1, compute how much water it can trap after raining.

heightOutput
[0,1,0,2,1,0,1,3,2,1,2,1]6
[4,2,0,3,2,5]9

Constraints:

trapped water involves the space between two walls (bars). width = (right - left - 1)

Abstraction

To calculate trapped water:

[1, 0, 1] -> 1 unit of water

[1, 0, 2] -> 1 unit of water

[1, 0, 0] -> 0 units of water

Definition of trapped water is: [ min(left, right) - currHeight ]

Now we need a way to traverse the array that allows us to take advantage of this pattern.

Pseudocode

Sol 1: Inner Pair Outer Pair Pointers Representing Wall Catchers
1. (outerLeftMax, outerRightMax = 0, 0)
2. (left, right = start, end)
3. (water = 0)
4. while left < right:
    a. if height[left] < height[right]:
        if height[left] > outerLeftMax:
            outerLeftMax = height[left]
        else:
            water += outerLeftMax - height[left] 
        left += 1
   b. else:
        if height[right] > outRightMax:
            outerRightMa = height[right]:
        else:
            water += outerRightMax - height[right]
        right -= 1
4. return water

Sol 2: Monotonic Stack Dragging Right Wall
1. stack = []
2. (water = 0)
2. For each i in height:
   a. While the stack is non-empty and height[i] breaks the monotonic
      decreasing order (is taller than the height at the top index):
      - Pop the top index as the depth candidate
      - If the stack is now empty, no left wall bound exists, stop this inner loop
      - Otherwise, the new stack top is the left wall bound and i is the right wall bound
      - Compute width as the distance between left and right wall bounds minus 1
      - Compute trapped water as (min(left wall height, right wall height) - depth height) * width
      - Add to water total
   b. Push i onto the stack
3. return total trapped water

Sol 3: (Dynamic Programming Left/Right Max Arrays):
1. Build leftMax array: leftMax[i] = tallest height seen from 0 to i (left to right)
2. Build rightMax array: rightMax[i] = tallest height seen from i to n-1 (right to left)
3. For each index i:
   a. Water trapped at i = min(leftMax[i], rightMax[i]) - height[i]
   b. Add to water total
4. return total trapped water

Solution 1: [Monotonic] [Two Pointers] 2 Inner/Outer Pointers Traversal Creating Bound Buckets By Monotonic Opposite Ends Pointer Shift Modification - Two Pointers/K Pointer Variants

    def trap(self, height: List[int]) -> int:

        # Bound Buckets:
        # [4, 0, 2, 6, 3, 5]: Monotonic quality defines buckets 
        #    Bucket from index 0-3 with heights of [4, 0, 2, 6]  (left to right monotonic increasing bucket)
        #    Bucket from index 3-5 with heights of [6, 3, 5]     (left to right monotonic decreasing bucket)
        #
        #    When we use 4 and 6 as the left and right bucket walls, bucket will catch any water up to and including 4
        #    When we use 6 and 5 as the left and right bucket walls, bucket will catch any water up to and including 5

        #   Bucket 1: w     Bucket 2: m
        #       --------- ++++++
        #                *     
        #                *  m  *
        #       *  w  w  *  m  *
        #       *  w  w  *  *  *
        #       *  w  *  *  *  *
        #       *  w  *  *  *  *
        #      ------------------
        #       0  1  2  3  4  5

        # Types Of Graphs
        # full width:  [4, 0, 2, 1, 3, 5], left < right for entire array (bucket from 5 -> 6)
        # split width: [4, 0, 2, 6, 3, 5], left < right broken at some point (bucket from 5 -> 9, 9 -> 6)

        #  Diagram 1:                  Diagram 2:
        #       ----------------            --------- ++++++
        #                                            *     
        #                      *                     *  m  *
        #       *  w  w  w  w  *            *  w  w  *  m  *
        #       *  w  w  w  *  *            *  w  w  *  *  *
        #       *  w  *  w  *  *            *  w  *  *  *  *
        #       *  w  *  *  *  *            *  w  *  *  *  *
        #      ------------------          ------------------
        #       0  1  2  3  4  5            0  1  2  3  4  5
        #       ^              ^            ^              ^
        #       L              R            L              R

        # Creating Buckets: 
        # Outer Pointers: serve as left and right most wall for buckets
        # Inner Pointers: traverse inward from both ends to verify if monotonic quality (our bucket definition) is kept or broken
        
        # Water Trapping: 
        # We use implications between outer and inner pointers
        # to find the limited wall for the current water height

        # Bucket Depth via Height Implications:
        # See diagrams below

        # outer pointers
        outerLeftMax, outerRightMax = 0, 0
        
        # inner pointers
        left, right = 0, len(height) - 1
        
        water = 0

        # tc: O(n)
        while left < right:
            
            # We grab the lower height, so we know that 
            # this lower height is bounded by the taller height on one of its sides,
            # we then are just left with finding the bound on the opposite side if one exists

            # Check: Left wall is shorter than Right wall
            # Implies: Left wall is covered by Right wall
            # Implies: We have a right wall bound
            # Implies: We have to check if a left wall bound exists
            if height[left] < height[right]:    
                
                # Implies:
                #
                #                   *   
                #                   *   
                #             *     *   
                #       ?     *     *   
                #      ------------------
                #      LM     L     R   

                # Check: Left wall is taller than current Left Max
                # Implies: New tallest left wall found
                # Implies: There is no left wall bound as left is taller than everything to the left,
                #          we cannot catch water
                # Then: Update left max
                if height[left] >= outerLeftMax:

                    # Implies:
                    #
                    #                   *   
                    #                   *   
                    #             *     *   
                    #       *     *     *   
                    #      ------------------
                    #      LM     L     R   
                    outerLeftMax = height[left]

                # Check: Left wall is shorter than left max
                # Implies: Left wall is covered by left max
                # Implies: There is a left wall bound, something to the left is taller,
                #          we can catch water
                # Invariant: There already exists a right bound, 
                #            and now we know height[left] < height[outerLeftMax] < height[right]
                # Implies: Water we catch is bounded by outerLeftMax
                # Then: Water caught is height difference between outerLeftMax and left
                else:
                    
                    # Implies:
                    #
                    #                   *   
                    #       *     w     *   
                    #       *     *     *   
                    #       *     *     *   
                    #      ----------------
                    #      LM     L     R   
                    water += outerLeftMax - height[left]

                # shift pointer
                left += 1

            # Check: Right wall is shorter than Left wall
            # Implies: Right wall is covered by Left Wall
            # Implies: We have a left wall found
            # Implies: We have to check if a right wall bound exists
            else:

                # Implies:
                #
                #       *              
                #       *                
                #       *     *         
                #       *     *     ?   
                #      ----------------
                #       L     R     RM   

                # Check: Right wall is taller than Right Max
                # Implies: New tallest right wall found
                # Implies: There is no right wall bound as right is taller than everything to the right,
                #          we cannot catch water
                # Then: update right max
                if height[right] >= outerRightMax:

                    # Implies:
                    #
                    #       *              
                    #       *                
                    #       *     *         
                    #       *     *     *   
                    #      ----------------
                    #       L     R     RM   
                    outerRightMax = height[right]

                # Check: Right wall is shorter than right Max
                # Implies: Right Wall is covered by right Max
                # Implies: There is a right wall bound, something to the right is taller,
                #          we can catch water
                # Invariant: There already exists a left bound,
                #            and now we know height[right] < height[outerRightMax] < height[left]
                #
                # Implies: Water we catch is bounded by outerRightMax
                # Then: Water caught is height difference between outerRightMax and left
                else:

                    # Implies:
                    #
                    #       *              
                    #       *     w     *     
                    #       *     *     *    
                    #       *     *     *   
                    #      ----------------
                    #       L     R     RM   
                    water += outerRightMax - height[right]

                # shift pointer
                right -= 1

        # overall: tc O(n)
        # overall: sc O(1)
        return water

Solution 2: [Monotonic] [Stack] Dragging Right Wall Height Over The Array And Catching Water With Depth Candidates And Left Wall By Building Monotonic Stack - Two Pointers/Algorithm

    def trap(self, height: List[int]) -> int:

        # Monotonic Stack: 
        # A stack that maintains monotonic decreasing heights
        # When monotonic decreasing rule breaks, curr height will serve as right wall,
        # and if stack is non empty, top of stack will serve as depth, 
        # and second top of stack - 1 will serve as left wall
        # we then pop off the top of the stack until the monotonic rule comes back into play,
        # or the right wall becomes the new leftmost wall

        # Monotonic Stack Of Heights:
        #
        #      *                                  *                    *                 *              *
        #      *                   *              *            *       *         *       *  w   *       *  *
        #      *  *                *              *  *         *       *  *  w   *       *  *   *       *  *
        #      *  *                *              *  *         *       *  *  w   *       *  *   *       *  *
        #      *  *  *             *              *  *  *  w   *       *  *  *   *       *  *   *       *  *
        #      *  *  *  *     +    *       ==>    *  *  *  *   *   =>  *  *  *   *   =>  *  *   *  ==>  *  *
        #     ------------   new  ---  pop off   ------------ ---     --------- ---     ------ ---     ------
        #    older --> newer        left candidates                                                  final result
        #                                              1 water            2 water        1 water
        #                                                caught            caught         caught

        # Monotonic stack to store indices
        stack = []  
        water = 0
        
        # Iterate
        i = 0
        n = len(height)-1

        # We will iterate over list and pushing and popping each bar only once
        # tc: O(n)
        while i <= n:

            # Goal:
            # To find a left bound, right bound, and depth candidate to catch water at the current index
            # We will find these candidates at feeder locations:
            # Right Bound Candidate: current index during iteration
            # Left Bound Candidate:  on the stack
            # Depth Candidate:       on the stack

            # Check: If stack is non empty, a depth candidate exists,
            # Check: If current height[i] breaks monotonic decreasing order (is taller than top of stack), 
            #        its viable as a new right wall bound
            # Implies: While right wall bound breaks monotonic decreasing, it serves as a right bound
            #           for whatever is on the top of the stack
            # Implies: We need to check if a left wall candidate exists (stack needs to have at least 2 elements)

            # implies: stack is kept in monotonic decreasing order
            # implies: when monotonic decreasing breaks, we have found right wall
            # implies: we have a depth candidate
            while stack and height[stack[-1]] < height[i]:
                
                depthCandidateIndex = stack.pop() 

                # Implies:
                #
                #                   *  
                #                   *   
                #             *     *   
                #       ?     *     *   
                #      ------------------
                #      LB   depth   RB   
                #           pop()

                # height[i]: right wall
                # pop stack[-1]: depth candidate
                # peak stack[-2]: ? 

                # Check:
                # If stack has at least 1 other elem,
                # it will serve as the left wall bound

                # While Loop:
                # We remove the depth candidate from stack, 
                # We check if stack is non-empty, then a left bound exists
                # We continue this, popping depth candidates, and using left bounds
                # as long as the right wall bound is taller than the top of the stack.
                # Imagine dragging the right wall over the monotonic stack,
                # and while its taller than all depth candidates, we add the corresponding water.

                # Exit Loop:
                # Once the right wall is shorter than the top of the stack, 
                # it can no longer serve as a right bound, 
                # so we simply add it to the stack so it itself can serve as a future depth candidate


                # Check: if stack empty after pop, (we popped the only element that was on the stack)
                # Implies: No left wall bound exists, we cannot trap water, add right wall bound to stack
                if not stack:
                    break

                # Left wall exists:
                # We have a left and right bound, so we can catch water
                # Water is bound between the shorter of the left and right wall bounds

                 # Implies:
                #
                #       ?           ?   
                #       *           *   
                #       *     *     *   
                #       *     *     *   
                #      ------------------
                #      LB   depth   RB   
                #           pop()

                # Summary:
                # height[i]: right wall
                # popped depthCandidate: depth
                # peak stack[-1]: left wall
                # width = (right wall bound index - left wall bound index - 1)
                
                # Width:
                rightWallIndex = i
                leftWallIndex = stack[-1]
                width = rightWallIndex - leftWallIndex - 1

                # Water Caught:
                rightWallBoundHeight = height[rightWallIndex]
                leftWallBoundHeight = height[leftWallIndex]
                bucketWallBoundHeight = min(rightBoundHeight, leftBoundHeight)

                depthHeight = height[depthCandidateIndex]

                # Water Caught:
                # Smaller bucket wall bound height - depth height = water getting caught

                # Implies:
                #
                #                   *   
                #       *     w     *         1 water
                #       *     *     *          caught
                #       *     *     *   
                #      ------------------
                #      LB   depth   RB   
                #           pop()

                waterCaught = bucketWallBoundHeight - depthHeight

                # Width Edge Case:
                # [5, 0, 0, 2]
                # in this case, (0, 0, 2)
                # left wall = 0, depth = 0, right wall = 2
                
                # So no water captured fir (0, 0, 2)
                # So water is ignored until a left wall bound is eventually found (5, 0, 2) 
                # or its determined that no left wall bound exists

                # but then due to pop, (5, 0, 2)
                # left wall = 5, depth = 0, right wall = 2
                # so water captured based on distance

                # Index Edge Case:
                # We take the above into account by saving the indexes on the stack, instead of the heights
                
                # Originally: [5, 0, 0, 2]
                #              0  1  2  3
                
                # So we can do width = right - left - 1 = (3 - 0) - 1 = 2
                
                water += width * waterCaught

            # Implies: right wall allows monotonic decreasing is be valid
            # Implies: right wall is shorter than top of stack
            # Then: append right wall to stack to serve as future depth candidate
            stack.append(i)
            i += 1

        # overall: tc O(n)
        # overall: sc O(n)
        return water

Solution 3: [Dynamic Programming] Creating Bucket Left Right Boundaries By Dynamic Programming Tracking Max Height Bucket Bounds Encountered L To R and R to L - Two Pointers/Algorithm

    def trap(self, height: List[int]) -> int:
        
        # Dynamic Programming Concept:
        # Left Maximum Array: 
        #       Stores the maximum height encountered iterating from left to right,
        #       these will serve as the left wall bounds
        # Right Maximum Array: 
        #       Stores the maximum height encountered iterating from right to left,
        #       these will serve as the right wall bounds
        #
        # To avoid recomputing maximum heights repeatedly, 
        # we instead build the bucket walls as we iterate

        # Empty Check:
        n = len(height)
        if n == 0:
            return 0

        # Iteration Arrays:
        # Store max heights from perspective of iterating left to right and right to left for all indexes
        # leftMax[i]:  Maximum height from  0 -> i:    (iterating left to right)
        # rightMax[i]: Maximum height from  i <- n-1:  (iterating right to left)
        # sc: relative to input O(n)
        leftMax = [0] * n
        rightMax = [0] * n
        water = 0

        # First Max Left To Right:
        leftMax[0] = height[0]

        # Iterating Left To Right:
        # Compare previous max to current height
        # tc: O(n)
        for i in range(1, n):
            previousMax = leftMax[i-1]
            leftMax[i] = max(previousMax, height[i])

        # First Max Right To Left:
        rightMax[n-1] = height[n-1]

        # Iterating Right To Left:
        # Compare previous max to current height
        # tc: O(n)
        for i in range(n-2, -1, -1):
            previousMax = rightMax[i+1]
            rightMax[i] = max(previousMax, height[i])

        # Depth Calculation:
        # tc: O(n)
        for i in range(n):

            # Bucket Wall Height:
            # The bucket is bounded by lower of 2 maxes (they represent the Left and Right bucket side heights)
            # Just subtract the lower height against the height of the bottom of the bucket
            water += min(leftMax[i], rightMax[i]) - height[i]

        # overall: tc O(n)
        # overall: sc O(n)
        return water

26. Remove Duplicates from Sorted Array ::1:: - Easy

Topics: Array, Two Pointers

Intro

Given an integer array nums sorted in non-decreasing order, remove the duplicates in-place such that each unique element appears only once. The relative order of the elements should be kept the same. Consider the number of unique elements in nums to be k. After removing duplicates, return the number of unique elements k. The first k elements of nums should contain the unique numbers in sorted order. The remaining elements beyond index k - 1 can be ignored. Custom Judge: The judge will test your solution with the following code: int[] nums = [...]; // Input array int[] expectedNums = [...]; // The expected answer with correct length int k = removeDuplicates(nums); // Calls your implementation assert k == expectedNums.length; for (int i = 0; i < k; i++) assert nums[i] == expectedNums[i]; If all assertions pass, then your solution will be accepted.

heightOutput
nums = [1,1,2]2, nums = [1,2,_]
nums = [0,0,1,1,1,2,2,3,3,4]5, nums = [0,1,2,3,4,,,,,_]

Constraints:

1 ≤ nums.length ≤ 3 * 10^4

-100 ≤ nums[i] ≤ 100

nums is sorted in non decreasing order.

Abstraction

They don't really want you to remove the duplicates. They want you to sort the uniques at the front, then return the length of the sorted part. Then, behind the scenes, they slice the array at the length you give them and the result of that is what they check.

Pseudocode

1. Initialize left write pointer at index 1
2. Initialize right scanner pointer at index 1
3. while right is within bounds:
   a. If current element differs from previous element (new unique value found):
      - Write it to the left pointer's position
      - Advance left pointer
   b. Advance right pointer regardless
4. return left pointer value as count of unique elements

Test Cases

if __name__ == "__main__":

    sol = Solution()

    sections = {
        "no modification":  [
                            [1], 
                            [1,2,3,4]
                            ],

        "general":          [
                            [0,0,1,1,1,2,2,3], 
                            [1,1,2], 
                            [1,2,2,3,3,4], 
                            [1,2,3,4,4,4,4], 
                            [1,1,2,3,3,3,4,5,5]
                            ],

        "all duplicates":   [
                            [2,2,2,2]
                            ],

        "front duplicates": [
                            [5,5,5,5,6,7]
                            ]
    }

    # flatten into (label, nums) tuples
    testCases = []

    # for each section
    for (label, cases) in sections.items():

        # for each testCase in that section
        for testCase in cases:

            # append to our list
            testCases.append((label, testCase))

    for (label, nums) in testCases:

        # Shallow Slice Copy: 
        # grabs list elements before in-place mutation
        # [:] copies the list values, not the reference
        # [start:end] => start defaults to 0, end defaults to len(nums)
        original = nums[:]

        # number of unique values
        k = sol.removeDuplicates(nums)

        # :<10 pads any string to fill width of 10
        print(f"{'Group Type':<10} -> {label}")
        print(f"{'original':<10} -> {original}")
        print(f"{'modified':<10} -> {nums}")

        # nums[:k]: slice to valid unique portion only,
        # elements beyond index k are leftover garbage values
        print(f"{'sliced':<10} -> {nums[:k]}")
        print(f"{'unique k':<10} -> {k}")
        print("\n------------------------\n")

Solution 1: [Two Pointers] Left Write Right Scan Pointers Keep Relative Order [TC Opt] [SC Opt] - Two Pointers/Read and Write Pointers (Lomuto Quicksort Partition)

    def removeDuplicates(self, nums: List[int]) -> int:
        
        # Two Pointer Pattern : Read and Write Pointers (Lomuto Quicksort Partition)
        
        # Idea:
        #   - Array is sorted so duplicates will appear consecutively
        #       [1, 1, 1, 1, 2, 2, 2, 3, 4, 5] etc...
        #   - Two Pointers Write/Read
        #       W: write pointer for unique values
        #       R: scans to find new unique values
    
        n = len(nums) - 1

        # Left most position:
        # Write index for unique values 
        W = 1

        # Right Scanner Pointer:
        # Reads/iterates over array searching for unique value,
        # Sends unique to the write index
        R = 1

        # tc: O(n)
        while R <= n:

            # Since array is sorted so duplicates appear consecutively,
            # check previous if match
            if nums[R] != nums[R - 1]:

                # We cannot swap the elements as this would break the 
                # sorted property of the array,
                # thus, we just place the unique element on the write pointer
                nums[W] = nums[R]

                # Iterate write index
                W += 1

            # Iterate scanner
            R += 1

        # W = count of unique elements (all elements to the left of W are unique)

        # overall: tc O(n)
        # overall: sc O(1)
        return W

27. Remove Element ::1:: - Easy

Topics: Array, Two Pointers

Intro

Given an integer array nums and an integer val, remove all occurrences of val in nums in-place. The order of the elements may be changed. Then return the number of elements in nums which are not equal to val. Consider the number of elements in nums which are not equal to val be k, to get accepted, you need to do the following things: Change the array nums such that the first k elements of nums contain the elements which are not equal to val. The remaining elements of nums are not important as well as the size of nums. Return k. Custom Judge: The judge will test your solution with the following code: int[] nums = [...]; // Input array int val = ...; // Value to remove int[] expectedNums = [...]; // The expected answer with correct length. // It is sorted with no values equaling val. int k = removeElement(nums, val); // Calls your implementation assert k == expectedNums.length; sort(nums, 0, k); // Sort the first k elements of nums for (int i = 0; i < actualLength; i++) assert nums[i] == expectedNums[i]; If all assertions pass, then your solution will be accepted.

heightOutput
nums = [3,2,2,3], val = 32, nums = [2,2,,]
nums = [0,1,2,2,3,0,4,2], val = 25, nums = [0,1,4,0,3,,,_]

Constraints:

0 ≤ nums.length ≤ 100

0 ≤ nums[i] ≤ 50

0 ≤ val ≤ 100

Abstraction

Partition an array in-place by maintaining a shrinking valid region, swapping unwanted elements to the end instead of preserving their order.

Pseudocode

1. Initialize:
   a. Left pointer scans current element
   b. Right pointer marks end of valid region

2. while left has not crossed right:
   a. If current element is valid:
      - Advance left, keeping element in valid region
   b. Otherwise:
      - Swap current element with element at right, putting element in invalid region
      - Shrink valid region by moving right one index to the left
      - Do NOT advance left, since swapped element is unprocessed

3. return size of valid region, left pointer value

Solution 1: [Two Pointers] Left Write Right Scan Pointers Keep Relative Order [TC Opt] [SC Opt] - Two Pointers/Read and Write Pointers (Lomuto Quicksort Partition)

    def removeElement(self, nums: List[int], val: int) -> int:
        
        # Two Pointer Pattern : Read and Write Pointers (Lomuto Quicksort Partition)
        
        # Idea:
        #   - Array is not sorted so target may appear at any pointer
        #       nums = [3,2,2,3], val = 3
        #   - Two Pointers Write/Read
        #       W: write pointer for unique values
        #       R: scans to find new unique values
    
        n = len(nums) - 1

        # Left most position:
        # Write index for non-target values 
        W = 1

        # Right Scanner Pointer:
        # Reads/iterates over array searching for non-target value,
        # Sends non-target to the write index
        R = 1

        # tc: O(n)
        while R <= n:

            # Since array is non-sorted, much check every element against target
            if nums[R] != val:

                
                # Place non-target element on the write pointer
                nums[W] = nums[R]

                # Iterate write index
                W += 1

            # Iterate scanner
            R += 1

        # W = count of non-target elements (all elements to the left of W are non-target)

        # overall: tc O(n)
        # overall: sc O(1)
        return W

Solution 2: [Two Pointers] Left Scan Right Swap Pointers Ignore Relative Order [TC Opt] [SC Opt] - Two Pointers/K Pointer Variants

    def removeElement(self, nums: List[int], val: int) -> int:
        
        # Two Pointer Pattern (Unordered Removal)
        
        # Idea:
        #   - Order of elements does NOT matter
        #   - If we find val, swap to end of list
        
        # Left:
        #   - scans for the target
        #   - swaps to end of list
        #   - all elements to the left are non-target
        left = 0
        
        # Right: 
        #   - receives target elements
        #   - acts as write pointer for target elements
        right = len(nums) - 1

        # tc: O(n)
        while left <= right:

            # Current element is non-target
            if nums[left] != val:

                # keep element, iterate write
                left += 1

            # Found target
            else:
                
                # Swap to the end of the list
                nums[left], nums[right] = nums[right], nums[left]
                
                # Invalidate target we just swapped
                right -= 1

                # Do not iterate left,
                # we still need to verify the element we just received from the swap

        # left = count of valid elements (all elements to the left of L are non-target)

        # overall: tc O(n)
        # overall: sc O(1)
        return left

75. Sort Colors ::1:: - Medium

Topics: Array, Two Pointers, String, Dutch National Flag

Intro

Given an array nums with n objects colored red, white, or blue, sort them in-place so that objects of the same color are adjacent, with the colors in the order red, white, and blue. We will use the integers 0, 1, and 2 to represent the color red, white, and blue, respectively. You must solve this problem without using the library's sort function. Follow up: Could you come up with a one-pass algorithm using only constant extra space?

InputOutput
nums = [2,0,2,1,1,0][0,0,1,1,2,2]
nums = [2,0,1][0,1,2]

Constraints:

n == nums.length

1 ≤ n ≤ 300

nums[i] is either 0, 1, or 2

Abstraction

A partitioning problem with 3 sections, with the 3 pointers LMR parsing unread data and sending chars to corresponding section.

Pseudocode

1. Initialize leftWrite0 at start (next write location for 0)
2. Initialize midCandidate at start (scans unknown region)
3. Initialize rightWrite2 at end (next write location for 2)
4. while midCandidate has not passed rightWrite2:
   a. If nums[midCandidate] == 0:
      - Swap with leftWrite0, advance leftWrite0
      - Advance midCandidate (swap always yields a validated element at midCandidate)
   b. Else if nums[midCandidate] == 1:
      - Leave in place, advance midCandidate
   c. Else (nums[midCandidate] == 2):
      - Swap with rightWrite2, retreat rightWrite2
      - Do NOT advance midCandidate, since the swapped-in element is unvalidated
5. return (array modified in place)

Test Cases

if __name__ == "__main__":

    sol = Solution()

    sections = {
        "already sorted":   [[0, 0, 1, 1, 2, 2], 
                             [0, 1, 2]],

        "reverse sorted":   [[2, 2, 1, 1, 0, 0], 
                             [2, 1, 0]],

        "all same":         [[0, 0, 0], 
                             [1, 1, 1], 
                             [2, 2, 2]],

        "single element":   [[0], 
                             [1], 
                             [2]],

        "two elements":     [[2, 0], 
                             [1, 0], 
                             [2, 1]],

        "general":          [[2, 0, 2, 1, 1, 0], 
                             [2, 0, 1]],
    }

    testCases = []

    # for each section
    for label, cases in sections.items():
        # for each testcase in that section
        for nums in cases:
            # add to our test list
            testCases.append((label, nums))

    for label, nums in testCases:

        # shallow item copy
        cpy = nums[:]

        sol.sortColors(nums)

        print(f"{'case':<10} -> {label}")
        print(f"{'orig':<10} -> {cpy}")
        print(f"{'sorted':<10} -> {nums}")
        print("\n----------------------\n")

Solution 1: [Follow Up] [Two Pointers] Dutch National Flag 3 Way In Place Quicksort Partition [TC Opt] [SC Opt] - Two Pointers/K Pointer Variants

    def sortColors(self, nums: List[int]) -> None:
        
        # Two Pointer Pattern (Dutch National Flag / 3 Way Partition)
        
        # Idea:
        #   - Use 3 pointers to define 4 sections: 0, 1, unknown, 2
        #
        #       [ 0s | 1s | unknown | 2s ]
        #            ^    ^         ^
        #            L    M         R

        # Writer Pointers:
        # L: swap spot for the next 0, then iterate L to the right +1
        # M: swap spot for the next 1, then iterate M to the right +1
        # R: swap spot for the next 2, then iterate R to the left -1
        
        # Area: 
        # L: everything to the left is a 0, may or may not point to first valid 1
        # M: everything from L to everything to the left is a 1, 
        #    acts as a scan pointer to verify candidate in unknown region
        # R: everything to the right is a 2

        # Swap Handling:
        #   - Swap to corresponding pointer, and iterate swap pointer
        #   - Iterate candidate pointer


        # L:
        # swap location for the next 0
        L0 = 0

        # M:
        # swap location for the next 1,
        # current unknown candidate
        MC = 0

        # R:
        # saves the write location for the next 2
        R2 = len(nums) - 1

        # L <= R Variation:
        # M acts as our candidate scanner, so once it goes past our R,
        # since everything to the R is guaranteed to be a 2,
        # that means M has run out of candidates and we can stop scanning
        # tc: O(n)
        while MC <= R2:

            # 0 Case:
            if nums[MC] == 0:

                # Swap to candidate and write pointer
                nums[L0], nums[MC] = nums[MC], nums[L0]

                # Iterate write and candidate
                L0 += 1
                MC += 1

                # Case 1: (L and M point to the same index)
                # - No '1's have been found yet 
                #   
                #       [ 0s | unknown | 2s ]
                #            L         R
                #            M
                #
                #  Here, the swap does nothing since both L and M are at the same index,
                #  but we still need to iterate M to get to a new candidate
                #  and iterate L to get to the next swap index for 0 

                # Case 2: (L and M are at different indexes)
                # - At least one '1' has been found
                #
                #       [ 0s | 1s | unknown | 2s ]
                #            L    M         R
                # 
                #  Here, the swap does actually switch elements,
                #  L was previously pointing to the left most 1,
                #  M was previously pointing to the unknown candidate that we just validated to be a 0,
                #  so after the swap, the 1 ends up at M (in the 1 zone),
                #  and the 0 ends up at L (in the 0 zone),
                #  and the 1 ends up at the M (in the 1 zone)
                #  now we just need to iterate M to get to a new candidate
                #  and iterate L to get to the next swap index for 0 

            elif nums[MC] == 1:


                # Technically, we still swap the candidate and write pointer,
                # but in this case they happen to be the same pointer so we can omit the following line
                # nums[midCandidate], nums[midCandidate] = nums[midCandidate], nums[midCandidate]

                # Iterate write and candidate
                # (which in this case happen to be the same pointer)
                MC += 1

                # Case 1: (L and M point to the same index)
                # - No '0's have been found yet 
                #   
                #       [ 0s | unknown | 2s ]
                #            L         R
                #            M
                #
                #  Here, the swap does nothing since M swaps with itself,
                #  but we still need to iterate M to get to a new candidate

                # Case 2: (L and M are at different indexes)
                # - At least one '1' has been found
                #
                #       [ 0s | 1s | unknown | 2s ]
                #            L    M         R
                #
                #  Here, the swap again does nothing since M swaps with itself,
                #  but we still need to iterate M to get to a new candidate

            else:

                # Swap to candidate and write pointer
                nums[MC], nums[R2] = nums[R2], nums[MC]

                # Iterate write and candidate
                # (we iterate candidate via the swap itself)
                R2 -= 1

                # Case 1: (M and R are at different indexes)
                # - There are still multiple unknown candidates
                #
                #   Right write was pointing to the right most unknown candidate,
                #   so since we swapped it, mid is pointing to a new unknown candidate
                #   so there is no need to iterate midCandidate

                #       [ 0s | 1s | unknown | 2s ]
                #            L    M         R

                #   Don't move midCandidate pointer as we need to check the candidate
                #   we just grabbed from the right side
                #
                # Case 2: (M and R are at the same index)
                # - This is the last unknown candidate
                #
                #       [ 0s | 1s | 2s ]
                #            L    M
                #                 R

                #  Here, the swap does actually switch elements,
                #  M was previously pointing to the last unknown candidate, that we just validated to be 2
                #  R was previously pointing to the last unknown candidate,
                #  now we just need to iterate R to the next swap spot,
                #  and technically, we can't move M because it needs to validate the candidate we just passed it,
                #  but we will trigger the M <= R break statement so this never happens

        # overall: tc O(n)
        # overall: sc O(1)
        return

28. Find the Index of the First Occurrence in a String ::2:: - Easy

Topics: Two Pointers, String, String Matching

Intro

Given two strings needle and haystack, return the index of the first occurrence of needle in haystack, or -1 if needle is not part of haystack.

heightOutput
haystack = "sadbutsad", needle = "sad"0
haystack = "leetcode", needle = "leeto"-1

Constraints:

1 ≤ haystack.length, needle.length ≤ 10^4

haystack and needle consist of only lowercase English characters.

Abstraction

Search for a needle string within a haystack string. Either by brute force n^2, a cache prefix table KMP in n, or Rabin-Karp Hashing in n.

Pseudocode

Solution 2 (Double KMP For Single LPS Failsafe Table From Needle Against Needle For Needle Against Haystack):
1. if not needle:
    a. return 0
2. (nl = len(needle))
3. (hl = len(needle))
4. (lps = [0] * nl)
5. (np = 0)
6. (hp = 1)
7. while hp < hl:
    a. if needle[np] == needle[hp]:
        np += 1
        lps[hp] = np
        hp += 1
    b. else:
        if np != 0:
            np = lps[np-1]
        else:
            lp[hp] = 0
            hp += 1
8. (nl = len(needle))
9. (hl = len(haystack))
10. (np = 0)
11. (hp = 0)
12. while hp < hl:
     a. if haystack[hp] == needle[np]:
             hp += 1
             np += 1
             if np == nl:
                 return hp - np
     b. else
         if np != 0:
             np = lps[np - 1]

         else:
             hp + 1
13. return -1

Test Cases

Solution 1: [Two Pointers] Brute Force Two Pointer Full Reset [SC Opt] - Two Pointers/K Pointer Variants

    def strStr(self, haystack: str, needle: str) -> int:
        
        # Two Pointer Pattern (Substring Search)
        
        # Idea:
        #   - Each index in haystack is a potential starting point
        #   - From that starting index, attempt to match needle character-by-character.
        #   - If all characters match: return that starting index.
        #   - Otherwise shift starting index by one and repeat.
        
        n = len(haystack)
        m = len(needle)

        # Edge Case:
        # If needle is longer than haystack,
        # it is impossible for a match to exist.
        if m > n:
            return -1

        # Left Pointer:
        # Represents candidate starting index in haystack
        left = 0

        # We only need to check positions where
        # a full length needle substring can exist
        # tc: iterate over near n O(n)
        while left <= n - m:

            # Right Pointer:
            # Traverses needle and compares against haystack
            right = 0

            # Compare characters until:
            #   - Needle length not reached
            #   - Failure occurs fail:
            while (right < m) and haystack[left+right] == needle[right]:

                # Iterate char candidate
                right += 1

            # Right reached end of needle length: Complete Match
            if right == m:

                # Return starting index of match
                return left

            # Right aborted before complete match:
            # Complete reset attempt, use next index as starting index
            left += 1

        # overall: tc O(n * m)
        # overall: sc O(1)
        return -1

Solution 2: [Two Pointers] Double KPM For Single LPS Failsafe Table From Needle Against Needle For Needle Against Haystack [TC Opt] - Two Pointers/Algorithm

    def strStr(self, haystack: str, needle: str) -> int:
        
        # --------------------------------------------------------
        # KMP LPS String Matching Algorithm (Knuth Morris Pratt) (Longest Prefix Suffix)
        # visual: https://www.youtube.com/watch?v=ynv7bbcSLKE
        # LPS Table Building Timestamp: (1:28)
        # Haystack Search Timestamp: (3:17)
        
        # -------------------------------------
        # KMP Matching Algorithm x2:
        # 1. match the needle against itself to create a LPS table
        # 2. match the needle against the haystack using the created LPS table


        # -------------------------------------
        # LPS Failsafe to Catch Failures:

        # We track repeating patterns within the needle
        # to avoid complete reset on needle to haystack match failure.
        # Repeating patterns within the needle string allow for 
        # partial restarts upon a failed haystack to needle character matched

        # -------------------------------------
        # Character Match Error Example (Failure At 'c' index 4):

        # LPS:
        #      Value  = [0 0 1 2 0 1 2 3 4]
        #      Needle = [a b a b c a b a b]
        #      Index  =  0 1 2 3 4 5 6 7 8

        # LPS to English:
        
        # 1. When:      There is a match failure at any index
        # 2. Invariant: Everything to the left of that failure has been a match,
        #               so we know 1 index prior to the failure was a match
        # 3. If:        If a character is a part of a subpattern, it is eligible for a partial non-zero needle restart,
        #               and its quickstart partial needle index will be noted in the LPS table as non-zero
        # 4. Then:      Use the partial match from the 1 index prior as a quickstart partial needle index match
        #               to avoid starting the needle pointer from index 0


        # (Match Failure At 'c' index 4 Diagram)

        #          haystack pointer
        #   ------- v
        #   a b a b a ...  haystack
        #   a b a b c      needle
        #   ------- ^ 
        #          needle pointer

        # LPS to English Step by Step (LPS to English):

        # 1. (When) Character match fail
        #   'c' at index 4 fails to match with 'a'
        
        # 2. (Invariant) Everything to the left of that failure has been a match,
        #                so 'b' at index 3, was a success

        # 3. (If) Character is eligible for a partial non-zero needle restart,
        #         as noted by the LPS table, 'b' at index 3 has value 2,
        #         which represents it's corresponding quickstart partial needle index match

        # 4. (Then) Instead of setting our needle pointer to 0 upon failure at index 4,
        #           we can set it to the quickstart partial needle index match
        #           pertaining to the index 1 to the left of the failure.
        #           For index 4 'c' failure, 1 index to the left is index 3 for 'b',
        #           'b' at index 3 has a quickstart index of 2,
        #           so we set the needle pointer to index 2
        

        # (Restarting At 0 Diagram): instead of restarting here

        #   haystack pointer
        #   v
        #   a b a b a ...  haystack
        #   a b a b c      needle
        #   ^ 
        #   needle pointer
        
        # (Jump to failsafe Diagram) we restart here:
        # (visual 1 and 2 are equivalent)

        # Visual 1:
        #           haystack pointer
        #       --- v
        #   a b a b a ...  haystack
        #   a b a b c      needle
        #   --- ^ 
        #       needle pointer

        # Visual 2:
        #          haystack pointer
        #       --- v
        #   a b a b a ...     haystack
        #       a b a b c     needle
        #       --- ^ 
        #          needle pointer

        # TC: Quadratic to Linear
        #   This allows us to get a match without a complete needle reset, 
        #   reducing this from O(n^2) quadratic time to O(n+m) linear time

        # --------------------------------------------------------
        # The Algorithm
        
        # Edge Case: needle is empty, first index is a match
        if not needle:
            return 0

        # ------------------------------------
        # Phase 1: Build LPS (Longest Pattern Table):

        # needle is needle
        nl = len(needle)

        # haystack is also needle
        hl = len(needle)

        # lps based on needle
        # sc: for each index of needle O(m)
        lps = [0] * nl

        # needle pointer
        np = 0

        # haystack pointer
        hp = 1

        # while we have haystack left
        # tc: iterate haystack O(m)
        while hp < hl:
            
            # if match, update failsafe
            if needle[np] == needle[hp]:

                # LPS Building:
                # needle matched against itself,
                # so the quick start index for the CURRENT index
                # is the FOLLOWING index
                np += 1
                lps[hp] = np

                # find quick start for the next needle index
                hp += 1


            # no match, check if failsafe exists
            else:

                # If left index exists, jump to failsafe
                if np != 0:

                    # Use Failsafe:
                    # always grab value from 1 index prior to where we just failed, 
                    # could either be 0 or a non-zero quickstart index
                    np = lps[np-1]

                    # Do not iterate haystack pointer:
                    # retry mechanism allows us to continue needle to haystack
                    # comparison at the quickstart index


                # No left index exists,
                # np is currently at 0 at the beginning of lps table,
                # triggers complete reset on haystack
                else:

                    # There is no failsafe to attempt,
                    # so we set the quickstart index will go to index 0
                    # to indicate full restart
                    lps[hp] = 0

                    # iterate haystack pointer to restart needle comparison
                    hp += 1

                    # do not iterate needle pointer,
                    # its already at 0 per the if statement, 
                    # (np == 0)


        # ------------------------------------
        # Phase 2: KMP Needle Haystack Matching

        # needle is needle
        nl = len(needle)
        
        # haystack is haystack
        hl = len(haystack)
        
        # needle pointer
        np = 0

        # haystack pointer
        hp = 0


        # while we have haystack left
        # tc: iterate haystack O(m)
        while hp < hl:

            # if match, iterate
            if haystack[hp] == needle[np]:
                hp += 1
                np += 1

                # check if needle length reached
                if np == nl:

                    # return starting index of needle match within haystack
                    return hp - np

            # if no match, init failsafe protocol
            else:

                # if left index exists, jump to failsafe
                if np != 0:

                    # Use Failsafe:
                    # always grab value from 1 index prior to where we just failed, 
                    # could either be 0 or a non-zero quickstart index
                    np = lps[np-1]

                    # Do not iterate haystack pointer:
                    # retry mechanism allows us to continue needle to haystack
                    # comparison at the quickstart index

                # hit beginning of lps table,
                # trigger complete reset on haystack
                else:
                    
                    # iterate haystack scanner pointer,
                    # to continue needle match
                    hp += 1

                    # do not iterate needle pointer,
                    # its already at 0 per the if statement, 
                    # (np == 0)

        # No match found

        # overall: tc O(n + m) 
        # overall: sc O(m)
        return -1

Solution 3: [Two Pointers] Rabin Karp Rolling Hash Substring Window [SC Opt] - Two Pointers/Algorithm

    def strStr(self, haystack: str, needle: str) -> int:
        
        # Rabin-Karp (Rolling Hash)
        # visual: https://www.youtube.com/watch?v=yFHV7weZ_as        


        # Idea:
        #   - Compare hash of needle with hash of sliding window in haystack
        #   - If hashes match: then iterate over to verify actual substring to avoid collision issues
        #   - If hashes don't match: instead of recomputing hash for next substring,
        #     remove the left char and add the right char to iterate the hash window in O(1)


        # Rolling Hash Function:
        # H = [c<1> * b^(n-1)] + [c<2> * b^(n-2)] + ... + [c<n-1> * b^(1)] +[c<n> * b^(0)]    mod p

        # Where:
        # c = ascii code of the characters
        # b = base of the code system (26 for lowercase, 256 for ascii, etc)
        # n = total number of characters
        # p = a large prime number


        # Rolling Hash Example:
        # If substring is "abc" and base = 26 for lowercase:
        # a * 26^2 + b * 26^1 + c * 26^0 

 
        n = len(haystack)
        m = len(needle)

        # Edge Case: empty needle always succeeds at first index
        if m == 0:
            return 0

        # If needle longer than haystack: not possible
        if m > n:
            return -1

        # Base For A Polynomial Hash:
        # 256 works for ASCII characters, or 26 for lowercase letters
        base = 256

        # Large prime modulus to reduce collisions
        # Keeps numbers small and stable
        mod = 10**9 + 7

        # Precompute base^(m-1) % mod

        # This represents the weight of the leftmost character
        # Used when removing leading character from window
        # tc: O(m)

        highBasePower = 1
        for _ in range(m - 1):
            highBasePower = (highBasePower * base) % mod

        # Compute initial hash for:
        #   - needle
        #   - first window of haystack
        # tc: O(m)

        needleHash = 0
        windowHash = 0

        # Iterate To Calculate Initial Hash:
        # tc: O(n)
        for i in range(m):
            needleHash = (needleHash * base + ord(needle[i])) % mod
            windowHash = (windowHash * base + ord(haystack[i])) % mod


        # Last Possible Start Point
        lastCandidateIndex = n - m - 1

        # Robin Karp Rolling Hash Iteration:  
        # Slide hash window across haystack to check if a substring hash matches:=
        # tc: O(n - m)
        for i in range(n - m + 1):

            # If hashes match:
            #   - Substring in haystack is a possible match 
            #   - Need to iterate to match to avoid hash collisions
            # tc: O(m)
            if needleHash == windowHash:

                # Check if substring matches needle:
                # if so return start index in the haystack 
                if haystack[i:i + m] == needle:
                    return i


            # Hashes did not match:
            # iterate hash window to check next substring hash
            if i <= lastCandidateIndex:

                # Update Rolling Hash:

                # Current window:
                #   haystack[i : i + m]
                #
                # Its hash represents:
                #
                #   H = s[i]*base^(m-1)
                #     + s[i+1]*base^(m-2)
                #     + ...
                #     + s[i+m-1]*base^0
                #
                # We want the hash for the next window:
                #
                #   haystack[i+1 : i+m+1]
                #
                # Which should equal:
                #
                #   H' = s[i+1]*base^(m-1)
                #      + s[i+2]*base^(m-2)
                #      + ...
                #      + s[i+m]*base^0


                # 1. Remove leftmost character contribution:

                # The outgoing character is: haystack[i]

                # Its contribution to the hash was: ord(haystack[i]) * base^(m-1)
                # We precomputed: highBasePower = base^(m-1) % mod
                losingChar = (ord(haystack[i]) * highBasePower)

                # Subtract it:
                windowHash = windowHash - losingChar

                # Optional safety mod to prevent negative overflow
                windowHash %= mod

                # After removing the leftmost term,
                # the remaining terms still look like:
                #
                #   s[i+1]*base^(m-2)
                #   s[i+2]*base^(m-3)
                #   ...
                #
                # But in the new window,
                # s[i+1] must now have power base^(m-1).
                #
                # So we multiply the entire hash by base.
                # This increases every exponent by 1

                # Multiple it:
                windowHash = windowHash * base

                # Optional safety mod to prevent negative overflow
                windowHash %= mod

                # The incoming character is:
                #   haystack[i + m]
                # After shifting,
                # the lowest power term is now base^0,
                # so we simply add the new character value.
                #
                windowHash = windowHash + ord(haystack[i + m])

                # Optional safety mod to prevent negative overflow
                windowHash %= mod

                # Final result:
                #
                # windowHash now represents:
                #
                #   s[i+1]*base^(m-1)
                # + s[i+2]*base^(m-2)
                # + ...
                # + s[i+m]*base^0

        # overall: tc Best/Average Case: O(n + m), Worst O(n * m)
        # overall: sc O(1)
        return -1

5. Longest Palindromic Substring ::2:: - Medium

Topics: Two Pointers, String, Dynamic Programming

Intro

Given a string s, return the longest palindromic substring in s.

InputOutput
"cbbd""bb"
"babad""bab" or "aba"

Constraints:

1 ≤ s.length ≤ 1000

s consists of only digits and English letters.

Abstraction

Find the longest palindrome in a string. At any index, we can expand outwards, while accounting for even/odd length palindromes, to determine if we have found a longer palindrome. Either by brute force index by index, or by caching a mirror length within the current longest palindrome

Pseudocode

Solution 2 (Manacher's Algorithm Mirror Radius Optimization):
1. (expandedStr = "#".join(f"^{s}$"))
2. (nExp = len(expandedStr))
3. (p = [0] * nExp)
4. (right, center = 0, 0)
5. for i in range(1, nExp-1):
    a. if i <= right:
         mirrorIndex = (2 * center) - i
         inBoundsLen = right - i
         p[i] = min(inBoundsLen, p[mirrorIndex])
    b. while expanded[i - p[i] - 1] == expandedStr[i + p[i] + 1]:
         p[i] += 1
    c. candRightBound = i + p[i]
    d. if right < candRightBound:
         right = candRightBound
         center = i
6. maxRadius = max(p)
7. centerIndex = p.index(maxRadius)
8. startIndex = (centerIndex - maxRadius) // 2
9. res = s[startIndex:startIndex + maxRadius]
10. return res

Solution 1: Odd Even Center Expansion Iteration [SC Opt] - Two Pointers/Algorithm

    def longestPalindrome(self, s: str) -> str:

        # Expand Around Center:
        
        # Helper:
        # Instead of opposite end pointers inwards, we expand from the middle.
        # We keep left == right or left  right+1
        # to account for both odd and even cases
        #
        #   odd:  "aba"  => palindrome
        #        L = 1, R = 1
        #
        #   even:   "abba" => palindrome
        #          L= 1, R = 2
        #
        def expandAroundCenter(left, right):
            
            # Continue expanding while within string boundaries
            # and while string is a palindrome
            while 0 <= left and right < n and s[left] == s[right]:
                left -= 1
                right += 1

            # Implies Either:
            #   - reached end of string
            #   - substring is no longer a palindrome
            # Implies:
            #   - previous iteration was a valid palindrome
            # Then:
            #   - shrink left and right to revert to previous palindrome

            # Exclude left, Exclude right
            previousValidString = s[left+1: right]

            # return widest valid palindrome
            return previousValidString
         

        n = len(s)

        # Current Max Palindrome
        maxPalindrome = ""

        # Take each index as chance to find a new longest palindrome substring,
        # expand outwards from each index and compare longest palindrome at that index,
        # with the current longest substring
        # tc: O(n)
        for i in range(n):

            # Odd Even Check:
            # for each candidate index i,
            # cover both the odd and even case, and just take longer palindrome substring

            # Odd expansion:
            # expand center from i
            oddPalindrome = expandAroundCenter(i, i)  

            # Even expansion:
            # expand center from i and i+1
            evenPalindrome = expandAroundCenter(i, i+1)

            # Take longer palindrome substring between:
            #   - odd palindrome
            #   - even palindrome
            #   - currMax palindrome
            if len(maxPalindrome) < len(oddPalindrome):
                maxPalindrome = oddPalindrome
            if len(maxPalindrome) < len(evenPalindrome):
                maxPalindrome = evenPalindrome

        # overall: tc O(n^2)
        # overall: sc O(1)
        return maxPalindrome

Solution 2: Manacher's Algorithm Mirror Radius Optimization Expanding [TC Opt] - Two Pointers/Algorithm

    def longestPalindrome(self, s: str) -> str:

        # ----------------------------------------------------------------------
        # Manacher's Algorithm

        # We use precomputed palindromes we saved during our iteration,
        # and we hydrate matching palindromes, 
        # since they are within the longest rightmost palindrome
        

        # -----------------------------------
        # Hydrating Example:

        # racecar deed level deed racecar
        # 0123456 789 ...

        # 'racecar' and 'deed' end up being sub palindromes, within the 'level' palindrome,
        # (the 'level' palindrome is composed of 'racecar deed level deed racecar')
        # and we can take advantage of this:

        # As we iterate left to right, we hit the 'racecar' and 'deed' palindromes first,
        # and keep track of their length by marking their center at index i in p[i]

        # When we calculate the center "level" palindrome, that will extend across all 5 words
        # acting as the right most reaching palindrome

        # When we hit 'deed' for the second time, we could manually calculate the palindrome 
        # by extending like how we did for the first 'deed', 
        # however, we can instead take advantage of the 'level' palindrome, 
        # and use the 'deed' palindrome we already calculated

        # Since 'deed' is within the bounds of the 'level' palindrome,
        # that guarantees that a matching palindrome is on the other side of center: 

        #  matching pair   curr here
        #          v           v        
        # racecar deed level deed racecar

        # This allows the palindrome length for the first 'deed' to be guaranteed for the second 'deed'


        # -----------------------------------
        # Preprocessing Original String (#, ^, and $):

        # Transform string so that all palindromes become odd length
        # by adding sentinel characters to allow for uniform odd length expansion palindrome validation
        # as well as uniform transformation from modified back to the original string

        # Preprocessing:
        # '^' and '$': Outer sentinel characters are not valid input characters, serve as true start and end markers
        # '#': forces all palindromes to be treated as odd, regardless if original is even or odd
        # '#': always occur on odd indexes
        # '#': palindromes always end on '#', thus always end on odd indexes
        # '#': allows original chars to pair with the '#' to the left of them for translation
        #
        #  Originally Odd Palindrome:     Originally Even Palindrome:
        #
        #       ^ # a # b # a # $           ^ # a # b # b # a # $
        #               ^                             ^
        #       0 1 2 3 4 5 6 7 8           0 1 2 3 4 5 6 7 8 9 10
        #
        #             ^ = longest palindrome start point,
        #                 both are treated as odd, in the sense that there is a single
        #                 middle point, instead of 2 middle points:
        #                 'aba'  : 1 point
        #                 'abba' : 2 points
        #
        #                now both 'aba' and 'abba' start at single point ^ above

        # Preprocessing Allows Odd Length Expansion: 
        # For any candidate index i, the palindrome at i, has a center char of either: 
        #   - character from the original string    (originally odd palindrome above)
        #   - placeholder '#'                       (originally even palindrome above)


        # -----------------------------------
        # Mirror Cache Validation:

        # Track the right boundary for the current rightmost palindrome substring:
        # If index i is within the bounds of the rightmost palindrome substring, 
        # we can apply the mirror trick, here 'deed' is valid for the mirror trick:

        #             rightmost palindrome substring
        #            |                             |
        # slfkmvsdlk racecar deed level deed racecar  awleerflkm
        # 0123456 78 ...                           ^
        #                                         right

        
        # -----------------------------------
        # Finding Start Of Final Longest Palindrome
        #  
        #   We need to find the starting index of the final longest palindrome
        #   so we can slice it and return the string
        #   We have access to:
        #       - palindrome radius
        #       - palindrome center index
        # 
        #   With these we can get the left starting point by:
        #       left index = center - radius
        #
        #   Now we just need to find the original index of the shifted left index  
        

        # -----------------------------------
        # Mapping Shifted Indexes To Original Indexes:
        #
        #   - Original characters occur on even indexes
        #   - '#' occurs on odd indexes
        #   - '^' and '$' occurs on outer indexes

        # We also know that every palindrome ends on a odd index.
        # Because of this we can actually pair a '#' with the character to its right,
        # to find its final location:

        #
        # [ ^ # a # b # a # $ ] -> [ a b a ]    
        #   0 1 2 3 4 5 6 7 8        0 1 2      
        #

        # Pairs:                    (# a)    (# b)    (# a)
        # Shifted Indexes:           1 2      3 4      5 6

        # Original Index
        # for character:              0        1        2

        # So if the take the '#' of the pair
        # and divide its index/2,
        # we get the original index for the char of the pair:


        # Pairs:                    (# a)    (# b)    (# a)
        # Shifted Indexes:           1 2      3 4      5 6

        # Original Index             1//2 =   3//2 =   5//2 = 
        # for character:              0        1        2                


        # ----------------------------------------------------------------------
        # The algorithm:

        # Preprocess Original String:
        expandedStr = "#".join(f"^{s}$")

        # Preprocessed String Length:
        # tc: O(1)
        nExp = len(expandedStr)


        # Mirror Cache:
        # p[i]: radius of palindrome centered at index i
        p = [0] * nExp

        # Mirror Right Most:
        # right index boundary of the current right most palindrome
        right = 0

        # Mirror Center Of Right Most: 
        # center index of current right most palindrome
        center = 0 

        # Avoid outer sentinels '^' and '$' to ensure palindrome check only on actual string
        # Check i as candidate for center of a palindrome
        # tc: O(n)
        for i in range(1, nExp-1):

            # Mirror Radius Validation:
            # if i lies within bounds of the right most palindrome,
            # the right most palindrome symmetry guarantees that 
            # the palindrome radius for the mirror of i on the left side of center
            # if it exists, is applicable to i as well,
            # again, while within the bounds of the right most palindrome
            if i <= right:

                # Mirror Cache Check:
                # i is current index being processed
                # i is to the right o f center and has a mirror to the left of center:
                #
                # ex: center = 6, i = 9                          a b c d e f g h i j k
                #      => mirror =  (2 * center) - curr_i        0 1 2 3 4 5 6 7 8 9 10
                #                   (2 * 6) - 9                        ^     ^     ^
                #                =  3                                  l     c     r
                mirrorIndex = (2 * center) - i 

                # Mirror radius is either:
                # - less than the distance between i and right bound,
                #   in which case all of the radius is valid
                # - exceeds bounds and is farther than right bound,
                #   in which case only the radius up until the right bound is valid
                
                # Aka: Cheat distance is bounded by min between:
                # - distance from i to the right bound of the mirror bounds
                #   as anything past that is not guaranteed by the mirror rule
                #
                # - Even if the mirror index radius is larger than this,
                #   the cheat distance cannot surpass it, as anything past 
                #   is not guaranteed by the mirror rule
                # 
                # - So we just grab the min between the mirror distance and the bounds distance
                inBoundsLen = right - i

                p[i] = min(inBoundsLen, p[mirrorIndex])

            # Invariant:
            # p[i] is set to the right most value we can start with, either:
            #   - a mirrored palindrome length across 'center', bounded by the mirror bounds
            #   - 0

            # IsPalindrome Odd Expansion LookAhead():
            # due to the '#' preprocessing, all strings are treated as odd,
            # thus, we can simply expand from center +1 and -1
            while expandedStr[i - p[i] - 1] == expandedStr[i + p[i] + 1]:
               
                # Increase radius for curr i center candidate
                p[i] += 1

            # Rightmost Palindrome Update:
            #   - p[i]:  radius for palindrome at i
            #   - i:     center for palindrome at i
            #   - right: index boundary for curr longest rightmost palindrome
            # Check: if this new palindrome goes farther to the right than curr right most palindrome

            candRightBound = i + p[i]

            if right < candRightBound:
                
                # Update Rightmost Palindrome
                right = candRightBound
                center = i

        # Invariant:
        #   - Reached outer sentinels  ^ and $
        #   - p[i] stores radius of palindrome for each index i
        # Then:
        #   - grab the longest palindrome and its center position

        # Max Calculation:
        # find the largest palindrome radius in the list
        maxRadius = max(p)
        # find which index (center) it belongs to
        centerIndex = p.index(maxRadius)

        # Mapping Preprocessed Indexes To Original Indexes:
        # Any palindrome will always end with a '#',
        # we can pair the '#' with a character,
        # to get the original index for that character:

        # Pairs:                 ^   (# a)    (# b)    (# a)   #  $
        # Shifted Indexes:       0    1 2      3 4      5 6    7  8

        # Original Index             1//2 =   3//2 =   5//2 = 
        # for character:              0        1        2      

        #                        a b a
        #                        0 1 2     

        # written out:
        # leftMostHashTagShifterIndex = (centerIndex - maxRadius)
        # leftMostCharOrigIndex = leftMostHashTagShifterIndex // 2
        
        startIndex = (centerIndex - maxRadius) // 2
        
        # MaxRadius Doubled:
        # due to our preprocessing, maxRadius is actually double what it should be,
        # so we can simply splice it to get the entire string

        # "#a#b#a#"  ->   "aba"     but with slicing [0,3]  -> 0,1,2
        #     4             2

        # "#a#b#b#a#" ->    "abba"  but with slicing [0, 5] -> 0,1,2,3
        #     5       ->      2

        # splice longest substring
        res = s[startIndex:startIndex + maxRadius]

        # overall: tc O(n)
        # overall: sc O(n)
        return res