๐ŸŽ“ A1Learner
๐Ÿ”

DSA Restoring IP Addresses

Restoring IP addresses rebuilds every valid address hidden in one string of digits. An address has four segments joined by dots, and a segment is one number from 0 to 255.

The problem

Restoring IP addresses rebuilds every valid address hidden in one string of digits. An address has four segments joined by dots. A segment is one number from 0 to 255.

The answer in this article is the list of valid addresses in the order the search finds them. A segment with more than one digit may not start with zero. A string longer than 12 digits holds no valid address.

The cases used in this article

for s in ['25525511135', '101023', '0000', '1111']:
    print('input:', s, 'length:', len(s))

Each case is a plain digit string with no dots. The dots are exactly what the search must place. Three cut positions split the string into four segments.

Intuition: cut anywhere and check at the end

The first idea places three cuts in every possible way. It builds each full split first. Only then does it check the four segments.

The waste is the trap. A first segment of 256 already dooms the whole split. The idea still builds and checks every completion of that split before learning so.

A doomed first segment and its completions

s = '256111'
count = 0
total = 0
for a in range(1, len(s)):
    for b in range(a + 1, len(s)):
        for c in range(b + 1, len(s)):
            total += 1
            parts = [s[:a], s[a:b], s[b:c], s[c:]]
            if parts[0] == '256':
                count += 1
print('splits built in total:', total)
print('splits starting with segment 256:', count)
print('segment 256 valid:', 0 <= 256 <= 255)

Every split that starts with 256 is invalid before its other segments are read. Checking a segment as soon as it is cut removes those splits at the first step. That check is the core of the optimal search.

Brute force: every split, checked whole

The brute force enumerates every choice of three cut positions. It validates the complete split only. A counter records how many splits it built.

Enumerating every split

def brute(s):
    results = []
    built = [0]
    n = len(s)
    for a in range(1, n):
        for b in range(a + 1, n):
            for c in range(b + 1, n):
                built[0] += 1
                parts = [s[:a], s[a:b], s[b:c], s[c:]]
                ok = True
                for part in parts:
                    if len(part) > 3 or (len(part) > 1 and part[0] == '0') or int(part) > 255:
                        ok = False
                if ok:
                    results.append('.'.join(parts))
    return results, built[0]
for s in ['1111', '101023']:
    results, built = brute(s)
    print('input:', s, 'splits built:', built)
    for address in results:
        print(address)

The string 1111 builds 1 split and keeps 1 address. The string 101023 builds 10 splits and keeps 5 addresses. Longer strings build far more splits, most of them with an invalid early segment, and the brute force pays that cost on every input.

The optimal solution: backtrack segment by segment

Backtracking chooses the length of the next segment, from one to three digits, and a segment is checked the moment it is cut, so an invalid segment ends its branch at once. That one sentence is the whole method.

Four placed segments with no digits left form one complete address. Trying lengths in the order 1, 2, and 3 fixes the output order. Every choice is undone after its branch.

The backtracking search on every case

def restore(s):
    results = []
    calls = [0]
    def valid_segment(text):
        if len(text) > 1 and text[0] == '0':
            return False
        return 0 <= int(text) <= 255
    def search(start, parts):
        calls[0] += 1
        if len(parts) == 4:
            if start == len(s):
                results.append('.'.join(parts))
            return
        for length in (1, 2, 3):
            end = start + length
            if end > len(s):
                break
            piece = s[start:end]
            if valid_segment(piece):
                parts.append(piece)
                search(end, parts)
                parts.pop()
    search(0, [])
    return results, calls[0]
for s in ['25525511135', '101023', '0000', '1111']:
    results, calls = restore(s)
    print('input:', s, 'count:', len(results), 'calls:', calls)
    for address in results:
        print(address)

Every case from the start of the article is answered. The string 0000 gives one address, because only single zeros are valid segments. The string 1111 also gives one address, with each segment holding one digit.

Walkthrough: restoring 101023

The walk follows the first address found. The state is the list of placed segments and the position of the next digit. Each step cuts one valid segment.

Cutting the first address step by step

s = '101023'
parts = []
start = 0
for piece in ['1', '0', '10', '23']:
    parts.append(piece)
    start += len(piece)
    print('placed:', list(parts), 'next digit index:', start)
print('address:', '.'.join(parts))

Four segments are placed and the next index is 6, the end of the string. That pair of facts makes the address complete. The search then undoes the last cut and tries the next length at that position.

Complexity

Each segment has at most three length choices, and an address has four segments, so the search makes at most 81 leaf choices and its time is a small constant for this problem. Its space is the four placed segments plus the recursion depth of four.

Counting calls at three lengths

def calls_for(s):
    calls = [0]
    def valid(t):
        return not (len(t) > 1 and t[0] == '0') and int(t) <= 255
    def search(start, depth):
        calls[0] += 1
        if depth == 4:
            return
        for length in (1, 2, 3):
            end = start + length
            if end > len(s):
                break
            if valid(s[start:end]):
                search(end, depth + 1)
    search(0, 0)
    return calls[0]
for s in ['1111', '101023', '25525511135']:
    print('length', len(s), 'calls:', calls_for(s))

The call counts stay small at every length in this range. The segment check does the pruning. A string of thirteen digits is rejected by length alone, before any search.

Edge cases and pitfalls

A leading zero is the classic trap. The segment 01 looks numeric and is still invalid. Only the single digit 0 may start with zero, because it is zero itself.

A segment above 255 is the other trap. The value 256 fits in three digits and still fails. Both rules must be checked, and a length check alone catches neither.

Edge cases, run

def restore(s):
    if len(s) > 12 or len(s) < 4:
        return []
    results = []
    def valid(t):
        return not (len(t) > 1 and t[0] == '0') and int(t) <= 255
    def search(start, parts):
        if len(parts) == 4:
            if start == len(s):
                results.append('.'.join(parts))
            return
        for length in (1, 2, 3):
            end = start + length
            if end > len(s):
                break
            if valid(s[start:end]):
                parts.append(s[start:end])
                search(end, parts)
                parts.pop()
    search(0, [])
    return results
print('0000:', restore('0000'))
print('010:', restore('010'))
print('empty:', restore(''))
print('too long:', restore('1111111111111'))
print('segment check 01:', (len('01') > 1 and '01'[0] == '0'))
print('segment check 256:', 256 <= 255)

The string 010 gives no address, because it holds only three digits for four segments. The empty string and the over-long string also give no address. Each answer follows from the rules, with no special handling.

Exercise

Which string is a valid segment?