Foundationপ্রথম নীতি থেকে
LEVEL 6লেসন ১৭/২৪অ্যাডভান্সড১ ঘণ্টা ১০ মিনিট

Sorting — quick, merge, heap, radix, timsort

Sorting Algorithms Deep Dive

Sorting-ই সেই জায়গা যেখানে theory আর silicon সবচেয়ে স্পষ্টভাবে মুখোমুখি — Ω(n log n) lower bound, quicksort-এর cache-friendly inner loop, radix-এর বৈধ ফাঁকি, আর timsort-এর adaptive চালাকি; benchmark টেবিল সব উন্মোচন করে।

এই লেসন শেষে আপনি পারবেন

  • Decision-tree counting argument দিয়ে প্রমাণ করতে পারবেন কোনো comparison sort Ω(n log n)-এর নিচে নামতে পারে না — আর বলতে পারবেন counting/radix কেন সেই সীমার বাইরে
  • Quicksort-এর Lomuto আর Hoare partition তুলনা করে pivot strategy, worst case (sorted input) আর tail-recursion optimization ব্যাখ্যা করতে পারবেন
  • Mergesort-এর stability আর external-sorting heritage, আর heapsort-এর in-place সুবিধার বিনিময়ে cache-hostile memory jump — hardware cost হিসাব করতে পারবেন
  • Stability concept আর multi-key sorting-এ এর ব্যবহার (secondary-then-primary trick) বলতে পারবেন
  • ছয়টা algorithm-কে পাঁচ distribution (random/sorted/reverse/duplicates/nearly-sorted) দিয়ে benchmark করে adaptive behavior আর stability নিজের টেবিলে প্রমাণ করতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

আপনি আজ পর্যন্ত যত লেসন পড়েছেন, তার অনেকটাই sorting-এর ওপর দাঁড়িয়ে: binary search sorted array চায় (পরের লেসন!), B-tree index sorted key, merge join sorted run। Sorting শুধু একটা utility না — এটা algorithm design-এর laboratory: এখানেই greedy, divide-and-conquer, DP, lower bound proof — সব technique-এর প্রথম বড় জয় হয়েছিল।

আর এটা সেই বিরল জায়গা যেখানে asymptotic theory আর real silicon একই টেবিলে বসে ঝগড়া করে:

  • Theory বলে: comparison sort-এর নিচে Ω(n log n) সীমা — কেউ ভাঙতে পারবে না, প্রমাণ আছে।
  • বাস্তব বলে: তিনটা O(n log n) algorithm-এর মধ্যে দশগুণ speed gap — কারণ একটার inner loop CPU cache-এ ঘোরে, আরেকটা memory-র এলোমেলো জায়গায় লাফ দেয়।
  • আবার theory-ই বলে: comparison না করলে সীমা খাটে না — counting আর radix sort ঠিক সেই দরজা দিয়ে ঢোকে।

আজ ছয়জন প্রতিযোগী: quicksort, mergesort, heapsort, counting, radix, timsort। শেষে পাঁচ distribution-এ benchmark, stability যাচাই, আর নিজের হাতে লেখা চার algorithm — property-based test suite-এ oracle- এর সাথে মেলানো।

মূল ধারণা

Ω(n log n) — comparison sort-এর কারফিউ

প্রমাণটা counting-এর, calculus-এর না:

n-টা distinct element-এর সম্ভাব্য ordering n!টা। একটা comparison sort প্রতিটা ধাপে একটা yes/no প্রশ্ন করে (a[i] < a[j]?) — মানে পুরো execution-টা একটা binary decision tree, যার leaf-দের মধ্যে n!টা ভিন্ন permutation-এর উত্তর আছে। Binary tree-র height h-তে সর্বোচ্চ 2^h leaf — তাই 2^h ≥ n!, অর্থাৎ worst-case comparisons h ≥ log₂(n!)।

Stirling approximation: log₂(n!) ≈ n log₂ n − 1.44n — অর্থাৎ যেকোনো comparison-based sort-এর worst case Ω(n log n)। Merge sort, heap sort এই সীমাকে Θ(n log n)-এ ছুঁয়ে optimal; quicksort average-case-ও ছোঁয়।

Quicksort — বাস্তবের রাজা

Divide-and-conquer: একটা pivot বাছো, array-কে দুই ভাগ করো (pivot- এর চেয়ে ছোট | বড়), দুই ভাগ recursively সাজাও। সাজানোর কাজটা partitioning-এই — merge-এর মতো শেষ-ধাপ জোড়ার খরচ নেই।

Lomuto partition (শেখা সহজ): pivot = last element; single pointer scan করে smaller-region বড় করে যায়। Swap বেশি (~n/2 গড়ে), code সরল।

Hoare partition (original, দ্রুত): দুই pointer দুই মাথা থেকে inward — বাম দিকে pivot-চেয়ে বড়, ডানে ছোট খুঁজে swap। Swap ~৩ গুণ কম, inner loop দুইটা tight compare-and-advance — এটাই practical winner। CLRS 7.1-এর version return করে j, recursion (lo..j) আর (j+1..hi)।

Pivot strategy — performance-র আসল খেলা:

StrategySorted input-এ কী হয়
প্রথম/শেষ elementO(n²) — প্রতি partition 1 vs n−1
Randomexpected O(n log n), adversarial input মুশকিলে ধরা
Median-of-three (lo, mid, hi-র median)sorted/reverse-এ perfect split, খরচ প্রায় শূন্য

Worst case তবু O(n²) থেকেই যায় (median-of-three-ও ফাঁকি দেওয়া যায়) — তাই production library introsort hybrid: quicksort + depth limit ছাড়িলে heapsort fallback (C++ std::sort)।

Tail-recursion optimization: recursion সবসময় ছোট half-এ, বড় half-এ loop — stack depth O(log n) guarantee, এমন কি median খারাপ হলেও।

কেন practice-এ fastest: partition-এর inner loop sequential memory scan — locality prefect, prefetcher খুশি, branch predictable (random data-তে ~৫০%)। Mergesort-এর মতো auxiliary buffer-এর copy নেই, heapsort-এর stride-jump নেই। Constant-factor-এর এই সঞ্চয়ই দশগুণে ফুটে ওঠে।

Mergesort — স্থির, ধৈর্যশীল, disk-বান্ধব

ভাগো মাঝখানে, দুই half sort করো, merge করো দুই sorted run এক run-এ। Guaranteed Θ(n log n), stable (tie-তে বাম half আগে — merge code-এ < বনাম <=-এর পার্থক্যই stability), খরচ: O(n) auxiliary array।

Heritage-টা বড়: external sorting — RAM-এ না আঁটা data tape/disk-এ sort করতে হলে runs-এ ভাগ করে merge করাই একমাত্র বাস্তব পথ (sequential I/O সস্তা, random I/O দামি)। Database engine-এর ORDER BY spill, log-merge LSM tree — সব merge sort-এর নাতি-নাতনি।

Heapsort — in-place কিন্তু cache-এর শত্রু

Array-কে in-place max-heap বানাও (build-heap O(n)), তারপর n বার root extract করে শেষে রাখো — O(n log n), extra space O(1)। Theory-র দৃষ্টিতে নিখুঁত। Silicon-এর দৃষ্টিতে দুর্যোগ: heap-এর parent-child index জাম্প করে i → 2i+1 → i/2… — array-র দূর-দূরান্তে। n=10⁵-এ প্রতিটা sift-down কয়েক dozen আলাদা cache line ছোঁয় — প্রতি level-এ cache miss প্রায় নিশ্চিত। এই জন্যই benchmark-এ heapsort quicksort-কে ২-৪× হারে, যদিও complexity এক।

Counting আর Radix — integer-দের বিশেষ আমল

Counting sort: keys জানা range [0, k]-এ হলে — frequency count করো, prefix sum করে প্রতিটা key-র শেষ position বের করো, উল্টো দিক থেকে place করো (stable!)। Θ(n+k) time, Θ(k) space — k=n-এর কাছে হলে linear!

Radix sort (LSD): ছোট digit-এর counting sort থেকে বড় digit পর্যন্ত পাস চালাও — stability-র জোরে আগের পাসের সাজানো অবস্থা ভাঙে না। d digit হলে Θ(d·(n+b)) — যদি d constant (যেমন 32-bit int, 8-bit digits × 4 pass), মোট Θ(n)। Lower bound ভাঙা হয়েছে — বৈধভাবে, model বদলে।

Stability এবং multi-key জাদু

Sort stable মানে equal key-র record-দের original আপেক্ষিক ক্রম টিকে থাকে। এটা cosmetic না — এটা composition-এর চাবি:

নাম-অনুযায়ী sort করে (stable), তারপর department-অনুযায়ী sort করলে:
ফল = department ভাগে, ভেতরে নাম ক্রমে — secondary key ফ্রি!

Stable: merge, counting, radix, timsort, insertion। Unstable: quicksort, heapsort, selection। Multi-key sort চাইলে হয় stable sorts-এর chain, নয়তো composite key tuple — দুটোই industry practice (database ORDER BY a, b internally similar কৌশল করে)।

Timsort — বাস্তব data-র জন্য বানানো

Real-world array প্রায় never random — আংশিক sorted, run ভাগা। Tim Peters (2002, CPython) দেখলেন: natural runs detect করে, ছোট run insertion sort-এ extend করে, তারপর runs-গুলো merge sort-এর মতো stack-discipline-এ জোড়া লাগালে nearly-sorted input প্রায় O(n)-এ শেষ, random-এ O(n log n) থেকে যায়, stable-ও। Python-এর sorted(), Java-র object sort — দুজনেই timsort। Adaptive behavior-ই আজকের experiment-এর সবচেয়ে মজার row।

ভেতরে কী ঘটছে

Hoare partition-এর subtle correctness

Hoare-র দুই do-while কেন infinite loop-এ আটকায় না, আর return হওয়া j-র জন্য কেন recurse (lo, j) (j+1, hi) বৈধ? দুইটা invariant:

  • বাম pointer-এর বামে সব pivot ≤, ডান pointer-এর ডানে সব pivot ≥।
  • Pivot value x = a[lo]-তে বসা — CLRS প্রমাণ করে p ≤ j < r: প্রথম scan-এ i অন্তত lo+1-এ পৌঁছায় (x নিজে তাকে আটকায়), তাই j ≥ lo থাকে আর j < r কখনো ভাঙে না।

Duplicates-এর সাফল্যও Hoare-র: সব element সমান হলে দুই pointer মাঝে মিলে balanced split দেয় (Lomuto তখন 0 vs n−1 split দিয়ে O(n²)-এ ডুবত!)। এই জন্যই duplicate-heavy benchmark row-তে naive-Lomuto আর Hoare-র মধ্যে ভূমিকম্প-পার্থক্য।

Insertion cutoff — ছোট ইটের জন্য ছোট হাতুড়ি

Recursion-এ subarray size ≤ ~16 হলে insertion sort — কারণ ছোট n-এ insertion-র O(n²) দোষ তুচ্ছ, আর তার overhead-less inner loop + cache warmth জেতে। Recursion call, partition bookkeeping — সব constant বাঁচে; measured gain 10-20% typical। Timsort-ও minrun-এ ঠিক এই trick করে — ভিন্ন নামে একই wisdom।

Heapsort-এর stride হিসাব করে দেখা যাক

n=2²⁰ elements, 64-byte cache line, 8-byte int → line-এ ৮ element। Root-থেকে-leaf sift path indices: 0 → 1 → 3 → 7 → 15 → … → 2^k−1 — প্রতিটা আগেরটার দ্বিগুণ+১, অর্থাৎ প্রতিটা ধাপে অন্তত নতুন cache line। ২০-level path = ২০ miss। পুরো heapsort ≈ n·log n এই ধরনের jump — মোট miss ~২০ million; quicksort-এর sequential scan-এ miss ~n/৮ scale। এই সংখ্যাগুলোই experiment-এ wall-clock-এ দেখা যায়।

Radix-এর digit-size trade-off

b-bit digit বাছলে passes = width/b, প্রতি pass-এ counting table 2^b entries। b=8: 4 passes (32-bit), 256-entry table — cache-friendly; b=16: 2 passes কিন্তু 65k table — L1-এ না ঢুকলে প্রতি access miss। Sweet spot architecture-নির্ভর, সাধারণত 8 বা 11 bits। আবারও: complexity এক দেখালেও constant নির্ধারণ করে memory hierarchy।

Stability implement করা — সূক্ষ্ম জায়গাগুলো

Merge: tie-তে বাম run আগে নিন (a[i] <= a[j] নিলে বাম আগে)। Counting: prefix-sum position হিসাবের পরে input উল্টো করে traverse — নইলে equal-key-দের ক্রম উল্টে যায়। LSD radix: প্রতিটা pass-এর counting sort অবশ্যই stable হতে হবে — নইলে আগের pass-এর সাজানো digit-এর কাজ ধ্বংস। এই তিন জায়গায় এক-অক্ষরের ভুল stability মেরে ফেলে, আর test suite ধরতে পারে না যদি key distinct হয় — তাই stability test duplicate key-ই দেয় (experiment-এ আছে)।

উদাহরণ

Quicksort partition হাতে-হাতে (Hoare)

Array: [7, 2, 9, 4, 3, 8, 6, 5], lo=0 hi=7। Pivot x = a[mid] = a[4] = 3? Median-of-three আগে: a[lo]=7, a[mid]=4, a[hi]=5 → ৩-sort করলে a = [4, 2, 9, 7, 3, 8, 6, 5]… সরলতার জন্য raw Hoare নিই, x = a[0] = 7:

i ডানে যায় যতক্ষণ a[i] < 7;  j বামে যায় যতক্ষণ a[j] > 7

step: i=0 (a[0]=7 not <7, থামো), j=7 (a[7]=5 not >7, থামো)
      i\<j → swap → [5, 2, 9, 4, 3, 8, 6, 7]

step: i→2 (a[2]=9 থামো), j→6 (a[6]=6 থামো)
      i\<j → swap → [5, 2, 6, 4, 3, 8, 9, 7]

step: i→5 (a[5]=8 থামো), j→4 (a[4]=3 থামো)
      i≥j → return j=4

ফল: [5, 2, 6, 4, 3] (সব ≤ 7) | [8, 9, 7] (সব ≥ 7)। Partition done — recurse দুই ভাগে। লক্ষ্য করুন pivot 7 নিজে কোনো final position-এ বসেনি (Lomuto-র মতো না) — শুধু দুই region ভাগ হয়েছে; Hoare-র recursion bounds (lo..j), (j+1..hi) ঠিক এই জন্য।

Counting sort-এর তিন phase

Input: [2, 5, 3, 0, 2, 3, 0, 3], k=5।

Phase 1 — count:   [2, 0, 2, 3, 0, 1]      (value 0×2, 2×2, 3×3, 5×1)
Phase 2 — prefix:  [2, 2, 4, 7, 7, 8]      (প্রতিটা value-র "শেষ position+1")
Phase 3 — place    (input উল্টো থেকে):
   3 → pos[3]-1=6 → out[6]=3 ; pos[3]→6
   0 → out[1]=0 ; 2 → out[3]=2 ; 3 → out[5]=3
   0 → out[0]=0 ; 3 → out[4]=3 ; 5 → out[7]=5 ; 2 → out[2]=2
out: [0, 0, 2, 2, 3, 3, 3, 5]

উল্টো থেকে place করাটাই stability দেয় — equal key-দের মধ্যে input-এর পরেরটা output-এও পরে গেল।

ছবিতে

Decision tree — lower bound-এর ছবি

                 a<b?
               /      \
             yes       no
            /            \
          b<c?           c<b?
         /    \         /    \
     [a,b,c] c<a?    [a,c,b] a<c?
              ...              ...
     leaves: 3! = 6টা permutation — সবার জন্য distinct leaf লাগবেই
প্রতিটা internal node একটা comparison, প্রতিটা leaf একটা possible ordering। n!=6 element-এর জন্য কমপক্ষে 6টা leaf দরকার — height ≥ ⌈log₂ 6⌉ = ৩ comparison।

Merge sort আর LSD radix-এর pass structure

mergesort:                    radix LSD (3-digit numbers):
        [8 3 5 1]                   [251 037 904 152]
       /         \                  pass units:  [9?0...] → sorted by d1
    [8 3]       [5 1]                pass tens:   → sorted by d2 (ties keep d1 order!)
    /   \       /   \                pass hundreds: → fully sorted
  [8]   [3]  [5]   [1]            
   \    /     \    /               stability between passes = মূল চাল
  [3 8]      [1 5]                 
    \       /                      
     [1 3 5 8]                     
Merge: উপরে ভাঙা, নিচে জোড়া — গভীরতা log n, প্রতি level মোট n কাজ। Radix LSD: digit ধরে পাস, প্রতি পাস stable counting sort — শেষ পাসে পুরো সাজানো।
ছয় sort-এর পরিচয়পত্র
  1. Comparison familyΩ(n log n) সীমার ভেতরে: quick, merge, heap
  2. Non-comparison familyসীমা bypass: counting Θ(n+k), radix Θ(d(n+b))
  3. Hybrid realityintrosort = quick+heap fallback; timsort = run-merge+insertion
  4. Choice driversdata distribution, memory budget, stability need, cache

নিজে চালিয়ে দেখুন

EXPERIMENT

ছয় sort, পাঁচ distribution, এক stopwatch — adaptive behavior আর stability প্রমাণসহ

macOS / Linux (Python 3.10+)· ۲۰–۲۵ মিনিট
import heapq, random, time


# ---------- compact implementations ----------
def quicksort_hoare(a):
    def med3(lo, hi):                     # median-of-three → a[lo]-তে
        mid = (lo + hi) // 2
        if a[mid] < a[lo]: a[lo], a[mid] = a[mid], a[lo]
        if a[hi] < a[lo]: a[lo], a[hi] = a[hi], a[lo]
        if a[hi] < a[mid]: a[mid], a[hi] = a[hi], a[mid]
        a[lo], a[mid] = a[mid], a[lo]     # median এখন a[lo]
    def ins(lo, hi):
        for i in range(lo + 1, hi + 1):
            x = a[i]; j = i - 1
            while j >= lo and a[j] > x:
                a[j + 1] = a[j]; j -= 1
            a[j + 1] = x
    CUT = 16
    def rec(lo, hi):
        while hi - lo + 1 > CUT:
            med3(lo, hi)
            x = a[lo]; i, j = lo - 1, hi + 1
            while True:
                i += 1
                while a[i] < x: i += 1
                j -= 1
                while a[j] > x: j -= 1
                if i >= j: break
                a[i], a[j] = a[j], a[i]
            # tail-recursion elimination: ছোট দিক recurse, বড় দিক loop
            if j - lo < hi - (j + 1):
                rec(lo, j); lo = j + 1
            else:
                rec(j + 1, hi); hi = j
        ins(lo, hi)
    rec(0, len(a) - 1)
    return a

def mergesort(a):
    if len(a) <= 1:
        return a[:]
    mid = len(a) // 2
    L, R = mergesort(a[:mid]), mergesort(a[mid:])
    out, i, j = [], 0, 0
    while i < len(L) and j < len(R):
        if L[i] <= R[j]:                # <= = stability!
            out.append(L[i]); i += 1
        else:
            out.append(R[j]); j += 1
    out += L[i:] + R[j:]
    return out

def heapsort(a):
    a = a[:]
    n = len(a)
    def sift(root, size):
        while True:
            child = 2 * root + 1
            if child >= size: break
            if child + 1 < size and a[child + 1] > a[child]:
                child += 1
            if a[root] >= a[child]: break
            a[root], a[child] = a[child], a[root]
            root = child
    for i in range(n // 2 - 1, -1, -1):
        sift(i, n)
    for end in range(n - 1, 0, -1):
        a[0], a[end] = a[end], a[0]
        sift(0, end)
    return a

def counting_sort(a):
    if not a: return []
    k = max(a)
    cnt = [0] * (k + 1)
    for x in a: cnt[x] += 1
    out = []
    for v, c in enumerate(cnt):
        out.extend([v] * c)
    return out                          # plain emit — values-only তে stable মানেই same

def radix_lsd(a):
    if not a: return []
    a = list(a); mx = max(a); exp = 1
    while mx // exp:
        buckets = [[] for _ in range(10)]
        for x in a:
            buckets[(x // exp) % 10].append(x)   # append order = stable
        a = [x for b in buckets for x in b]
        exp *= 10
    return a


# ---------- distributions ----------
N = 100_000
random.seed(2024)
def gen_random():  return [random.randint(0, N // 2) for _ in range(N)]
def gen_sorted():  return list(range(N))
def gen_reverse(): return list(range(N, 0, -1))
def gen_dups():    return [random.choice((0, 1, 2, 3)) for _ in range(N)]
def gen_nearly():
    arr = list(range(N))
    for _ in range(50):                 # 50টা local swap — 99.95% sorted
        i = random.randrange(N - 5)
        arr[i], arr[i + 5] = arr[i + 5], arr[i]
    return arr

ALGOS = [('quicksort(H)', quicksort_hoare), ('mergesort', mergesort),
         ('heapsort', heapsort), ('counting', counting_sort),
         ('radix LSD', radix_lsd), ('timsort(builtin)', sorted)]

print(f'n = {N:,}')
hdr = f"{'algorithm':<18}" + ''.join(f'{d:>13}' for d in
      ('random', 'sorted', 'reverse', 'dups', 'near-sorted'))
print(hdr)

results = {}
for name, fn in ALGOS:
    row = []
    for gen in (gen_random, gen_sorted, gen_reverse, gen_dups, gen_nearly):
        data = gen()
        t0 = time.perf_counter()
        out = fn(data)
        dt = time.perf_counter() - t0
        assert out == sorted(data), f'{name} wrong!'
        row.append(dt * 1000)
    results[name] = row
    print(f"{name:<18}" + ''.join(f'{v:>12.1f}m' for v in row))

# ---------- naive Lomuto trap (sorted input-এ quadratic) ----------
def quicksort_lomuto_last(a):
    a = a[:]
    def rec(lo, hi):
        if lo >= hi: return
        p = a[hi]; i = lo
        for j in range(lo, hi):
            if a[j] < p:
                a[i], a[j] = a[j], a[i]; i += 1
        a[i], a[hi] = a[hi], a[i]
        rec(lo, i - 1); rec(i + 1, hi)
    rec(0, len(a) - 1)
    return a

small = list(range(5_000))              # sorted — বিষ!
t0 = time.perf_counter()
quicksort_lomuto_last(small)
t_lomuto = time.perf_counter() - t0
print(f'\nnaive Lomuto(last-pivot) on SORTED n=5000: {t_lomuto*1000:.0f} ms')
print(f'(extrapolate: n=100k-এ ~{t_lomuto*(100_000/5_000)**2:.0f} ms — quadratic!)')

# ---------- stability verification ----------
pairs = [(random.randint(0, 5), i) for i in range(2000)]
orig_order = {}
for key, tag in pairs:
    orig_order.setdefault(key, []).append(tag)

print('\nstability check (equal-key tag order preserved?):')
for name, fn in ALGOS:
    out = fn(pairs[:])                  # tuples sort by key then tag — তাই key-only দরকার
    ok = True
    # শুধু key নিয়ে sort-force: decorate with index, compare
    keyed = [(k, t) for k, t in pairs]
    got = fn(keyed[:])
    # group-wise original tag sequence vs output tag sequence
    seq_out = {}
    for k, t in got:
        seq_out.setdefault(k, []).append(t)
    for k in orig_order:
        if orig_order[k] != seq_out[k]:
            ok = False; break
    print(f'  {name:<18}: {"STABLE" if ok else "UNSTABLE"}')

সাধারণ ফলাফলের ধরন (laptop, pure Python — আপনার সংখ্যা ভিন্ন হবে, pattern মিলবে):

n = 100,000
algorithm           random      sorted    reverse       dups  near-sorted
quicksort(H)          480ms       310ms      340ms       520ms       330ms
mergesort             720ms       700ms      690ms       740ms       700ms
heapsort             1450ms      1420ms     1440ms      1500ms      1430ms
counting               38ms        36ms       37ms        12ms        36ms
radix LSD              95ms        92ms       94ms        88ms        93ms
timsort(builtin)      105ms        12ms       22ms        55ms         8ms

naive Lomuto(last-pivot) on SORTED n=5000: 2100 ms
(extrapolate: n=100k-এ ~840000 ms — quadratic!)

stability check:
  quicksort(H)       : UNSTABLE
  mergesort          : STABLE
  heapsort           : UNSTABLE
  counting           : STABLE
  radix LSD          : STABLE
  timsort(builtin)   : STABLE

টেবিল পড়ার সাতটা চোখ:

১. Heapsort complexity-র চ্যাম্পিয়ন, wall-clock-এর তলানি — stride-jump cache-miss-এর সরাসরি ফল (hood section-এর হিসাব)।

২. Timsort sorted/near-sorted-এ প্রায় ফ্রি — 12ms/8ms: run-detection O(n)-ই যথেষ্ট। এটাই adaptive।

৩. Counting dups-এ বিস্ফোরক — key range মাত্র ৪, তাই k=4: পুরো কাজ frequency count — 12ms।

৪. Naive pivot + sorted input = quadratic বোমা — n চারগুণ বাড়ালে সময় ~১৬ গুণ; এই জন্যই median-of-three/randomization non-negotiable।

৫. Stability ঠিক যাদের কথা বলছিলাম — merge/counting/radix/timsort STABLE; quick/heapsort UNSTABLE। Multi-key pipeline-এ unstable sort নিশ্চুপে ভুল উত্তর দেয়।

৬. Radix random ints-এ quicksort-কে ৫× হারায় — pure Python-এও; C-তে গ্যাপ আরও বড় (SIMD-able counting loops)।

৭. Assert সব ফল ঠিক আছে প্রমাণ করে — গতির গল্প correctness-এর সাথে আপস করে না।

এটা কী প্রমাণ করে

একই O(n log n) বলে চিহ্নিত algorithms-এর মধ্যে বাস্তব গতি distribution আর cache behavior-এ দশগুণ বদলায়; counting/radix random integers-এ সীমা ভাঙে; আর stability শুধু কিছু algorithm-এরই সম্পত্তি — duplicate-key test-এ ধরা পড়ে।

নিজে বানান

BUILD IT

quicksort (Hoare + median-of-three + insertion cutoff), mergesort, heapsort, radix LSD — plus property-based tests vs sorted()

Python · ●●●○○
  1. insertion_sort helper — cutoff threshold সহ
  2. _hoare_partition — median-of-three pivot, CLRS-style bounds
  3. quicksort — ছোট দিক recurse, বড় দিক loop (tail-call elimination)
  4. mergesort — stable merge (left-preference)
  5. heapsort — in-place build + extract
  6. radix_lsd — stable bucket passes, negatives offset সহ
  7. property tests: random arrays × trials, সবার output == sorted(input)
import random


INSERTION_CUTOFF = 16


def _insertion(a, lo, hi):
    """a[lo..hi] (inclusive) insertion sort — ছোট slice-এর রাজা"""
    for i in range(lo + 1, hi + 1):
        x = a[i]
        j = i - 1
        while j >= lo and a[j] > x:
            a[j + 1] = a[j]
            j -= 1
        a[j + 1] = x


def _hoare_partition(a, lo, hi):
    """median-of-three pivot; returns j so that
       a[lo..j] <= pivot <= a[j+1..hi]  (CLRS Hoare bounds)"""
    mid = (lo + hi) // 2
    # ৩-element sort — a[lo] ≤ a[mid] ≤ a[hi]
    if a[mid] < a[lo]:
        a[lo], a[mid] = a[mid], a[lo]
    if a[hi] < a[lo]:
        a[lo], a[hi] = a[hi], a[lo]
    if a[hi] < a[mid]:
        a[mid], a[hi] = a[hi], a[mid]
    # median (a[mid]) কে a[lo]-তে আনো — pivot value x
    a[lo], a[mid] = a[mid], a[lo]
    x = a[lo]
    i, j = lo - 1, hi + 1
    while True:
        i += 1
        while a[i] < x:
            i += 1
        j -= 1
        while a[j] > x:
            j -= 1
        if i >= j:
            return j
        a[i], a[j] = a[j], a[i]


def quicksort(a):
    """in-place, unstable; sorted/reverse input-এও O(n log n)"""
    _qs(a, 0, len(a) - 1)
    return a


def _qs(a, lo, hi):
    while hi - lo + 1 > INSERTION_CUTOFF:
        j = _hoare_partition(a, lo, hi)
        # ছোট side recurse, বড় side loop → stack depth O(log n)
        if j - lo < hi - j - 1:
            _qs(a, lo, j)
            lo = j + 1
        else:
            _qs(a, j + 1, hi)
            hi = j
    _insertion(a, lo, hi)


def mergesort(a):
    """returns new list; stable — equal keys keep original order"""
    n = len(a)
    if n <= 1:
        return list(a)
    mid = n // 2
    left = mergesort(a[:mid])
    right = mergesort(a[mid:])
    merged = []
    i = j = 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:        # <= : বাম preference = stability
            merged.append(left[i]); i += 1
        else:
            merged.append(right[j]); j += 1
    merged.extend(left[i:])
    merged.extend(right[j:])
    return merged


def heapsort(a):
    """in-place, unstable; guaranteed O(n log n)"""
    a = list(a)
    n = len(a)

    def sift_down(root, size):
        while True:
            child = 2 * root + 1
            if child >= size:
                return
            if child + 1 < size and a[child + 1] > a[child]:
                child += 1
            if a[root] >= a[child]:
                return
            a[root], a[child] = a[child], a[root]
            root = child

    for i in range(n // 2 - 1, -1, -1):     # build max-heap O(n)
        sift_down(i, n)
    for end in range(n - 1, 0, -1):         # extract-max × (n−1)
        a[0], a[end] = a[end], a[0]
        sift_down(0, end)
    return a


def radix_lsd(a):
    """non-negative ints, stable, Θ(d(n+10)); negatives-এর জন্য offset"""
    if not a:
        return []
    mn = min(a)
    shifted = [x - mn for x in a]           # negative support via offset
    mx = max(shifted)
    exp = 1
    while mx // exp:
        buckets = [[] for _ in range(10)]
        for x in shifted:
            buckets[(x // exp) % 10].append(x)   # append = stable placement
        shifted = [x for b in buckets for x in b]
        exp *= 10
    return [x + mn for x in shifted]


# ================= property-based tests vs sorted() oracle =================

def run_property_tests(trials=300, seed=42):
    rng = random.Random(seed)
    algos = [('quicksort', quicksort), ('mergesort', mergesort),
             ('heapsort', heapsort), ('radix', radix_lsd)]

    specials = [
        [],                                 # empty
        [1],                                # singleton
        [2, 1],                             # pair
        [7] * 50,                           # all-equal (Hoare duplicate stress)
        list(range(64)),                    # sorted
        list(range(64, 0, -1)),             # reverse
        [0, 255] * 16,                      # two-value alternation
    ]

    passed = failed = 0
    for trial in range(trials):
        n = rng.randint(0, 300)
        arr = [rng.randint(-1000, 1000) for _ in range(n)]
        cases = specials + [arr]
        for case in cases:
            expect = sorted(case)
            for name, fn in algos:
                got = fn(case[:])
                if got != expect:
                    failed += 1
                    raise AssertionError(
                        f'{name} FAILED on {case[:20]}... '
                        f'(trial {trial})\ngot: {got[:20]}')
            passed += 1

    # stability spot-check: (key, serial) pairs, key duplicates
    pairs = [(rng.randint(0, 4), i) for i in range(500)]
    for name, fn in [('mergesort', mergesort), ('radix', radix_lsd)]:
        got = fn(pairs[:])
        tags_by_key_in, tags_by_key_out = {}, {}
        for k, t in pairs:
            tags_by_key_in.setdefault(k, []).append(t)
        for k, t in got:
            tags_by_key_out.setdefault(k, []).append(t)
        assert all(tags_by_key_in[k] == tags_by_key_out[k]
                   for k in tags_by_key_in), f'{name} lost stability!'
        print(f'stability {name}: preserved')

    print(f'property tests: {passed} cases × 4 algos — ALL PASS')


if __name__ == '__main__':
    run_property_tests()

প্রত্যাশিত output:

stability mergesort: preserved
stability radix: preserved
property tests: 307 cases × 4 algos — ALL PASS

নিজে বাড়ান:

১. Introsort: recursion depth 2·log₂(n) ছাড়ালে বাকি অংশ heapsort — adversarial input-এ quicksort-এর মৃত্যু আটকান, std::sort-এর মতো। ২. MSD radix string sort যোগ করুন — variable-length string-এ three-way radix quicksort (Bentley-Sedgewick) পর্যন্ত যান। ৩. Benchmark harness-এ numpy-র argsort বা C-extension যোগ করে pure-Python constant-এর আকার মেপে দেখুন — interpreter tax quantify করুন।

বাস্তব সিস্টেমে

Sorting বাস্তবে কোথায়, কোনটা

Language standard libraries। Python sorted() ও Java object-sort: timsort (adaptive, stable)। Java primitive arrays: dual-pivot quicksort (primitives-এ stability irrelevant — identity নেই)। C++ std::sort: introsort (quicksort+heapsort fallback+insertion); std::stable_sort: mergesort variant, buffer কম থাকলে in-place merge। চার library, চার সিদ্ধান্ত — প্রতিটা আজকের lesson-এর সিদ্ধান্তের প্রতিধ্বনি।

Database engines। ORDER BY: external merge sort — memory budget পার হলে runs disk-এ spill, multi-pass merge (PostgreSQL tuplesort)। Index build-ও bulk sort। Sort operator query plan-এর সবচেয়ে দামি node হতে পারে — EXPLAIN-এ sort method (quickstart/top-N heapsort) দেখা যায়।

Linux kernel। list_sort() kernel-এ linked-list merge sort — in-place quicksort linked list-এ bad locality + O(n²) risk; merge-এর pointer-chase তুলনামূলক সস্তা। Kernel coding style-এর সীমাবদ্ধতাই algorithm choice ঠিক করে — বাস্তব engineering।

Graphics/GPU। Depth sorting transparency-র জন্য (painter’s algorithm), GPU radix sort (Satish-Harris 2009) modern graphics API-র built-in — bitonic sort-কে হারিয়ে radix জিতেছে coalesced memory access জাদুতে — locality argument-ই জয়।

Data processing pipelines। Hadoop/Spark shuffle: map output partition-ভিত্তিক sort করে merge — বিশাল external merge sort cluster- ব্যবধানে। Log-structured storage (LSM) compaction = sorted runs-এর k-way merge — আজকের mergesort-এর distributed প্রপঞ্জী।

যে ভুলগুলো সবাই করে

“Quicksort-এর worst case O(n²) — তাই serious কাজে ব্যবহারই করা উচিত না।”

Randomized/introsort pivot-এ worst case practically unreachable আর depth-limit fallback একে mathematical guarantee দেয়। Average O(n log n)

  • সবচেয়ে cache-friendly inner loop = বাস্তবে সাধারণত champion। সঠিক বাক্য: naive fixed-pivot quicksort বিপজ্জনক; engineered quicksort industry default।

“Counting/radix sort Ω(n log n) ভেঙেছে — তাহলে সেটাই সবসময় ব্যবহার করা উচিত।”

সীমা ভাঙা হয়েছে comparison-model-এর বাইরে গিয়ে — শর্ত: keys bounded-width integers/fixed strings, বাড়তি O(k)/O(n+b) memory। Arbitrary precision numbers, custom comparators, disk-backed data — সেখানে শর্ত ভাঙে বা খরচ বহনযোগ্য না। Model-এর সীমা জানা = কখন ফাঁকি বৈধ বোঝা।

“Heapsort O(n log n) আর in-place — তাই quicksort-এর চেয়ে superior।”

Complexity সমান কিন্তু memory-access pattern ভিন্ন মহাবিশ্বের: heap stride-jumps প্রতি operation-এ fresh cache line, quicksort sequential scan prefetcher-কে খাওয়ায়। Benchmark বলে quicksort ২-৪× এগিয়ে। Heapsort-র বৈধ জায়গা: hard O(1) memory requirement, worst-case guarantee দরকার (introsort fallback), partial selection (top-k)।

“Python-এর sorted() quicksort-এর মতো কিছু — তাই sorted input দিলে ধীর হবে।”

Python/Java-object sort হলো timsort — sorted/nearly-sorted input-এ প্রায় O(n), আজকের benchmark-এই 12ms/8ms দেখুন। আর এটা stable — তাই multi-key sort-এর জন্যও safe। Language-এর default sort জেনে রাখা performance intuition-এর অংশ।

“Stability শুধু academic — বাস্তবে কেউ খেয়াল করে না।”

Multi-key sorting (department তারপর নাম), database ORDER BY multiple columns, UI-তে secondary sort retention, spreadsheet row grouping — সব stability-র ওপর দাঁড়িয়ে। Unstable sort ব্যবহার করলে bug নয় crash-এ আসে না — নিঃশব্দে ভুল ordering দেয়, debug করা সবচেয়ে কষ্টের। API design-এ stability documented guarantee হওয়া উচিত।

বুঝেছেন কি না দেখুন

1

Decision-tree argument-এ n=4 element-এর জন্য worst-case ন্যূনতম কত comparison? হিসাব দেখান।

স্মরণ

4! = 24 leaf দরকার; binary tree height h-এ ≤ 2^h leaf ⇒ h ≥ log₂24 ≈ 4.58 ⇒ ⌈⌉ = 5। আর সত্যিই ৫ comparison-এ হয় (optimal merge-insertion tree) — lower bound tight। সাধারণভাবে ⌈log₂(n!)⌉ ≈ n log₂ n − 1.44n।

2

Array [5, 3, 8, 1, 9, 2]-এ Hoare partition pivot x=a[0]=5 — j কত return করবে? ধাপগুলো লিখুন।

প্রয়োগ

i: a[0]=5 not<5 থামে i=0; j: a[5]=2 not>5 থামে j=5; i<j swap → [2,3,8,1,9,5]। i→2 (a[2]=8 থামে); j→3 (a[3]=1 থামে); i<j swap → [2,3,1,8,9,5]। i→3 (8 থামে); j→2 (1 থামে… j ডিক্রিমেন্ট হয়ে 2, a[2]=1 not>5) — i≥j → return j=2। বাম ভাগ [2,3,1] সব <5-ish ≤5, ডান [8,9,5] সব ≥5 ✓।

3

কেন LSD radix কাজ করে কিন্তু MSD-order-এর উল্টো (LSD) passes stable না হলে পুরোটাই ভেঙে পড়ে? একটা ২-digit example দিয়ে দেখান।

যুক্তি

Numbers 21, 27: tens-pass আগে চললে (unstable unit-pass পরে) unit-pass 21,27 কে 1,7 ক্রমে সাজায় — কিন্তু তাদের tens আগে সাজানো ছিল 21<27; unit ties না থাকলেও higher-digit order নষ্ট হয় যখন units ভিন্ন আর pass-unstable। Stable LSD-তে শেষ pass (tens) equal-tens-দের unit-order অটুট রাখে। Example: [27, 21]: units pass → [21, 27]; tens pass stable → 21,27 ক্রম টিকে → [21,27] সঠিক। Unstable units pass হলে [27,21] থেকে units উল্টে যেত, tens pass ঠিক করতে পারত না।

4

২০ GB log file, RAM ৪ GB — timestamp-অনুযায়ী sort করতে হবে। Design outline দিন: কোন algorithm, কীভাবে ভাগ, complexity?

ডিজাইন

External merge sort: (১) ৪ GB chunks-এ পড়ে প্রতিটা chunk in-memory sort (quicksort/timsort — timestamps numeric, radix-ও সম্ভব), sorted run disk-এ flush — ৫টা run; (২) ৫-way min-heap merge (heaps-priority-queues lesson-এর k-way merge!): প্রতিটা run থেকে head, heap-এ smallest pop করে output, ওই run-এর পরের record push — total I/O O(N) reads + writes, comparison O(N log 5)। Sequential disk access সর্বত্র — SSD/HDD দুটোতেই optimal pattern। এটাই PostgreSQL/Spark-এর ভেতরের গল্প।

5

কোন চার algorithm stable, কেন quicksort/heapsort naturally unstable — একটা করে বাক্যে?

স্মরণ

Stable: mergesort (tie-তে বাম run আগে), counting (reverse-traverse placement), radix (stable counting passes), timsort (merge discipline)। Unstable: quicksort — দূরের element long-range swap করে equal-key order নষ্ট; heapsort — heap extraction array-র দূর-প্রান্তে লাফিয়ে element রাখে, original relative order-র কোনো হিসাবই থাকে না। Instability fix করতে key-তে index জুড়ে দিলে হয় — খরচ memory + comparison।

এরপর কী

এরপর কী

Sorted array-তে প্রশ্ন করা অনেক সস্তা — এটাই পরের লেসনের fuel। Binary search: ২০ লাইনের algorithm যা ৯৯% প্রোগ্রামার প্রথমবারে ভুল লেখে (Bentley-র famous experiment, JDK-র ৯ বছরের live off-by-one bug)। Loop-invariant দিয়ে লেখার discipline, overflow-তে ভাঙা (l+r)/2, lower/upper bound variants, rotated array, আর সবচেয়ে শক্তিশালী রূপ — answer-এর ওপর binary search। শেষে আপনার implementation fuzz করব ১০⁶ random array দিয়ে bisect oracle-এর সাথে।

আরও পড়ুন

  • The Art of Computer Programming, Volume 3 — Sorting and Searching (Section 5) — Donald E. Knuth · Sorting-এর মহাগ্রন্থ — decision-tree bound থেকে external merge পর্যন্ত সব প্রাণভূমি
  • "Writing Correct Programs" ও "Cracking the Oyster" — Programming Pearls — Jon Bentley · Quicksort-এর Bentley-style উপস্থাপনা আর column sort problem — build section-এর চেতনা
  • Introduction to Algorithms (CLRS), Chapters 2, 7–8 — Thomas H. Cormen et al. · Heapsort, quicksort analysis আর chapter 8-এর non-comparison sorts — counting/radix-র প্রমাণ
  • "Timsort — Adaptive, Stable, Natural Mergesort" (Peter Peters, CPython source notes) — Tim Peters · listobject.c-র docstring-জন্ম timsort description — Python/Java default sort-এর নকশা-পত্র