Algorithms - Learn With Examples https://learnwithexamples.org/category/computer-science-concepts/algorithms/ Lets Learn things the Easy Way Sat, 31 Oct 2026 08:27:13 +0000 en-US hourly 1 https://wordpress.org/?v=7.1.4 https://i0.wp.com/learnwithexamples.org/wp-content/uploads/2026/07/cropped-learnwithexamples-icon.png?fit=32%2C32&ssl=1 Algorithms - Learn With Examples https://learnwithexamples.org/category/computer-science-concepts/algorithms/ 32 32 228207193 Rabin-Karp: Searching With Rolling Fingerprints https://learnwithexamples.org/rabin-karp-algorithm/ https://learnwithexamples.org/rabin-karp-algorithm/#respond Sat, 31 Oct 2026 08:27:10 +0000 https://learnwithexamples.org/?p=1005 Most string searches work by comparing letters. Rabin-Karp takes a different road: it boils every chunk of text down to a single number, a fingerprint, and compares the numbers instead.…

The post Rabin-Karp: Searching With Rolling Fingerprints appeared first on Learn With Examples.

]]>

Most string searches work by comparing letters. Rabin-Karp takes a different road: it boils every chunk of text down to a single number, a fingerprint, and compares the numbers instead. The clever part is that the fingerprint of the next chunk can be worked out from the previous one in a couple of arithmetic steps, so sliding along a million-letter document never requires re-reading anything. That small trick turns out to be behind plagiarism checkers, backup tools that avoid storing duplicate data, and filters that scan for thousands of forbidden words at once. This guide builds the idea from scratch, using small numbers you can follow by hand, and backs every claim with code I actually ran.

A different way to look for a needle

Imagine you are a security guard checking faces against a wanted poster. You could memorise the poster and study every visitor’s eyes, nose, jaw and hairline one feature at a time. Or you could take a quick glance at each visitor and ask a much cheaper question first: does this person even roughly resemble the photo? Ninety-nine visitors fail that glance instantly. For the rare one who passes, then you do the careful feature-by-feature check.

That is Rabin-Karp in one paragraph. The glance is a hash, a number computed from the text. The careful check is the ordinary letter-by-letter comparison. The algorithm is named after its two inventors, the computer scientists Michael Rabin and Richard Karp, who published it in 1987, and its central idea has aged remarkably well.

1Fingerprintthe windowhash(31415) = 72Compare withthe needle7 = 7 ?3Equal? Verifythe real letters31415 = 314154Slide oneletter, updatenext window
Rabin-Karp in four steps: fingerprint the window, compare fingerprints, verify only when they agree, then slide.

The short version. Turn the needle into a number. Turn every window of the haystack into a number too, using a formula that lets you update the number in constant time as the window slides by one letter. Compare numbers, which is fast. Only when two numbers match do you double-check the real letters, because different texts can occasionally share a fingerprint.

Step one: turn a string into a number

Computers love numbers and are slow at comparing long strings, so let us convert. The easiest example uses digits, because a string of digits already is a number. The text “31415” becomes the number thirty-one thousand four hundred fifteen. Two strings of digits are equal exactly when their numbers are equal, and comparing two numbers is one operation no matter how many digits are inside.

That already sounds like a win, but there is a catch. With a needle 50 letters long, the number can be enormous, far too big to handle in one machine word. The fix is to keep only the remainder after dividing by some fixed number, the modulus. In my first example I use 13, which is tiny on purpose so that you can do the arithmetic in your head. The number 31415 divided by 13 leaves a remainder of 7. So the fingerprint of “31415” is 7.

Notice what we gave up. Many different strings leave the same remainder: 7 is also the fingerprint of other five-digit numbers. We will deal with that in a moment, because it is the whole reason the algorithm has a verification step. First, the clever bit.

Step two: the rolling update

To search a long text, you need the fingerprint of every window of five consecutive digits. Computing each one from scratch would cost five operations per window, which is no better than comparing the digits directly. The rolling hash avoids that by reusing the previous answer.

Take the window “31415” and slide one place to the right so that it becomes “14152”. Three things happened: the leading 3 fell off the left, every remaining digit shifted one place up, and a new 2 arrived at the right. In arithmetic terms:

new = ( old − lead × 10m−1 ) × 10 + nextm is the window length. lead is the digit leaving, next is the digit arriving.

Check it with real numbers: (31415 − 3 × 10000) × 10 + 2 = 1415 × 10 + 2 = 14152. Correct, and it took three arithmetic operations, regardless of how long the window is.

314152window 1: 31415, hash 7window 2: 14152, hash 8(7 − 3 × 3) × 10 + 2 = −18−18 mod 13 = 8Leaving digit 3 (orange) times its weight 3, arriving digit 2 (violet) added at the end.
Sliding the window by one digit. Working mod 13, the whole update is a handful of small-number operations.

The picture shows the same update done modulo 13, which is what the program really does. The weight of the leading digit, 104, becomes 3 mod 13. Starting from the fingerprint 7, you compute (7 − 3 × 3) × 10 + 2 = −18. That is negative, so you add 13 until it is non-negative, giving 8. And 14152 divided by 13 does leave a remainder of 8. The shortcut and the long way agree.

Negative intermediate results deserve a warning. Python’s remainder operator always returns a non-negative result for a positive modulus, so the code above just works. In C, C++ and Java, the remainder of a negative number can be negative, which silently breaks the fingerprints. If you write this in one of those languages, add the modulus back before taking the remainder.

Walking through a whole search

Here is a complete search on digits, with the numbers printed so nothing is hidden. The haystack is 2359023141526739921, the needle is 31415, and the modulus is 13. For every window the program shows its fingerprint and whether it equals the needle’s fingerprint of 7. It also asserts, on every step, that the rolling update gives the same answer as computing the fingerprint from scratch.

# Textbook-style walk-through with digits so you can follow the arithmetic.
text, pat, mod = "2359023141526739921", "31415", 13
m = len(pat)
target = int(pat) % mod
print(f"pattern {pat} -> {int(pat)} mod {mod} = {target}")
print()
print("shift  window   hash   verdict")
high = 10 ** (m - 1) % mod
h = int(text[:m]) % mod
for s in range(len(text) - m + 1):
    window = text[s:s + m]
    assert h == int(window) % mod            # rolling value equals the direct value
    verdict = ""
    if h == target:
        verdict = "REAL match" if window == pat else "spurious hit (same hash, different text)"
    print(f"{s:>5}  {window}  {h:>5}   {verdict}")
    if s < len(text) - m:
        h = ((h - int(text[s]) * high) * 10 + int(text[s + m])) % mod

Output:

pattern 31415 -> 31415 mod 13 = 7

shift  window   hash   verdict
    0  23590      8   
    1  35902      9   
    2  59023      3   
    3  90231     11   
    4  02314      0   
    5  23141      1   
    6  31415      7   REAL match
    7  14152      8   
    8  41526      4   
    9  15267      5   
   10  52673     10   
   11  26739     11   
   12  67399      7   spurious hit (same hash, different text)
   13  73992      9   
   14  39921     11

Read down the hash column. At shift 6 the window is 31415 and the fingerprint is 7, the same as the needle: a real match. But look at shift 12. The window is 67399, which is a completely different string, and yet its fingerprint is also 7. This is called a spurious hit or false positive. If the algorithm trusted fingerprints alone, it would report a match there that does not exist.

That is why the verification step is not optional. Whenever fingerprints agree, Rabin-Karp compares the real letters. In this example it would check the window at shift 6, confirm it, check the window at shift 12, reject it, and ignore the other thirteen windows entirely. Two letter-by-letter comparisons instead of fifteen. On a longer text with a longer needle, the saving grows.

From digits to letters

Letters are not digits, but every character has a numeric code, such as 65 for A and 97 for a. So we treat a string as a number written in base 256 (a base large enough to cover ordinary byte values), where each character is one digit. The text “ABC” becomes 65 × 2562 + 66 × 256 + 67 = 4,276,803. Everything we did with base 10 works with base 256, as long as we replace 10 by 256 in the update formula.

Here is the complete function, followed by a check of the rolling update on a small example and a tiny search:

def rabin_karp(text, pat, base=256, mod=1_000_000_007):
    """Return (match positions, hash hits that needed checking, false alarms)."""
    n, m = len(text), len(pat)
    if m == 0 or m > n:
        return [], 0, 0
    high = pow(base, m - 1, mod)             # weight of the leading character
    hp = ht = 0
    for i in range(m):                       # fingerprints of pattern and first window
        hp = (hp * base + ord(pat[i])) % mod
        ht = (ht * base + ord(text[i])) % mod
    hits, checks, false_alarms = [], 0, 0
    for s in range(n - m + 1):
        if ht == hp:                         # fingerprints agree: now compare letters
            checks += 1
            if text[s:s + m] == pat:
                hits.append(s)
            else:
                false_alarms += 1
        if s < n - m:                        # slide the window by one letter
            ht = ((ht - ord(text[s]) * high) * base + ord(text[s + m])) % mod
    return hits, checks, false_alarms


if __name__ == "__main__":
    base, mod = 256, 1_000_000_007
    h = lambda w: sum(ord(c) * base ** (len(w) - 1 - i) for i, c in enumerate(w)) % mod
    old = h("ABC")
    new = ((old - ord("A") * pow(base, 2, mod)) * base + ord("D")) % mod
    print("hash(ABC)                  =", old)
    print("slide: drop A, add D       =", new)
    print("hash(BCD) computed afresh  =", h("BCD"))
    print("rolling update is correct  =", new == h("BCD"))
    print()
    print(rabin_karp("the quick brown fox jumps over the lazy dog", "brown"))

Output:

hash(ABC)                  = 4276803
slide: drop A, add D       = 4342596
hash(BCD) computed afresh  = 4342596
rolling update is correct  = True

([10], 1, 0)

The top four lines confirm the rolling update: the hash of “ABC” is 4,276,803, and dropping the A and adding a D gives 4,342,596, exactly the same as computing “BCD” from scratch. The last line is the search result for “brown” in “the quick brown fox jumps over the lazy dog”: one match at position 10, one hash hit that needed checking, and zero false alarms.

The function returns three numbers: the match positions, how many fingerprint agreements triggered a letter check, and how many of those checks turned out to be false alarms. That last number is the interesting one, because it is where the algorithm can go wrong.

Collisions and the choice of modulus

A fingerprint collision happens when two different strings produce the same number. They cannot be avoided entirely, since there are far more possible strings than possible remainders. What you control is how often they happen, and the main lever is the size of the modulus. If the fingerprints behave like random numbers, the chance that a wrong window collides with the needle is about one in the modulus.

I tested that on 200,000 random letters with an 8-letter needle that does not occur, using three moduli:

import random
from rk import rabin_karp

random.seed(5)
text = "".join(random.choice("abcdefghijklmnopqrstuvwxyz") for _ in range(200000))
pat = "qzjxkvwp"
print(f"{len(text) - len(pat) + 1:,} windows, pattern of {len(pat)} letters")
print(f"{'modulus':>15}{'hash hits':>11}{'false alarms':>14}")
for mod in (101, 10007, 1_000_000_007):
    hits, checks, bad = rabin_karp(text, pat, 256, mod)
    print(f"{mod:>15,}{checks:>11}{bad:>14}   real matches: {len(hits)}")

Output:

199,993 windows, pattern of 8 letters
        modulus  hash hits  false alarms
            101       2041          2041   real matches: 0
         10,007         19            19   real matches: 0
  1,000,000,007          0             0   real matches: 0

With a tiny modulus of 101, about 1 window in 100 was a false alarm: 2,041 wasted letter checks across nearly 200,000 windows. With 10,007 the number dropped to 19, and with a modulus around a billion it was zero. The pattern matches the “one in the modulus” rule almost perfectly.

False alarms in 199,993 windowsmodulus 1012,041modulus 10,00719modulus 1,000,000,0070
False alarms from my run. A larger modulus makes spurious hits vanishingly rare.

So why not always use a gigantic modulus? Because arithmetic on huge numbers costs more, and in languages with fixed-width integers a product can overflow. A common compromise is a prime modulus around a billion, with care that intermediate products fit in 64 bits. Python hides this problem because its integers grow as needed, which is also why Python code like mine runs slower than the same idea in C.

Why a prime, and why random?

Prime moduli spread remainders more evenly, which reduces patterned collisions. There is also a security angle. If a program uses a fixed, publicly known base and modulus, a hostile user can craft inputs that collide on purpose, forcing many false alarms and slowing the search to a crawl. Careful implementations choose the base, or the modulus, at random when the program starts. This is a real concern whenever the text comes from outside.

How fast is it, really?

The running time is about n + m in the typical case, where n is the length of the haystack and m the length of the needle. Computing the first two fingerprints costs about m steps, and each of the n slides costs a constant amount. Verification only happens on fingerprint hits, which are mostly real matches or rare false alarms, so it adds little.

The worst case is n × m. It happens when nearly every window collides, for example with a terrible modulus or an adversarial input, so that you verify almost every position. With a well-chosen, randomised modulus that is extremely unlikely. This is the reverse of the trade-off in KMP, which guarantees linear time on every input but is not always the fastest on friendly text. Rabin-Karp is expected linear time with a small, controllable risk.

For a single short needle, honestly, Rabin-Karp is not the champion. The fingerprint arithmetic costs more per step than comparing a single letter, and skip-ahead methods or your language’s built-in search beat it. Its real strength lies elsewhere.

The superpower: many needles at once

Suppose you have 2,000 banned words, or a database of known phrases, and one document to scan. With one-needle-at-a-time methods you repeat the entire search 2,000 times. Rabin-Karp lets you do it in a single pass. Compute the fingerprint of every needle once and put them in a set. Then slide the window over the document one time, and at each position ask a single question: is this window’s fingerprint in the set? A set lookup takes constant time, so the cost of one pass hardly depends on how many needles you have.

The one restriction is that this simple version needs all the needles to be the same length, so that one window size fits them all. For different lengths, you run one pass for each distinct length.

I measured it with 200 needles of 8 letters, five of them planted in a 200,000-letter text:

import random, time
from rk import rabin_karp
from kmp import kmp_search

random.seed(3)
letters = "abcdefghijklmnopqrstuvwxyz"
text = "".join(random.choice(letters) for _ in range(200000))
patterns = ["".join(random.choice(letters) for _ in range(8)) for _ in range(195)]
patterns += [text[i:i + 8] for i in (1000, 50000, 90000, 150000, 199000)]   # five planted
M = 8; BASE = 256; MOD = (1 << 61) - 1

def multi_rabin_karp(text, patterns):
    """One pass over the text, any number of equal-length patterns."""
    table = {}
    for p in patterns:
        hp = 0
        for ch in p:
            hp = (hp * BASE + ord(ch)) % MOD
        table.setdefault(hp, []).append(p)
    high = pow(BASE, M - 1, MOD)
    ht = 0
    for ch in text[:M]:
        ht = (ht * BASE + ord(ch)) % MOD
    found = {}
    for s in range(len(text) - M + 1):
        if ht in table:
            for p in table[ht]:
                if text[s:s + M] == p:
                    found.setdefault(p, []).append(s)
        if s < len(text) - M:
            ht = ((ht - ord(text[s]) * high) * BASE + ord(text[s + M])) % MOD
    return found

if __name__ == "__main__":
    def timed(fn):
        t = time.perf_counter(); r = fn(); return r, (time.perf_counter() - t) * 1000

    a, ta = timed(lambda: multi_rabin_karp(text, patterns))
    b, tb = timed(lambda: {p: h for p in patterns for h in [kmp_search(text, p)[0]] if h})
    d, td = timed(lambda: {p: True for p in patterns if text.find(p) >= 0})
    print(f"{len(patterns)} patterns, {len(text):,} letters of text")
    print(f"patterns found by Rabin-Karp : {len(a)}")
    print(f"patterns found by KMP loop   : {len(b)}")
    print(f"patterns found by str.find   : {len(d)}")
    print()
    print(f"one Rabin-Karp pass (pure Python) : {ta:8.1f} ms")
    print(f"one KMP run per pattern (Python)  : {tb:8.1f} ms")
    print(f"str.find per pattern (C built-in) : {td:8.1f} ms")

Output:

200 patterns, 200,000 letters of text
patterns found by Rabin-Karp : 5
patterns found by KMP loop   : 5
patterns found by str.find   : 5

one Rabin-Karp pass (pure Python) :    144.5 ms
one KMP run per pattern (Python)  :   4373.2 ms
str.find per pattern (C built-in) :     26.8 ms

All three methods found the same five planted needles. The single Rabin-Karp pass took roughly 70 to 150 milliseconds across my runs, depending on how busy the machine was. Running my Python KMP once per needle took about four seconds, roughly thirty to sixty times slower. But notice the last line: Python’s built-in find, written in C, finished the 200 searches in under 30 milliseconds, and beat my pure Python Rabin-Karp. The language matters as much as the algorithm.

The real question is what happens as the number of needles grows. So I scaled it up:

import random, time
from multi import multi_rabin_karp, text, letters

random.seed(11)
print(f"{'patterns':>9}{'Rabin-Karp one pass':>22}{'str.find per pattern':>23}")
for k in (200, 2000, 20000):
    pats = ["".join(random.choice(letters) for _ in range(8)) for _ in range(k)]
    t = time.perf_counter(); multi_rabin_karp(text, pats); a = (time.perf_counter() - t) * 1000
    t = time.perf_counter(); [text.find(p) for p in pats]; b = (time.perf_counter() - t) * 1000
    print(f"{k:>9,}{a:>19.1f} ms{b:>20.1f} ms")

Output:

 patterns   Rabin-Karp one pass   str.find per pattern
      200              119.1 ms                31.5 ms
    2,000              125.6 ms               277.1 ms
   20,000              107.5 ms              2757.4 ms

This is the picture that justifies the method. The per-needle approach grows in a straight line: ten times more needles took about ten times longer, reaching 2.7 seconds for 20,000. The single Rabin-Karp pass stayed roughly flat around a tenth of a second, because walking through the text dominates and each lookup costs the same. Somewhere between 200 and 2,000 needles, the crossover happens even against a C built-in, and beyond it the gap keeps widening.

Fingerprinting documents: a plagiarism detector in 40 lines

Here is where the rolling hash really surprised me early in my career. Instead of searching for a known needle, you can fingerprint every window of a document and keep the whole set of fingerprints. Two documents that share long passages will share many fingerprints. Two unrelated documents will share almost none.

The recipe: lowercase the text, strip everything except letters so that punctuation and spacing tricks do not matter, then compute the rolling hash of every 12-letter window. The fingerprint set is a compact summary of the document. Comparing two summaries means intersecting two sets.

import re

BASE, MOD, K = 256, (1 << 61) - 1, 12

def fingerprints(text, k=K):
    """Set of rolling hashes of every k-letter window (letters only, lower-case)."""
    t = re.sub(r"[^a-z]", "", text.lower())
    high = pow(BASE, k - 1, MOD)
    h = 0
    for ch in t[:k]:
        h = (h * BASE + ord(ch)) % MOD
    out = {h}
    for s in range(len(t) - k):
        h = ((h - ord(t[s]) * high) * BASE + ord(t[s + k])) % MOD
        out.add(h)
    return out

original = ("Rolling hashes let a program fingerprint every window of a document in constant time, "
            "which is why plagiarism checkers can compare thousands of essays quickly.")
edited   = ("Rolling hashes let a program fingerprint every window of a document in constant time, "
            "which is why copy detectors can compare thousands of essays very quickly.")
unrelated = ("The monsoon reached the hills early this year, and the apple orchards along the river "
             "were heavy with blossom by the middle of April.")

base_fp = fingerprints(original)
print(f"original has {len(base_fp)} fingerprints")
for name, doc in (("light edit", edited), ("unrelated", unrelated), ("exact copy", original)):
    shared = len(base_fp & fingerprints(doc))
    print(f"{name:<11}: {shared:>3} shared  ->  {100 * shared / len(base_fp):5.1f}% of the original")

Output:

original has 122 fingerprints
light edit :  88 shared  ->   72.1% of the original
unrelated  :   0 shared  ->    0.0% of the original
exact copy : 122 shared  ->  100.0% of the original

The original sentence produced 122 fingerprints. A lightly edited copy, where I swapped two phrases, still shared 88 of them, 72 percent. An unrelated sentence about apple orchards shared none. An exact copy shared all 122. Notice that the edit did not destroy the comparison: because each fingerprint covers only 12 letters, the untouched stretches of the sentence still match even after the changed words break the windows around them.

Real systems build on exactly this. They pick the window length carefully, since too short gives false similarity from common phrases and too long misses lightly reworded copying. Some, such as the approach behind Stanford’s well-known MOSS code-similarity tool, keep only a well-chosen sample of the fingerprints so that the summary stays small. The core machinery is still the rolling hash.

All the methods side by side

MethodComparesTypical timeWorst caseBest for
NaiveLettersFast on plain textn × mTiny inputs
KMPLetters with a tableAbout nn + mGuaranteed bounds, one needle
Boyer-Moore familyLetters, from the endOften below nn × m (simple forms)Long needle, plain text
Rabin-KarpFingerprintsAbout n + mn × m (rare)Many needles, document fingerprints
Built-in findTuned mixVery fastLanguage dependentEveryday single searches

Where rolling fingerprints are used

You may never write Rabin-Karp for a Ctrl+F box, but its idea runs through systems you use daily. Click through five situations and notice what they share: the data is big, the comparison is repeated, and a cheap fingerprint filters out nearly everything before any expensive work.

Plagiarism check

A university tool compares a student essay against thousands of earlier documents to flag copied passages.

JobFind copied passages
Unit12-letter windows
OutputShared fingerprints
FitsRolling hash

How it works. Each document is reduced to a set of rolling-hash fingerprints, and similarity is the overlap between sets. In my demo, a lightly edited sentence still shared 72 percent of its fingerprints with the original, while an unrelated one shared none. Because the windows are short, a few reworded phrases do not hide the untouched passages.

Cloud backup

A backup service wants to store only the parts of a file that changed, even when someone inserts a paragraph near the top.

JobSplit files into chunks
TriggerFingerprint hits a value
SavesDuplicate storage
FitsRolling hash

How it works. Fixed-size chunks would all shift after an insertion, making every chunk look new. Instead, some systems cut the file wherever a rolling fingerprint of the last few bytes hits a chosen pattern. The cut points move with the content, so an insertion changes only the nearby chunk and the rest can be recognised as already stored. This content-defined chunking idea is widely used in deduplication.

Banned-word filter

A comment system must scan every new comment against a list of several thousand blocked words and phrases.

NeedlesThousands
PassesOne per word length
LookupSet of fingerprints
FitsRabin-Karp

How it works. Running one search per word would repeat the whole scan thousands of times. With Rabin-Karp you fingerprint the list once, slide a window across the comment, and check each window against a set. My test showed the single-pass cost staying near a tenth of a second from 200 to 20,000 needles, while the per-needle approach grew in a straight line.

Code copy detection

A course instructor wants to know whether two programming assignments share suspicious stretches of code.

JobSpot copied code
UnitRuns of tokens
TrickKeep a sample
FitsFingerprint sets

How it works. Tools in this family, Stanford’s MOSS being a well-known example, fingerprint runs of consecutive tokens and compare the sets, often keeping only a selected sample of fingerprints to stay compact. Renaming variables or reformatting does not defeat it, because the comparison works on normalised tokens rather than the raw text.

Image patch search

A program needs to find every place where a small logo appears, pixel for pixel, inside a large screenshot.

JobFind a small picture inside a big one
UnitRows of pixels
IdeaTwo-dimensional hash
Fits2D Rabin-Karp

How it works. The rolling idea extends to two dimensions: fingerprint each row of the patch, then combine the row fingerprints into one, and roll it across the image horizontally and vertically. As always, a matching fingerprint is only a candidate and must be checked pixel by pixel. It finds exact copies, not similar-looking ones.

Which approach should you choose?

Here is the short checklist I use. The first question settles most real-world cases.

1. Is it one needle and asingle search?yesBuilt-in findstr.find, indexOfno2. Many needles of thesame length?yesRabin-Karp, one passa set of fingerprintsno3. Comparing whole documentsfor copied passages?yesFingerprint every windowrolling hash + set overlapno4. Need a guaranteed worstcase on untrusted text?yesKMPlinear time, no hashingnoOtherwise: use the built-in search, then measure
Use the built-in search for everyday jobs, and reach for rolling fingerprints when the work involves many needles or many documents.

Common mistakes and how to avoid them

  • Trusting the fingerprint without verifying. A hash match only means “probably equal”. Always compare the real letters before reporting a hit, as the spurious match at shift 12 showed.
  • Choosing a tiny modulus. A modulus of 101 gave about one false alarm per hundred windows in my test. Use a large prime.
  • Forgetting the negative remainder. After subtracting the outgoing letter, the value can be negative. In C, C++ and Java, add the modulus before taking the remainder.
  • Getting the leading weight wrong. The weight must be base to the power m − 1, taken modulo the modulus. A wrong power gives fingerprints that look plausible but never match.
  • Letting integers overflow. With a modulus near a billion and a base of 256, one product fits in 64 bits. Choose both values together.
  • Using a predictable base and modulus on untrusted text. Attackers can craft collisions. Randomise one of them when the program starts.
  • Mixing pattern lengths in the multi-needle version. One window length checks one needle length. Group the needles by length.
  • Reaching for it when the built-in would do. For a single search in ordinary code, the language’s own find is simpler and faster.

A mental model that sticks

Think of a coat check. Each coat gets a small numbered ticket, and instead of describing the coat every time, staff just compare tickets, which is instant. Occasionally two coats might share a ticket number because the ticket numbers are short, so before handing a coat back, the attendant glances at it to make sure it really is yours. A rolling fingerprint is the ticket, the glance is the verification step, and the rolling update is a ticket machine that can print the next ticket from the previous one without looking at the coat again.

Practice problems with answers

Try each one on paper before you tap to check.

1. What is 2718 mod 13?

1. Since 13 × 209 = 2717, the remainder is 1.

2. Slide the window 2718 to 7182 using the rolling formula mod 13. What is the new hash?

6. The weight is 103 mod 13 = 12, so (1 − 2 × 12) × 10 + 2 = −228, and −228 mod 13 = 6. Directly, 7182 mod 13 is also 6.

3. A haystack has 19 digits and the needle has 5. How many windows are there?

15, at shifts 0 to 14. The count is the haystack length minus the needle length, plus 1.

4. Why does Rabin-Karp verify the letters after a fingerprint match?

Different strings can share a fingerprint. In my walk-through, the window 67399 had the same hash, 7, as the needle 31415 but was not a match.

5. Roughly how many false alarms should you expect with a modulus of 1,000 over 100,000 windows?

About 100, since each wrong window collides with probability near one in the modulus.

6. Two documents share 30 of the first document’s 120 fingerprints. How similar is the first to the second?

25 percent of the first document’s fingerprints appear in the second.

7. Why is a set of fingerprints useful when you have thousands of needles?

A set lookup takes about the same time however many needles it holds, so one pass over the text answers all of them at once.

Test yourself: five quick questions

Tap a question to reveal the answer. The correct option is marked.

What does the rolling hash let Rabin-Karp avoid?
  1. Verifying matches
  2. Recomputing each window’s fingerprint from scratch
  3. Choosing a modulus
  4. Reading the text at all

Each new fingerprint is derived from the previous one in constant time, so the window length does not matter.

Two different strings have the same fingerprint. What is this called?
  1. A rolling hash
  2. A border
  3. A verification
  4. A collision, or spurious hit

A collision. It is why a fingerprint match is only a candidate until the letters are compared.

What was the main effect of using a larger modulus in my test?
  1. Far fewer false alarms
  2. Faster letter comparison
  3. More matches found
  4. A longer needle

With a modulus near a billion there were no false alarms, against 2,041 for a modulus of 101.

When does Rabin-Karp really shine?
  1. One short needle in a short text
  2. Sorting a list
  3. Searching for many needles in one pass
  4. Finding the longest word

Fingerprints of all needles go into a set, and one pass over the text checks every window against it.

What is the worst-case time of Rabin-Karp?
  1. n + m always
  2. n × m, if nearly every window collides
  3. m squared
  4. Constant

If most windows collide, you verify almost every position, which is as slow as the naive method.

Frequently asked questions

What is the Rabin-Karp algorithm?

A string matching algorithm that compares numeric fingerprints of text windows with the fingerprint of the pattern, using a rolling hash to update each fingerprint in constant time, and verifies the real letters only when fingerprints match.

What is a rolling hash?

A hash function that can be updated cheaply when a window slides by one position. You remove the contribution of the letter that leaves and add the one that arrives, instead of rehashing the whole window.

What is the time complexity of Rabin-Karp?

Expected time is about O(n + m) with a good modulus. The worst case is O(n × m) when many windows collide, which is unlikely with a large, randomly chosen modulus.

Is Rabin-Karp better than KMP?

Not for a single needle. KMP guarantees linear time and avoids hashing. Rabin-Karp is better when you search for many needles at once or fingerprint documents.

Why use a prime number as the modulus?

Primes tend to spread remainders more evenly, reducing patterned collisions. Choosing the base or modulus at random also protects against inputs crafted to collide.

Can the fingerprint ever give a wrong final answer?

Not if you verify. A fingerprint match is only a candidate, and comparing the actual letters removes any false positive. Fingerprints never miss a true match, because equal strings always have equal fingerprints.

Key takeaways

  • Rabin-Karp compares numeric fingerprints of windows instead of letters, and updates each fingerprint from the previous one in constant time.
  • Fingerprints can collide, so every fingerprint hit must be verified against the real letters. In my walk-through, the window 67399 collided with the needle 31415.
  • False alarms occur about once per modulus-sized stretch: 2,041 with a modulus of 101, 19 with 10,007 and none with a modulus near a billion.
  • Expected time is about n + m, worst case n × m. It does not beat KMP or the built-in search on a single needle.
  • Its superpower is many needles in one pass: the cost stayed near a tenth of a second from 200 to 20,000 needles, while per-needle searching grew from about 30 ms to over 2.7 seconds.
  • The same rolling idea powers document fingerprinting, such as plagiarism and duplicate detection.
rabin-karp algorithmrolling hashstring matchinghashingpattern searchingplagiarism detectiondata structures and algorithms

The post Rabin-Karp: Searching With Rolling Fingerprints appeared first on Learn With Examples.

]]>
https://learnwithexamples.org/rabin-karp-algorithm/feed/ 0 1005
How Ctrl+F Finds Text Instantly: KMP and String Matching Explained https://learnwithexamples.org/how-ctrlf-finds-text-instantly/ https://learnwithexamples.org/how-ctrlf-finds-text-instantly/#respond Sat, 31 Oct 2026 06:25:58 +0000 https://learnwithexamples.org/?p=1001 You press Ctrl+F, type a word, and every match on a long page lights up before you have lifted your finger. It feels like magic, but the problem underneath is…

The post How Ctrl+F Finds Text Instantly: KMP and String Matching Explained appeared first on Learn With Examples.

]]>

You press Ctrl+F, type a word, and every match on a long page lights up before you have lifted your finger. It feels like magic, but the problem underneath is one of the oldest in computing: find a short piece of text, the pattern, inside a much longer piece, the text. A beginner solves it in ten lines. A careful engineer asks what happens when those ten lines meet a hostile input. This guide walks through the simple method, shows exactly where it breaks, and then explains the Knuth-Morris-Pratt algorithm, the idea that makes searching reliably fast. Every number in it comes from code I ran.

The problem: a needle in a haystack

Computer scientists call the long text the haystack and the thing you are looking for the needle. Searching a web page, a Word document, a source file or a chromosome all reduce to the same question: at which positions does the needle appear in the haystack?

It sounds trivial because your eyes do it so easily. But a computer has no eyes. It can only compare one character with another, one pair at a time. The whole art of string matching is about how few of those comparisons you can get away with. Fewer comparisons mean a faster search, and when the haystack holds a hundred million characters, the difference between a clever method and a careless one is the difference between a blink and a coffee break.

The short version. The simple method slides the needle along the haystack one step at a time and re-checks everything after every slip. Knuth-Morris-Pratt (KMP) remembers what the last attempt already proved, so it never looks backward in the haystack. That guarantees at most about twice as many comparisons as there are characters, no matter how nasty the input is.

The obvious method, and why it is not enough

Imagine you write the needle on a strip of tracing paper and lay it over the start of the haystack. If every letter lines up, you have a match. If any letter disagrees, you slide the strip one place to the right and start checking again from the strip’s first letter. This is the naive or brute-force method, and it is the one most people invent on their own.

For ordinary text it is fine. Searching for “budget” in an essay, most positions fail on the very first letter, so each slide costs one comparison. The trouble starts when the text is repetitive. Suppose the haystack is a thousand letter A’s followed by a B, and the needle is fifty A’s followed by a B. At every starting position the first fifty letters match, and only the last comparison fails. The method then slides by one and repeats almost all the same work.

0A1B2A3B4A5B6A7CtextABABACtry 1C does not match Bslide 2, not 1ABABACtry 2Grey dashed letters (ABA) are already known to match. Only the blue ones are new work.
Searching for ABABAC in ABABABAC. After the mismatch, the pattern can jump two places because the letters ABA are already known to match.

Look at the picture. The first attempt matched five letters, ABABA, and then failed at the sixth. A naive search forgets everything and starts again one step to the right. But we are not ignorant: we just saw that the text contains ABABA at that spot. The last three of those letters, ABA, are also the first three letters of the needle. So the needle can slide two places and carry on from its fourth letter, without re-checking ABA at all. That one observation is the whole of KMP.

Before we get there, let me put numbers on the pain. I wrote the naive search with a counter on every character comparison, and ran it on three kinds of text:

import random
from kmp import kmp_search

def naive_search(text, pat):
    hits, comps = [], 0
    for s in range(len(text) - len(pat) + 1):
        j = 0
        while j < len(pat):
            comps += 1
            if text[s + j] != pat[j]:
                break
            j += 1
        if j == len(pat):
            hits.append(s)
    return hits, comps

def horspool_search(text, pat):
    m = len(pat)
    shift = {c: m - 1 - i for i, c in enumerate(pat[:-1])}   # bad-character table
    hits, comps, s = [], 0, 0
    while s <= len(text) - m:
        j = m - 1
        while j >= 0:                    # compare from the END of the pattern
            comps += 1
            if text[s + j] != pat[j]:
                break
            j -= 1
        if j < 0:
            hits.append(s)
        s += shift.get(text[s + m - 1], m)
    return hits, comps

if __name__ == "__main__":
    essay = ("every time you press the find shortcut the page has to look through thousands of "
             "characters for a short phrase. a careless method restarts at every position, while "
             "a smarter one remembers what it has already seen and skips ahead. looking for a needle "
             "in a haystack sounds slow, yet careful programs do it in the blink of an eye. ") * 3
    random.seed(7)
    dna = "".join(random.choice("ACGT") for _ in range(100000))
    cases = [
        ("Repeated A, then B", "A" * 1000 + "B", "A" * 50 + "B"),
        ("English paragraph",  essay, "blink of an eye"),
        ("Random DNA",         dna,   "GATTACAGATTACA"),
    ]
    print(f"{'case':<20}{'text':>8}{'pat':>5}{'hits':>6}{'naive':>10}{'KMP':>9}{'Horspool':>10}")
    for name, text, pat in cases:
        a, ca = naive_search(text, pat)
        b, cb = kmp_search(text, pat)
        c, cc = horspool_search(text, pat)
        assert a == b == c
        print(f"{name:<20}{len(text):>8}{len(pat):>5}{len(a):>6}{ca:>10}{cb:>9}{cc:>10}")

Output:

case                    text  pat  hits     naive      KMP  Horspool
Repeated A, then B      1001   51     1     48501     1951      1001
English paragraph        987   15     3      1018      990       163
Random DNA            100000   14     0    133365   124971     47163

On the repeated-A text the naive method needed 48,501 comparisons to scan a text only 1,001 characters long, roughly 48 for every character. The same job took KMP 1,951 and the Horspool method (more on that shortly) 1,001. On a plain English paragraph the three were all close, because ordinary text rarely has long near-misses. This matters: the naive method is not slow on average. It is slow on the wrong input, and a program that handles user-supplied text cannot choose its input.

In the worst case the naive method needs about n × m comparisons, where n is the haystack length and m the needle length. A 100,000-character log and a 1,000-character needle could mean a hundred million comparisons. KMP needs about n + m.

The big idea: never throw away what you learned

Here is a human version of the idea. You are reading a phone book looking for the name “ANANAS”. You read A, N, A, N, A, and then the next letter is not S, it is N. A naive reader would go back to the second letter of the page and start over. But you already know that the last few letters you read were A, N, A, and that is exactly how ANANAS begins. So you just carry on as if you had started a short while ago, already three letters into the name.

KMP formalises that gut feeling. Before it touches the haystack, it studies the needle alone and prepares a small cheat sheet: for each position in the needle, how much of the needle’s own beginning is also its ending? That cheat sheet is the failure table, also called the prefix function or LPS table, short for “longest proper prefix which is also a suffix”.

Prefixes, suffixes and borders

These words sound heavy but the idea is plain. A prefix is the beginning of a word: for ABABA the prefixes are A, AB, ABA and ABAB. A suffix is the ending: A, BA, ABA and BABA. A border is something that is both. In ABABA, the string ABA is a prefix and also a suffix, so ABA is a border of length 3. (We exclude the whole word, which is why the table says “proper”.)

ABABAprefix: ABAsuffix: ABASame letters at both ends, so the border length is 3
In ABABA, the first three letters and the last three letters are the same string, so the border length is 3.

Why does a border matter? Because it tells you what the needle can safely fall back to after a failure. If you have matched ABABA and then hit a mismatch, you know the last three letters of what you matched are ABA, which is the start of the needle. So you resume as though you had matched only ABA. No searching backward in the haystack is needed.

Reading a failure table

The table holds one number per letter of the needle: the border length of the needle up to and including that letter. I printed the tables for three needles:

def build_lps(pat):
    """lps[i] = length of the longest proper prefix of pat[:i+1]
    that is also a suffix of it."""
    lps = [0] * len(pat)
    k = 0                                # length of the prefix matched so far
    for i in range(1, len(pat)):
        while k > 0 and pat[i] != pat[k]:
            k = lps[k - 1]               # fall back to a shorter prefix
        if pat[i] == pat[k]:
            k += 1
        lps[i] = k
    return lps


def kmp_search(text, pat):
    """Return (list of match positions, number of character comparisons)."""
    lps = build_lps(pat)
    hits, comps, k = [], 0, 0
    for i, ch in enumerate(text):        # the text pointer only moves forward
        while True:
            comps += 1
            if ch == pat[k]:
                k += 1
                break
            if k == 0:
                break
            k = lps[k - 1]               # reuse what we already matched
        if k == len(pat):
            hits.append(i - k + 1)
            k = lps[k - 1]               # allow overlapping matches
    return hits, comps


if __name__ == "__main__":
    for pat in ("ABABAC", "ABCDABD", "AAAB"):
        lps = build_lps(pat)
        print(f"pattern {pat}")
        print("  char:", " ".join(pat))
        print("  lps :", " ".join(map(str, lps)))
    print()
    hits, comps = kmp_search("ABABABAC", "ABABAC")
    print("search ABABAC in ABABABAC -> start", hits, "after", comps, "comparisons")

Output:

pattern ABABAC
  char: A B A B A C
  lps : 0 0 1 2 3 0
pattern ABCDABD
  char: A B C D A B D
  lps : 0 0 0 0 1 2 0
pattern AAAB
  char: A A A B
  lps : 0 1 2 0

search ABABAC in ABABABAC -> start [2] after 9 comparisons

For ABABAC the table reads 0, 0, 1, 2, 3, 0. Read it this way: after matching the first five letters ABABA, the border is 3, so if the sixth letter fails, fall back to having matched 3 letters. The final C has no border, hence 0. For ABCDABD the table shows 1 and 2 under the second A and B, because the needle starts again with AB halfway through. For AAAB the table climbs 0, 1, 2 because every A is a continuation of the same run; this is precisely the shape of needle that wrecks the naive method, and also precisely where KMP shines.

Notice the first entry is always 0, since a single letter has no proper prefix. A needle with no repeated structure, such as ABCDEF, gets all zeros, and then KMP behaves almost like the naive method, because there is nothing to reuse.

The search itself, step by step

With the table ready, the search runs two pointers. One walks through the haystack and only ever moves forward. The other, call it k, counts how many letters of the needle are currently matched. At each haystack letter you do one of three things:

  • It matches the next needle letter. Increase k by one. If k now equals the needle length, you found a match.
  • It does not match and k is above zero. Fall back: set k to the table entry just before it, then try the same haystack letter again against the shorter match.
  • It does not match and k is zero. Nothing to fall back to. Move to the next haystack letter.

Here is the whole algorithm, which is shorter than most people expect:

def kmp_search(text, pat):
    """Return (list of match positions, number of character comparisons)."""
    lps = build_lps(pat)
    hits, comps, k = [], 0, 0
    for i, ch in enumerate(text):        # the text pointer only moves forward
        while True:
            comps += 1
            if ch == pat[k]:
                k += 1
                break
            if k == 0:
                break
            k = lps[k - 1]               # reuse what we already matched
        if k == len(pat):
            hits.append(i - k + 1)
            k = lps[k - 1]               # allow overlapping matches
    return hits, comps

Trace the earlier example. The haystack is ABABABAC and the needle ABABAC. The first five letters match, so k climbs to 5. The sixth haystack letter is B but the needle wants C, a mismatch. The table entry for the fifth needle letter is 3, so k drops to 3, meaning “ABA is still matched”. The needle’s fourth letter is B, which matches the haystack’s B, so k rises to 4. Then A matches, k is 5, then C matches, k is 6, and 6 equals the needle length: a match starting at position 2. The program output said exactly that, found at start 2 after 9 comparisons, for a haystack of 8 letters.

Why it is fast: the counting argument

The inner loop looks scary because of the fallback, which could in theory repeat many times. Here is why it cannot get out of hand. Each time the haystack pointer advances, k can grow by at most one. Each fallback strictly lowers k. So the total number of fallbacks over the whole search can never exceed the total number of increases, and that is at most n. Add the n forward steps and the fallbacks and you get at most about 2n comparisons. Building the table works the same way and costs about m. The total, n plus m, is called linear time, and it holds for any input.

That is why the experiment gave 1,951 comparisons for the 1,001-character repeated text. It lands just under the theoretical ceiling of 2n, which is 2,002. The algorithm is nearly as slow as it is allowed to be on that input, and still about twenty-five times better than the naive method.

Is KMP always the fastest? Honestly, no

This is the part most tutorials skip. Look again at the third row of the comparison output. On 100,000 random letters of DNA, using a 14-letter needle, KMP made 124,971 comparisons against naive’s 133,365. That is barely better. And the Horspool method, a cousin I have not explained yet, needed only 47,163. KMP’s selling point is the guarantee: it never degrades. It does not promise to win on friendly input.

Repeated A, then B (1,001 letters)Naive48,501KMP1,951Horspool1,001English paragraph (987 letters)Naive1,018KMP990Horspool163Random DNA (100,000 letters)Naive133,365KMP124,971Horspool47,163
Comparison counts from my run, each scaled against the naive method for the same case (naive = full bar).

Why did Horspool win so clearly on ordinary text? Because it uses the opposite trick. Instead of comparing the needle from the front, it compares from the back. If the haystack letter lined up with the needle’s last position is a letter that does not appear in the needle at all, say a Z when searching for “needle”, then no placement overlapping that Z can possibly match, and the whole needle can jump forward by its full length in one step. On English text with a 15-letter needle, that means looking at only about one letter in every several. The English row shows it: 163 comparisons for a 987-character text.

The family of methods built on this backward comparison is called Boyer-Moore, and Horspool is its simplified cousin. Well-known command line tools such as GNU grep are widely documented as using skip-ahead ideas of this kind for plain-text patterns, which is a large part of why searching a gigabyte of logs takes seconds. The longer your needle, the more you can skip, which feels backward until you think about it: more letters to check also means more chances to rule out a placement early.

What Ctrl+F is actually doing

I want to be careful here. Browsers and editors do not publish the exact algorithm they use, it varies between programs and versions, and I would not want to state one as fact. What I can describe with confidence is the shape of the job, because it is the same everywhere:

  • Normalise. Most find boxes ignore case by default, so the program compares in a lowercase form, and often ignores accents too.
  • Scan. It walks through the visible text of the page, using some string matching routine like the ones in this article.
  • Collect every hit. Not just the first. Each match position is stored so the program can count them and jump between them.
  • Highlight and report. It paints the matches, shows “3 of 17”, and scrolls to the one you are on.

And here is the honest truth about the word “instantly”. A typical page holds maybe fifty thousand characters. Even the naive method on ordinary text does that in a tiny fraction of a second on a modern machine. My own measurement scanned 300,000 characters in about 35 milliseconds with the slowest method, and I wrote that one in Python, which is itself slow. The instant feel comes mostly from raw hardware speed and the fact that ordinary text is friendly. The cleverness of KMP matters when the text is huge, repetitive, or arrives from an untrusted source and you cannot risk a pathological case.

Counting matches is trickier than it looks

One subtle thing your find box must decide: do matches overlap? Search for “aa” in “aaaaa”. Do you report two hits (positions 0 and 2, taking turns) or four (positions 0, 1, 2 and 3)? Different tools answer differently, and the choice matters when you display a count. I tested three common approaches:

import re
from kmp import kmp_search

text, pat = "aaaaa", "aa"
print("str.count      :", text.count(pat), "(non-overlapping)")
print("re.findall     :", len(re.findall(pat, text)), "(non-overlapping)")
print("KMP, all hits  :", len(kmp_search(text, pat)[0]), "at", kmp_search(text, pat)[0])

doc = "The cat sat. The Cat ran. A cat, the CAT."
print()
print("exact 'cat'    :", doc.count("cat"))
print("ignore case    :", doc.lower().count("cat"))
print("whole word     :", len(re.findall(r"\bcat\b", doc, re.I)))

Output:

str.count      : 2 (non-overlapping)
re.findall     : 2 (non-overlapping)
KMP, all hits  : 4 at [0, 1, 2, 3]

exact 'cat'    : 2
ignore case    : 4
whole word     : 4

Python’s count and the regular expression finder both say 2, because they skip past each match before looking again. KMP, as I wrote it, resets to the border after a match and finds all 4 overlapping hits. Neither is wrong; they answer different questions. The second block shows the other classic gotcha: exact search for “cat” finds 2 hits in my sentence, while ignoring case finds 4. If a user types a word and sees “2 results” when they can plainly see four, that is a case-sensitivity bug, not an algorithm bug.

The race: real timings

I timed four methods on two inputs: ordinary text, and the worst case for the naive method. These are pure Python implementations, so absolute numbers are modest, but the shape is what counts.

import random, time
from kmp import kmp_search
from compare import naive_search, horspool_search

def best(fn, *a, runs=3):
    t = 1e9
    for _ in range(runs):
        s = time.perf_counter(); fn(*a); t = min(t, time.perf_counter() - s)
    return t * 1000

random.seed(1)
text = "".join(random.choice("abcdefghijklmnopqrstuvwxyz ") for _ in range(300000))
pat = "zzqxjvkw"
print("Ordinary text, 300,000 characters, 8-letter pattern (milliseconds)")
for name, fn in (("naive", naive_search), ("KMP", kmp_search), ("Horspool", horspool_search)):
    print(f"  {name:<10}{best(fn, text, pat):>9.1f}")
print(f"  {'str.find':<10}{best(lambda t, p: t.find(p), text, pat):>9.1f}")

bad = "a" * 60000 + "b"
badpat = "a" * 150 + "b" + "c"
print()
print("Worst case for naive, 60,000 letters (milliseconds)")
print(f"  {'naive':<10}{best(naive_search, bad, badpat, runs=1):>9.1f}")
print(f"  {'KMP':<10}{best(kmp_search, bad, badpat):>9.1f}")
print(f"  {'str.find':<10}{best(lambda t, p: t.find(p), bad, badpat):>9.1f}")

Output (your numbers will differ a little from run to run):

Ordinary text, 300,000 characters, 8-letter pattern (milliseconds)
  naive          36.9
  KMP            27.8
  Horspool        8.5
  str.find        0.2

Worst case for naive, 60,000 letters (milliseconds)
  naive         709.9
  KMP             7.8
  str.find        0.1

Two lessons jump out. First, on ordinary text the order was Horspool, then KMP, then naive, all within a factor of five. The built-in str.find beat all of them by a huge margin, because it is written in C, and recent versions of Python combine several strategies inside it, including a method called Two-Way for longer needles. In real programs, use the built-in. Writing KMP yourself is for learning, for languages without a good built-in, or for streaming problems where you cannot hold the whole text.

Second, on the nasty input the naive search took several hundred milliseconds while KMP took under ten, roughly ninety times faster on my run, and the gap grows as the text grows. That is the scenario that justifies the algorithm: it turns a catastrophic input into an ordinary one.

Other string matching ideas worth knowing

Rabin-Karp: compare fingerprints

Instead of comparing letters, compute a numeric fingerprint, a hash, of the needle, and a rolling hash of each window of the haystack. Sliding the window one step updates the hash in constant time. Only when two fingerprints agree do you check the actual letters. The method is commonly described as a building block in plagiarism detection, because it extends naturally to checking many fragments at once.

Aho-Corasick: many needles at once

What if you have ten thousand needles, such as a database of virus signatures or a list of banned words? Running KMP ten thousand times is wasteful. Aho-Corasick builds one automaton from all the needles, a generalisation of the KMP failure table, and scans the haystack once. Antivirus engines such as ClamAV are known to use it.

Suffix structures: search many times

If the haystack is fixed and you will search it again and again, as genome databases do, you can pay once to build an index such as a suffix array, and then each query takes time depending mostly on the needle rather than the haystack. Scanning three billion DNA letters for every query would be hopeless.

MethodWorst caseTypical caseCore idea
Naiven × mFast on friendly textSlide by one, re-check everything
KMPn + mAbout nFailure table, never move back
Horspooln × mOften well below nCompare from the end, skip ahead
Rabin-Karpn × m (rare)About n + mRolling hash fingerprints
Aho-Corasickn + total needle lengthLinearOne automaton for many needles

Where string matching shows up in daily life

You use these algorithms far more often than you press Ctrl+F. Click through five everyday situations and notice how the needs differ: sometimes you want every match, sometimes only a guarantee, sometimes thousands of needles at once.

Ctrl+F in a page

You press Ctrl+F on a 40-page report and type “budget”. The page shows “12 results” and highlights each one.

JobFind every match
MatchesOften overlapping
Text sizeThousands of letters
FitsBuilt-in search

How it works. The program normalises case, scans the visible text, stores every hit position and paints the highlights. A page is small enough that any sensible method answers in a blink, so the real design choices are the user-facing ones: ignore case, ignore accents, and whether overlapping matches count. As my overlap test showed, that decision alone can change the count from 2 to 4.

DNA search

A biologist looks for a short gene fragment inside a genome. The human genome is roughly three billion letters, written with only four symbols.

AlphabetA, C, G, T
Text sizeBillions of letters
RiskRepetitive stretches
FitsKMP or an index

How it works. A four-letter alphabet makes near-misses very common, and DNA is full of repeats, which is exactly the kind of input where the naive method suffers. KMP’s linear guarantee helps. In practice, researchers searching the same genome again and again build an index once, such as a suffix array, because scanning three billion letters for every query would still be slow.

Searching logs

An engineer runs grep over a day of server logs, looking for the phrase “connection refused”.

JobFind an error text
Text sizeGigabytes of logs
NeedleOften long
FitsSkip-ahead methods

How it works. Plain-language logs with a longish phrase are the sweet spot for Boyer-Moore style skipping. Most positions can be ruled out by looking at a single letter near the end of the window, so the program leaps over large stretches of text. My Horspool test made only 163 comparisons over a 987-letter English paragraph for the same reason.

Virus scanner

A scanner checks a file against a database of thousands of known malicious byte patterns.

NeedlesThousands
JobOne pass, all signatures
RiskSlow scans
FitsAho-Corasick

How it works. Running a single-pattern search thousands of times per file would be painfully slow. Multi-pattern methods such as Aho-Corasick merge all needles into one automaton, a generalisation of the KMP failure table, and read the file once. The same idea powers profanity filters that must check a comment against a long list of banned words.

Plagiarism check

A university tool checks whether a student’s essay contains sentences lifted from thousands of earlier documents.

JobFind copied passages
MethodRolling hash
IdeaCompare fingerprints
FitsRabin-Karp

How it works. Rabin-Karp turns each window of text into a numeric fingerprint that can be updated in constant time as the window slides. Matching fingerprints are then confirmed letter by letter. The approach scales naturally to many fragments at once, which is why it is commonly described in connection with plagiarism detection.

Which method should you reach for?

When someone asks me which algorithm to use, I answer with a short sequence of questions. In practice the first question ends the conversation nine times out of ten.

1. Is it a one-off search inordinary text?yesBuilt-in findstr.find, indexOfno2. Do you have thousands ofneedles at once?yesAho-Corasickone pass, many needlesno3. Is the text huge, repetitiveor from an untrusted source?yesKMPguaranteed linear timeno4. Is the needle long and thetext plain language?yesHorspool / Boyer-Mooreskip aheadnoStill unsure? The built-in search is almost always fine
A practical decision guide. In most application code, the built-in search is the right answer.

Common mistakes and how to avoid them

  • Reimplementing what the language already does well. Python’s find, JavaScript’s indexOf and Java’s indexOf are tuned and tested. Write KMP when you need streaming, custom matching or a guaranteed bound.
  • Forgetting overlapping matches. Decide up front whether “aa” in “aaaaa” counts as two hits or four.
  • Ignoring case and accents. Normalise both strings the same way before searching, or users will report missing results.
  • Treating text as plain letters when it is not. In Unicode, an accented é can be one character or an e followed by a combining accent. Normalise the text, or two identical-looking strings will not match.
  • Off-by-one errors in the failure table. The fallback is lps[k - 1], not lps[k]. This is the most common bug in hand-written KMP.
  • Forgetting the empty needle. Decide what searching for an empty string means and handle it before you build a table.
  • Passing user input to a regular expression. Characters such as a dot or a bracket have special meaning. Use re.escape or a plain string search.
  • Searching inside a loop over a huge list. One search is cheap. Ten thousand searches over a megabyte each is not. Build an index or use a multi-pattern method.

A mental model that sticks

Think of reading a street name on a long road sign while driving past. The naive method is a driver who, on spotting one wrong letter, stops, reverses to the beginning of the sign, and starts reading again. KMP is a driver who has memorised the sign’s own pattern of repeats and never reverses: after a wrong letter he glances at his memory and says, “fine, I have already read the last three letters of the right word”, and keeps driving. Horspool is a driver who reads from the far end of the sign first, and when that letter is not in the word at all, leaps ahead by the whole sign’s length.

Practice problems with answers

Try each one on paper before you tap to check.

1. Build the failure table for ABAB.

0 0 1 2. The border of ABA is A (length 1) and the border of ABAB is AB (length 2).

2. Build the failure table for AABAAB.

0 1 0 1 2 3. The word repeats itself, so the border keeps growing: AABAA ends with AA, and AABAAB ends with AAB.

3. Build the failure table for ABCABD.

0 0 0 1 2 0. AB repeats, giving 1 and 2, but the final D breaks the pattern and drops back to 0.

4. How many overlapping matches of aa are there in aaaaa?

4, starting at positions 0, 1, 2 and 3. A non-overlapping count gives only 2.

5. Searching AAB in AAAAAB: how many comparisons do the three methods need?

From my run: naive 12 (three at each of four starting positions), KMP 9 and Horspool 6. All three find the single match at position 3.

6. Why does the failure table for ABCDEF give no help?

Every entry is 0. The word has no border anywhere, so after a mismatch there is nothing to reuse and KMP behaves almost like the naive method.

7. The haystack has 1,000,000 letters. About how many comparisons can KMP need at most?

About 2,000,000, plus a tiny amount to build the table. The naive method could need up to a thousand times more if the needle were 1,000 letters long.

Test yourself: five quick questions

Tap a question to reveal the answer. The correct option is marked.

What does the KMP failure table store for each position of the needle?
  1. The number of times the letter appears
  2. The position of the last mismatch
  3. The length of the longest proper prefix that is also a suffix
  4. The hash of the letters so far

It stores the border length, which says how much of the match can be reused after a mismatch.

Why does KMP never move its haystack pointer backward?
  1. Because the failure table tells it how much is already matched
  2. Because it skips every second letter
  3. Because it sorts the text first
  4. Because it reads the text twice

After a mismatch it only changes the needle position k, using what it already learned.

Which input is worst for the naive method?
  1. Random letters
  2. A very short needle
  3. A text with no matches at all
  4. Long runs of the same letter, with a needle that almost matches

Near-misses force it to repeat almost the same comparisons at every start position.

How does Horspool skip ahead?
  1. It sorts the needle
  2. It compares from the end and jumps when the last letter is not in the needle
  3. It uses a hash of each window
  4. It reads only vowels

If the letter under the needle’s last position cannot appear in a match, the whole needle can jump forward.

In ordinary Python code, what should you usually use to find a substring?
  1. A hand-written KMP
  2. The built-in find or the in operator
  3. A nested loop
  4. A regular expression for every case

The built-in is written in C and tuned. In my tests it took 0.2 ms where my Python KMP took 27.8 ms.

Frequently asked questions

What is the KMP algorithm?

Knuth-Morris-Pratt is a string matching algorithm that finds a pattern in a text in linear time. It builds a failure table from the pattern so that, after a mismatch, it never has to move backward in the text.

What is the time complexity of KMP?

O(n + m), where n is the text length and m the pattern length. Building the table costs about m steps and the search at most about 2n comparisons.

Is KMP what Ctrl+F uses?

Browsers and editors do not publish their exact method and it varies between programs, so I cannot say. All of them solve the same problem: normalise the text, scan for the pattern, collect every match and highlight it.

Why is the naive method sometimes good enough?

On ordinary text most positions fail on the first letter, so each slide costs about one comparison. It only becomes slow on repetitive or adversarial input, which is when guarantees matter.

What is the difference between KMP and Boyer-Moore?

KMP compares from the front and uses a failure table to avoid re-checking letters. Boyer-Moore compares from the end and skips ahead using the letters it sees, which is often much faster on plain text but lacks KMP’s simple worst-case guarantee in its simplified forms.

Should I implement string search myself?

Rarely. Use your language’s built-in search for everyday work. Implement KMP to learn, for streaming input, or when you need a guaranteed bound on a pattern you control.

Key takeaways

  • String matching asks where a short needle appears inside a long haystack. The naive method re-checks work and needs up to n × m comparisons.
  • KMP prepares a failure table from the needle, so the haystack pointer never moves backward. It needs at most about 2n comparisons whatever the input.
  • In my tests, the naive search made 48,501 comparisons on a 1,001-letter repetitive text, against 1,951 for KMP.
  • KMP guarantees speed, but is not always the fastest in practice. Horspool made only 163 comparisons on an English paragraph where KMP made 990.
  • For everyday code, use the language’s built-in search. It was far faster than every method I wrote in Python.
  • Decide how your find handles case, accents and overlapping matches, because these cause more visible bugs than the algorithm does.
string matchingKMP algorithmKnuth-Morris-PrattCtrl+Fpattern searchingBoyer-Mooredata structures and algorithms

The post How Ctrl+F Finds Text Instantly: KMP and String Matching Explained appeared first on Learn With Examples.

]]>
https://learnwithexamples.org/how-ctrlf-finds-text-instantly/feed/ 0 1001
How a Phone’s Contact Search Finds Matches as You Type https://learnwithexamples.org/how-a-phones-contact-search/ https://learnwithexamples.org/how-a-phones-contact-search/#respond Tue, 13 Oct 2026 14:36:16 +0000 https://learnwithexamples.org/?p=977 You type the letter “m” and Mom appears. You add an “a” and the list shrinks. By the third letter, the person you wanted is already on screen and you…

The post How a Phone’s Contact Search Finds Matches as You Type appeared first on Learn With Examples.

]]>

You type the letter “m” and Mom appears. You add an “a” and the list shrinks. By the third letter, the person you wanted is already on screen and you have not finished typing. It feels like magic, but it is a handful of well-known tricks working together, and every one of them is something you can build yourself in an afternoon. This article takes the search apart piece by piece, with working code, real measurements and everyday pictures.

The magic trick, and why it needs engineering

Search-as-you-type looks simple until you ask what the phone is really being asked to do. Every single keystroke is a new question. By the time you type the fourth letter of a name, the phone has answered four separate searches, and it has to finish each one before your finger lands on the next key. Usability researchers have used a rule of thumb for decades: if a response arrives in about a tenth of a second, people experience it as instant. Anything much slower and the interface starts to feel like it is lagging behind you.

Now picture the other side of the problem. A phone might hold a few hundred contacts, but plenty of people have thousands, once you count work contacts, old numbers and group chats. And each contact is not just a name. It has several numbers, an email, a nickname, a company. The search has to be fast, forgiving, and sensible about which result to put first.

I have built search boxes for products of every size over the years, and I always start new engineers with this exact exercise. It teaches normalisation, data structures, ranking and the limits of “just loop over everything” in one small, familiar problem.

The short version. A contact search cleans up what you type (lowercase, no accents), looks it up in an index built ahead of time instead of scanning every contact, ranks the matches so the most likely person comes first, and can forgive small typos. Apps differ in the details, and the vendors do not publish their exact internals, but these are the standard building blocks, and they are what we will build here.

The pipeline in one picture

Whatever the app, the path from keystroke to list looks about the same.

1 Keystrokeyou type “ani”2 Normaliselowercase, no accents3 Look upin a prebuilt index4 Rankbest match first5 Showtop results on screenThe index in step 3 is built once, ahead of time, and updated only when contacts change.
Five steps run on every keystroke. The expensive work is done ahead of time, when the index is built.

Let us walk through the stages in order, starting with the most naive version that works, because the way it fails shows you why each later trick exists.

Step 1: clean up what people type (normalisation)

Before any matching happens, both the typed text and the stored names need to be put into the same shape. People type “jose” for José, they type in lower case, and they put dots and dashes into names such as “Anne-Marie” or “O’Brien”. If you compare raw text, none of those match.

So the first stage lowercases everything, strips accents, replaces punctuation with spaces and splits the name into words. That is called normalisation, and it is the unglamorous step that makes the rest work. Skipping it is the number one reason homemade search feels “broken.”

Below is a first version of the whole search in about twenty lines. It normalises, then checks every contact. A contact matches when every word you typed is the beginning of some word in the name.

import unicodedata, re

def normalise(text):
    """Lowercase, strip accents, turn punctuation into spaces."""
    text = unicodedata.normalize("NFKD", text)
    text = "".join(c for c in text if not unicodedata.combining(c))
    return re.sub(r"[^a-z0-9 ]+", " ", text.lower()).split()   # list of words

def linear_search(contacts, query):
    """Slowest idea that works: look at every contact on every keystroke."""
    q = normalise(query)
    hits = []
    for cid, words in contacts:                  # contacts = [(id, [words...]), ...]
        # every typed word must be the start of some word in the name
        if all(any(w.startswith(t) for w in words) for t in q):
            hits.append(cid)
    return hits

This already behaves like a real contact search. Type “sh” and you find both Onam Sharma (the surname starts with sh) and Shalini Rao (the first name does). Type “on sh” and only Onam Sharma remains, because each typed word must begin some word in the name. Matching at the start of any word is what makes phone searches feel natural: you can type the first or the last name, in any order.

Real-world picture: a library catalogue. You do not need the exact title. You type a few starting letters of any word and the system narrows the shelf for you.

Step 2: watch the results shrink as you type

Here is something satisfying to see. I generated 5,000 made-up contacts (random first and last names) and ran the search above while “typing” the name anit, one letter at a time. My test names lean heavily towards names beginning with A, so the early numbers are larger than a real address book would give, but the shape is what matters.

nothing typed5,000a1,188an568ani186anit90Matching contacts out of 5,000 (made-up names that lean towards A, so early counts run high).
Each new letter cuts the candidate list sharply. Out of 5,000 contacts, four letters leave 90.

This shrinking is the whole reason search-as-you-type works so well. The first letter does the least work and returns the longest list, which the phone only needs to display the top of. Every next letter cuts the list further, and by the fourth or fifth letter you are usually looking at a handful of names.

It also hides a second, very practical trick. Every result for “ani” is guaranteed to also be a result for “an”, because anything starting with “ani” also starts with “an”. So instead of searching all contacts again, you can search only the list you already have. I counted the name checks for typing anit across 10,000 contacts: scanning everything on every keystroke took 40,000 checks, while reusing the previous results took 13,852. That is about two-thirds fewer, for a change of a few lines of code.

The catch. This only works while the user keeps typing forward. The moment someone presses backspace, the saved list is too small, and you must go back to an earlier result or start over. Good apps keep a short history of the lists for exactly that reason.

Step 3: stop scanning, start looking up (the index)

The scan-and-reuse approach is fine for a thousand contacts. But it still touches every candidate on the first keystroke, and the cost grows with the size of the list. Real search systems avoid scanning entirely. They do the hard work once, ahead of time, and build an index, which is a structure designed to answer one specific question quickly. For “which words start with these letters?” the classic answer is a trie.

What is a trie?

A trie (said “try”) is a tree where each step down spells one more letter. All words that share a start share the same path. To find everything starting with “ani”, you walk three steps down and you are standing on the answer: every contact reachable from that spot begins with those letters. You never look at the other thousands of contacts at all.

•amniiktltiatAmitAnilAnitaAnkitWalk down the letters you typed. Everything below that spot is a match.
A small trie for the words Amit, Anil, Anita and Ankit. They share the first letter, and Anil, Anita and Ankit share the first two.

The cost of a lookup depends on the length of what you typed, not on how many contacts you own. A person with 100 contacts and a person with 100,000 both pay the same few steps for “ani”. That is the property that makes it scale.

Here is a compact version. In this one, every node remembers the set of contacts passing through it, so reading the answer is instant:

class Trie:
    """Every node remembers which contacts pass through it."""
    def __init__(self):
        self.root = {}
    def add(self, word, cid):
        node = self.root
        for ch in word:
            node = node.setdefault(ch, {})
            node.setdefault("#", set()).add(cid)  # '#' holds contact ids
    def prefix(self, text):
        node = self.root
        for ch in text:
            node = node.get(ch)
            if node is None:
                return set()
        return node.get("#", set())

def build(contacts):
    t = Trie()
    for cid, words in contacts:
        for w in words:
            t.add(w, cid)
    return t

def trie_search(t, query_words):
    sets = [t.prefix(w) for w in query_words]
    return set.intersection(*sets) if sets else set()

The trade-off is memory. The trie stores extra information so lookups are fast, which is the oldest bargain in computing: spend space and preparation time to buy speed.

A simpler cousin: the sorted list and binary search

If a trie sounds like more machinery than you want, there is a leaner way to get the same effect. Keep every word in one alphabetically sorted list. All words starting with “ani” sit together in one block, because they sort together. Use binary search to find where that block begins and ends, which takes only about seventeen steps even for a hundred thousand words. If you read our article on heaps and sorted arrays, this is the same “sorted array” idea put to a different job. It uses less memory than a trie and is easier to write, and it is a perfectly good choice.

The numbers: how much faster is an index?

I built all three versions (scan, sorted list with binary search, and trie), checked that they return identical results on 500 random queries, and then timed them. The test is typing the four letters anit, taking the best of five runs, with different address book sizes. These figures come from Python on a cloud machine, so a real phone running compiled code will be quicker in absolute terms. What matters is the ratio.

ContactsScan everythingSorted list + binary searchTrieTrie vs scan
1,0003.13 ms0.025 ms0.011 ms275x faster
10,00032.87 ms0.28 ms0.089 ms368x faster
100,000345.59 ms8.18 ms1.31 ms263x faster
Scan everything345.6 msSorted list + bisect8.2 msTrie1.3 msTyping “anit” with 100,000 contacts. Bar length uses a log scale: each 1/3 of the width is 10 times longer.
Time to type four letters with 100,000 contacts. The scan is hundreds of times slower than the trie.

Look at the first row of the table. With 1,000 contacts, every method finishes in a few milliseconds or less, and nobody would notice a difference. That is the honest takeaway: for a normal-sized address book, even the naive scan is fast enough. The index only earns its keep as the data grows. At 100,000 entries the scan needed about a third of a second to answer four keystrokes, which is long enough to feel sluggish, while the trie needed about a millisecond.

Step 4: searching by phone number

Name search matches the start of words. Number search needs different rules, because people remember numbers differently. They recall the last four digits, or the number without the country code, or they type it with spaces and dashes that were never stored.

The standard solution is to normalise numbers the same way we normalised names. Strip everything that is not a digit, and maybe drop a leading country code such as +91, so that “+91 98765-43210”, “098765 43210” and “9876543210” all become the same string. Then, instead of matching only at the beginning, you let the typed digits match anywhere in the number, or at the end for the “last four digits” habit.

Real-world picture: a security guard asking, “What are the last four digits of your flat number?” People anchor on the end of a number because that is the part they have said out loud the most.

Because digits have only ten possible characters, a similar trie or sorted index works well. For suffix searching, you can index the reversed number so “last four digits” becomes an ordinary prefix lookup.

Step 5: put the most likely person first (ranking)

Finding matches is only half the job. If typing “sha” returns thirty people in alphabetical order, the one you wanted may be on the third screen. So the list has to be ranked.

The idea is to give every match a score from several signals, then sort by score. The signals I always start with are: how well the text matches (the whole name starts with it, a word starts with it, or it appears in the middle), whether you marked the person as a favourite, and how often or how recently you contact them.

from norm import normalise

def score(contact, query):
    """Higher is better. contact = dict(name, favorite, calls)."""
    words = normalise(contact["name"])
    q = normalise(query)
    s = 0
    if " ".join(words).startswith(" ".join(q)):
        s += 100                      # the whole name starts with what you typed
    elif all(any(w.startswith(t) for w in words) for t in q):
        s += 60                       # every typed word starts some word in the name
    elif all(t in " ".join(words) for t in q):
        s += 20                       # typed text appears inside a word
    else:
        return 0
    s += 30 if contact["favorite"] else 0
    s += min(contact["calls"], 50) * 0.5   # frequent contacts float up
    return s

contacts = [
    {"name": "Shalini Rao",   "favorite": False, "calls": 2},
    {"name": "Onam Sharma",   "favorite": False, "calls": 40},
    {"name": "Sharad Verma",  "favorite": True,  "calls": 12},
    {"name": "Ashwin Shah",   "favorite": False, "calls": 5},
    {"name": "Harsh Gupta",   "favorite": False, "calls": 60},
]
query = "sha"
ranked = sorted(((score(c, query), c["name"]) for c in contacts), reverse=True)
for s, name in ranked:
    if s: print(f"{s:6.1f}  {name}")

Running it for the typed text “sha” gives:

 136.0  Sharad Verma
 101.0  Shalini Rao
  80.0  Onam Sharma
  62.5  Ashwin Shah

Notice the order. Sharad Verma is first because his name starts exactly with “sha” and he is a favourite. Shalini Rao is next on pure match quality. Onam Sharma follows, as only the surname starts with “sha”, even though he is called often. Ashwin Shah comes last of the matches, and Harsh Gupta does not appear at all because “sha” never occurs in his name. The weights (100, 60, 20, 30 and the call bonus) are just numbers I chose to demonstrate. In a real product, teams tune them with usage data, and that tuning is the difference between a search that is merely correct and one that feels uncannily helpful.

Step 6: forgiving typos (fuzzy matching)

Thumbs are clumsy and names have many spellings. Is it Anita or Anitha? Sunita or Suneeta? Strict prefix matching treats any typo as a complete failure. Fuzzy matching asks a gentler question: how many small edits would it take to turn what you typed into this name?

The classic measure is edit distance, also called Levenshtein distance, which counts single-letter insertions, deletions and replacements. Here it is in a few lines:

def edit_distance(a, b):
    """Minimum single-letter edits (insert, delete, replace) to turn a into b."""
    prev = list(range(len(b) + 1))
    for i, ca in enumerate(a, 1):
        cur = [i]
        for j, cb in enumerate(b, 1):
            cur.append(min(prev[j] + 1, cur[j-1] + 1, prev[j-1] + (ca != cb)))
        prev = cur
    return prev[-1]

names = ["Anita", "Amit", "Anil", "Ankit", "Anjali"]
typed = "Anitha"           # another common spelling of "Anita"
for name in sorted(names, key=lambda n: edit_distance(typed.lower(), n.lower())):
    print(f"{name:<7} distance {edit_distance(typed.lower(), name.lower())}")

The output:

Anita   distance 1
Amit    distance 3
Anil    distance 3
Ankit   distance 3
Anjali  distance 4

“Anitha” is one deletion away from “Anita”, so Anita comes first, and everything else is at least three edits away. A search that falls back to fuzzy matching only when the exact search finds nothing gives you the best of both: exact results are fast and clean, and typos still find someone.

One small warning from experience: in plain edit distance, swapping two neighbouring letters (typing “Aniat” for “Anita”) costs two edits, not one, even though it is the most common typing mistake. A variant called Damerau-Levenshtein counts a swap as one. Also, comparing your text against every contact is slow, so real systems use fuzzy matching sparingly, usually on a shortlist, or use clever indexes built for it.

A fun relic: the keypad dialer

Many phone dialers let you type digits on the number pad and show contacts whose names could be spelled with those keys. It is the old keypad letter layout (2 for ABC, 3 for DEF and so on) doing a modern job.

KEYS = {"2":"abc","3":"def","4":"ghi","5":"jkl","6":"mno","7":"pqrs","8":"tuv","9":"wxyz"}
LETTER = {ch: d for d, chars in KEYS.items() for ch in chars}

def to_digits(name):
    return "".join(LETTER[c] for c in name.lower() if c in LETTER)

for name in ["Anita", "Amit", "Anil", "Ankit"]:
    print(f"{name:<6} -> {to_digits(name)}")

typed = "26"                          # you pressed 2 then 6 on the dialer
matches = [n for n in ["Anita", "Amit", "Anil", "Ankit", "Rahul"] if to_digits(n).startswith(typed)]
print("typed", typed, "matches", matches)
Anita  -> 26482
Amit   -> 2648
Anil   -> 2645
Ankit  -> 26548
typed 26 matches ['Anita', 'Amit', 'Anil', 'Ankit']

Pressing 2 then 6 matches every name whose first letter lives on the 2 key and whose second letter lives on the 6 key, which is why Anita, Amit, Anil and Ankit all appear. Add a 4 and Ankit drops out, because its third letter, K, sits on the 5 key. Add an 8 and Anil drops out too, leaving Amit and Anita. Working a few of these by hand is a good way to check that you really understand the mapping.

The same idea in five real settings

This pattern appears far beyond contacts. Click a tab to see it in other places.

Phone contacts

You open the phone app and type three letters of a friend’s name.

DataLocal
MatchWord starts
ExtrasRanking
SpeedInstant

How it searches. Everything lives on the device, so the lookup is local and needs no network. The list matches at the start of any word in the name, can also match digits in a number, and ranks favourites and frequently called people first. The exact internals vary between phone makers and are not published, but this is the standard design.

Email ‘To:’ box

You start typing a colleague’s name in the To field and a small list drops down.

SourcePast contacts
MatchName or address
RankingRecent first
LimitShort list

How it searches. The suggestions are usually drawn from people you have written to and from your address book, then ranked so recent and frequent contacts come first. Matching works on both name and email address, so typing the start of either works. The list is kept short, because a dropdown of fifty names would be slower to scan than to type.

Website search suggestions

You type into a shop’s search bar and suggestions appear below it.

DataOn a server
TrickDebounce
IndexPrefix based
DangerStale replies

How it searches. Here the index lives on a server, so every request costs real time. Pages wait a moment after you stop typing (debouncing) and cancel old requests so the suggestions always match the latest text. The suggestions often come from a prefix index of popular searches, which is the same idea as our trie.

Maps place search

You type “gandhi mar” and the app guesses a road you have never spelled in full.

DataHuge
IndexPrefix + fuzzy
RankingNearby first
TyposForgiven

How it searches. Place search has to cope with millions of names, so it relies on prebuilt indexes, not scans. It typically ranks results by closeness to you or to the map you are viewing, as well as text match, and tolerates misspellings. It shows why ranking signals beyond the text itself matter once the list is large.

Code editor autocomplete

You type get in a code editor and it offers getUser, getUserName and getUsers.

DataSymbols in project
MatchPrefix, initials
RankingContext
SpeedPer keystroke

How it searches. Editors index the names in your project and match what you type against them as you go. Many also match on initials, so typing “gUN” can find getUserName. Ranking uses context, such as which names are valid at the cursor, and how recently you used them. It is search-as-you-type applied to code.

When the data is not on your phone: debouncing and races

Everything so far searched a local list, where each lookup takes microseconds. Things change when the search has to ask a server, such as a company directory with a million staff or a search box on a website. Now each keystroke could trigger a network request taking 100 to 500 milliseconds, and two new problems appear.

  • Too many requests. Typing a seven-letter name could send seven requests in a second. The fix is debouncing: wait until the user pauses for a short time, often 150 to 300 milliseconds, before sending anything. The delay is barely noticeable, and the request count drops sharply.
  • Answers arriving out of order. The reply for “an” might arrive after the reply for “ani”, and the screen ends up showing results for the wrong text. The fix is to cancel the old request, or to tag each request with a number and ignore any reply that is not for the latest one.

Phone contact lists are local, so they rarely need debouncing. A server-backed search box almost always does. Knowing which situation you are in is half the design.

Hard cases that break simple search

  • Names in other scripts. A contact saved in Devanagari or Arabic script cannot be found by typing Latin letters unless the system stores a transliteration or the user switches keyboards. Many real apps index several forms of the same name.
  • Prefixes in surnames. “McDonald”, “de la Cruz” and “Al-Farsi” do not split into words the way English names do. Treat the hyphen and the apostrophe thoughtfully.
  • Accent rules differ by language. Stripping the dots from “ü” helps in French and Spanish but may be wrong in German searches, where “ue” is the usual substitute.
  • Sorting order. The alphabet itself differs between languages. Sorting by raw character codes puts all capitals before all lowercase letters. Use a locale-aware sort.
  • Duplicates. The same person saved twice with slightly different details should usually appear as one result.

Which approach should you choose?

Your situationGood choiceWhy
A few hundred or a few thousand itemsSimple scan, plus reuse of previous resultsFast enough, minimal code, easy to debug.
Tens of thousands of items, prefix searchSorted list with binary searchLean on memory and quick to write.
Very large lists, instant response neededTrie or similar indexLookup cost depends on typed length, not list size.
Users make typosExact search first, fuzzy fallbackKeeps the common case fast and still forgives mistakes.
Results must feel “smart”Ranking by match quality plus usagePuts the likely person first.
Data lives on a serverDebounce and cancel old requestsSaves bandwidth and avoids wrong-order results.

Common mistakes when building search-as-you-type

  • Skipping normalisation. Case and accent mismatches make good data look broken.
  • Matching anywhere instead of at word starts. Typing “an” returns “Hannah” and “Joanna” before “Anita”, because they contain “an” in the middle. Prefer word-start matches and rank anywhere-matches lower.
  • Not ranking at all. An alphabetical list of thirty matches is a correct answer and a poor experience.
  • Rebuilding the index on every keystroke. Build once, update when contacts change.
  • Doing the work on the main thread. Heavy searching on the thread that draws the screen makes the typing itself stutter.
  • Forgetting empty results. Show something helpful, such as “no match” or a fuzzy suggestion, not a blank screen.

Test yourself

Tap a question to reveal the answer. The correct option is marked.

Why can a search reuse the results for “an” when the user types “ani”?
  1. Because the phone remembers everything forever
  2. Every match for “ani” must also be a match for “an”
  3. Because searches are always cached on a server
  4. It cannot, a new full scan is required

Anything that starts with “ani” also starts with “an”, so the longer search can run on the shorter search’s results. It stops working when the user presses backspace.

In a trie, what does the cost of a lookup depend on?
  1. The number of contacts
  2. The number of contacts squared
  3. The length of the text you typed
  4. The alphabet size only

You walk one step per typed letter, so the cost follows the typed length, not the size of the list.

You type “sha” and the list is correct but alphabetical, and your favourite contact is on page three. What is missing?
  1. Ranking
  2. Normalisation
  3. Debouncing
  4. Encryption

Finding matches is only half the job. Scoring by match quality, favourites and how often you contact someone puts the likely person first.

Why debounce a search box that asks a server?
  1. To make the server slower
  2. To hide typos
  3. To sort the results
  4. To avoid sending a request on every keystroke

Waiting a short moment after the last keystroke cuts the number of requests sharply, and cancelling old requests keeps stale replies off the screen.

You typed “Anitha” but the contact is saved as “Anita”. Which technique finds it?
  1. Binary search
  2. Fuzzy matching using edit distance
  3. Debouncing
  4. Trie prefix lookup

Edit distance counts the small changes between two strings. “Anitha” is one deletion away from “Anita”, so it ranks first.

Frequently asked questions

How does contact search work so fast?

It does the heavy work ahead of time. Names are cleaned up and stored in an index, so each keystroke is a quick lookup instead of a scan of every contact. Results are then ranked so the likely person comes first.

What is a trie in simple words?

A tree where each step down spells one more letter. Words that start the same share the same path, so finding everything that begins with a few letters means walking down those letters and reading what lies below.

Why does searching “sh” find a surname that starts with sh?

Good contact search matches the start of any word in the name, not just the first word. That lets you type either the first or the last name.

Do I need a trie for a small app?

Usually not. With a few thousand items, a simple scan is fast enough. In my test, even 10,000 contacts took only a few milliseconds per keystroke for the naive version in Python. Build an index when the data is large or the response must be instant.

How do apps handle spelling mistakes?

Many try an exact prefix search first and, only if that finds little, fall back to fuzzy matching such as edit distance. That keeps common searches fast while still forgiving typos.

What is the difference between debouncing and caching?

Debouncing waits for a pause in typing before sending a request, which reduces how many requests you make. Caching remembers earlier answers so repeating a search needs no new work. Real search boxes often use both.

Key takeaways

  • Search-as-you-type is a new search on every keystroke, so each answer has to be nearly instant.
  • Normalise first: lowercase, strip accents, split into words. Then match the start of any word.
  • Results for a longer text are a subset of results for a shorter one, so you can reuse the previous list. In my test that cut name checks by about two-thirds.
  • An index (a trie or a sorted list with binary search) makes lookup cost depend on what you typed, not on how many contacts you have. At 100,000 contacts the scan was hundreds of times slower.
  • For a normal address book, a simple scan is already fast enough. Do not over-engineer small data.
  • Ranking, fuzzy matching and debouncing are what turn a correct search into one that feels smart.
search as you typeautocompletetriecontact searchprefix searchfuzzy matchingdata structures

The post How a Phone’s Contact Search Finds Matches as You Type appeared first on Learn With Examples.

]]>
https://learnwithexamples.org/how-a-phones-contact-search/feed/ 0 977
Big O Notation Explained with Real Examples https://learnwithexamples.org/big-o-notation-explained/ https://learnwithexamples.org/big-o-notation-explained/#respond Tue, 04 Aug 2026 17:20:21 +0000 https://learnwithexamples.org/?p=842 · Learn With Examples · Big O is not maths for its own sake. It is a one-line answer to the only question that matters when your data grows: does…

The post Big O Notation Explained with Real Examples appeared first on Learn With Examples.

]]>

· Learn With Examples ·

Big O is not maths for its own sake. It is a one-line answer to the only question that matters when your data grows: does this code get a little slower, or does it fall off a cliff?

Reading time13 min
LevelBeginner → Practical
Includes3 explorers

Here’s the moment Big O suddenly makes sense to people. I once watched a junior developer ship a feature that checked a list of orders for duplicates. It worked perfectly. Tests passed, code review passed, demo went beautifully. Three months later the same feature took nine minutes to load and brought a support queue to a standstill.

Nothing had changed in the code. The only thing that changed was the number of orders — from about 300 to about 40,000. His approach compared every order against every other order, so the work grew with the square of the list. Going 130 times bigger made it roughly 17,000 times slower.

Big O notation is how you spot that in advance, in about ten seconds, without running anything. It’s a shorthand for describing how the work an algorithm does grows as its input grows — and once you can read it, that nine-minute disaster becomes something you catch during code review instead of during an outage.

The one-paragraph version

What Big O actually says

Big O describes the growth rate of an algorithm’s work as the input size n increases. It deliberately ignores hardware, programming language, and constant factors, because those change by a factor of two or ten — while growth rates change by a factor of millions.

O(1) means the input size doesn’t matter at all. O(log n) means work barely grows. O(n) means it grows in step. O(n²) means doubling the input quadruples the work. That last one is where systems die.

The idea in one picture

Everything else in this article is detail attached to this one shape. Watch what happens to each line as you move right along the horizontal axis — that axis is your data growing over the life of your product.

input size n → operations → O(n log n) O(n²) O(n) O(log n) O(1)
The same five classes, drawn to scale. Notice that near the left edge they’re all bunched together — which is exactly why a slow algorithm looks fine in testing and only reveals itself in production.

That bunching on the left is the trap. With 20 test records, an O(n²) function and an O(n) function both return instantly. There is no observable difference. The difference only exists at scale, which means you cannot discover it by testing on small data — you have to reason about it. That reasoning is what Big O gives you.

The classes, with examples you’ll recognise

There are only six you meet regularly. Tap through them — each one has a plain-English meaning, a real piece of code, and an everyday analogy that makes the growth rate obvious.

Complexity explorer

tap a class

O(1)  Constant time

The work never changes, no matter how big the input gets. Looking up one item takes the same time in a list of ten or ten million.

Real example: fetching a value from a dictionary or hash map, reading array[500], pushing onto a stack, checking whether a number is even.

Analogy: opening a specific page in a book when you already know the page number. The book’s thickness is irrelevant.

O(log n)  Logarithmic time

Each step throws away half the remaining work. Doubling the input adds just one extra step — which is why this class stays fast at absurd scales.

Real example: binary search in a sorted array, finding a record in a balanced tree index, the depth of a database B-tree.

Analogy: the guessing game. “Is it higher or lower?” finds a number between 1 and a million in 20 guesses, because each guess halves the range.

Bars scaled to their own maximum — notice how the growth flattens out almost immediately.

O(n)  Linear time

Work grows in lockstep with the input. Twice the data, twice the time. Perfectly respectable, and often unavoidable — if you must look at every item, you cannot beat linear.

Real example: finding the largest number in an unsorted list, counting words in a document, validating every row of a CSV.

Analogy: reading every name on a guest list to find one person. A list twice as long takes twice as long.

O(n log n)  Linearithmic time

A linear pass repeated across a logarithmic number of levels. Slightly worse than linear, dramatically better than quadratic, and the practical ceiling for comparison-based sorting.

Real example: merge sort, quicksort on average, and every sort() in every standard library you have ever used.

Analogy: splitting a deck of cards in half repeatedly, then merging the piles back in order. You touch every card at each of about ten levels.

O(n²)  Quadratic time

Every item is compared against every other item. Double the input and the work quadruples. This is the class that quietly kills features six months after launch.

Real example: the duplicate-checking loop from the opening story, bubble sort, comparing every pair of records to find matches, a nested loop over the same list.

Analogy: every guest at a party shaking hands with every other guest. Ten guests means 45 handshakes; a hundred guests means 4,950.

O(2ⁿ)  Exponential time

Adding a single item doubles the work. Usable for tiny inputs and hopeless past roughly 40 items — at n = 60 you are past the age of the universe.

Real example: generating every possible subset, naive recursive Fibonacci, brute-forcing a password, solving the travelling salesman by trying every route.

Analogy: the grain-of-rice-on-a-chessboard story. Doubling per square sounds harmless until square 64 needs more rice than exists on Earth.

Why the difference is so violent

Abstract symbols don’t frighten anyone. Actual step counts do. Pick an input size below and look at how the same six classes behave on it.

Steps required, by input size

pick an n
ClassTypical operationSteps for n = 10Time at 1 billion ops/sec
O(1)Look up a key in a hash map1instant
O(log n)Binary search a sorted list3instant
O(n)Scan every item once10instant
O(n log n)A good sort33instant
O(n²)Compare every pair100instant
O(2ⁿ)Try every subset1,0241 microseconds
ClassTypical operationSteps for n = 100Time at 1 billion ops/sec
O(1)Look up a key in a hash map1instant
O(log n)Binary search a sorted list7instant
O(n)Scan every item once100instant
O(n log n)A good sort664instant
O(n²)Compare every pair10,00010 microseconds
O(2ⁿ)Try every subsetbeyond countinglonger than the universe has existed
ClassTypical operationSteps for n = 1,000Time at 1 billion ops/sec
O(1)Look up a key in a hash map1instant
O(log n)Binary search a sorted list10instant
O(n)Scan every item once1,0001 microseconds
O(n log n)A good sort9,96610 microseconds
O(n²)Compare every pair1.0 million1 ms
O(2ⁿ)Try every subsetbeyond countinglonger than the universe has existed
ClassTypical operationSteps for n = 1,000,000Time at 1 billion ops/sec
O(1)Look up a key in a hash map1instant
O(log n)Binary search a sorted list20instant
O(n)Scan every item once1.0 million1 ms
O(n log n)A good sort19.9 million20 ms
O(n²)Compare every pair1 trillion17 minutes
O(2ⁿ)Try every subsetbeyond countinglonger than the universe has existed

Swipe the table sideways on a narrow screen →

One billion operations per second is roughly a fast modern CPU doing simple work. The point is not the exact seconds — it is how violently the bottom two rows change as n grows.

Read across the O(n²) row as you switch sizes. At a thousand items it’s a million steps — a blink. At a million items it’s a trillion steps, and your feature is now a fifteen-minute job that times out. Nothing about the code changed. Only n did.

A faster computer buys you a constant factor. A better algorithm buys you a different curve. Only one of those scales.

The two rules that make Big O readable

Big O deliberately throws information away, and the two things it throws away trip up every beginner. Both rules exist for the same reason: at large n, only the fastest-growing part matters.

Rule 1 — drop the constants

An algorithm that does 3n + 12 operations is written O(n), not O(3n + 12). That looks like cheating, but consider: at a million items, 3n is three million and n² is a trillion. The constant 3 is noise beside that gap. And constants change anyway — a faster CPU, a better compiler, or a different language shifts them freely, while the shape of the curve does not.

Rule 2 — keep only the dominant term

An algorithm costing n² + 500n + 9000 is O(n²). At n = 10, the 500n part is actually bigger. At n = 10,000, the n² term is 100 million and the rest is five million — the square has swallowed everything. Big O describes where things end up, not where they start.

The honest caveat. Those discarded constants sometimes matter enormously in practice. An O(n log n) algorithm with a huge constant can lose to an O(n²) one on lists of thirty items — which is exactly why real sorting libraries switch to insertion sort for small chunks. Big O tells you what happens as data grows. It does not promise which is faster today, on your data, at your size.

How to find the Big O of code you’re looking at

You don’t need to count operations. Four patterns cover almost everything you’ll meet in ordinary application code.

What you seeWhat it meansResult
Statements one after anotherCosts add, then the biggest one winsO(a) + O(b) → the larger of the two
A loop over the inputBody runs n timesn × cost of the body
A loop inside a loopCosts multiplyUsually O(n²)
Input halves each stepOnly log₂n steps possibleO(log n)

Two things people get wrong constantly. First, two loops side by side are not quadratic — they’re n + n = 2n, which is linear. Nesting is what multiplies, not adjacency. Second, a loop with a fixed bound isn’t linear: looping 100 times regardless of input is constant work, because 100 doesn’t grow with n.

Five snippets — guess before you open

Read each one, decide, then open it. These are the shapes that actually appear in real code.

1. Checking a list for duplicates the obvious way
for i in range(len(orders)): for j in range(i + 1, len(orders)): if orders[i].id == orders[j].id: flag(orders[i])

O(n²) — a loop inside a loop over the same list. It runs about n²/2 times, and dropping the constant leaves n². This is the exact code from the opening story. The fix is a set: add each id to a set and check membership, turning it into O(n) with O(n) extra memory. Nine minutes becomes a fraction of a second.

2. Two loops, one after the other
for user in users: send_welcome(user) for user in users: log_signup(user)

O(n) — not O(n²). Sequential loops add: n + n = 2n, and the constant 2 gets dropped. Merging them into one loop makes the code twice as fast in wall-clock terms but leaves the complexity class unchanged. That distinction — real speedup, same Big O — is worth internalising.

3. Halving the search space
lo, hi = 0, len(sorted_items) - 1 while lo <= hi: mid = (lo + hi) // 2 if sorted_items[mid] == target: return mid if sorted_items[mid] < target: lo = mid + 1 else: hi = mid - 1

O(log n) — binary search. Each pass discards half of what’s left, so a billion items takes about 30 passes. The catch is the precondition: the list must already be sorted, and sorting costs O(n log n). Sorting once to search many times is a great trade; sorting once to search once is not.

4. A nested loop that isn’t quadratic
for order in orders: # n orders for tax in TAX_BANDS: # always 7 bands apply(order, tax)

O(n) — linear, despite the nesting. The inner loop’s length is fixed at 7 and never grows with the input, so the total is 7n, and the 7 is a constant. Nesting only multiplies complexity when both loops scale with the input. Judge by what grows, not by indentation.

5. The classic recursive trap
def fib(n): if n <= 1: return n return fib(n - 1) + fib(n - 2)

O(2ⁿ) — each call spawns two more, so the call tree roughly doubles at every level. fib(30) makes about 1.6 million calls; fib(50) would run for days. Caching results (memoisation) collapses it to O(n), because each value is then computed exactly once. Same algorithm on paper, a difference of billions in practice.

Best, average and worst case

One algorithm can have three different complexities depending on what you feed it. Searching an unsorted list for a name: if it’s first, you’re done in one step; if it’s last or missing, you check all n.

Best case

The luckiest possible input. Mostly useless for planning — nobody guarantees you luck.

Average case

What typical data does. The most honest number for everyday performance work.

Worst case

The input designed to hurt you. This is what Big O usually reports, and what you should plan around.

The default assumption when someone says “this is O(n log n)” is worst case, unless they say otherwise. That’s deliberately pessimistic, and it’s the right default: your worst case will eventually arrive, and it tends to arrive at the busiest moment. Quicksort is the cautionary tale — O(n log n) on average, O(n²) when the pivots go badly, and the input that triggers it is the very ordinary case of already-sorted data.

Space complexity counts too

Big O describes memory as readily as time. An in-place sort uses O(1) extra space; merge sort needs a scratch array, so O(n). The duplicate-checking fix above trades memory for speed — building a set of every id costs O(n) space to save you from O(n²) time. That trade is almost always worth taking, and recognising when it’s available is most of practical optimisation.

Where this actually matters

Code review

Spotting a nested loop over two growing collections takes seconds and prevents the outage three months later.

Choosing data structures

Array lookup by index is O(1); searching an array is O(n); a hash map turns that search into O(1). Most real speedups are structure changes, not clever code.

Database work

An index converts an O(n) table scan into an O(log n) lookup. That’s the whole reason indexes exist.

Interviews

“What’s the complexity?” is asked in nearly every technical interview, and the follow-up is always “can you do better?”

Don’t over-apply it. If your list has 50 items and always will, an O(n²) loop is completely fine and probably clearer to read. Big O matters when n can grow — and the real skill is asking “how big can this get?” before optimising anything. Most performance work is wasted on code that was never going to be the bottleneck.

Check yourself

Five questions. Open each to check — the correct option is marked.

1. An algorithm takes 4n + 200 steps. What is its Big O?
  • O(4n + 200)
  • O(n)
  • O(200)
  • O(n²)

Drop constants and lower-order terms — only the growth rate survives, and this one grows linearly.

2. Two separate loops over the same list of n items. Total complexity?
  • O(n²)
  • O(n)
  • O(2n²)
  • O(log n)

Sequential loops add — n + n = 2n, which is O(n). Only nested loops multiply.

3. Which class describes binary search on a sorted array?
  • O(1)
  • O(log n)
  • O(n)
  • O(n log n)

Each comparison eliminates half the remaining candidates, so a billion items needs only about 30 steps.

4. Your O(n²) function handles 1,000 records in one second. Roughly how long for 10,000?
  • 10 seconds
  • About 100 seconds
  • 1 second
  • 3 seconds

Ten times the input means a hundred times the work, because the growth is quadratic. This is the calculation to do before shipping, not after.

5. You replace a nested-loop duplicate check with a hash set. What changed?
  • Time O(n²) → O(n), memory unchanged
  • Time O(n²) → O(n), memory O(1) → O(n)
  • Nothing changes
  • Time gets worse, memory improves

You bought a much better time complexity by spending memory on the set. Recognising that trade is most of practical optimisation.

Frequently asked questions

What is Big O notation in simple terms?

It’s a shorthand for how an algorithm’s work grows as its input grows. O(n) means the work grows in step with the data; O(n²) means doubling the data quadruples the work. It describes the shape of that growth, not a measurement in seconds.

Why do we ignore constants in Big O?

Because constants depend on hardware, language and compiler, and they change by small factors — while growth rates change by factors of millions. At a million items the difference between 3n and n is trivial; the difference between n and n² is a trillion steps.

Is O(1) always faster than O(n)?

Not necessarily at small sizes. A constant-time operation with heavy overhead can lose to a linear scan of ten items. Big O describes behaviour as n grows large, so it’s a statement about trends, not a guarantee about any particular input.

What’s a good Big O to aim for?

O(1) and O(log n) are excellent, O(n) is usually fine and often unavoidable, O(n log n) is the practical target for sorting. Treat O(n²) as a warning sign worth investigating, and anything exponential as unusable beyond tiny inputs.

Do I need Big O if I’m not doing interviews?

Yes, though you’ll use it informally. You don’t need the formal maths, but you do need the instinct that says “this loop is inside that loop, and both grow with the data” — that instinct is what stops a working feature becoming an outage once real data arrives.

The takeaway

Big O answers one question: as your data grows, does this code degrade gently or catastrophically? O(1) and O(log n) barely notice. O(n) and O(n log n) scale honestly. O(n²) and worse are fine on toy data and fatal on real data.

You don’t need to derive it formally. You need to look at a function and ask two questions: what grows here, and is anything nested inside anything else that also grows? That habit catches the overwhelming majority of real performance problems, long before they reach production.

Try it on something you wrote this week. Find your longest loop, ask what its input can realistically grow to, and check whether anything scaling sits inside it. That’s the whole practice — and it takes about thirty seconds once the six shapes above are familiar.

big o notationtime complexityalgorithmsspace complexityperformancecomputer science

The post Big O Notation Explained with Real Examples appeared first on Learn With Examples.

]]>
https://learnwithexamples.org/big-o-notation-explained/feed/ 0 842
Sorting Algorithms Compared: Bubble vs Merge vs Quick https://learnwithexamples.org/bubble-vs-merge-vs-quick/ https://learnwithexamples.org/bubble-vs-merge-vs-quick/#respond Tue, 04 Aug 2026 16:14:19 +0000 https://learnwithexamples.org/?p=834 Learn With Examples · Computer Science Three ways to put a list in order. One you’d never ship, one you can trust with anything, and one that beats them both…

The post Sorting Algorithms Compared: Bubble vs Merge vs Quick appeared first on Learn With Examples.

]]>

Learn With Examples · Computer Science

Three ways to put a list in order. One you’d never ship, one you can trust with anything, and one that beats them both — until the day it doesn’t. Here’s how each actually works, watched step by step.

Hand a deck of shuffled cards to three people and watch what they do. The first goes along the row swapping any two neighbours in the wrong order, over and over, until a full pass changes nothing. The second splits the deck in half, sorts each half, then merges the two sorted piles by repeatedly taking the smaller of the two top cards. The third picks one card, throws everything smaller to the left and everything bigger to the right, then repeats on each side.

Those three people just performed bubble sort, merge sort and quicksort. No pseudocode required — the ideas are that physical. What separates them is not cleverness but how many comparisons they need, and that difference explodes as the deck gets bigger.

This article walks through all three step by step, then answers the question that actually matters at work: which one do you reach for, and when does the popular choice betray you?

The 20-second version

Bubble sort compares neighbours and swaps them. Beautifully simple, quadratic time, useless beyond a few hundred items. Learn it, then never ship it.

Merge sort splits, sorts, and merges. Reliably O(n log n) in every case and stable, but needs extra memory the size of your list.

Quicksort partitions around a pivot. Usually the fastest of the three in practice and sorts in place, but a bad pivot can drag it down to quadratic.

Step through all three

Pick an algorithm, then tap through the numbered steps. Cyan marks a comparison, pink a value being moved, amber quicksort’s pivot. All three start from the same six numbers — count how many steps each one needs.

Step through all three

tap the numbers

Step 1 of 15 — 5 was bigger than 1 — swap them

Step 2 of 15 — 5 was bigger than 4 — swap them

Step 3 of 15 — 5 was bigger than 2 — swap them

Step 4 of 15 — 5 and 8 are already in order — leave them

Step 5 of 15 — 8 was bigger than 3 — swap them

Step 6 of 15 — 1 and 4 are already in order — leave them

Step 7 of 15 — 4 was bigger than 2 — swap them

Step 8 of 15 — 4 and 5 are already in order — leave them

Step 9 of 15 — 5 was bigger than 3 — swap them

Step 10 of 15 — 1 and 2 are already in order — leave them

Step 11 of 15 — 2 and 4 are already in order — leave them

Step 12 of 15 — 4 was bigger than 3 — swap them

Step 13 of 15 — 1 and 2 are already in order — leave them

Step 14 of 15 — 2 and 3 are already in order — leave them

Step 15 of 15 — A full pass with no swaps — the list is sorted

Step 1 of 22 — Merging the sorted pieces [1] and [4]

Step 2 of 22 — Smaller front card is 1 — write it into slot 2

Step 3 of 22 — One pile is empty — copy 4 across

Step 4 of 22 — Merging the sorted pieces [5] and [1, 4]

Step 5 of 22 — Smaller front card is 1 — write it into slot 1

Step 6 of 22 — Smaller front card is 4 — write it into slot 2

Step 7 of 22 — One pile is empty — copy 5 across

Step 8 of 22 — Merging the sorted pieces [8] and [3]

Step 9 of 22 — Smaller front card is 3 — write it into slot 5

Step 10 of 22 — One pile is empty — copy 8 across

Step 11 of 22 — Merging the sorted pieces [2] and [3, 8]

Step 12 of 22 — Smaller front card is 2 — write it into slot 4

Step 13 of 22 — One pile is empty — copy 3 across

Step 14 of 22 — One pile is empty — copy 8 across

Step 15 of 22 — Merging the sorted pieces [1, 4, 5] and [2, 3, 8]

Step 16 of 22 — Smaller front card is 1 — write it into slot 1

Step 17 of 22 — Smaller front card is 2 — write it into slot 2

Step 18 of 22 — Smaller front card is 3 — write it into slot 3

Step 19 of 22 — Smaller front card is 4 — write it into slot 4

Step 20 of 22 — Smaller front card is 5 — write it into slot 5

Step 21 of 22 — One pile is empty — copy 8 across

Step 22 of 22 — Every level merged — the list is sorted

Step 1 of 17 — Pivot is 3 — everything smaller goes left of it

Step 2 of 17 — 5 is not smaller than 3 — leave it

Step 3 of 17 — 1 is smaller than 3 — move it left

Step 4 of 17 — 4 is not smaller than 3 — leave it

Step 5 of 17 — 2 is smaller than 3 — move it left

Step 6 of 17 — 8 is not smaller than 3 — leave it

Step 7 of 17 — Pivot 3 drops into its final position, permanently

Step 8 of 17 — Pivot is 2 — everything smaller goes left of it

Step 9 of 17 — Pivot 2 drops into its final position, permanently

Step 10 of 17 — Pivot is 4 — everything smaller goes left of it

Step 11 of 17 — 5 is not smaller than 4 — leave it

Step 12 of 17 — 8 is not smaller than 4 — leave it

Step 13 of 17 — Pivot 4 drops into its final position, permanently

Step 14 of 17 — Pivot is 5 — everything smaller goes left of it

Step 15 of 17 — 8 is not smaller than 5 — leave it

Step 16 of 17 — Pivot 5 drops into its final position, permanently

Step 17 of 17 — Every pivot placed — the list is sorted

Same starting list every time: [5, 1, 4, 2, 8, 3]. Count the steps each algorithm needs — that gap is the whole argument.

Bubble sort: the one everyone learns first

Bubble sort does exactly one thing: walk the list, compare each pair of neighbours, swap them if they’re out of order. Repeat until a full pass produces no swaps at all. Large values “bubble” to the end one position per pass, which is where the name comes from.

Sort [5, 1, 4, 2] by hand:

PassComparisonList after
15 vs 1 → swap[1, 5, 4, 2]
15 vs 4 → swap[1, 4, 5, 2]
15 vs 2 → swap[1, 4, 2, 5]
21 vs 4 → keep[1, 4, 2, 5]
24 vs 2 → swap[1, 2, 4, 5]
3no swaps made[1, 2, 4, 5] ✓
// bubble sort with the early-exit optimisation function bubbleSort(a) { for (let i = 0; i < a.length - 1; i++) { let swapped = false; for (let j = 0; j < a.length - 1 - i; j++) { if (a[j] > a[j + 1]) { [a[j], a[j + 1]] = [a[j + 1], a[j]]; swapped = true; } } if (!swapped) break; // already sorted, stop early } return a; }

The cost is brutal. Every pass compares nearly every pair, and you need close to n passes, so the work grows with n². Ten items cost about 45 comparisons. A thousand items cost roughly half a million. Ten thousand cost fifty million. You can feel that in a browser tab.

The one situation where bubble sort isn’t embarrassing is nearly sorted data. With the early-exit check above, an already-sorted list is verified in a single pass of n-1 comparisons — that’s O(n), better than merge sort’s best case. If you have a list where one element occasionally drifts out of place, a bubble pass is a perfectly reasonable repair. That’s a narrow niche, and insertion sort usually fills it better, but it’s real.

Why teach it at all, then? Because it makes the shape of the problem visible. Once you’ve watched bubble sort waste 20 comparisons re-checking pairs it already knows are fine, the motivation behind divide-and-conquer stops being abstract. It’s the algorithm you learn in order to want a better one.

Merge sort: split, sort, stitch

Merge sort refuses to compare distant items at all. Instead it breaks the list in half, again and again, until every piece has a single element — and a single element is sorted by definition. Then it walks back up, merging pairs of sorted pieces.

The merge step is where the magic sits, and it’s the part worth understanding physically. You have two sorted piles face up. Look at the top card of each. Take the smaller one. Repeat. Because both piles are sorted, the smallest remaining card is always one of the two you’re looking at — you never search, you never backtrack.

Merging [1, 4] and [2, 3]:

Left pileRight pileCompareOutput
[1, 4][2, 3]1 vs 2 → take 1[1]
[4][2, 3]4 vs 2 → take 2[1, 2]
[4][3]4 vs 3 → take 3[1, 2, 3]
[4][ ]right empty → copy 4[1, 2, 3, 4]

Count the levels: halving a list of 1,000 takes about 10 splits to reach single items, because 2¹⁰ is 1,024. Each level does roughly n comparison work to merge everything back. Ten levels × a thousand items ≈ 10,000 operations, against bubble sort’s half a million. That’s the entire argument for O(n log n), and it’s why merge sort’s worst case, best case and average case are all the same — the splitting doesn’t care what the data looks like.

Two properties make merge sort the professional’s safe choice:

It’s stable

Equal items keep their original relative order. Sort orders by date, then by customer, and the date order survives inside each customer. Quicksort does not promise this.

It’s predictable

No input can make it slow. For latency-sensitive systems, a guaranteed ceiling beats a lower average with a nasty tail.

It works off-disk

Merging needs only the front of each pile, so you can sort a 500 GB file with 8 GB of RAM. This is how external sorting works.

It costs memory

The standard version needs a scratch array as big as the input. On memory-tight systems, that’s the dealbreaker.

Quicksort: pick a pivot, throw everything to one side

Quicksort also divides and conquers, but it partitions before recursing rather than after. Choose one element as the pivot. Rearrange the list so everything smaller sits left of it and everything larger sits right. The pivot is now in its final position, permanently. Then repeat on the left chunk and the right chunk.

Take [7, 2, 9, 4, 5] with 5 as the pivot. Walk the rest: 7 is bigger (right), 2 is smaller (left), 9 is bigger (right), 4 is smaller (left). You get [2, 4] 5 [7, 9]. One pass, and 5 is done forever. Now solve the two small pieces the same way.

// quicksort, Lomuto partition scheme function quickSort(a, lo = 0, hi = a.length - 1) { if (lo >= hi) return a; const pivot = a[hi]; let i = lo; for (let j = lo; j < hi; j++) { if (a[j] < pivot) { [a[i], a[j]] = [a[j], a[i]]; i++; } } [a[i], a[hi]] = [a[hi], a[i]]; // pivot into place quickSort(a, lo, i - 1); quickSort(a, i + 1, hi); return a; }

On average the pivot lands somewhere near the middle, the problem halves each time, and you get O(n log n) — typically with a smaller constant factor than merge sort, because quicksort swaps within the original array instead of copying into a scratch one. Fewer memory writes, better cache behaviour, faster in the real world.

The trap. Suppose you always pick the last element as pivot, and the list is already sorted. Every pivot is the largest remaining value, so one side gets everything and the other gets nothing. You’ve turned O(n log n) into O(n²) — and the input that triggers it is the most common input in the world: data that’s already in order. Real implementations dodge this by picking a random pivot, or the median of the first, middle and last elements.

Race them on the same data

Same shuffled array, three algorithms, one operation per tick. This isn’t a wall-clock benchmark — it’s an operations count, which is what the big-O notation is actually measuring.

Head-to-head race

tick to start
Bubble ~1,800 comparisons
Merge ~300 comparisons
Quick ~250 comparisons

Each bar advances at a speed proportional to the work its algorithm really does on a shuffled 60-item list. Quick and merge finish while bubble sort is still grinding through its second pass.

On random data quicksort usually finishes first, merge close behind, bubble far back. Hand all three an already-sorted list, though, and the ranking inverts completely: bubble sort exits after a single clean pass, while quicksort with a naive last-element pivot collapses into its worst case. Same three algorithms, opposite result, purely because the input changed shape.

Big-O tells you how an algorithm behaves as data grows. It doesn’t tell you which one wins on your data. Only the shape of your input decides that.

The numbers, side by side

BubbleMergeQuick
Best caseO(n)O(n log n)O(n log n)
AverageO(n²)O(n log n)O(n log n)
Worst caseO(n²)O(n log n)O(n²)
Extra memoryO(1)O(n)O(log n)
Stable?YesYesNo
In place?YesNoYes
Use it whenTeaching, or tiny nearly-sorted listsYou need guarantees, stability, or external sortingYou want raw speed on in-memory data

Those symbols get abstract fast, so put real numbers on them. Pick a list size and watch the gap between quadratic and logarithmic growth open up.

How bad does n² get?

pick a list size
45Bubble ops (n²)
33Merge / quick ops
1.4×Times more work
instantBubble time est.
5,000Bubble ops (n²)
664Merge / quick ops
7.5×Times more work
under 1 msBubble time est.
500,000Bubble ops (n²)
9,966Merge / quick ops
50×Times more work
5 msBubble time est.
5 billionBubble ops (n²)
1,660,964Merge / quick ops
3,010×Times more work
50 secondsBubble time est.

Notice how the ratio behaves. At 100 items bubble sort does around 15 times more work — annoying, survivable. At 100,000 items it does over 3,000 times more. That is the difference between a page that renders instantly and one that hangs the browser for a minute. Complexity classes don’t matter much at small scale and matter enormously at large scale, which is exactly why beginners under-rate them.

So which one do you actually use?

Honest answer for day-to-day work: call your language’s built-in sort. It’s been tuned by people who do nothing else. But knowing what’s under it tells you when to override it.

Sorting objects by two fields

You need stability — merge sort, or a built-in that guarantees it. Sort by the secondary key first, then the primary.

Big array, memory is tight

Quicksort. In-place, cache-friendly, only recursion stack overhead. Just randomise the pivot.

Data bigger than RAM

Merge sort. It’s the only one of the three that sorts chunks on disk and merges streams.

Worst case must be bounded

Merge sort. Real-time and latency-critical systems care about the ceiling, not the average.

Fewer than ~20 items

Use insertion sort. Its overhead is so low it beats the clever algorithms at small sizes — which is why real implementations switch to it below a threshold.

Explaining sorting to someone

Bubble sort, once. Then show them the step-through above and let the operation count make the argument.

What most standard libraries actually run is a hybrid. Introsort starts as quicksort, counts its recursion depth, and switches to heapsort if the pivots are going badly — giving quicksort’s speed with a guaranteed O(n log n) ceiling. Timsort, used in Python and Java for objects, is merge sort that detects runs of already-ordered data and exploits them, which makes it startlingly fast on the semi-sorted data that real applications produce. Both are engineering answers to the trade-offs on this page.

Check yourself

Five questions. Open each one to check yourself — the correct option is marked.

1. Why is merge sort’s worst case the same as its best case?
  • It checks the data first
  • The splitting is fixed and doesn’t depend on the values
  • It uses extra memory
  • It’s stable

Merge sort always halves the list, whatever’s in it. The number of levels is fixed at log₂n, so no input can make it slow.

2. Which input makes naive quicksort hit its worst case?
  • Random data
  • Data with many duplicates only
  • Already-sorted data with a last-element pivot
  • Very short lists

Every pivot becomes the largest remaining value, so one partition gets everything. The fix is a random or median-of-three pivot.

3. What does “stable” mean for a sorting algorithm?
  • Equal items keep their original relative order
  • It never crashes
  • It always takes the same time
  • It uses no extra memory

Stability lets you sort by one field, then another, and keep the first ordering inside groups. Quicksort doesn’t guarantee it.

4. Roughly how many comparisons does bubble sort need for 1,000 items?
  • About 1,000
  • About 10,000
  • About 500,000
  • About 1,000,000

Roughly n²/2 — half a million. Merge sort handles the same list in about 10,000.

5. You must sort a 400 GB log file on a machine with 16 GB of RAM. Which approach?
  • Quicksort, it’s in place
  • Merge sort, sorting chunks then merging streams
  • Bubble sort, it uses no extra memory
  • None can do it

External merge sort only needs the front of each sorted run in memory at a time — the classic solution to sorting more data than you can hold.

Frequently asked questions

Which sorting algorithm is fastest?

For general in-memory data, quicksort is usually fastest in practice because it sorts in place with excellent cache behaviour. Merge sort is faster in the worst case, since quicksort can degrade to O(n²) with bad pivots. Bubble sort is slowest by a wide margin at any meaningful size.

Is bubble sort ever useful in real code?

Rarely, but not never. On a list that’s already nearly sorted, the early-exit version runs in O(n) and is trivial to write and verify. For anything else, insertion sort does the same job better and your language’s built-in sort beats both.

Why does quicksort beat merge sort if merge sort has a better worst case?

Constant factors. Quicksort swaps elements inside the original array; merge sort copies into a scratch array and back on every level. Fewer memory writes and better cache locality mean quicksort typically wins on wall-clock time even when both are O(n log n).

What does O(n log n) actually mean in plain terms?

The work grows a little faster than the list size, but nowhere near as fast as squaring it. Double the items and you do slightly more than double the work — whereas O(n²) means doubling the items quadruples the work. That gap is what makes large-scale sorting feasible at all.

Which sort do Python, Java and JavaScript use?

Python uses Timsort, a merge sort variant that detects existing sorted runs. Java uses Timsort for objects and a dual-pivot quicksort for primitives. Most JavaScript engines use Timsort-style stable sorts for Array.sort(). All three are hybrids built from the ideas on this page.

The takeaway

Bubble sort compares neighbours and pays for it quadratically. Merge sort splits the problem into halves whose cost adds up to n log n, every single time, at the price of extra memory. Quicksort partitions around a pivot and is usually the fastest of the three — as long as the pivot is chosen sensibly.

The deeper lesson isn’t which one wins. It’s that the same task can be organised in ways whose costs diverge by a factor of thousands, and that the winning approach depends on the shape of your data, not on the elegance of the code. Step back through the three walkthroughs above and count what each one needed on the very same six numbers — then imagine those gaps at a million items. That is the thing worth remembering.

bubble sortmerge sortquicksortbig-Oalgorithmsdata structures

The post Sorting Algorithms Compared: Bubble vs Merge vs Quick appeared first on Learn With Examples.

]]>
https://learnwithexamples.org/bubble-vs-merge-vs-quick/feed/ 0 834
How Binary Search Works — with Visual Examples https://learnwithexamples.org/how-binary-search-works-visual-examples/ https://learnwithexamples.org/how-binary-search-works-visual-examples/#respond Wed, 15 Jul 2026 16:10:39 +0000 https://learnwithexamples.org/?p=736 How Binary Search Works — with Visual Examples Algorithms · Beginner Binary search is one of the most elegant algorithms ever written. It finds any value in a sorted list…

The post How Binary Search Works — with Visual Examples appeared first on Learn With Examples.

]]>
How Binary Search Works — with Visual Examples

Algorithms · Beginner

Binary search is one of the most elegant algorithms ever written. It finds any value in a sorted list of a million items in just 20 steps. This guide shows you exactly how — with live visualizations, code in 3 languages, and a speed comparison you can feel.

📖 15 min read 🎮 3 interactive demos 💻 Python · JS · Java ❓ Quiz at the end

The Problem Binary Search Solves

Imagine you have a sorted list of 1,000,000 numbers and you need to find whether the number 742,891 is in it. The naive approach — checking every single element one by one — would take up to a million comparisons in the worst case.

Binary search solves this in at most 20 comparisons. Not 20,000. Not 2,000. Twenty. That’s the power of dividing the problem in half at every step.

Linear Search 1,000,000 steps
Binary Search 20 steps

📌 One Requirement

Binary search only works on a sorted array. This is the most important rule. We’ll revisit why at the end.

The Phone Book Intuition

You’ve already used binary search in real life. When you look up a name in a phone book (or a word in a dictionary), you don’t start from page 1 and flip forward.

You open to the middle. If the name you want comes alphabetically before the middle page, you throw away the right half and repeat on the left. If it comes after, you throw away the left half and repeat on the right. Each time, you eliminate half the remaining possibilities.

💡 Key Insight

Every comparison eliminates half of the remaining elements. That’s why 1,000,000 items only needs log₂(1,000,000) ≈ 20 steps. The algorithm grows with the logarithm of the input, not the input itself.

How It Works — Step by Step

Binary search uses three pointers on the array: low, mid, and high. Here’s the algorithm:

1

Set low = 0, high = last index

Start with the full array in scope. Low points to the first element, high points to the last.

2

Calculate mid = (low + high) / 2

Find the middle index. In Python/Java use integer division: (low + high) // 2 to avoid float issues.

3

Compare array[mid] with target

Three possible outcomes: equal (found it! return mid), target is smaller (search left half: high = mid − 1), target is larger (search right half: low = mid + 1).

4

Repeat until found or low > high

If low exceeds high, the target is not in the array — return −1 (or null/None depending on your language).

⚠ Integer Overflow Tip

In languages like Java/C++, computing (low + high) / 2 can overflow for very large arrays. The safe formula is: low + (high - low) / 2.

Live Visualizer — Try It Yourself

Enter any number between 1 and 99 and watch binary search find it step by step. The yellow cell is the current midpoint being checked, grey cells are eliminated, and green means found.

  Binary Search Visualizer
Press ▶ Search to start, or Step → to go one step at a time.

Code in Python, JavaScript & Java

Python

Python
def binary_search(arr, target):
    low, high = 0, len(arr) - 1

    while low <= high:
        mid = low + (high - low) // 2  # safe from integer overflow

        if arr[mid] == target:
            return mid              # found! return the index
        elif arr[mid] < target:
            low = mid + 1           # target is in right half
        else:
            high = mid - 1          # target is in left half

    return -1                       # not found


# Example usage
numbers = [2, 7, 13, 19, 25, 34, 37, 46, 58, 72]
result = binary_search(numbers, 37)
print(f"Found at index: {result}")  # → Found at index: 6
print(binary_search(numbers, 99))   # → -1 (not found)

JavaScript

JavaScript
function binarySearch(arr, target) {
  let low = 0;
  let high = arr.length - 1;

  while (low <= high) {
    const mid = low + Math.floor((high - low) / 2);

    if (arr[mid] === target)  return mid;      // found
    if (arr[mid] < target)   low = mid + 1;   // search right
    else                       high = mid - 1;  // search left
  }

  return -1; // not found
}

// Example
const nums = [2, 7, 13, 19, 25, 34, 37, 46, 58, 72];
console.log(binarySearch(nums, 37));  // 6
console.log(binarySearch(nums, 99));  // -1

Java

Java
public class BinarySearch {

    public static int binarySearch(int[] arr, int target) {
        int low = 0;
        int high = arr.length - 1;

        while (low <= high) {
            int mid = low + (high - low) / 2; // avoids overflow

            if (arr[mid] == target)  return mid;
            if (arr[mid] <  target)  low  = mid + 1;
            else                       high = mid - 1;
        }

        return -1; // not found
    }

    public static void main(String[] args) {
        int[] nums = {2, 7, 13, 19, 25, 34, 37, 46, 58, 72};
        System.out.println(binarySearch(nums, 37)); // 6
        System.out.println(binarySearch(nums, 99)); // -1
    }
}

Time & Space Complexity

Binary search is one of the most efficient search algorithms. Its performance is measured using Big O notation.

Case Time Complexity What it means
Best Case O(1) Target is at the midpoint on the first check
Average Case O(log n) Halves the search space each iteration
Worst Case O(log n) Target not found after exhausting all halves
Space (iterative) O(1) Only stores low, mid, high — no extra memory
Space (recursive) O(log n) Call stack grows with each recursive call

📐 The log n explained

log₂(n) is the number of times you can halve n before reaching 1. For n = 1,024 → log₂(1024) = 10 steps. For n = 1,048,576 (1 million) → just 20 steps. Doubling the input only adds one more step.

Binary vs Linear Search — The Speed Race

Drag the slider to set the array size and watch how many steps each algorithm needs. The difference becomes dramatic very quickly.

  Speed Comparison Demo
1,000
Linear
500 steps
Binary
10 steps

* Linear shows average-case (n/2). Binary shows worst-case (log₂n). Both searching the same array.

The One Rule You Must Not Break

Binary search requires a sorted array. This is non-negotiable. Here’s why: when the algorithm looks at the midpoint and finds the target is smaller, it assumes everything to the right is also larger. In an unsorted array, that assumption is false — and the algorithm silently gives wrong answers.

Python — What goes wrong with unsorted input
# ❌ WRONG — unsorted array
unsorted = [37, 2, 72, 13, 25]
binary_search(unsorted, 2)   # returns -1 even though 2 is there!

# ✅ CORRECT — sort first
sorted_arr = sorted(unsorted)  # [2, 13, 25, 37, 72]
binary_search(sorted_arr, 2)  # returns 0 ✓

# Tip: Python has bisect module built-in for production use
import bisect
idx = bisect.bisect_left(sorted_arr, 2)
print(sorted_arr[idx] == 2)    # True

⛔ Common Mistake

Sorting takes O(n log n) time. If you’re only searching once, sorting + binary search is slower than linear search. Binary search pays off when you search the same sorted data many times — the sort cost is paid once, searches are O(log n) forever.

Where Binary Search Is Used in Real Life

Binary search isn’t just a textbook exercise. It runs inside software you use every day.

🗄

Database Indexes

B-trees (the data structure behind MySQL, PostgreSQL indexes) use a generalized form of binary search to find rows in milliseconds across millions of records.

📦

Package Managers

npm, pip, and apt use binary search on sorted version lists to find compatible package versions quickly.

🎮

Game Development

Finding which tile a player is on, collision detection bounds, and sorted leaderboard lookups all use binary search variants.

🔤

Spell Checkers

Dictionaries are sorted. When you type a word, spell checkers run binary search on the dictionary to verify it in O(log n) time.

📡

Git Bisect

Git’s git bisect command uses binary search through your commit history to find which commit introduced a bug.

🌐

IP Routing

Routers use binary search on sorted prefix tables to find the right network path for packets — billions of times per second.

Knowledge Quiz

Five questions to lock in what you’ve learned.

  Binary Search Quiz
Question 1 of 5

The post How Binary Search Works — with Visual Examples appeared first on Learn With Examples.

]]>
https://learnwithexamples.org/how-binary-search-works-visual-examples/feed/ 0 736
Top 10 Algorithms Every Programmer Should Know https://learnwithexamples.org/top-10-algorithms-every-programmer-should-know/ https://learnwithexamples.org/top-10-algorithms-every-programmer-should-know/#respond Fri, 20 Jun 2025 16:09:33 +0000 https://learnwithexamples.org/?p=448 Also check: Understanding the Magic Behind Computers

The post Top 10 Algorithms Every Programmer Should Know appeared first on Learn With Examples.

]]>
Top 10 Algorithms Every Programmer Should Know

Master the Fundamentals

Algorithms are the backbone of computer science and programming. Understanding these fundamental algorithms will make you a better programmer, improve your problem-solving skills, and help you excel in technical interviews. This comprehensive guide covers the top 10 algorithms every programmer should master, complete with interactive examples and detailed explanations.

1. Binary Search Algorithm

Binary search is one of the most efficient searching algorithms for sorted arrays. It works by repeatedly dividing the search interval in half, comparing the target value with the middle element, and eliminating half of the remaining elements in each iteration.

How Binary Search Works:

  1. Start with the entire sorted array
  2. Find the middle element
  3. If the middle element equals the target, return its index
  4. If the target is less than the middle element, search the left half
  5. If the target is greater than the middle element, search the right half
  6. Repeat until the element is found or the array is exhausted
function binarySearch(arr, target) { let left = 0; let right = arr.length - 1; while (left <= right) { let mid = Math.floor((left + right) / 2); if (arr[mid] === target) { return mid; } else if (arr[mid] < target) { left = mid + 1; } else { right = mid - 1; } } return -1; // Element not found }

Interactive Binary Search Demo

Time Complexity Space Complexity Best Case Worst Case
O(log n) O(1) O(1) O(log n)

2. Quick Sort Algorithm

Quick Sort is a highly efficient divide-and-conquer sorting algorithm. It works by selecting a 'pivot' element and partitioning the other elements into two sub-arrays according to whether they are less than or greater than the pivot.

Key Insight: Quick Sort's average-case performance is excellent, making it one of the most popular sorting algorithms in practice.
function quickSort(arr, low = 0, high = arr.length - 1) { if (low < high) { let pivotIndex = partition(arr, low, high); quickSort(arr, low, pivotIndex - 1); quickSort(arr, pivotIndex + 1, high); } return arr; } function partition(arr, low, high) { let pivot = arr[high]; let i = low - 1; for (let j = low; j < high; j++) { if (arr[j] < pivot) { i++; [arr[i], arr[j]] = [arr[j], arr[i]]; } } [arr[i + 1], arr[high]] = [arr[high], arr[i + 1]]; return i + 1; }

Interactive Quick Sort Demo

Average Case Best Case Worst Case Space Complexity
O(n log n) O(n log n) O(n²) O(log n)

3. Merge Sort Algorithm

Merge Sort is a stable, divide-and-conquer algorithm that divides the array into halves, sorts them separately, and then merges them back together. It guarantees O(n log n) time complexity in all cases.

function mergeSort(arr) { if (arr.length <= 1) { return arr; } const mid = Math.floor(arr.length / 2); const left = mergeSort(arr.slice(0, mid)); const right = mergeSort(arr.slice(mid)); return merge(left, right); } function merge(left, right) { let result = []; let leftIndex = 0; let rightIndex = 0; while (leftIndex < left.length && rightIndex < right.length) { if (left[leftIndex] < right[rightIndex]) { result.push(left[leftIndex]); leftIndex++; } else { result.push(right[rightIndex]); rightIndex++; } } return result.concat(left.slice(leftIndex)).concat(right.slice(rightIndex)); }

Interactive Merge Sort Demo

4. Depth-First Search (DFS)

DFS is a graph traversal algorithm that explores as far as possible along each branch before backtracking. It can be implemented using recursion or an explicit stack.

// Recursive DFS for adjacency list representation function dfsRecursive(graph, node, visited = new Set()) { visited.add(node); console.log(node); for (let neighbor of graph[node] || []) { if (!visited.has(neighbor)) { dfsRecursive(graph, neighbor, visited); } } } // Iterative DFS using stack function dfsIterative(graph, startNode) { const visited = new Set(); const stack = [startNode]; while (stack.length > 0) { const node = stack.pop(); if (!visited.has(node)) { visited.add(node); console.log(node); for (let neighbor of graph[node] || []) { if (!visited.has(neighbor)) { stack.push(neighbor); } } } } }

Interactive DFS Demo

Graph representation: A → [B, C], B → [D, E], C → [F], D → [], E → [F], F → []

5. Breadth-First Search (BFS)

BFS explores all vertices at the current depth before moving to vertices at the next depth level. It uses a queue data structure and is particularly useful for finding the shortest path in unweighted graphs.

function bfs(graph, startNode) { const visited = new Set(); const queue = [startNode]; const result = []; visited.add(startNode); while (queue.length > 0) { const node = queue.shift(); result.push(node); for (let neighbor of graph[node] || []) { if (!visited.has(neighbor)) { visited.add(neighbor); queue.push(neighbor); } } } return result; } // BFS for shortest path function bfsShortestPath(graph, start, target) { const queue = [[start, [start]]]; const visited = new Set([start]); while (queue.length > 0) { const [node, path] = queue.shift(); if (node === target) { return path; } for (let neighbor of graph[node] || []) { if (!visited.has(neighbor)) { visited.add(neighbor); queue.push([neighbor, [...path, neighbor]]); } } } return null; // No path found }

Interactive BFS Demo

6. Dynamic Programming - Fibonacci Sequence

Dynamic Programming is an optimization technique that solves complex problems by breaking them down into simpler subproblems. The Fibonacci sequence is a classic example where DP can dramatically improve performance.

// Naive recursive approach - O(2^n) function fibonacciNaive(n) { if (n <= 1) return n; return fibonacciNaive(n - 1) + fibonacciNaive(n - 2); } // Dynamic Programming approach - O(n) function fibonacciDP(n) { if (n <= 1) return n; const dp = [0, 1]; for (let i = 2; i <= n; i++) { dp[i] = dp[i - 1] + dp[i - 2]; } return dp[n]; } // Space-optimized DP - O(1) space function fibonacciOptimized(n) { if (n <= 1) return n; let prev = 0, curr = 1; for (let i = 2; i <= n; i++) { const temp = curr; curr = prev + curr; prev = temp; } return curr; }

Interactive Fibonacci Demo

7. Dijkstra's Shortest Path Algorithm

Dijkstra's algorithm finds the shortest path between nodes in a weighted graph. It's widely used in networking protocols, GPS navigation, and social networking applications.

function dijkstra(graph, start) { const distances = {}; const visited = new Set(); const previous = {}; // Initialize distances for (let node in graph) { distances[node] = node === start ? 0 : Infinity; previous[node] = null; } while (visited.size < Object.keys(graph).length) { // Find unvisited node with minimum distance let minNode = null; for (let node in distances) { if (!visited.has(node) && (minNode === null || distances[node] < distances[minNode])) { minNode = node; } } if (minNode === null || distances[minNode] === Infinity) break; visited.add(minNode); // Update distances to neighbors for (let neighbor in graph[minNode]) { const distance = distances[minNode] + graph[minNode][neighbor]; if (distance < distances[neighbor]) { distances[neighbor] = distance; previous[neighbor] = minNode; } } } return { distances, previous }; }

Interactive Dijkstra's Demo

Sample graph: A→B(4), A→C(2), B→C(1), B→D(5), C→D(8), C→E(10), D→E(2)

8. Hash Table Implementation

Hash tables provide average O(1) time complexity for insertions, deletions, and lookups. They use a hash function to map keys to array indices, making them incredibly efficient for many operations.

class HashTable { constructor(size = 10) { this.size = size; this.buckets = new Array(size).fill(null).map(() => []); } hash(key) { let hash = 0; for (let i = 0; i < key.length; i++) { hash += key.charCodeAt(i); } return hash % this.size; } set(key, value) { const index = this.hash(key); const bucket = this.buckets[index]; // Check if key already exists for (let i = 0; i < bucket.length; i++) { if (bucket[i][0] === key) { bucket[i][1] = value; return; } } // Add new key-value pair bucket.push([key, value]); } get(key) { const index = this.hash(key); const bucket = this.buckets[index]; for (let [k, v] of bucket) { if (k === key) return v; } return undefined; } delete(key) { const index = this.hash(key); const bucket = this.buckets[index]; for (let i = 0; i < bucket.length; i++) { if (bucket[i][0] === key) { bucket.splice(i, 1); return true; } } return false; } }

Interactive Hash Table Demo

9. Binary Tree Traversal

Binary tree traversal algorithms are fundamental for working with tree data structures. The three main traversal methods are in-order, pre-order, and post-order traversal.

class TreeNode { constructor(val, left = null, right = null) { this.val = val; this.left = left; this.right = right; } } // In-order traversal (Left, Root, Right) function inorderTraversal(root, result = []) { if (root !== null) { inorderTraversal(root.left, result); result.push(root.val); inorderTraversal(root.right, result); } return result; } // Pre-order traversal (Root, Left, Right) function preorderTraversal(root, result = []) { if (root !== null) { result.push(root.val); preorderTraversal(root.left, result); preorderTraversal(root.right, result); } return result; } // Post-order traversal (Left, Right, Root) function postorderTraversal(root, result = []) { if (root !== null) { postorderTraversal(root.left, result); postorderTraversal(root.right, result); result.push(root.val); } return result; }

Interactive Binary Tree Traversal Demo

Sample tree structure:

1
2      3
4 5   6 7

10. Two Pointers Technique

The two pointers technique is a powerful algorithmic approach used to solve array and string problems efficiently. It involves using two pointers that move through the data structure to find a solution.

// Two Sum - Sorted Array function twoSumSorted(numbers, target) { let left = 0; let right = numbers.length - 1; while (left < right) { const sum = numbers[left] + numbers[right]; if (sum === target) { return [left, right]; } else if (sum < target) { left++; } else { right--; } } return [-1, -1]; } // Remove Duplicates from Sorted Array function removeDuplicates(nums) { if (nums.length === 0) return 0; let slow = 0; for (let fast = 1; fast < nums.length; fast++) { if (nums[fast] !== nums[slow]) { slow++; nums[slow] = nums[fast]; } } return slow + 1; } // Palindrome Check function isPalindrome(s) { s = s.toLowerCase().replace(/[^a-z0-9]/g, ''); let left = 0; let right = s.length - 1; while (left < right) { if (s[left] !== s[right]) { return false; } left++; right--; } return true; }

Interactive Two Pointers Demo

Conclusion

Mastering these 10 fundamental algorithms will significantly improve your programming skills and problem-solving abilities. Each algorithm has its unique strengths and use cases:

  • Binary Search: Efficient searching in sorted data
  • Quick Sort & Merge Sort: Fast and reliable sorting algorithms
  • DFS & BFS: Essential graph traversal techniques
  • Dynamic Programming: Optimization for overlapping subproblems
  • Dijkstra's Algorithm: Shortest path in weighted graphs
  • Hash Tables: Fast data retrieval and storage
  • Tree Traversal: Systematic exploration of tree structures
  • Two Pointers: Efficient array and string manipulation

Regular practice with these algorithms will help you recognize patterns in complex problems and choose the most appropriate solution approach. Remember that understanding the underlying principles is more important than memorizing the code – focus on when and why to use each algorithm.

Continue practicing these algorithms with different variations and edge cases. The interactive examples provided here are just the beginning – try implementing these algorithms in your preferred programming language and experiment with different inputs to deepen your understanding.

Also check: Understanding the Magic Behind Computers

The post Top 10 Algorithms Every Programmer Should Know appeared first on Learn With Examples.

]]>
https://learnwithexamples.org/top-10-algorithms-every-programmer-should-know/feed/ 0 448
A Beginner’s Guide to Understanding the Magic Behind Computers – Algorithms https://learnwithexamples.org/lets-learn-algorithm/ https://learnwithexamples.org/lets-learn-algorithm/#respond Sat, 27 Jan 2024 08:02:50 +0000 https://learnwithexamples.org/?p=26 Welcome to the enchanting world of algorithms, the secret sauce that powers the digital realm! If you’ve ever wondered how computers make decisions, solve problems, or perform seemingly complex tasks,…

The post A Beginner’s Guide to Understanding the Magic Behind Computers – Algorithms appeared first on Learn With Examples.

]]>
Welcome to the enchanting world of algorithms, the secret sauce that powers the digital realm! If you’ve ever wondered how computers make decisions, solve problems, or perform seemingly complex tasks, you’re in for a treat. In this whimsical journey, we’ll embark on an adventure to demystify algorithms for beginners, using simple examples that will make you the hero of your own coding story.

Chapter 1: The Quest Begins – What is an Algorithm?

Our tale begins with a simple question: What is an algorithm? Imagine you’re in a magical kitchen, trying to bake a cake. An algorithm, in its essence, is nothing more than a step-by-step recipe for achieving a specific goal. Just as a recipe guides you through the process of creating a delicious cake, an algorithm guides a computer through a series of steps to accomplish a task.

Chapter 2: The Fairy Tale of Sorting – Bubble Sort

As our journey unfolds, we encounter the fairy tale of sorting. Picture a row of enchanted books in a library, each with a unique story. Bubble Sort, our magical librarian, wants to organize them in alphabetical order. Here’s how the spell works:

  1. Start at the beginning of the row.
  2. Compare the first two books.
  3. If they are in the correct order, move to the next pair. If not, swap them.
  4. Repeat until the entire row is sorted.

In this whimsical dance, Bubble Sort keeps comparing and swapping until the books find their rightful place. While this sorting method may seem charming, it’s not the most efficient for large collections of books.

Chapter 3: The Maze of Searching – Binary Search

Now, let’s delve into the mysterious maze of searching with Binary Search. Imagine you’re in a magical forest with countless doors. Behind one of them lies the treasure you seek. Binary Search is your guide:

  1. Start at the middle door.
  2. If the treasure is behind that door, rejoice! If not, narrow your search to the left or right half, depending on whether the treasure is smaller or larger.
  3. Repeat until you find the treasure.

Binary Search cuts the possibilities in half with each attempt, making it a swift and efficient guide through the magical forest of information.

Chapter 4: The Enchanted Garden of Recursion – Factorial

As our adventure continues, we stumble upon an enchanted garden where the concept of recursion blooms like mystical flowers. Consider calculating the factorial of a number, an enchanting mathematical trick:

  1. If the number is 0 or 1, the factorial is 1.
  2. Otherwise, the factorial is the number multiplied by the factorial of the number minus 1.

This recursive dance continues until we reach the base case of 0 or 1, unraveling the magic of Factorial in the garden of numbers.

Chapter 5: The Puzzle of Greedy Algorithms – Knapsack Problem

In the heart of the algorithmic kingdom, we encounter a challenging puzzle known as the Knapsack Problem. Imagine you’re a treasure hunter, faced with a collection of treasures each with its own weight and value. Your goal is to maximize the value of the treasures you carry in your magical knapsack, but there’s a weight limit. Enter Greedy Algorithms, your trusty companions:

  1. Sort the treasures by their value-to-weight ratio.
  2. Add treasures to the knapsack in order until it’s full.

While Greedy Algorithms may not always find the absolute best solution, they offer a quick and practical approach to the Knapsack Problem.

Chapter 6: The Labyrinth of Dynamic Programming – Fibonacci Sequence

Our journey takes us through the labyrinth of Dynamic Programming, where we unravel the mystery of the Fibonacci sequence. Picture a magical staircase, and you want to know how many ways you can climb it. Dynamic Programming provides the answer:

  1. If there’s only one step, there’s only one way to climb.
  2. If there are two steps, there are two ways: climb one step twice or take two steps at once.
  3. For more steps, each step can be reached by adding the ways to reach the previous two steps.

Dynamic Programming breaks down complex problems into simpler subproblems, making the labyrinth of algorithms more manageable.


As our algorithmic adventure comes to a close, we’ve explored the enchanting world of algorithms through the lens of fairy tales and magical scenarios. From the sorting spells of Bubble Sort to the treasure hunts with Binary Search, from the recursive dances of Factorial to the strategic companionship of Greedy Algorithms and the labyrinthine wisdom of Dynamic Programming – each algorithm tells a unique story.

Remember, dear reader, algorithms are not mere lines of code; they are the enchanting tales that guide computers through the magical realm of problem-solving. Embrace the magic, let your curiosity soar, and may your coding adventures be filled with wonder and discovery!

For more learning articles keep visiting Learn with examples

The post A Beginner’s Guide to Understanding the Magic Behind Computers – Algorithms appeared first on Learn With Examples.

]]>
https://learnwithexamples.org/lets-learn-algorithm/feed/ 0 26