Skip to content
academia.sh

Lesson 06 / 23

Randomized Algorithms

Randomness can be used in two separate places, and the two are measured separately: unverified sampling returns a wrong value on all 40 of the 40 arrays with no majority, while verified sampling never errs on the same arrays, and on a sixteen-queens board, random column order brings a 10,053-node deterministic search down to an average of 360 nodes.

Contents

Every procedure up to this point has been deterministic: the same input always gave the same steps and the same answer. The final design approach loosens this guarantee. A random choice is placed inside the procedure, and something is hoped for in exchange — either not getting stuck in bad regions of the search space, or reaching an answer without doing an exhaustive count.

Which guarantee is loosened is decisive, and there are two options. In the first, the answer stays certain and only the step count becomes random; the procedure always gives the correct answer, but how many steps it takes is unknown. In the second, the step count stays bounded and the answer becomes probabilistic; the procedure is fast but sometimes wrong. This lesson counts both with the same measure and models randomness according to this course’s rule: with a deterministic generator.

  • DA47. Randomness is modeled with a deterministic generator; the standard library’s random number generator is not used. Every run is reproducible with its seed.
  • DA48. Two seeds are used: 20260218 and 20260219. A random procedure’s result is not reported with a single seed.
  • DA49. The first measured problem: the majority element in a 15-value array — the value occupying more than half the length. If there is none, the correct answer is “none.”
  • DA50. The oracle counts every value one by one; it does no sampling at all.
  • DA51. The corpus is two batches: 40 arrays with a majority and 40 without.
  • DA52. A step is drawing a sample or comparing a value.
  • DA53. The approach’s precondition: the majority element exists. The second batch breaks this precondition.
  • DA54. The second measured problem: finding the first solution in a queens placement; column order is randomized.
  • DA55. The random step count is reported as minimum, average, and maximum; twenty runs are taken, and a single number is not considered sufficient.
  • DA56. Expected performance is the average of the steps, not time.

How Randomness Is Modeled

In this course, randomness is not a library call but a seeded generator. The reason is measurement: a random procedure’s diverging-input count can only be reported if the run is reproducible.

# Randomness is modeled with a deterministic generator; the standard library's generator is not used.
LENGTH = 15


class Counter:
    def __init__(self):
        self.steps = 0

    def add(self, n=1):
        self.steps += n


def generator(seed):
    d = seed

    def next_value(n):
        nonlocal d
        d = (d * 1103515245 + 12345) % 2147483648
        return d % n
    return next_value


def majority_corpus(seed, count=40, has_majority=True):
    """has_majority=True -> one value takes up more than half the length."""
    r = generator(seed)
    items = []
    for i in range(count):
        dominant = r(6)
        share = 8 + r(3) if has_majority else 5 + r(3)      # 8..10 or 5..7
        array = [dominant] * share + [6 + r(6) for _ in range(LENGTH - share)]
        for j in range(len(array) - 1, 0, -1):          # deterministic shuffle
            k = r(j + 1)
            array[j], array[k] = array[k], array[j]
        items.append({"no": i + 1, "array": array})
    return items


def oracle_majority(array, s):
    """Counts every value one by one. This is the oracle."""
    for i in range(len(array)):
        count = 0
        for j in range(len(array)):
            s.add()
            if array[j] == array[i]:
                count += 1
        if count * 2 > len(array):
            return array[i]
    return None


WITH_MAJORITY = majority_corpus(20260218)
WITHOUT_MAJORITY = majority_corpus(20260218, has_majority=False)
s1, s2 = Counter(), Counter()
v = sum(1 for item in WITH_MAJORITY if oracle_majority(item["array"], s1) is not None)
y = sum(1 for item in WITHOUT_MAJORITY if oracle_majority(item["array"], s2) is not None)
print("corpus with majority: 40 arrays x", LENGTH, "values | have a majority:", v)
print("corpus without majority:", 40, "arrays | have a majority:", y)
print("oracle steps:", s1.steps, "(with majority) |", s2.steps, "(without majority)")
corpus with majority: 40 arrays x 15 values | have a majority: 40
corpus without majority: 40 arrays | have a majority: 0
oracle steps: 780 (with majority) | 9000 (without majority)

Both batches are built as intended: one has a majority in 40 of 40 arrays, the other in none. The difference in the oracle’s step count is also meaningful — 780 versus 9000. When a majority exists, the oracle usually finds it at the first value it tries and stops; when there is none, it has to count all 15 values through to the end, that is, exactly 225 steps per array. A procedure’s most expensive case is the one where the answer is “none.”

The Probabilistic Answer and the Role of Verification

A random procedure for the majority element is built in one sentence: pick a random position, take the value there as a candidate. If a majority exists, the candidate is correct with probability more than half. The two variants below share this idea and diverge at a single point — one verifies the candidate, the other does not.

# Continuing from the previous block: Counter, generator, majority_corpus, oracle_majority, WITH_MAJORITY come from there.
def sampled_majority(array, trials, r, s, verify=True):
    """Picks 'trials' random positions. If verify=True, tests the candidate by counting."""
    if verify:
        for _ in range(trials):
            candidate = array[r(len(array))]
            s.add()
            count = 0
            for x in array:
                s.add()
                if x == candidate:
                    count += 1
            if count * 2 > len(array):
                return candidate
        return None
    sample = []                                   # no verification: the sample's mode
    for _ in range(trials):
        s.add()
        sample.append(array[r(len(array))])
    best, best_count = None, -1
    for a in sample:
        c = sample.count(a)
        if c > best_count:
            best, best_count = a, c
    return best


def measure(items, trials, seed, verify):
    r = generator(seed)
    diverging, ac, oc = 0, Counter(), Counter()
    for item in items:
        a = sampled_majority(item["array"], trials, r, ac, verify)
        if a != oracle_majority(item["array"], oc):
            diverging += 1
    return {"diverging": diverging, "approach_steps": ac.steps, "oracle_steps": oc.steps}


print("trials  seed       verified diverging/steps   unverified diverging/steps")
for trials in (1, 3, 5):
    for seed in (20260218, 20260219):
        a = measure(WITH_MAJORITY, trials, seed, True)
        b = measure(WITH_MAJORITY, trials, seed, False)
        print(f"{trials:6d}  {seed}  {a['diverging']:12d} / {a['approach_steps']:5d}"
              f" {b['diverging']:16d} / {b['approach_steps']:4d}")
trials  seed       verified diverging/steps   unverified diverging/steps
     1  20260218            10 /   640               10 /   40
     1  20260219            17 /   640               17 /   40
     3  20260218             0 /   944                6 /  120
     3  20260219             1 /   928               13 /  120
     5  20260218             0 /   944                6 /  200
     5  20260219             0 /  1056                7 /  200

Three numbers side by side. The oracle spends 780 steps. Verified sampling spends 944 to 1056 steps at five trials and diverges on 0 inputs in both seeds. Unverified sampling spends 200 steps — almost four times fewer than the oracle — and diverges on 6 and 7 inputs across the two seeds.

The effect of trial count differs across the two columns. In the verified column, the diverging count drops from 10 and 17 to 0 and 1 at three trials, and to 0 and 0 at five trials: every new trial cuts the probability of all previous trials failing by more than half. In the unverified column, the diverging count drops from 10 and 17 to 6 and 7 and stays there; it improves as the sample grows but never reaches zero.

The real difference is not the size of the numbers but the direction of the error. The verified version cannot return a wrong value; because it never accepts a candidate without counting it, it either gives the correct value or says “none.” The 10 divergences seen at one trial do not mean it gave a wrong value on ten inputs — they mean it failed to find one on ten inputs. The unverified version really does return a wrong value. The verification step turns a two-sided error into a one-sided one, and that is what 944 steps buy over 200.

When the Precondition Breaks

The importance of this distinction becomes visible when the majority element does not exist.

# Continuing from the previous blocks: measure, WITH_MAJORITY and WITHOUT_MAJORITY come from there.
print("precondition  seed       verified diverging  unverified diverging")
for label, items in (("holds   ", WITH_MAJORITY), ("broken  ", WITHOUT_MAJORITY)):
    for seed in (20260218, 20260219):
        a = measure(items, 5, seed, True)
        b = measure(items, 5, seed, False)
        print(f"{label}  {seed}  {a['diverging']:16d}  {b['diverging']:20d}")
precondition  seed       verified diverging  unverified diverging
holds     20260218                 0                     6
holds     20260219                 0                     7
broken    20260218                 0                    40
broken    20260219                 0                    40

On the 40 arrays without a majority, unverified sampling diverges from the oracle on all 40 of the 40 inputs; verified sampling diverges on none. The result is the same across both seeds, so it does not depend on the corpus.

The reason is clear and fits the course’s general shape. The unverified procedure has buried the “a majority exists” assumption inside its code: it returns the sample’s most frequent value and never asks whether that value is actually a majority. When the precondition breaks, it does not slow down — it still finishes in 200 steps — but every time, it reports an answer that does not exist. Verification takes the precondition out of the code and turns it into a condition tested at run time; its cost is one exhaustive count per array, its gain is not staying silent when the precondition breaks.

Certain Answer, Random Steps

The second use of randomness never touches the answer. The previous lesson’s pruned queens search always tried columns left to right; once the order is randomized, the solution found is still valid, and the only thing that changes is how many nodes it takes to reach it.

# Continuing from the previous block: Counter and generator come from there.
def queens_first_solution(n, r=None):
    """Stops at the first solution. If column order is random, steps change, the answer does not."""
    s = Counter()
    found = None

    def visit(row, placement):
        nonlocal found
        s.add()
        if row == n:
            found = tuple(placement)
            return
        columns = list(range(n))
        if r:
            for j in range(n - 1, 0, -1):
                k = r(j + 1)
                columns[j], columns[k] = columns[k], columns[j]
        for column in columns:
            if any(column == y or abs(column - y) == row - i
                   for i, y in enumerate(placement)):
                continue
            placement.append(column)
            visit(row + 1, placement)
            placement.pop()
            if found is not None:
                return
    visit(0, [])
    return {"nodes": s.steps, "valid": found is not None}


print(" n  deterministic  seed       min  average  max  valid solution")
for n in (8, 12, 16):
    deterministic = queens_first_solution(n)
    for seed in (20260218, 20260219):
        r = generator(seed)
        runs = [queens_first_solution(n, r) for _ in range(20)]
        nodes = [run["nodes"] for run in runs]
        print(f"{n:2d} {deterministic['nodes']:13d}  {seed}  {min(nodes):6d} {sum(nodes) / 20:9.1f}"
              f" {max(nodes):7d}  {sum(run['valid'] for run in runs):9d} / 20")
 n  deterministic  seed       min  average  max  valid solution
 8           114  20260218       9      35.7      89         20 / 20
 8           114  20260219      16      35.0     101         20 / 20
12           262  20260218      15      98.7     491         20 / 20
12           262  20260219      17      90.1     356         20 / 20
16         10053  20260218      19     359.6    3379         20 / 20
16         10053  20260219      19     355.8    1237         20 / 20

The last column is 20 / 20 in every row: all forty runs find a valid placement. The answer is not random; only the steps are. On the sixteen-queens board, the deterministic order visits 10,053 nodes, the random order an average of 359.6 and 355.8 nodes — roughly 28 times fewer. The averages for the two seeds are very close to each other, meaning expected performance does not depend on the corpus.

The extreme values complete this table and show why a single average is not enough. The minimum is 19 nodes, the maximum 3379; the gap between them is 178 times, and the two seeds’ maximums are also far apart, 3379 versus 1237. A random procedure’s performance cannot be reported with a single number; the average is not a guarantee, it is an expectation. The deterministic order’s 10,053 nodes is not an accident: a fixed order walks the same solution-free region to the same depth on every run, and a bad order’s cost never shrinks on any run. What randomization buys is never trying the same bad order twice.

Cutting the Long Tail

The 3379-node outlier is the random procedure’s real problem: most runs are short, a few are very long. This tail can be cut with a budget — if search exceeds a set node count, it is abandoned and restarted from scratch with a new random order.

# Continuing from the previous block: Counter, generator and queens_first_solution come from there.
def budgeted_search(n, r, budget):
    """Gives up if the budget is exceeded. Giving up is not a wrong answer - there is no answer."""
    s = Counter()
    found = None

    def visit(row, placement):
        nonlocal found
        s.add()
        if s.steps > budget or found is not None:
            return
        if row == n:
            found = tuple(placement)
            return
        columns = list(range(n))
        for j in range(n - 1, 0, -1):
            k = r(j + 1)
            columns[j], columns[k] = columns[k], columns[j]
        for column in columns:
            if any(column == y or abs(column - y) == row - i
                   for i, y in enumerate(placement)):
                continue
            placement.append(column)
            visit(row + 1, placement)
            placement.pop()
            if found is not None or s.steps > budget:
                return
    visit(0, [])
    return s.steps, found


print("budget  seed       restarts  total nodes  max     valid")
for budget in (100, 400, 100000):
    for seed in (20260218, 20260219):
        r = generator(seed)
        total, attempts, worst, valid = 0, 0, 0, 0
        for _ in range(20):
            run_nodes = 0
            while True:
                steps, result = budgeted_search(16, r, budget)
                run_nodes += steps
                attempts += 1
                if result is not None:
                    valid += 1
                    break
            total += run_nodes
            worst = max(worst, run_nodes)
        print(f"{budget:6d}  {seed}  {attempts:16d} {total / 20:13.1f} {worst:7d}"
              f" {valid:8d} / 20")
budget  seed       restarts  total nodes  max     valid
   100  20260218                54         225.8     940       20 / 20
   100  20260219                57         240.8    1229       20 / 20
   400  20260218                27         228.8    1731       20 / 20
   400  20260219                29         276.1     835       20 / 20
100000  20260218                20         359.6    3379       20 / 20
100000  20260219                20         355.8    1237       20 / 20

With a budget of a hundred nodes, 54 and 57 attempts are made for twenty solutions, meaning between 2.70 and 2.85 restarts per solution. In exchange, the total node average drops from 359.6 to 225.8, and the worst run from 3379 to 940. The last rows repeat the unbudgeted run and confirm the difference.

The answer still does not break: the last column is 20 / 20 in all six rows. Giving up is not the same as giving a wrong answer — when the budget is exceeded, the procedure says nothing, and because it says nothing, it does not diverge from the oracle. The distinction from the course’s first lesson takes its final form here: a procedure that reports what it does not know can be measured; a procedure that fabricates what it does not know cannot.

Summary

  • Randomization can loosen two separate guarantees: the answer can stay certain while the step count becomes random, or the step count can stay bounded while the answer becomes probabilistic. The two are measured separately.
  • On the majority element, the oracle spends 780 steps; verified sampling spends 944 to 1056 steps at five trials with 0 diverging inputs, unverified sampling 200 steps with 6 to 7 diverging inputs.
  • The verification step makes error one-sided: the verified version cannot return a wrong value, it can only say “none”; the unverified version really does return a wrong value.
  • When the precondition breaks, unverified sampling diverges on 40 of 40 inputs, verified sampling on none; the result is the same across both seeds.
  • On a sixteen-queens board, the deterministic order visits 10,053 nodes, the random order an average of 359.6 and 355.8 nodes, and all forty runs find a valid solution; the extremes range from 19 to 3379, so the average is an expectation, not a guarantee.
  • With a hundred-node budget, restarting brings the average down to 225.8, the worst run from 3379 to 940, and the answer still stays 20 / 20 valid.

Next Step

This topic ran six design approaches through the same measure, and the same thing came out every time: an approach’s value is visible not in the steps it saves but in the wrong answers it gives once its precondition breaks. The next topic carries this measure to problem patterns. There, the patterns are narrower and more recognizable — scanning from both ends in a sorted array, incremental computation on a contiguous subarray, connected-component search on a grid — and each one’s precondition can be written in a single sentence. The first lesson opens with the two-pointer pattern, which requires sorted input, and its question is the same as here: on how many inputs does a wrong answer appear once the ordering breaks.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close