Prerequisites

Before attempting this problem, you should be comfortable with:

  • String Parsing - Extracting characters and numeric values from a formatted string
  • Iterator Pattern - Understanding how iterators maintain state between successive calls
  • Arrays/Lists - Using dynamic arrays to store precomputed results

1. Uncompressing the String (Time Limit Exceeded)

Intuition

The most straightforward approach is to fully decompress the string during initialization. We parse each character-count pair and append the character that many times to a result array. Then iteration simply walks through this precomputed array. This is easy to implement but can use excessive memory and time when counts are very large.

Algorithm

  1. Parse the compressed string, extracting each character followed by its numeric count.
  2. For each pair, append the character to a result array the specified number of times.
  3. Maintain a pointer ptr starting at 0.
  4. For next(): If hasNext() is false, return a space. Otherwise, return the character at ptr and increment ptr.
  5. For hasNext(): Return true if ptr is less than the length of the result array.
class StringIterator:
    def __init__(self, compressedString: str):
        self.res = []
        self.ptr = 0
        
        i = 0
        while i < len(compressedString):
            ch = compressedString[i]
            i += 1
            num = 0
            while i < len(compressedString) and compressedString[i].isdigit():
                num = num * 10 + int(compressedString[i])
                i += 1
            
            for j in range(num):
                self.res.append(ch)

    def next(self) -> str:
        if not self.hasNext():
            return ' '
        
        char = self.res[self.ptr]
        self.ptr += 1
        return char

    def hasNext(self) -> bool:
        return self.ptr != len(self.res)

Time & Space Complexity

  • We precompute the elements of the uncompressed string. Thus, the space required in this case is O(m)O(m), where mm refers to the length of the uncompressed string.

  • The time required for precomputation is O(m)O(m) since we need to generate the uncompressed string of length mm.

  • Once the precomputation has been done, the time required for performing next() and hasNext() is O(1)O(1) for both.

  • This approach can be easily extended to include previous(), last() and find() operations. All these operations require the use of an index only and thus, take O(1)O(1) time. Operations like hasPrevious() can also be easily included.

  • Since once the precomputation has been done, next() requires O(1)O(1) time, this approach is useful if next() operation needs to be performed a large number of times. However, if hasNext() is performed most of the times, this approach isn't much advantageous since precomputation needs to be done anyhow.

  • A potential problem with this approach could arise if the length of the uncompressed string is very large. In such a case, the size of the complete uncompressed string could become so large that it can't fit in the memory limits, leading to memory overflow.

Where mm is the length of the uncompressed string.


2. Pre-Computation

Intuition

Instead of fully decompressing, we can store the compressed representation more efficiently by separating characters and their counts into parallel arrays. During iteration, we track which character group we're in and how many of that character remain. When a count reaches zero, we move to the next group. This uses space proportional to the compressed string rather than the decompressed length.

Algorithm

  1. Parse the compressed string using regex or manual parsing to extract two arrays: chars (the letters) and nums (the counts).
  2. Maintain a pointer ptr to the current character group.
  3. For next(): If hasNext() is false, return a space. Otherwise, decrement nums[ptr], get the character at chars[ptr], and if nums[ptr] becomes 0, increment ptr. Return the character.
  4. For hasNext(): Return true if ptr is less than the length of chars.
class StringIterator:
    def __init__(self, compressedString: str):
        self.ptr = 0
        import re
        self.nums = list(map(int, re.findall(r'\d+', compressedString)))
        self.chars = re.findall(r'[a-zA-Z]', compressedString)

    def next(self) -> str:
        if not self.hasNext():
            return ' '
        
        self.nums[self.ptr] -= 1
        res = self.chars[self.ptr]
        
        if self.nums[self.ptr] == 0:
            self.ptr += 1
        
        return res

    def hasNext(self) -> bool:
        return self.ptr != len(self.chars)

Time & Space Complexity

  • The space required for storing the results of the precomputation is O(n)O(n), where nn refers to the length of the compressed string. The numsnums and charschars array contain a total of nn elements.

  • The precomputation step requires O(n)O(n) time. Thus, if hasNext() operation is performed most of the times, this precomputation turns out to be non-advantageous.

  • Once the precomputation has been done, hasNext() and next() require O(1)O(1) time.

  • This approach can be extended to include the previous() and hasPrevious() operations, but that would require making some simple modifications to the current implementation.

Where nn is the length of the compressed string.


3. Demand-Computation

Intuition

The most space-efficient approach avoids any preprocessing. We store the original compressed string and parse it lazily as we iterate. We keep track of the current character and how many times it should still be returned. When that count reaches zero, we parse the next character-count pair from the string on demand.

Algorithm

  1. Store the compressed string. Initialize ptr = 0, num = 0, and ch = ' '.
  2. For next(): If hasNext() is false, return a space. If num is 0, parse the next character (store in ch) and its following digits (store in num). Decrement num and return ch.
  3. For hasNext(): Return true if ptr is less than the string length OR num is greater than 0.
class StringIterator:
    
    def __init__(self, compressedString: str):
        self.res = compressedString
        self.ptr = 0
        self.num = 0
        self.ch = ' '
    
    def next(self) -> str:
        if not self.hasNext():
            return ' '
        
        if self.num == 0:
            self.ch = self.res[self.ptr]
            self.ptr += 1
            while self.ptr < len(self.res) and self.res[self.ptr].isdigit():
                self.num = self.num * 10 + int(self.res[self.ptr])
                self.ptr += 1

        self.num -= 1
        return self.ch
    
    def hasNext(self) -> bool:
        return self.ptr != len(self.res) or self.num != 0

Time & Space Complexity

  • Since no precomputation is done, constant space is required in this case.

  • The time required to perform next() operation is O(1)O(1).

  • The time required for hasNext() operation is O(1)O(1).

  • Since no precomputations are done, and hasNext() requires only O(1)O(1) time, this solution is advantageous if hasNext() operation is performed most of the times.

  • This approach can be extended to include previous() and hasPrevious() operations, but this will require the use of some additional variables.


Common Pitfalls

Not Handling Multi-Digit Counts

Counts can be more than one digit (e.g., "a12" means 12 a's). A common mistake is reading only a single digit.

# Wrong - only reads single digit
def next(self) -> str:
    if self.num == 0:
        self.ch = self.res[self.ptr]
        self.ptr += 1
        self.num = int(self.res[self.ptr])  # Fails for "a12"
        self.ptr += 1
    self.num -= 1
    return self.ch

# Correct - parse all consecutive digits
def next(self) -> str:
    if self.num == 0:
        self.ch = self.res[self.ptr]
        self.ptr += 1
        while self.ptr < len(self.res) and self.res[self.ptr].isdigit():
            self.num = self.num * 10 + int(self.res[self.ptr])
            self.ptr += 1
    self.num -= 1
    return self.ch

Incorrect hasNext() Logic with Demand Computation

When using lazy parsing, hasNext() must check both whether unparsed string remains AND whether current character still has remaining count.

# Wrong - only checks string position
def hasNext(self) -> bool:
    return self.ptr != len(self.res)  # Misses remaining count!

# Correct - check both conditions
def hasNext(self) -> bool:
    return self.ptr != len(self.res) or self.num != 0