๐ŸŽ“ A1Learner
๐Ÿ”

DSA 3Sum

3Sum finds every triplet of values at three different positions that adds to zero. Sorting the list first lets two pointers hunt each remaining target.

The problem

3Sum finds every triplet of values at three different positions that adds to zero. Each distinct triplet is reported once. A list with no working triplet answers with an empty list.

Triplets are printed in sorted order inside, and the list of triplets follows the order of the sorted input. That fixed order keeps the printed answer the same on every run.

The cases used in this article

cases = [
    ([-1, 0, 1, 2, -1, -4], [[-1, -1, 2], [-1, 0, 1]]),
    ([-2, 0, 0, 2, 2], [[-2, 0, 2]]),
    ([0, 0, 0], [[0, 0, 0]]),
    ([1, 2, 3], []),
    ([], []),
]
for nums, expected in cases:
    print("nums", nums, "->", expected)

The first case holds two triplets. The second case holds one triplet built from repeated values. The last two cases hold none.

Intuition: try every triple, and drown in repeats

A first idea tests every group of three positions. Some group either sums to zero or does not. The idea always finds the true triplets.

Two traps wait inside it. The count of triples grows fast as the list grows. Repeated values also produce the same triplet again and again, once per matching group of positions.

Triples waiting in a short list, and one triplet found four times

nums = sorted([-2, 0, 0, 2, 2])
found = []
triples = 0
for i in range(len(nums)):
    for j in range(i + 1, len(nums)):
        for k in range(j + 1, len(nums)):
            triples += 1
            if nums[i] + nums[j] + nums[k] == 0:
                found.append([nums[i], nums[j], nums[k]])
print("triples tested:", triples)
print("zero-sum finds:", found)

Five values hold ten triples. The single triplet of values appears four times in the finds, because either 0 and either 2 can take part. The values repeat, and the raw search cannot tell the copies apart.

Brute force: every triple, with duplicates removed

The brute force sorts the list first. Three nested loops then test every group of three positions in order. A set of tuples keeps each distinct triplet once.

The brute force, with triples counted

def three_sum_brute(nums):
    nums = sorted(nums)
    seen = set()
    tested = 0
    for i in range(len(nums)):
        for j in range(i + 1, len(nums)):
            for k in range(j + 1, len(nums)):
                tested += 1
                if nums[i] + nums[j] + nums[k] == 0:
                    seen.add((nums[i], nums[j], nums[k]))
    return [list(t) for t in sorted(seen)], tested

result, tested = three_sum_brute([-1, 0, 1, 2, -1, -4])
print("triplets:", result)
print("triples tested:", tested)
result, tested = three_sum_brute([-2, 0, 0, 2, 2])
print("triplets:", result)
print("triples tested:", tested)

The set repairs the repeat problem. The cost problem stays. A list of twenty values holds over a thousand triples, and every one is tested even after the answers are already found.

The optimal solution: fix one value, hunt the pair with two pointers

Fixing one value turns the rest into a two-sum hunt on the sorted remainder. Two pointers start at the ends of the remainder and close in. A sum below zero moves the left pointer up, and a sum above zero moves the right pointer down.

Sorting groups equal values together, so duplicates sit side by side. A fixed value equal to the previous fixed value is skipped whole. Its triplets were already found by the earlier copy.

The pointer solution on every case

def three_sum(nums):
    nums = sorted(nums)
    triplets = []
    skips = 0
    for i in range(len(nums) - 2):
        if i > 0 and nums[i] == nums[i - 1]:
            skips += 1
            continue
        left, right = i + 1, len(nums) - 1
        while left < right:
            total = nums[i] + nums[left] + nums[right]
            if total == 0:
                triplets.append([nums[i], nums[left], nums[right]])
                left += 1
                right -= 1
                while left < right and nums[left] == nums[left - 1]:
                    skips += 1
                    left += 1
                while left < right and nums[right] == nums[right + 1]:
                    skips += 1
                    right -= 1
            elif total < 0:
                left += 1
            else:
                right -= 1
    return triplets, skips

cases = [
    ([-1, 0, 1, 2, -1, -4], [[-1, -1, 2], [-1, 0, 1]]),
    ([-2, 0, 0, 2, 2], [[-2, 0, 2]]),
    ([0, 0, 0], [[0, 0, 0]]),
    ([1, 2, 3], []),
    ([], []),
]
for nums, expected in cases:
    result, skips = three_sum(nums)
    print("nums", nums, "->", result, "match:", result == expected, "skips:", skips)

All five cases pass. The skip counts show the duplicate rules firing. No triplet appears twice, because every repeat is passed over by rule instead of filtered afterwards.

Walkthrough: the hunt with the fixed value at -1

The walk sorts the main case first. The fixed value is the second value in the sorted list. The pointers then close in on zero.

Pointer steps for one fixed value

nums = sorted([-1, 0, 1, 2, -1, -4])
print("sorted:", nums)
i = 1
left, right = i + 1, len(nums) - 1
while left < right:
    total = nums[i] + nums[left] + nums[right]
    print("fixed", nums[i], "+ left", nums[left], "+ right", nums[right], "=", total)
    if total == 0:
        print("triplet found:", [nums[i], nums[left], nums[right]])
        break
    if total < 0:
        left += 1
    else:
        right -= 1

The very first sum already lands on zero. The triplet is -1, -1, and 2, built from the fixed value and the second copy of -1 beside it. One fixed value needs only this single inward walk, and here the walk ends at its first step.

Complexity

The brute force tests every triple of positions, so its time is O(n^3) for n values. The pointer solution runs one linear hunt per fixed value, so its time is O(n^2) after an O(n log n) sort.

The pointer solution stores only the answer triplets and a few indexes, so its extra space is O(1) beyond the sorted copy and the answer. The brute force also stores its set of found triplets.

Counting the work instead of timing it

for n in [5, 10, 20]:
    triples = n * (n - 1) * (n - 2) // 6
    hunts = n * n
    print("values", n, "- brute triples:", triples, " pointer steps up to:", hunts)

At twenty values the brute force tests 1140 triples. The pointer hunts reach at most 400 steps. Sorting pays a small price once and buys the whole gap.

Edge cases and pitfalls

Fewer than three values can hold no triplet. Four zeros hold exactly one distinct triplet, not four. Skipping repeats after a find is what keeps that answer at one.

Edge cases, run

def three_sum(nums):
    nums = sorted(nums)
    triplets = []
    skips = 0
    for i in range(len(nums) - 2):
        if i > 0 and nums[i] == nums[i - 1]:
            skips += 1
            continue
        left, right = i + 1, len(nums) - 1
        while left < right:
            total = nums[i] + nums[left] + nums[right]
            if total == 0:
                triplets.append([nums[i], nums[left], nums[right]])
                left += 1
                right -= 1
                while left < right and nums[left] == nums[left - 1]:
                    skips += 1
                    left += 1
                while left < right and nums[right] == nums[right + 1]:
                    skips += 1
                    right -= 1
            elif total < 0:
                left += 1
            else:
                right -= 1
    return triplets, skips

print("empty:", three_sum([]))
print("two values:", three_sum([0, 0]))
print("four zeros:", three_sum([0, 0, 0, 0]))
print("no triplet:", three_sum([1, 2, 3]))
print("all negative:", three_sum([-5, -2, -1]))
print("pair uses one position twice check:", three_sum([0, 1, -1]))

The last case sums to zero with three distinct positions. A search that reused one position could also fake answers from a single value and its double. Three separate indexes rule that error out by construction.

Forgetting the skip after a find is the classic duplicate bug. The pointers move past equal values on both sides before the hunt resumes. Each side needs its own skip loop.

Exercise

The fixed value equals the previous fixed value. What does the algorithm do?