Multiples stream

0
Remix

Generate the infinite stream of multiples (or Hamming-like numbers) lazily, yielding values in order on demand. Reported on 1point3acres' Sierra bank; reconstructed in our own words.

SierraCodingMedium

What L5 looks like

  • O(log k) per value with a heap and a clear statement of memory use.
  • Solves the Hamming variant with one pointer per prime and explains why advancing all matching pointers removes duplicates.
  • Validates input and keeps memory bounded on long runs.
Interviewer expectations for every level ↓

Clarify the setup

Two related tasks hide under this title. Ask which one, then offer the other as a follow-up.

  1. Multiples of a set. Given positive integers bases = [3, 5], yield every positive number divisible by at least one base, in increasing order, without duplicates: 3, 5, 6, 9, 10, 12, 15, 18, …
  2. Numbers built from a set of primes (Hamming numbers for [2, 3, 5]): every number whose prime factors all come from the set: 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, …

Both streams are infinite, so the answer is a generator that does a small amount of work per value. Also ask whether "the n-th value" queries come without iterating (part 2 below).

Approach

Multiples: a heap of next multiples. Each base b contributes the arithmetic sequence b, 2b, 3b, …. Merging k sorted sequences is a heap problem: keep (next_multiple, base) for each base, pop the smallest, push next_multiple + base. Skip a popped value equal to the last yielded one (15 comes from both 3 and 5). Each output costs O(log k), and memory is O(k).

Hamming numbers: one pointer per prime. Every Hamming number other than 1 is a smaller Hamming number times one of the primes. Keep the output list h and, for each prime p, an index i_p into h. The next value is min(p × h[i_p]); advance every pointer that produced it (advancing all of them is what removes duplicates like 6 = 2 × 3 = 3 × 2). O(k) per value; it does keep the generated values in memory, but only from the smallest pointer onward is needed, so trim the list's front.

The n-th multiple directly (part 2). Count the multiples up to x with inclusion and exclusion over subsets of the bases: count(x) = Σ over non-empty subsets S of (−1)^(|S|+1) · floor(x / lcm(S)). count is non-decreasing in x, so binary-search the smallest x with count(x) >= n. That is O(2^k · log(n · min(bases))): instant for a handful of bases, even for n = 10^18.

Code

import heapq
from math import gcd
from itertools import combinations
from typing import Dict, Iterator, List, Sequence


def multiples(bases: Sequence[int]) -> Iterator[int]:
    """Every positive number divisible by some base, increasing, no duplicates."""
    if not bases or any(b <= 0 for b in bases):
        raise ValueError("bases must be positive")
    heap = [(b, b) for b in sorted(set(bases))]
    heapq.heapify(heap)
    last = 0
    while True:
        value, base = heapq.heappop(heap)
        heapq.heappush(heap, (value + base, base))
        if value != last:
            last = value
            yield value


def smooth_numbers(primes: Sequence[int]) -> Iterator[int]:
    """Numbers whose prime factors all lie in `primes`, increasing (1 first)."""
    primes = sorted(set(primes))
    h: List[int] = [1]
    offset = 0                    # h[0] is the value with global index `offset`
    idx: Dict[int, int] = {p: 0 for p in primes}
    yield 1
    while True:
        nxt = min(p * h[idx[p] - offset] for p in primes)
        for p in primes:
            if p * h[idx[p] - offset] == nxt:
                idx[p] += 1
        h.append(nxt)
        yield nxt
        lowest = min(idx.values())
        if lowest - offset > 1024:        # drop values no pointer can reach again
            del h[: lowest - offset]
            offset = lowest


def _lcm(a: int, b: int) -> int:
    return a // gcd(a, b) * b


def count_multiples(x: int, bases: Sequence[int]) -> int:
    """How many numbers in [1, x] are divisible by at least one base."""
    bases = sorted(set(bases))
    total = 0
    for r in range(1, len(bases) + 1):
        for subset in combinations(bases, r):
            l = 1
            for b in subset:
                l = _lcm(l, b)
                if l > x:
                    break
            if l <= x:
                total += (x // l) * (1 if r % 2 else -1)
    return total


def nth_multiple(n: int, bases: Sequence[int]) -> int:
    lo, hi = 1, n * min(bases)    # the n-th multiple of the smallest base is an upper bound
    while lo < hi:
        mid = (lo + hi) // 2
        if count_multiples(mid, bases) >= n:
            hi = mid
        else:
            lo = mid + 1
    return lo

Complexity

Task Time per value Memory
multiples (k bases) O(log k) O(k)
smooth_numbers (k primes) O(k) O(values between the slowest and fastest pointer)
nth_multiple O(2^k · log(n · min base)) total O(k)

For [2, 3, 5] the gap between pointers grows slowly: generating the millionth Hamming number keeps only a few thousand values once the front is trimmed.

Test cases

from itertools import islice as _islice


def test_multiples_of_3_and_5():
    assert list(_islice(multiples([3, 5]), 10)) == [3, 5, 6, 9, 10, 12, 15, 18, 20, 21]


def test_multiples_match_brute_force():
    bases = [4, 6, 9]
    expected = [x for x in range(1, 2000) if any(x % b == 0 for b in bases)]
    assert list(_islice(multiples(bases), len(expected))) == expected


def test_duplicate_and_bad_bases():
    assert list(_islice(multiples([2, 2, 4]), 4)) == [2, 4, 6, 8]
    try:
        next(multiples([0, 3]))
        assert False
    except ValueError:
        pass


def test_hamming_numbers():
    assert list(_islice(smooth_numbers([2, 3, 5]), 15)) == [1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 24]
def test_hamming_long_run_with_trimming():
    vals = list(_islice(smooth_numbers([2, 3, 5]), 5000))
    assert vals == sorted(set(vals))
    assert vals[1690] == 2125764000          # the 1691st Hamming number


def test_nth_multiple_matches_stream():
    for bases in ([3, 5], [4, 6, 9], [7], [2, 3, 5, 7]):
        stream = list(_islice(multiples(bases), 500))
        for n in (1, 2, 17, 250, 500):
            assert nth_multiple(n, bases) == stream[n - 1]
    assert count_multiples(15, [3, 5]) == 7


def test_nth_multiple_huge_n():
    n = 10 ** 15
    x = nth_multiple(n, [3, 5])
    assert count_multiples(x, [3, 5]) == n and count_multiples(x - 1, [3, 5]) == n - 1

Follow-ups

  1. Many bases. Inclusion-exclusion is 2^k. With 30 bases, prune subsets whose lcm already exceeds x (the code breaks early), and drop any base that is a multiple of another base first: it adds nothing.
  2. Merging streams in general. The multiples generator is the k-way merge of sorted streams. The same heap pattern merges sorted event logs, paginated API results or sorted files.
  3. Concurrency. Two consumers sharing one infinite generator need a lock around next, or each consumer gets its own generator. Generators are not thread-safe.
  4. Overflow in other languages. In Java or C++, p * h[i] and value + base overflow 64 bits for large indexes; bound the stream or use 128-bit math.
  5. Inside an interview loop. Interviewers often start with "multiples of 3 or 5 up to N" and ask for a stream, then the n-th term. Recognizing the shift from enumeration to counting is what they are looking for.

Interviewer expectations

Mid-level (L4)

  • A working generator for multiples with the heap or a per-base counter scan, without duplicates.
  • Yields lazily and handles duplicate bases.
  • Tests against a brute-force filter.

Senior (L5)

  • O(log k) per value with a heap and a clear statement of memory use.
  • Solves the Hamming variant with one pointer per prime and explains why advancing all matching pointers removes duplicates.
  • Validates input and keeps memory bounded on long runs.

Staff (L6+)

  • Gets the n-th value without iterating, with inclusion-exclusion and binary search, and states its cost.
  • Connects the stream to k-way merges of real data and discusses thread safety and overflow.
  • Chooses enumeration or counting based on the queries the caller makes.

Make it yours

Remix it: write each part yourself, grade it against this answer, then let the coach ask you follow-ups. Practice it with the 2-level tests, run in your browser.