সরাসরি পর্বে যান
পর্ব ২২ · ৭২-এর মধ্যে · মডিউল ৩

Quick sort, pivot আর quickselect

Quicksort — Lomuto & Hoare Partitioning, Pivot Choice, 3-Way Partitioning, Introsort, Quickselect & Median of Medians

Taught in Bangla, this lesson traces Lomuto and Hoare partitioning and proves Lomuto correct with a loop invariant. It analyses best, average and worst cases, compares pivot strategies including randomised and median-of-three, covers 3-way partitioning for duplicates, recursing on the smaller side, introsort inside std::sort, quickselect for the kth smallest and the linear worst case of median of medians.

এই পর্বে যা শিখবেন

  • Lomuto আর Hoare partition হাতে trace করা, আর loop invariant দিয়ে Lomuto-র শুদ্ধতা প্রমাণ করা
  • Quick sort-এর best, average আর worst case recurrence দিয়ে বিশ্লেষণ, আর কোন ইনপুট Θ(n²) ঘটায় তা চেনা
  • Pivot বাছাইয়ের কৌশল — first/last, middle, random, median-of-three — আর randomized quicksort-এর প্রত্যাশিত O(n log n)-এর মূল যুক্তি
  • ডুপ্লিকেটের জন্য 3-way partition, ছোট ভাগে রিকার্শন, tail-call elimination আর introsort দিয়ে “শিল্পমানের” quick sort বোঝা
  • Quickselect দিয়ে k-তম ছোট বা median বের করা, আর median of medians-এর O(n) worst case প্রমাণ বোঝা

১ · মূল চিন্তা — একজনকে মাপকাঠি ধরে দুই দলে ভাগ

স্কুলের মাঠে পিটি-র ক্লাস। স্যার চান ৪০ জন ছাত্র উচ্চতা অনুযায়ী এক লাইনে দাঁড়াক। সবাইকে একে একে সবার সাথে মাপার বদলে তিনি একটি চালাক কাজ করলেন: একজন ছাত্রকে — ধরুন রাফিকে — মাঝখানে দাঁড় করালেন, আর বললেন, “রাফির চেয়ে খাটো সবাই বাঁ দিকে, লম্বা সবাই ডান দিকে।” এক মিনিটে দুটি দল তৈরি। এখন রাফি ঠিক তার চূড়ান্ত জায়গায় দাঁড়িয়ে আছে — তার বাঁয়ে যতজন, সবাই খাটো; ডানে যতজন, সবাই লম্বা। বাঁ দলের ভেতরে আর ডান দলের ভেতরে একই কৌশল আবার চালালেই পুরো লাইন সাজানো হয়ে যাবে।

এটিই quick sort. যে উপাদানকে মাপকাঠি ধরা হয় তার নাম pivot (পিভট). অ্যালগরিদমের তিনটি ধাপ:

  1. Pivot বাছাই: অংশটির কোনো একটি উপাদান।
  2. Partition: অংশটিকে এমনভাবে সাজানো, যাতে pivot-এর বাঁয়ে সব ছোট (বা সমান), ডানে সব বড় (বা সমান) থাকে। Pivot তখন নিজের চূড়ান্ত ঘরে।
  3. রিকার্শন: বাঁ অংশ আর ডান অংশকে আলাদাভাবে quick sort. একটি বা শূন্যটি উপাদানের অংশ আগে থেকেই সাজানো — সেটিই base case.
Pseudocode — quick sort (0-based, inclusive range lo..hi)

quicksort(A, lo, hi):
    if lo >= hi: return              // 0 or 1 element: already sorted
    p = partition(A, lo, hi)         // pivot ends at index p
    quicksort(A, lo, p - 1)          // everything here <= A[p]
    quicksort(A, p + 1, hi)          // everything here >= A[p]

এটি পর্ব ০৮-০৯-এর divide and conquer-এর আরেকটি রূপ, কিন্তু merge sort-এর (পর্ব ২১) ঠিক উল্টো ছাঁচে। Merge sort ভাগ করে চোখ বুজে — মাঝখান থেকে — আর আসল পরিশ্রম করে জোড়া দেওয়ার (merge) সময়। Quick sort পরিশ্রম করে ভাগ করার (partition) সময়, আর জোড়া দেওয়ার কোনো কাজই থাকে না: দুই ভাগ সাজানো হয়ে গেলে পুরো অ্যারে আপনাআপনি সাজানো, কারণ বাঁয়ের প্রতিটি উপাদান ডানের প্রতিটির চেয়ে ছোট বা সমান। এর একটি বড় সুবিধা — merge sort-এর মতো বাড়তি n ঘরের অ্যারে লাগে না, সব কাজ মূল অ্যারের ভেতরেই হয়।

১৯৫৯ সালে ২৫ বছর বয়সী ব্রিটিশ তরুণ Tony Hoare (C. A. R. Hoare) মস্কো স্টেট ইউনিভার্সিটিতে একজন ভিজিটিং ছাত্র হিসেবে রুশ থেকে ইংরেজিতে যন্ত্র-অনুবাদের (machine translation) একটি প্রকল্পে কাজ করছিলেন। অভিধানের সাথে মেলাতে একটি বাক্যের শব্দগুলো আগে সাজাতে হতো। সেখানেই তাঁর মাথায় partition করে সাজানোর চিন্তা আসে। দেশে ফিরে Elliott Brothers কোম্পানিতে কাজ করার সময় তিনি এটি বাস্তবায়ন করেন। ১৯৬১ সালে Communications of the ACM-এ তিনটি ছোট অ্যালগরিদম ছাপা হয় — Algorithm 63: Partition, Algorithm 64: Quicksort আর Algorithm 65: Find; শেষেরটিই এই পর্বের quickselect. ১৯৬২ সালে The Computer Journal-এ “Quicksort” নামে পূর্ণ বিশ্লেষণসহ প্রবন্ধ বের হয়। ১৯৮০ সালে Hoare Turing Award পান।

২ · Lomuto partition — একদিকে চলা দুটি pointer

Partition কীভাবে করবেন, সেটিই quick sort-এর হৃদয়। প্রথম পদ্ধতিটি সহজ, আর Jon Bentley তাঁর Programming Pearls বইয়ে এটি জনপ্রিয় করার সময় কৃতিত্ব দেন Nico Lomuto-কে — তাই নাম Lomuto partition. Cormen ও সহলেখকদের বিখ্যাত বই (CLRS)-ও মূলত এটিই শেখায়।

নিয়ম: শেষ উপাদান A[hi] হলো pivot. একটি pointer i মনে রাখে “ছোট-অংশের” শেষ ঘর (শুরুতে lo − 1, অর্থাৎ ছোট-অংশ খালি). আরেকটি pointer j বাঁ থেকে ডানে প্রতিটি উপাদান একবার দেখে। A[j] ≤ pivot হলে i এক বাড়িয়ে A[i] আর A[j] swap — অর্থাৎ ছোট উপাদানটিকে ছোট-অংশের ঠিক পরের ঘরে টেনে আনা। শেষে pivot-কে A[i+1]-এ বসানো।

Pseudocode — Lomuto partition (0-based)

partition(A, lo, hi):
    pivot = A[hi]
    i = lo - 1                       // A[lo..i] holds elements <= pivot
    for j = lo .. hi-1:
        if A[j] <= pivot:
            i = i + 1
            swap A[i], A[j]
    swap A[i+1], A[hi]               // pivot moves to its final place
    return i + 1

অ্যারে [4, 7, 2, 6, 1, 8, 3, 5]-এ ধাপে ধাপে দেখুন। Pivot = 5.

প্রতিটি j-এর পর অ্যারে A[0..7] আর তিনটি অংশ
j, A[j]
সিদ্ধান্ত
i
A[0..7]
≤5 · বড় · না-দেখা
  1. শুরু. Pivot = A[7] = 5. i = −1, অর্থাৎ “≤ 5” অংশ এখনো খালি। j এখন একে একে A[0] থেকে A[6] দেখবে।
  2. j = 0, A[j] = 4. 4 ≤ 5, তাই i = 0 আর A[0]-এর সাথে A[0]-এর swap — নিজের সাথে নিজের, কিছুই বদলায় না। ছোট-অংশ: 4.
  3. j = 1, A[j] = 7. 7 > 5. কিছু করতে হয় না; j এগোলেই 7 আপনাআপনি “বড়” অংশে ঢুকে যায়।
  4. j = 2, A[j] = 2. 2 ≤ 5. i = 1, swap A[1] (7) আর A[2] (2). বড়-অংশের প্রথম উপাদান 7 ডানে সরে গেল, 2 ঢুকল ছোট-অংশে: 4 2 | 7.
  5. j = 3, A[j] = 6. 6 > 5, কিছু না। বড়-অংশ এখন 7 6.
  6. j = 4, A[j] = 1. 1 ≤ 5. i = 2, swap A[2] (7) আর A[4] (1). ছোট-অংশ: 4 2 1; বড়-অংশ: 6 7.
  7. j = 5, A[j] = 8. 8 > 5, কিছু না। বড়-অংশ: 6 7 8.
  8. j = 6, A[j] = 3. 3 ≤ 5. i = 3, swap A[3] (6) আর A[6] (3). ছোট-অংশ: 4 2 1 3; বড়-অংশ: 7 8 6. সব উপাদান দেখা শেষ — মোট ৭টি তুলনা, অর্থাৎ n − 1.
  9. শেষ swap. Pivot-কে বড়-অংশের প্রথম ঘর A[i+1] = A[4]-এ বসাই: 7 আর 5 swap. ফল 4 2 1 3 [5] 8 6 7, p = 4. 5 এখন চিরকালের জন্য ঠিক জায়গায় — সাজানো অ্যারেতেও 5 থাকবে index 4-এ। দুই পাশ নিজেরা সাজানো নয়, সেটি রিকার্শনের কাজ।

কেন Lomuto ঠিক — একটি loop invariant

পর্ব ০৩-এর পদ্ধতিতে প্রমাণ করি। Invariant: for লুপের প্রতিটি ঘূর্ণনের শুরুতে —

  • A[lo..i]-এর প্রতিটি উপাদান ≤ pivot;
  • A[i+1..j−1]-এর প্রতিটি উপাদান > pivot;
  • A[hi] = pivot, আর A[j..hi−1] এখনো দেখা হয়নি।

শুরু (initialization): i = lo − 1, j = lo — প্রথম দুটি অংশ খালি, তাই শর্ত দুটি ফাঁকা অর্থে সত্য। রক্ষা (maintenance): A[j] > pivot হলে শুধু j বাড়ে, আর A[j] বড়-অংশের শেষে যুক্ত হয় — invariant টিকে থাকে। A[j] ≤ pivot হলে i বাড়ে, আর A[i] (যা বড়-অংশের প্রথম উপাদান ছিল, অথবা বড়-অংশ খালি থাকলে A[j] নিজেই) A[j]-এর সাথে swap হয়: ছোট উপাদানটি ছোট-অংশের শেষে আসে, বড়টি বড়-অংশের শেষে যায় — আবার invariant সত্য। সমাপ্তি (termination): j = hi হলে সব উপাদান তিন অংশের কোনো একটিতে। শেষ swap pivot-কে ছোট-অংশ আর বড়-অংশের মাঝে বসায় — ঠিক যা partition-এর কথা ছিল। লুপ ঠিক hi − lo বার ঘোরে, প্রতিবার O(1) কাজ: সময় Θ(n), বাড়তি জায়গা O(1).

পুরো quick sort-এর শুদ্ধতা এবার induction দিয়ে আসে: partition-এর পর A[p] সঠিক ঘরে; induction hypothesis অনুযায়ী দুই পাশের ছোট অংশ দুটি সঠিকভাবে সাজানো হয়; আর বাঁয়ের সব ≤ A[p] ≤ ডানের সব — তাই পুরোটা সাজানো। এবার কোডে চালিয়ে প্রতিটি partition দেখুন:

#include <cstdio>
#include <vector>
#include <algorithm>
#include <initializer_list>
using namespace std;
long long comparisons = 0;
int maxDepth = 0, show = 1;                        // show: print each partition
int partition(vector<int>& a, int lo, int hi) {   // Lomuto: pivot = last element
    int pivot = a[hi], i = lo - 1;                 // a[lo..i] <= pivot
    for (int j = lo; j < hi; ++j)
        if (++comparisons; a[j] <= pivot) swap(a[++i], a[j]);
    swap(a[i + 1], a[hi]);                         // pivot to its final place
    return i + 1;
}
void quickSort(vector<int>& a, int lo, int hi, int depth) {
    if (lo >= hi) return;
    maxDepth = max(maxDepth, depth);
    int p = partition(a, lo, hi);
    if (show) {
        printf("%*s[%d..%d] pivot %d:", 2 * depth - 2, "", lo, hi, a[p]);
        for (int t = lo; t <= hi; ++t) printf(t == p ? " [%d]" : " %d", a[t]);
        printf("\n");
    }
    quickSort(a, lo, p - 1, depth + 1);
    quickSort(a, p + 1, hi, depth + 1);
}
int main() {
    vector<int> a{4, 7, 2, 6, 1, 8, 3, 5};
    quickSort(a, 0, 7, 1);
    printf("sorted:"); for (int x : a) printf(" %d", x);
    printf("\ncomparisons = %lld, max depth = %d\n", comparisons, maxDepth);
    show = 0;
    for (int n : {8, 16, 32}) {
        vector<int> b(n); for (int v = 0; v < n; ++v) b[v] = v + 1;  // sorted: the worst case
        comparisons = maxDepth = 0;
        quickSort(b, 0, n - 1, 1);
        printf("sorted input n = %d: comparisons = %lld, max depth = %d\n", n, comparisons, maxDepth);
    }
}

আউটপুট:

[0..7] pivot 5: 4 2 1 3 [5] 8 6 7
  [0..3] pivot 3: 2 1 [3] 4
    [0..1] pivot 1: [1] 2
  [5..7] pivot 7: 6 [7] 8
sorted: 1 2 3 4 5 6 7 8
comparisons = 13, max depth = 3
sorted input n = 8: comparisons = 28, max depth = 7
sorted input n = 16: comparisons = 120, max depth = 15
sorted input n = 32: comparisons = 496, max depth = 31

প্রথম লাইনটি ওপরের ডায়াগ্রামের শেষ অবস্থার সাথে হুবহু মেলে। তারপর বাঁ অংশ [0..3]-এ pivot 3, তার বাঁ অংশ [0..1]-এ pivot 1; ডান অংশ [5..7]-এ pivot 7. এক উপাদানের অংশ ([3..3], [2..2]) base case, তাই ছাপা হয়নি। তুলনা 7 + 3 + 1 + 2 = 13. কিন্তু শেষ তিন লাইন দেখুন: আগে থেকেই সাজানো অ্যারেতে তুলনা 28, 120, 496 — ঠিক n(n − 1)/2 — আর রিকার্শনের গভীরতা n − 1 (এক উপাদানের base case কলগুলো এখানে গোনা হয়নি). n দ্বিগুণ হলে কাজ প্রায় চার গুণ: এটিই Θ(n²), যা অংশ ৪-এ বিশ্লেষণ করা হবে। swap(a[++i], a[j]) এক লাইনে Lomuto-র দুটি কাজ করে: আগে i বাড়ায়, তারপর swap.

comparisons = max_depth = 0
show = True

def partition(a, lo, hi):              # Lomuto: pivot = last element
    global comparisons
    pivot, i = a[hi], lo - 1           # a[lo..i] <= pivot
    for j in range(lo, hi):
        comparisons += 1
        if a[j] <= pivot:
            i += 1
            a[i], a[j] = a[j], a[i]
    a[i + 1], a[hi] = a[hi], a[i + 1]  # pivot to its final place
    return i + 1

def quick_sort(a, lo, hi, depth):
    global max_depth
    if lo >= hi:
        return
    max_depth = max(max_depth, depth)
    p = partition(a, lo, hi)
    if show:
        part = ' '.join(f'[{a[t]}]' if t == p else str(a[t]) for t in range(lo, hi + 1))
        print(f"{'  ' * (depth - 1)}[{lo}..{hi}] pivot {a[p]}: {part}")
    quick_sort(a, lo, p - 1, depth + 1)
    quick_sort(a, p + 1, hi, depth + 1)

a = [4, 7, 2, 6, 1, 8, 3, 5]
quick_sort(a, 0, len(a) - 1, 1)
print("sorted:", *a)
print(f"comparisons = {comparisons}, max depth = {max_depth}")
show = False
for n in (8, 16, 32):
    comparisons = max_depth = 0
    quick_sort(list(range(1, n + 1)), 0, n - 1, 1)   # already sorted: the worst case
    print(f"sorted input n = {n}: comparisons = {comparisons}, max depth = {max_depth}")

আউটপুট C++-এর সাথে হুবহু এক:

[0..7] pivot 5: 4 2 1 3 [5] 8 6 7
  [0..3] pivot 3: 2 1 [3] 4
    [0..1] pivot 1: [1] 2
  [5..7] pivot 7: 6 [7] 8
sorted: 1 2 3 4 5 6 7 8
comparisons = 13, max depth = 3
sorted input n = 8: comparisons = 28, max depth = 7
sorted input n = 16: comparisons = 120, max depth = 15
sorted input n = 32: comparisons = 496, max depth = 31

Python-এ a[i], a[j] = a[j], a[i] এক লাইনেই swap. global দিয়ে counter দুটি ফাংশনের ভেতর থেকে বদলানো হয়েছে — ছোট পরীক্ষার জন্য চলে, বড় কোডে একটি class বা ফেরত-মান ভালো। সতর্কতা: Python-এর ডিফল্ট রিকার্শন সীমা প্রায় 1000 (পর্ব ০৭). সাজানো অ্যারেতে এই সরল quick sort-এর গভীরতা n − 1, তাই n = 2000-এর মতো ছোট ইনপুটেই RecursionError আসবে। সমাধান অংশ ৫ আর ৭-এ।

৩ · Hoare partition — দুই প্রান্ত থেকে মুখোমুখি

Hoare-এর নিজের ১৯৬১ সালের পদ্ধতিটি আলাদা। দুটি pointer দুই প্রান্ত থেকে মাঝের দিকে এগোয়: i বাঁ থেকে খোঁজে এমন উপাদান যা pivot-এর চেয়ে বড় বা সমান (অর্থাৎ ভুল দিকে আছে), j ডান থেকে খোঁজে এমন উপাদান যা pivot-এর চেয়ে ছোট বা সমান। দুজনেই থামলে তাদের swap — একসাথে দুটি ভুল-জায়গার উপাদান ঠিক দিকে চলে যায়। Pointer দুটি একে অপরকে পার হলে কাজ শেষ। ট্রেনের দুই প্রান্ত থেকে দুজন টিকিট-চেকারের কথা ভাবুন: প্রত্যেকে নিজের দিক থেকে ভুল বগিতে বসা একজন যাত্রী খুঁজে পেলেই দুজনে যাত্রী বদলে নেন। এই রূপটিকে বলে Hoare partition.

Pseudocode — Hoare partition (0-based, pivot = first element)

partition(A, lo, hi):
    pivot = A[lo]
    i = lo - 1;  j = hi + 1
    loop:
        repeat j = j - 1 until A[j] <= pivot
        repeat i = i + 1 until A[i] >= pivot
        if i < j: swap A[i], A[j]
        else: return j               // A[lo..j] <= pivot <= A[j+1..hi]

quicksort(A, lo, hi):                // with Hoare: recurse on lo..j and j+1..hi
    if lo < hi:
        j = partition(A, lo, hi)
        quicksort(A, lo, j);  quicksort(A, j + 1, hi)

একই অ্যারে [4, 7, 2, 6, 1, 8, 3, 5], কিন্তু এবার pivot প্রথম উপাদান 4:

দুই pointer-এর অবস্থান আর অ্যারে
ঘটনা
i, j
A[0..7]
মোট swap
  1. শুরু. Pivot = A[0] = 4. i = −1, j = 8 — দুজনেই অ্যারের ঠিক বাইরে, প্রথম পদক্ষেপেই ভেতরে ঢুকবে।
  2. j-এর খোঁজ. j = 7: A[7] = 5 > 4, ঠিক দিকেই আছে, এগিয়ে যাই। j = 6: A[6] = 3 ≤ 4 — এটি ভুল দিকে (ডানে থাকা ছোট উপাদান). j থামল।
  3. i-এর খোঁজ আর swap. i = 0: A[0] = 4 ≥ 4 — থামল (pivot নিজেই). i = 0 < j = 6, তাই swap: 3 গেল বাঁয়ে, 4 গেল ডানে। খেয়াল করুন, pivot নিজের জায়গা ছেড়ে সরে গেছে — Hoare-এ এটি স্বাভাবিক।
  4. দ্বিতীয় রাউন্ড. j: A[5] = 8 বড়, এগোয়; A[4] = 1 ≤ 4 — j = 4. i: A[1] = 7 ≥ 4 — i = 1. i < j, swap 7 ⇄ 1. অ্যারে: 3 1 2 6 7 8 4 5.
  5. তৃতীয় রাউন্ড — পার হওয়া. j: A[3] = 6 বড়, এগোয়; A[2] = 2 ≤ 4 — j = 2. i: A[2] = 2 < 4, এগোয়; A[3] = 6 ≥ 4 — i = 3. এখন i = 3 > j = 2: pointer দুটি পার হয়ে গেছে, return j = 2.
  6. ফল. বাঁ অংশ A[0..2] = 3 1 2 (সব ≤ 4), ডান অংশ A[3..7] = 6 7 8 4 5 (সব ≥ 4). Pivot 4 আছে index 6-এ — তার চূড়ান্ত ঘর নয়! তাই Hoare-এর পর রিকার্শন হয় [lo..j] আর [j+1..hi]-এ, pivot-কে বাদ না দিয়ে। মোট swap মাত্র ২টি; Lomuto একই অ্যারেতে ৫টি swap করেছিল (নিজের সাথে swap-সহ).
বিষয়LomutoHoare
Pointerদুটিই বাঁ থেকে ডানেদুই প্রান্ত থেকে মুখোমুখি
Partition-এর পর pivotচূড়ান্ত ঘরে, index p ফেরতযেকোনো এক পাশে; শুধু সীমা j ফেরত
Swap (এলোমেলো ইনপুটে গড়ে)বেশি — প্রায় প্রতিটি ছোট উপাদানে একটিপ্রায় তিন গুণ কম
সব উপাদান সমান হলেসব যায় বাঁ দিকে → Θ(n²)প্রায় মাঝ বরাবর ভাগ → Θ(n log n)
লেখা আর প্রমাণসহজ, ভুলের সুযোগ কমসূক্ষ্ম — সীমা নিয়ে ভুল করা সহজ

Hoare-এর দুটি ফাঁদ। (১) Pivot হিসেবে A[hi] নিলে, যেমন [1, 2]-এ, partition ফেরত দিতে পারে j = hi — তখন quicksort(lo, j) আবার পুরো অংশটিই পায়, আর রিকার্শন কখনো থামে না। Hoare-এ pivot নিন A[lo] বা মাঝের উপাদান (A[(lo+hi)/2], যা নিচের দিকে round হয়). (২) তুলনায় < আর > হতে হবে কঠোর — অর্থাৎ pivot-এর সমান উপাদানে দুই pointer-ই থামবে। শুনতে অপচয় মনে হয় (সমান উপাদানও swap হয়), কিন্তু এটিই সব-সমান অ্যারেকে মাঝ বরাবর ভাগ করে, আর থামার নিশ্চয়তা দেয়।

৪ · বিশ্লেষণ — সেরা, গড় আর সবচেয়ে খারাপ

n উপাদানের অংশে partition নেয় Θ(n). Pivot যদি ছোট দিকে q টি আর বড় দিকে n − q − 1 টি উপাদান রাখে, তাহলে recurrence:

T(n) = T(q) + T(n − q − 1) + Θ(n)

সেরা ক্ষেত্র (best case): প্রতিবার pivot ঠিক median, q ≈ n/2. তখন T(n) = 2T(n/2) + Θ(n) — merge sort-এর হুবহু recurrence, master theorem-এর (পর্ব ০৯) দ্বিতীয় ক্ষেত্র: Θ(n log n). Recursion tree-র প্রতিটি স্তরে মোট কাজ প্রায় n, স্তর log₂ n টি।

সবচেয়ে খারাপ ক্ষেত্র (worst case): প্রতিবার pivot সবচেয়ে ছোট বা সবচেয়ে বড়, q = 0. তখন T(n) = T(n − 1) + Θ(n), যার সমাধান (n − 1) + (n − 2) + … + 1 = n(n − 1)/2 = Θ(n²). কখন এমন হয়? সবচেয়ে সাধারণ উদাহরণ: আগে থেকেই সাজানো (বা উল্টো সাজানো) অ্যারে, আর pivot প্রথম বা শেষ উপাদান। বাস্তবে এমন ইনপুট মোটেই বিরল নয় — বাজারের দামের তালিকা যা গতকালই সাজানো হয়েছিল, তাতে দুটি নতুন দাম যোগ হয়েছে। নিচে [1, 2, 3, 4, 5, 6]-এ শেষ উপাদান pivot ধরে প্রতিটি রিকার্সিভ কল দেখুন:

প্রতিটি partition কল
কল (অংশ)
pivot · ভাগের মাপ
এই কলে তুলনা
মোট তুলনা
call stack-এর গভীরতা
  1. qs(0, 5). Pivot 6 — অংশের সবচেয়ে বড়। ৫টি তুলনার প্রতিটিতে A[j] ≤ 6, তাই সবাই বাঁ দিকে; ডান অংশ খালি। অ্যারে একটুও বদলায়নি, কিন্তু কাজ হয়েছে n − 1.
  2. qs(0, 4). বাঁ অংশে আবার একই ঘটনা: pivot 5 সবচেয়ে বড়, ৪টি তুলনা, ভাগ 4 আর 0. প্রতিটি স্তরে সমস্যা মাত্র এক কমছে, অর্ধেক নয়।
  3. qs(0, 3). ৩টি তুলনা, মোট 12. Call stack-এ এখন তিনটি অসমাপ্ত কল জমেছে।
  4. qs(0, 2). ২টি তুলনা, মোট 14.
  5. qs(0, 1). ১টি তুলনা, মোট 15.
  6. qs(0, 0). এক উপাদান — base case. মোট তুলনা 5 + 4 + 3 + 2 + 1 = 15 = 6 · 5 / 2. Call stack-এর গভীরতা ৬, অর্থাৎ n. n = 106 হলে প্রায় ৫ × 1011 তুলনা (সাধারণ কম্পিউটারে কয়েক মিনিট) আর দশ লক্ষ স্তরের রিকার্শন — সাধারণত কাজ শেষ হওয়ার অনেক আগেই stack overflow হয়ে প্রোগ্রাম থেমে যাবে।

মাঝামাঝি ভাগও যথেষ্ট ভালো। প্রতিবার ভাগ যদি 9 : 1 অনুপাতেও হয় — খুবই একপেশে — তবু recursion tree-র গভীরতা log10/9 n (n = 106-এ প্রায় ১৩১), আর প্রতিটি স্তরে কাজ ≤ n. ফল এখনো Θ(n log n), শুধু ধ্রুবকটি বড়। অর্থাৎ quick sort-এর জন্য নিখুঁত median লাগে না; লাগে শুধু এটুকু যে pivot বারবার একেবারে প্রান্তের হবে না।

গড় ক্ষেত্র (average case): সব ক্রম সমান সম্ভাব্য ধরলে (n! টি ক্রমের প্রতিটি), pivot-এর rank 1 থেকে n-এর যেকোনোটি সমান সম্ভাবনায় হয়। তখন প্রত্যাশিত তুলনার সংখ্যা প্রায় 2n ln n ≈ 1.39 n log₂ n — Θ(n log n), best case-এর চেয়ে মাত্র ৩৯% বেশি। ভালো আর খারাপ ভাগ মিশে থাকে, আর একটি খারাপ ভাগের পরে একটি ভালো ভাগ এলেই ক্ষতি পুষিয়ে যায়।

ক্ষেত্রসময়রিকার্শনের গভীরতা (সরল কোড)কখন
BestΘ(n log n)Θ(log n)প্রতিবার pivot ≈ median
Average (এলোমেলো ইনপুট)Θ(n log n), ≈ 1.39 n log₂ n তুলনাপ্রত্যাশিত Θ(log n)সব ক্রম সমান সম্ভাব্য
WorstΘ(n²)Θ(n)প্রতিবার pivot সবচেয়ে ছোট/বড়

৫ · Pivot বাছাই আর randomized quicksort

Worst case নির্ভর করে pivot বাছাইয়ের নিয়মের ওপর। প্রচলিত নিয়মগুলো:

নিয়মসুবিধাদুর্বলতা
প্রথম বা শেষ উপাদানসবচেয়ে সহজসাজানো, উল্টো সাজানো বা প্রায়-সাজানো ইনপুটে Θ(n²)
মাঝের উপাদান A[(lo+hi)/2]সাজানো ইনপুটে নিখুঁত ভাগবিশেষভাবে বানানো ইনপুটে এখনো Θ(n²)
এলোমেলো (random) indexপ্রতিটি ইনপুটে প্রত্যাশিত Θ(n log n)random সংখ্যা তৈরির খরচ; ফল প্রতিবার একটু আলাদা পথে আসে
Median-of-three (প্রথম, মাঝ, শেষের median)সাজানো ইনপুটে নিখুঁত; গড় তুলনাও কমে“median-of-3 killer” নামে পরিচিত বানানো ইনপুটে এখনো Θ(n²)

লক্ষ করুন: যেকোনো নির্দিষ্ট (deterministic) নিয়মের জন্য এমন একটি ইনপুট বানানো যায় যা প্রতিবার সবচেয়ে খারাপ pivot দেয় — কারণ শত্রু আগে থেকেই জানে আপনি কোন ঘর বাছবেন। এমন ইচ্ছাকৃত ইনপুটকে বলে adversarial input. সমাধান হলো শত্রুর কাছ থেকে সিদ্ধান্তটি লুকিয়ে ফেলা: pivot বাছুন এলোমেলোভাবে। এটিই randomized quicksort — একটি randomized algorithm.

Pseudocode — randomized partition (0-based)

randomized_partition(A, lo, hi):
    r = random integer in [lo, hi]   // every index equally likely
    swap A[r], A[hi]                 // the random element becomes the pivot
    return partition(A, lo, hi)      // ordinary Lomuto from here on

ফল (প্রমাণ পর্ব ৬১-এ): যেকোনো n উপাদানের ইনপুটে — সাজানো, উল্টো, বা শত্রুর বানানো — randomized quicksort-এর expected running time O(n log n), আর প্রত্যাশিত তুলনা প্রায় 2n ln n. এখানে “প্রত্যাশা” নেওয়া হচ্ছে অ্যালগরিদমের নিজের coin toss-এর ওপর, ইনপুটের ওপর নয়। তাই এটি “ইনপুট এলোমেলো হলে গড়ে ভালো” বলার চেয়ে অনেক শক্ত দাবি: কোনো খারাপ ইনপুটই আর নেই — আছে শুধু খারাপ ভাগ্য, আর তার সম্ভাবনা n বাড়ার সাথে দ্রুত শূন্যের দিকে যায়।

মূল যুক্তির আভাস. সাজানো ক্রমে উপাদানগুলোকে z1 < z2 < … < zn বলি। Quick sort-এ দুটি উপাদানের তুলনা হয় শুধু তখনই, যখন তাদের একটি pivot — আর একবার তুলনা হওয়ার পর তারা আলাদা হয়ে যায় বা pivot সরে যায়, তাই কোনো জোড়া দুবার তুলনা হয় না। zi আর zj-এর তুলনা হবে ঠিক তখনই, যখন zi, zi+1, …, zj — এই j − i + 1 টি উপাদানের মধ্যে প্রথম যে pivot হয়, সে zi বা zj. মাঝের কেউ আগে pivot হলে দুজন দুই দিকে চলে যায়, আর কখনো দেখা হয় না। এলোমেলো pivot-এ এই সম্ভাবনা 2/(j − i + 1). সব জোড়ার যোগফল প্রায় 2n · (1 + 1/2 + 1/3 + … + 1/n) = 2n Hn ≈ 2n ln n — পর্ব ০৬-এর harmonic series এখানে কাজে লাগল।

আরও গভীরে: Robert Sedgewick ১৯৭৫ সালে Donald Knuth-এর তত্ত্বাবধানে quick sort নিয়েই PhD করেন, আর এর প্রায় প্রতিটি রূপের নিখুঁত গড় খরচ হিসাব করেন। যেমন median-of-three pivot প্রত্যাশিত তুলনা 2n ln n থেকে কমিয়ে প্রায় (12/7) n ln n করে — প্রায় ১৪% কম। তুলনা-ভিত্তিক যেকোনো সর্টের তুলনার নিম্নসীমা log₂(n!) ≈ n log₂ n − 1.44n (পর্ব ২৪), তাই quick sort গড়ে নিম্নসীমার মাত্র ৩৯% ওপরে। বড় অ্যারেতে Bentley আর McIlroy “ninther” ব্যবহার করেন — তিনটি করে উপাদানের তিনটি median-এর median, মোট নয়টি উপাদান দেখে।

৬ · ডুপ্লিকেট আর 3-way partition — ডাচ পতাকা ফিরে এলো

ধরুন একটি বোর্ড পরীক্ষার ১০ লক্ষ খাতা GPA অনুযায়ী সাজাতে হবে। GPA-র আলাদা মান মাত্র কয়েক ডজন — অর্থাৎ বিপুল ডুপ্লিকেট। Lomuto-তে pivot-এর সমান সব উপাদান বাঁ দিকে যায়; সব-সমান অংশে প্রতিটি partition মাত্র একটি উপাদান (pivot) কমায় — আবার Θ(n²). Hoare এটি সামলায়, কিন্তু সমান উপাদানগুলো নিয়ে বারবার রিকার্শন চালিয়ে যায়।

ভালো সমাধান: অংশটিকে তিন ভাগে ভাগ করা — pivot-এর চেয়ে ছোট, সমান, বড়। সমান অংশটি তখনই চূড়ান্ত; রিকার্শন হয় শুধু ছোট আর বড় অংশে। এটিই 3-way partition, আর এটি পর্ব ১৬-এর Dutch national flag সমস্যার হুবহু রূপ: তিনটি pointer lt, i, gt. A[lo..lt−1] < pivot, A[lt..i−1] = pivot, A[gt+1..hi] > pivot, আর A[i..gt] এখনো দেখা হয়নি।

Pseudocode — 3-way partition (0-based, Dijkstra's Dutch flag)

pivot = A[lo];  lt = lo;  i = lo;  gt = hi
while i <= gt:
    if A[i] < pivot:   swap A[lt], A[i];  lt++;  i++
    elif A[i] > pivot: swap A[i], A[gt];  gt--          // do NOT advance i
    else:              i++
// now recurse on A[lo..lt-1] and A[gt+1..hi] only
প্রতিটি ধাপের পর
দেখা উপাদান
lt, i, gt
A[0..7]
ছোট · সমান · না-দেখা · বড়
  1. শুরু. Pivot = A[0] = 3. lt = i = 0, gt = 7. পুরো অ্যারে “না-দেখা”.
  2. A[0] = 3, সমান. শুধু i এগোয়। সমান-অংশ: 3.
  3. A[1] = 5, বড়. একে ডান প্রান্তে পাঠাই: swap A[1] ⇄ A[7], gt = 6. i এগোয় না — ডান থেকে আসা 3 এখনো পরীক্ষা হয়নি।
  4. A[1] = 3, সমান. i = 2. সমান-অংশ: 3 3.
  5. A[2] = 3, সমান. i = 3. সমান-অংশ: 3 3 3.
  6. A[3] = 1, ছোট. swap A[lt] ⇄ A[i] — অর্থাৎ সমান-অংশের প্রথম 3 আর 1 জায়গা বদলায়। lt = 1, i = 4. ছোট-অংশ: 1.
  7. A[4] = 3, সমান. i = 5. সমান-অংশে এখন চারটি 3.
  8. A[5] = 6, বড়. swap A[5] ⇄ A[gt = 6], gt = 5. A[5]-এ এলো 2, যা এখনো দেখা হয়নি।
  9. A[5] = 2, ছোট. swap A[1] ⇄ A[5], lt = 2, i = 6. এখন i = 6 > gt = 5, থামি। ফল: 1 2 | 3 3 3 3 | 6 5. চারটি 3 একসাথে চূড়ান্ত জায়গায়; রিকার্শন হবে শুধু [1, 2] আর [6, 5]-এ। ৮টি উপাদানে ৮টি ধাপ — এক পাসে Θ(n).

যদি অ্যারেতে মাত্র k টি আলাদা মান থাকে, 3-way quick sort প্রত্যাশিত O(n log k) সময় নেয় — সব-সমান অ্যারেতে মাত্র এক পাস, Θ(n). ১৯৯৩ সালে Jon Bentley আর Douglas McIlroy “Engineering a Sort Function” প্রবন্ধে C লাইব্রেরির qsort-এর জন্য একটি দ্রুত 3-way রূপ দেন, যা পরে বহু লাইব্রেরিতে গৃহীত হয়।

৭ · Stack-এর গভীরতা, ছোট ভাগে রিকার্শন আর stability

অংশ ৪-এর stepper-এ দেখেছেন, একপেশে ভাগে recursion depth হয় n. প্রতিটি স্তর call stack-এ একটি frame — দশ লক্ষ উপাদানে নিশ্চিত stack overflow. এর একটি সুন্দর প্রতিকার আছে, যা সময় না বদলেও জায়গা নিশ্চিতভাবে কমায়:

মূল কৌশল

ছোট ভাগে রিকার্শন, বড় ভাগে লুপ। Partition-এর পর দুই ভাগের মধ্যে যেটি ছোট, শুধু তার জন্য রিকার্সিভ কল করুন; বড়টির জন্য lo বা hi বদলে একই ফাংশনের লুপে ফিরে যান। প্রতিটি রিকার্সিভ কল তখন সর্বোচ্চ অর্ধেক মাপের অংশ পায়, তাই গভীরতা ≤ log₂ n — worst case-এও O(log n) stack. সময় অবশ্য worst case-এ এখনো Θ(n²).

// quick sort with O(log n) stack: recurse on the smaller side, loop on the larger
void quickSort(vector<int>& a, int lo, int hi) {
    while (lo < hi) {
        int p = partition(a, lo, hi);          // Lomuto from section 2
        if (p - lo < hi - p) {                 // left side is smaller
            quickSort(a, lo, p - 1);
            lo = p + 1;                        // continue with the right side
        } else {
            quickSort(a, p + 1, hi);
            hi = p - 1;                        // continue with the left side
        }
    }
}

এটি আসলে হাতে করা tail-call elimination. সরল কোডের দ্বিতীয় রিকার্সিভ কলটি ফাংশনের একেবারে শেষ কাজ — একটি tail call (পর্ব ০৭). ভালো C++ compiler অপটিমাইজেশন চালু থাকলে নিজেই সেটিকে লুপে বদলে দিতে পারে, কিন্তু তাতে শুধু দ্বিতীয় কলটি বাদ যায় — বড় ভাগটি প্রথম কলে পড়লে গভীরতা তবু n হতে পারে। কোন ভাগ ছোট তা দেখে সিদ্ধান্ত নেওয়াটাই গ্যারান্টি দেয়। Python কখনো tail call অপটিমাইজ করে না, তাই সেখানে এই লুপ-রূপ বা নিজের একটি explicit stack অপরিহার্য। অংশ ৪-এর সাজানো ইনপুটে মজার ঘটনা ঘটে: ছোট ভাগটি সবসময় খালি, তার রিকার্সিভ কল সাথে সাথে ফিরে আসে, তাই stack-এ কখনো দুটির বেশি frame জমে না — অথচ সময় এখনো Θ(n²).

In-place, কিন্তু stable নয়

Quick sort in-place: partition চলে O(1) বাড়তি জায়গায়, আর পুরো অ্যালগরিদমে বাড়তি শুধু রিকার্শনের stack — ওপরের কৌশলে O(log n). Merge sort-এর Θ(n) বাড়তি অ্যারের তুলনায় এটি বড় সুবিধা।

কিন্তু quick sort stable নয়: সমান key-এর উপাদানগুলোর আগের ক্রম রক্ষা হয় না, কারণ swap উপাদানদের দূরে লাফিয়ে নিয়ে যায়। উদাহরণ: তিনজন পরীক্ষার্থী, নম্বর অনুযায়ী সাজাতে হবে — [(3, Anik), (3, Bithi), (1, Rafi)], যেখানে Anik আগে, Bithi পরে। Lomuto-তে pivot (1, Rafi); কেউ ≤ 1 নয়, তাই শেষ swap A[0] ⇄ A[2]: [(1, Rafi), (3, Bithi), (3, Anik)]. ডান অংশে আর কিছু বদলায় না। Bithi এখন Anik-এর আগে — সমান নম্বরের দুজনের ক্রম উল্টে গেছে। Stability দরকার হলে merge sort, C++-এর std::stable_sort, বা Python-এর sorted (Timsort) ব্যবহার করুন (পর্ব ২৫).

৮ · Introsort — quick + heap + insertion, std::sort-এর ভেতরে

Randomized quicksort-এর worst case-এর সম্ভাবনা নগণ্য, কিন্তু শূন্য নয়; আর লাইব্রেরি লেখকেরা গ্যারান্টি চান। ১৯৯৭ সালে David Musser একটি মিশ্র পদ্ধতি দেন, নাম introsort (introspective sort — যে নিজের অবস্থা নিজে পর্যবেক্ষণ করে):

  1. শুরুতে quick sort (সাধারণত median-of-three pivot দিয়ে) — কারণ গড়ে এটিই সবচেয়ে দ্রুত।
  2. রিকার্শন বেশি গভীর হলে heapsort: গভীরতা একটি সীমা (সাধারণত 2⌊log₂ n⌋) পার হলে বোঝা যায় ভাগগুলো খারাপ হচ্ছে; তখন সেই অংশটি heapsort-এ (পর্ব ২৩) সাজানো হয়, যার worst case O(n log n).
  3. ছোট টুকরোয় insertion sort: ১৬-র মতো ছোট অংশে রিকার্শনের খরচ কাজের চেয়ে বেশি; সেখানে insertion sort (পর্ব ২০) দ্রুততর।

ফল: গড়ে quick sort-এর গতি, worst case-এ O(n log n), আর বাড়তি জায়গা O(log n). GCC-র libstdc++ আর অন্যান্য প্রধান C++ লাইব্রেরির std::sort introsort বা তার কাছাকাছি কোনো রূপ ব্যবহার করে। C++11 থেকে standard নিজেই std::sort-এর জন্য worst case O(n log n) তুলনা দাবি করে — C++98 শুধু গড় দাবি করত, যা সরল quick sort-এও মিটত। অন্য ভাষার পছন্দ আলাদা: Java primitive অ্যারের জন্য Vladimir Yaroslavskiy-র dual-pivot quicksort (দুটি pivot, তিন ভাগ) ব্যবহার করে, আর object-এর জন্য stable Timsort; Python-এর list.sort শুধুই Timsort. Orson Peters-এর pdqsort (pattern-defeating quicksort) — introsort-এর আধুনিক উত্তরসূরি — Rust আর Go-র unstable sort-এর ভিত্তি হয়েছে।

৯ · Quickselect আর median of medians — k-তম ছোট, পুরো সর্ট ছাড়াই

বোর্ড পরীক্ষার ফলের পর একজন সাংবাদিক জানতে চান median নম্বর কত — অর্থাৎ ঠিক মাঝের পরীক্ষার্থীর নম্বর। পুরো তালিকা সাজিয়ে মাঝেরটি নিলে O(n log n). কিন্তু আমাদের তো পুরো ক্রম দরকার নেই, দরকার শুধু একটি উপাদান। এই প্রশ্নের সাধারণ রূপ: k-তম ছোট উপাদান খোঁজা, যাকে বলে k-তম order statistic; k = 1 হলে minimum, k = n হলে maximum, মাঝামাঝি k হলে median.

Hoare-এর Find (১৯৬১), আজ যার নাম quickselect, একটি সরল পর্যবেক্ষণের ওপর দাঁড়ানো: partition-এর পর pivot index p-তে বসে, অর্থাৎ সে নিজেই (0-based) p-তম ছোট। এখন তিনটি সম্ভাবনা — k = p হলে উত্তর পেয়ে গেছি; k < p হলে উত্তর বাঁ অংশে, ডান অংশ পুরোটা ফেলে দিন; k > p হলে উত্তর ডানে, বাঁ অংশ ফেলে দিন। Quick sort দুই দিকেই রিকার্শন করে, quickselect করে মাত্র এক দিকে — binary search-এর (পর্ব ১৮) মতো।

প্রতিটি রাউন্ডে Lomuto partition (pivot = অংশের শেষ উপাদান)
অংশ [lo..hi]
pivot → p
A[0..8]
সিদ্ধান্ত
মোট তুলনা
  1. শুরু. ৯টি উপাদানের median মানে 0-based k = 4 — পঞ্চম ছোট উপাদান। পুরো অংশ [0..8] বিবেচনায়।
  2. প্রথম রাউন্ড. Pivot 4; partition-এর পর 3 1 2 [4] 6 9 8 5 7, p = 3. অর্থাৎ 4 হলো চতুর্থ ছোট (0-based rank ৩) — আমাদের দরকার আরও বড় একটি। k > p, তাই বাঁয়ের চারটি উপাদান (3 1 2 4) চিরতরে বাদ। ৮টি তুলনা।
  3. দ্বিতীয় রাউন্ড. শুধু [4..8] = 6 9 8 5 7. Pivot 7; ফল 6 5 [7] 9 8, p = 6. k < p — উত্তর বাঁয়ে, তাই 7, 9, 8 বাদ। আর ৪টি তুলনা।
  4. তৃতীয় রাউন্ড. [4..5] = 6 5. Pivot 5, p = 4 = k. উত্তর 5 — মিলিয়ে দেখুন, 1 2 3 4 5 6 7 8 9. মোট মাত্র ১৩টি তুলনা, আর অ্যারের অনেক অংশ (যেমন শেষের 9 8) কখনো সাজানোই হয়নি।

কেন প্রত্যাশিত O(n). প্রতিবার pivot যদি অংশটিকে মোটামুটি অর্ধেক করে, কাজ হয় n + n/2 + n/4 + … ≤ 2n — পর্ব ০৬-এর geometric series. এলোমেলো pivot-এ প্রতিটি রাউন্ড অর্ধেক না করলেও, গড়ে অংশ একটি ধ্রুবক অনুপাতে ছোট হয়; ফল প্রত্যাশিত Θ(n) (median-এর জন্য প্রায় 3.4n তুলনা). Worst case অবশ্য quick sort-এর মতোই Θ(n²): সাজানো অ্যারেতে শেষ উপাদান pivot হলে প্রতিবার মাত্র একটি উপাদান বাদ যায়। নিচের কোড দুটি pivot নিয়মে দুই ধরনের ইনপুটে তুলনা গোনে:

#include <cstdio>
#include <vector>
#include <utility>
#include <initializer_list>
using namespace std;
int partition(vector<int>& a, int lo, int hi, long& cnt) {   // Lomuto, pivot = a[hi]
    int pivot = a[hi], i = lo - 1;
    for (int j = lo; j < hi; ++j)
        if (++cnt; a[j] <= pivot) swap(a[++i], a[j]);
    swap(a[i + 1], a[hi]);
    return i + 1;
}
int quickselect(vector<int> a, int k, bool mo3, long& cnt) {  // k-th smallest, 0-based
    int lo = 0, hi = (int)a.size() - 1;
    while (lo < hi) {
        if (mo3) {                          // median of a[lo], a[mid], a[hi] -> a[hi]
            int mid = (lo + hi) / 2;
            if (a[mid] < a[lo]) swap(a[mid], a[lo]);
            if (a[hi] < a[lo]) swap(a[hi], a[lo]);
            if (a[mid] < a[hi]) swap(a[mid], a[hi]);
        }
        int p = partition(a, lo, hi, cnt);
        if (k == p) return a[p];
        if (k < p) hi = p - 1; else lo = p + 1;   // keep only the side holding k
    }
    return a[lo];
}
int main() {
    const int n = 101;
    vector<int> mixed(n), sorted(n);
    for (int i = 0; i < n; ++i) mixed[i] = (i * 37) % n, sorted[i] = i;  // mixed: a fixed shuffle
    for (auto [name, data] : {pair{"mixed", mixed}, pair{"sorted", sorted}})
        for (bool mo3 : {false, true}) {
            long cnt = 0;
            int med = quickselect(data, n / 2, mo3, cnt);
            printf("%-6s pivot=%-11s median = %d, comparisons = %ld\n",
                   name, mo3 ? "median-of-3" : "last", med, cnt);
        }
}

আউটপুট:

mixed  pivot=last        median = 50, comparisons = 277
mixed  pivot=median-of-3 median = 50, comparisons = 267
sorted pivot=last        median = 50, comparisons = 3825
sorted pivot=median-of-3 median = 50, comparisons = 100

mixed হলো 0..100-এর একটি নির্দিষ্ট এলোমেলো ক্রম (i × 37 mod 101 — 37 আর 101 সহমৌলিক বলে প্রতিটি সংখ্যা ঠিক একবার আসে), তাই প্রতিবার একই ফল। n = 101-এ median 50 পেতে লাগল ২৬৭–২৭৭টি তুলনা, মোটামুটি 2.7n. একই অ্যারে একই partition দিয়ে পুরো quick sort করলে লাগে ৫৫৩টি তুলনা — অর্থাৎ শুধু median-এর জন্য সাজানো দ্বিগুণ খরচ। সাজানো ইনপুটে “শেষ উপাদান” নিয়মের করুণ দশা: 100 + 99 + … + 50 = 3825 তুলনা — Θ(n²)-এর চেহারা। Median-of-three সেখানে প্রথম রাউন্ডেই ঠিক মাঝের উপাদানটি pivot পায়: ১০০টি তুলনায় শেষ। গোনা হয়েছে শুধু partition-এর তুলনা; median-of-three-এর তিনটি করে বাড়তি তুলনা ধরা হয়নি। ফাংশন a-কে value হিসেবে নেয় (কপি), তাই মূল অ্যারে বদলায় না।

def partition(a, lo, hi, cnt):         # Lomuto, pivot = a[hi]
    pivot, i = a[hi], lo - 1
    for j in range(lo, hi):
        cnt[0] += 1
        if a[j] <= pivot:
            i += 1
            a[i], a[j] = a[j], a[i]
    a[i + 1], a[hi] = a[hi], a[i + 1]
    return i + 1

def quickselect(a, k, mo3, cnt):       # k-th smallest, 0-based
    lo, hi = 0, len(a) - 1
    while lo < hi:
        if mo3:                        # median of a[lo], a[mid], a[hi] -> a[hi]
            mid = (lo + hi) // 2
            if a[mid] < a[lo]: a[mid], a[lo] = a[lo], a[mid]
            if a[hi] < a[lo]: a[hi], a[lo] = a[lo], a[hi]
            if a[mid] < a[hi]: a[mid], a[hi] = a[hi], a[mid]
        p = partition(a, lo, hi, cnt)
        if k == p: return a[p]
        if k < p: hi = p - 1           # answer is on the left: drop the right
        else: lo = p + 1               # answer is on the right: drop the left
    return a[lo]

n = 101
mixed = [(i * 37) % n for i in range(n)]   # a fixed shuffle of 0..100
for name, data in (("mixed", mixed), ("sorted", list(range(n)))):
    for mo3 in (False, True):
        cnt = [0]
        med = quickselect(data[:], n // 2, mo3, cnt)
        rule = "median-of-3" if mo3 else "last"
        print(f"{name:6} pivot={rule:11} median = {med}, comparisons = {cnt[0]}")

আউটপুট C++-এর সাথে হুবহু এক:

mixed  pivot=last        median = 50, comparisons = 277
mixed  pivot=median-of-3 median = 50, comparisons = 267
sorted pivot=last        median = 50, comparisons = 3825
sorted pivot=median-of-3 median = 50, comparisons = 100

এখানে রিকার্শন নেই — while লুপ lo আর hi বদলে এক দিকে এগোয়, তাই stack-এর গভীরতার কোনো চিন্তা নেই; বাড়তি জায়গা O(1). cnt একটি এক-ঘরের list, যাতে ফাংশনের ভেতর থেকে বদলানো যায়। quickselect(data[:], …)-এ [:] একটি কপি দেয় — quickselect অ্যারের ক্রম বদলে দেয়, তাই পরের পরীক্ষার জন্য মূল ডেটা অক্ষত রাখা দরকার।

লাইব্রেরিতে: C++-এর std::nth_element(v.begin(), v.begin() + k, v.end()) গড়ে linear সময়ে k-তম উপাদানকে তার সাজানো ঘরে বসায়, বাঁয়ে ছোট আর ডানে বড় রেখে (libstdc++ ভেতরে introselect — quickselect আর একটি worst-case-নিরাপদ পদ্ধতির মিশ্রণ — ব্যবহার করে). Python-এর standard library-তে সরাসরি quickselect নেই: statistics.median ভেতরে সর্ট করে (O(n log n)), আর ছোট k-এর জন্য heapq.nsmallest(k, a) লাগে O(n log k). NumPy-র numpy.partition introselect ব্যবহার করে।

Median of medians — worst case-এও O(n)

Quickselect-কে worst case-এ linear করা যায় কি? ১৯৭৩ সালে পাঁচজন — Manuel Blum, Robert Floyd, Vaughan Pratt, Ronald Rivest আর Robert Tarjan — “Time Bounds for Selection” প্রবন্ধে দেখান, হ্যাঁ। তাঁদের নামের আদ্যক্ষর থেকে একে BFPRT অ্যালগরিদমও বলে, আর সাধারণ নাম median of medians. কৌশল হলো pivot এমনভাবে বাছা, যাতে সে নিশ্চিতভাবে মাঝামাঝি হয়:

  1. n উপাদানকে ৫টি করে দলে ভাগ করুন (⌈n/5⌉ টি দল).
  2. প্রতিটি দলের median বের করুন — ৫টি উপাদান, তাই প্রতিটি O(1), মোট O(n).
  3. এই ⌈n/5⌉ টি median-এর median x বের করুন — রিকার্সিভভাবে এই একই অ্যালগরিদম দিয়ে।
  4. x-কে pivot ধরে partition, তারপর quickselect-এর মতো শুধু এক দিকে রিকার্শন।

ছোট একটি উদাহরণ, ১৫টি উপাদান তিনটি দলে:

দলউপাদানসাজালেদলের median
১12 3 25 7 183 7 12 18 2512
২9 30 1 21 141 9 14 21 3014
৩6 27 16 2 112 6 11 16 2711

Median-গুলো {12, 14, 11}, এদের median x = 12. x-এর চেয়ে ছোট বা সমান উপাদান ৮টি, বড় ৭টি — প্রায় নিখুঁত ভাগ।

কেন ভাগ নিশ্চিতভাবে ভালো. দলগুলোর অর্ধেকের median ≥ x (x নিজে median-গুলোর median বলে). এমন প্রতিটি দলে অন্তত ৩টি উপাদান (median আর তার চেয়ে বড় দুটি) ≥ x. দলের সংখ্যা n/5, তার অর্ধেক n/10, প্রতিটিতে ৩ — তাই অন্তত প্রায় 3n/10 টি উপাদান x-এর চেয়ে বড় বা সমান। একইভাবে অন্তত প্রায় 3n/10 টি ছোট বা সমান। অতএব যেদিকেই রিকার্শন হোক, অংশটির মাপ সর্বোচ্চ প্রায় 7n/10 (নিখুঁত হিসাবে সীমানার দলগুলোর জন্য 7n/10 + 6). সময়ের recurrence:

T(n) ≤ T(n/5) + T(7n/10) + O(n)

প্রমাণ (substitution, পর্ব ০৮): ধরি O(n) অংশটি ≤ an, আর অনুমান করি T(m) ≤ cm সব ছোট m-এর জন্য। তাহলে T(n) ≤ c(n/5) + c(7n/10) + an = (9/10)cn + an. এটি ≤ cn হবে যদি an ≤ cn/10, অর্থাৎ c ≥ 10a. তাই T(n) = O(n). মূল কারণ: দুই রিকার্সিভ অংশের মোট মাপ n/5 + 7n/10 = 9n/10 < n — প্রতিটি স্তরে মোট কাজ জ্যামিতিক হারে কমে। ৩-এর দলে ভাঙলে এই অনুপাত হয় n/3 + 2n/3 = n, আর সরল সংস্করণে ফল দাঁড়ায় Θ(n log n); ৭-এর দলও কাজ করে। Recursion tree-র চোখে দেখলে: মূলে কাজ an, পরের স্তরে (9/10)an, তারপর (9/10)²an… — যোগফল ≤ 10an.

তত্ত্বে চমৎকার, কিন্তু ধ্রুবক বড় — প্রতিটি স্তরে দল বানানো আর দুটি রিকার্শন। তাই বাস্তবে randomized quickselect-ই বেশি দ্রুত; median of medians ব্যবহৃত হয় introselect-এর মতো মিশ্র পদ্ধতিতে “শেষ ভরসা” হিসেবে, যখন quickselect খারাপ চলছে বলে ধরা পড়ে। এটি দিয়ে quick sort-এর pivot বাছলে quick sort-ও তত্ত্বে worst case Θ(n log n) হয়, কিন্তু বাস্তবে introsort-এর চেয়ে ধীর।

পরীক্ষা ও ইন্টারভিউতে:

  • Quick sort-এর worst case কখন, আর কত? — প্রতিবার pivot সবচেয়ে ছোট বা বড় হলে (যেমন সাজানো অ্যারে, শেষ উপাদান pivot) T(n) = T(n − 1) + Θ(n) = Θ(n²).
  • Quick sort কি stable? In-place? — Stable নয় (দূরের swap সমান উপাদানের ক্রম ভাঙে); in-place — partition O(1) জায়গায়, রিকার্শন stack ছোট-ভাগ কৌশলে O(log n).
  • Lomuto-তে n উপাদানের partition-এ কয়টি তুলনা? — ঠিক n − 1; pivot ছাড়া প্রতিটি উপাদান একবার।
  • Unsorted অ্যারেতে k-তম বড় উপাদান O(n log n)-এর চেয়ে দ্রুত কীভাবে? — Quickselect (প্রত্যাশিত O(n)), বা size-k min-heap (O(n log k)); worst-case O(n)-এর জন্য median of medians.
  • Median of medians-এ দল ৫ কেন? — তখন রিকার্শনের মাপ n/5 + 7n/10 = 9n/10 < n, তাই T(n) = O(n); ৩-এর দলে যোগফল n হয়ে সরল রূপে O(n log n).

১০ · AI-এর যুগে quick sort — চেনা কোড, অচেনা ভুল

Quick sort এত পরিচিত যে AI এক সেকেন্ডে লিখে দেয়। কিন্তু চেনা কোডেই ভুল সবচেয়ে বিপজ্জনক, কারণ আমরা সেটি খুঁটিয়ে দেখি না। Partition-এর সীমায় একটি off-by-one, Hoare-এ ভুল pivot, বা “quick sort সবসময় O(n log n)” — এমন দাবি AI-এর উত্তরে প্রায়ই পাওয়া যায়। এলোমেলো ছোট ইনপুটে সব ঠিক দেখায়; সাজানো, সব-সমান বা বড় ইনপুটে ধরা পড়ে।

Quick sort-এর কোডে AI সাধারণত যেসব ভুল করে

AI-এর ভুলকীভাবে ধরবেন
“Quick sort-এর time complexity O(n log n)” — worst case উল্লেখ না করাসাজানো 104 উপাদানে তুলনা গুনুন — প্রায় 5 × 107 হলে সেটি n²/2
Hoare partition-এ pivot A[hi], বা রিকার্শন (lo, j−1) আর (j+1, hi)[1, 2] আর [2, 1] দিয়ে চালান — অসীম লুপ বা ভুল ফল
Random index বাছাই করে কিন্তু pivot-এর ঘরে swap করতে ভুলে যাওয়াকোডে swap(A[r], A[hi]) খুঁজুন; সব-সমান ও সাজানো ইনপুটে brute force-এর সাথে মেলান
দুই দিকেই রিকার্শন — বড় সাজানো ইনপুটে stack overflow বা Python-এ RecursionErrorn = 105 সাজানো ইনপুট দিন; ছোট ভাগে রিকার্শন চান
“Quickselect worst case O(n)” — median of medians ছাড়াইসাজানো ইনপুটে শেষ-উপাদান pivot দিয়ে তুলনা গুনুন
“Quick sort stable” বা Python-এ নতুন list বানানো রূপকে “in-place” বলাসমান key, আলাদা নামের রেকর্ড দিয়ে ক্রম পরীক্ষা করুন; বাড়তি list গুনুন

AI-কে কোড লিখতে না বলে বরং আপনার নিজের partition আর trace যাচাই করতে বলুন — আর প্রতিটি দাবির পক্ষে যুক্তি চান:

আমি DSA শিখছি, এখন quick sort আর quickselect. নিচে আমার লেখা Lomuto partition, পুরো quick sort আর হাতে করা trace দিলাম: [এখানে কোড আর trace পেস্ট করুন] পুরো সমাধান নতুন করে লিখে দিও না। শুধু এগুলো করো: ১. [4, 7, 2, 6, 1, 8, 3, 5] আর [3, 3, 3, 3] ইনপুটে প্রতিটি swap-এর পর অ্যারে দেখাও, আমার trace-এর সাথে কোথায় অমিল বলো। ২. আমার loop invariant-টি লিখে দেখাও লুপের প্রতিটি ঘূর্ণনে সেটি কেন টিকে থাকে। ৩. কোন ইনপুটে আমার কোড Θ(n²) সময় আর Θ(n) গভীরতার রিকার্শন নেবে? কীভাবে ঠিক করব, শুধু ইঙ্গিত দাও। ৪. আমার কোড কি stable? একটি ছোট উদাহরণ দিয়ে প্রমাণ করো।

Quick sort-এর কোড যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১যন্ত্র-অনুবাদের একটি প্রকল্প থেকে কীভাবে জন্ম নিল পৃথিবীর সবচেয়ে বেশি ব্যবহৃত সর্টিং অ্যালগরিদমগুলোর একটি?

১৯৫০-এর দশকের শেষে শীতল যুদ্ধের সময় রুশ বৈজ্ঞানিক লেখা ইংরেজিতে অনুবাদ করা পশ্চিমে খুব গুরুত্বপূর্ণ ছিল, আর কম্পিউটার দিয়ে অনুবাদের চেষ্টা চলছিল। Tony Hoare অক্সফোর্ডে classics আর দর্শন পড়ার পর রুশ ভাষা শিখেছিলেন, আর ১৯৫৯ সালে মস্কো স্টেট ইউনিভার্সিটিতে একজন ভিজিটিং ছাত্র হিসেবে যান। সেখানে তিনি ব্রিটেনের National Physical Laboratory-র একটি যন্ত্র-অনুবাদ প্রকল্পের জন্য কাজ করছিলেন। সমস্যাটি ছিল: একটি রুশ বাক্যের শব্দগুলো চৌম্বক টেপে রাখা অভিধানের সাথে মেলাতে হবে, আর টেপ শুধু সামনের দিকে পড়া যায় — তাই শব্দগুলো আগে বর্ণানুক্রমে সাজিয়ে নিলে অভিধান একবার পড়েই সব মেলানো যায়।

প্রথমে তাঁর মাথায় আসে insertion sort-এর মতো কিছু, যা ধীর। তারপর আসে partition-এর চিন্তা। Hoare পরে বলেছেন, বিভাজনের ধারণাটি পাওয়া গেলেও দুটি ভাগকে আবার সাজানোর অংশটি সেই সময়ের ভাষায় পরিষ্কারভাবে লিখতে তাঁর অসুবিধা হয়েছিল — কারণ তখনকার বেশিরভাগ প্রোগ্রামিং ভাষায় রিকার্শন ছিল না। পরে ALGOL 60 ভাষার সাথে পরিচয় হয়, যেখানে ফাংশন নিজেকে ডাকতে পারে, আর তখন পুরো অ্যালগরিদম কয়েক লাইনে লেখা যায়। অর্থাৎ quick sort আর রিকার্শনের জনপ্রিয়তা একসাথে বেড়েছে।

দেশে ফিরে Elliott Brothers কোম্পানিতে Hoare-এর গল্পটি বিখ্যাত: তাঁর বস নাকি Shellsort-এর চেয়ে দ্রুত কিছু তিনি জানেন না — এই নিয়ে ছয় পেনির বাজি ধরেন, আর Hoare quick sort দেখিয়ে বাজি জেতেন (Hoare নিজে বিভিন্ন সাক্ষাৎকারে এর কথা বলেছেন). ১৯৬১ সালে Communications of the ACM-এ তাঁর Partition, Quicksort আর Find প্রকাশিত হয়; ১৯৬২ সালে The Computer Journal-এ বিস্তারিত বিশ্লেষণ, যেখানে তিনি গড় তুলনার সংখ্যাও হিসাব করেন।

Hoare পরে প্রোগ্রামের শুদ্ধতা প্রমাণের জন্য Hoare logic (১৯৬৯) দেন — যে loop invariant-এর ধারণা পর্ব ০৩-এ শিখেছেন, তার আনুষ্ঠানিক ভিত্তির বড় অংশ সেখান থেকে। আবার ১৯৬৫ সালে ALGOL W-তে null reference যোগ করাকে তিনি নিজেই পরে বলেছেন তাঁর “billion-dollar mistake”. ১৯৮০ সালে তিনি Turing Award পান।

মূল উপলব্ধি: মহান অ্যালগরিদম প্রায়ই জন্মায় একটি বাস্তব, সংকীর্ণ সমস্যা থেকে — আর তাকে প্রকাশ করার মতো ভাষা (এখানে রিকার্শন) না থাকলে ভালো ধারণাও আটকে থাকে। চিন্তার হাতিয়ার আর সমাধান একসাথে এগোয়।

প্র ০২Worst case Θ(n²) হওয়া সত্ত্বেও quick sort বাস্তবে merge sort আর heapsort-এর চেয়ে প্রায়ই দ্রুত কেন?

Big-O শুধু বৃদ্ধির হার বলে, ধ্রুবক আর মেশিনের আচরণ লুকিয়ে রাখে (পর্ব ০৪). তিনটি অ্যালগরিদমই গড়ে Θ(n log n), তাই পার্থক্য আসে ধ্রুবক আর হার্ডওয়্যারের সাথে মিল থেকে।

প্রথম কারণ — মেমরির ক্রমিক ব্যবহার. Partition-এর pointer-গুলো অ্যারের ওপর দিয়ে একটানা এক দিকে হাঁটে। আধুনিক CPU মেমরি থেকে একবারে একটি cache line (সাধারণত 64 byte, অর্থাৎ ১৬টি int) আনে, আর ক্রমিক পড়া দেখে আগে থেকেই পরের line এনে রাখে (prefetch). তাই quick sort-এর প্রায় সব পড়া cache থেকে আসে। Heapsort-এ উল্টো: heap-এ index i থেকে 2i + 1-এ লাফ দিতে হয়, বড় অ্যারেতে প্রায় প্রতিটি লাফ নতুন cache line — আর RAM থেকে একটি পড়া cache-এর চেয়ে প্রায় একশো গুণ ধীর হতে পারে। তাই worst case গ্যারান্টি থাকা সত্ত্বেও heapsort বাস্তবে সাধারণত quick sort-এর চেয়ে কয়েক গুণ ধীর।

দ্বিতীয় কারণ — কম ডেটা সরানো আর বাড়তি অ্যারে নেই. Merge sort প্রতিটি স্তরে সব n উপাদান একটি সহায়ক অ্যারেতে কপি করে আবার ফেরত আনে। Quick sort শুধু ভুল জায়গার উপাদান swap করে — Hoare partition-এ এলোমেলো ইনপুটে গড়ে প্রায় n/6 টি swap প্রতি পাসে। বাড়তি Θ(n) মেমরি বরাদ্দও লাগে না, যা বড় ডেটায় নিজেই একটি খরচ।

তৃতীয় কারণ — ছোট ভেতরের লুপ. Partition-এর লুপে একটি তুলনা, একটি index বাড়ানো, মাঝে মাঝে একটি swap — compiler এটিকে খুব ছোট মেশিন কোডে বদলায়, আর pivot একটি register-এ বসে থাকে।

তবে ছবিটি একতরফা নয়। Merge sort stable, linked list-এ সহজ, আর ডিস্কে রাখা বিশাল ডেটার external sorting-এ (যা RAM-এ আঁটে না) প্রায় একমাত্র পছন্দ। আর প্রায়-সাজানো বাস্তব ডেটায় Timsort-এর মতো অভিযোজিত merge sort quick sort-কে হারায়। এজন্যই লাইব্রেরিগুলো মিশ্র পদ্ধতি নেয়: introsort গতি নেয় quick sort থেকে, গ্যারান্টি heapsort থেকে, ছোট অংশের দক্ষতা insertion sort থেকে।

মূল উপলব্ধি: একই Big-O-র দুটি অ্যালগরিদমের মধ্যে জয়ী হয় যে মেমরিকে ভালোভাবে ব্যবহার করে। অ্যালগরিদম বিশ্লেষণে RAM model (পর্ব ০২) একটি সরলীকরণ — বাস্তব মেশিনে cache-এর কথাও ভাবতে হয়।

প্র ০৩“Killer adversary” কী, আর কেন অনলাইন কনটেস্ট ও সার্ভারে নির্দিষ্ট-pivot quick sort বিপজ্জনক?

যেকোনো নির্দিষ্ট pivot নিয়মের জন্য একটি খারাপ ইনপুট থাকে — অংশ ৫-এ এটি দেখেছেন। প্রশ্ন হলো, সেই ইনপুট কি সত্যিই বানানো যায়, আর বানালে কী ক্ষতি? ১৯৯৯ সালে Bell Labs-এর Douglas McIlroy “A Killer Adversary for Quicksort” প্রবন্ধে একটি চমৎকার কৌশল দেখান। তাঁর “শত্রু” ইনপুট আগে থেকে ঠিক করে না; বরং sort ফাংশনটি যখন comparator ডাকে, শত্রু তখন উত্তর দিতে দিতে মানগুলো ঠিক করে — এমনভাবে, যাতে pivot বলে যে উপাদানটিকে মনে হয়, সেটিই সবচেয়ে ছোট হয়। শেষে যে ক্রম দাঁড়ায়, সেটি ওই নির্দিষ্ট লাইব্রেরির জন্য quadratic ইনপুট। তিনি দেখান যে তখনকার বেশ কয়েকটি প্রচলিত C লাইব্রেরির qsort এভাবে Θ(n²)-এ ঠেলে দেওয়া যায় — median-of-three বা ninther থাকা সত্ত্বেও।

এর বাস্তব গুরুত্ব আছে। একটি ওয়েব সার্ভার যদি ব্যবহারকারীর পাঠানো তালিকা নির্দিষ্ট-pivot quick sort-এ সাজায়, আক্রমণকারী ছোট একটি অনুরোধ দিয়েই সার্ভারকে মিনিটের পর মিনিট ব্যস্ত রাখতে পারেন — algorithmic complexity attack, এক ধরনের denial of service. Hash table-এর বিরুদ্ধে একই ধরনের “hash flooding” আক্রমণ পরে অনেক ভাষাকে hash function-এ এলোমেলোতা যোগ করতে বাধ্য করেছে (পর্ব ৩২).

প্রতিযোগিতামূলক প্রোগ্রামিংয়েও এটি পরিচিত বাস্তবতা। যেসব অনলাইন জাজে অন্য প্রতিযোগী আপনার সমাধানের বিরুদ্ধে ইনপুট বানিয়ে “hack” করতে পারেন, সেখানে নির্দিষ্ট-pivot quick sort নিজে লিখলে সাজানো বা বিশেষ ক্রমের একটি ইনপুটেই Time Limit Exceeded. Java-র primitive অ্যারের Arrays.sort dual-pivot quicksort হওয়ায় এর বিরুদ্ধেও বিশেষ ইনপুট বানানো যায় — অনেক কনটেস্ট প্রোগ্রামার তাই Java-য় primitive-এর বদলে object অ্যারে (যা merge-ধাঁচের stable sort পায়) সাজান, বা আগে অ্যারেটি এলোমেলো করে নেন।

প্রতিরক্ষা তিন রকম: (১) random pivot — শত্রু আপনার coin toss আগে থেকে জানে না; (২) introsort-এর মতো গ্যারান্টি — খারাপ চললে heapsort; (৩) নিজে না লিখে ভালো লাইব্রেরি — C++-এর std::sort introsort হওয়ায় worst case O(n log n).

মূল উপলব্ধি: Average case বিশ্লেষণ ধরে নেয় প্রকৃতি নিরপেক্ষ; কিন্তু ইনপুট যখন কোনো মানুষ পাঠায়, তখন worst case-ই আসল case. Randomization বা worst-case গ্যারান্টি তখন বিলাসিতা নয়, নিরাপত্তা।

প্র ০৪BFPRT-এর পাঁচ লেখকের চারজন পরে Turing Award পান — অথচ তাঁদের অ্যালগরিদম বাস্তবে কম ব্যবহৃত। তাহলে এর মূল্য কোথায়?

১৯৭৩ সালে Journal of Computer and System Sciences-এ “Time Bounds for Selection” প্রকাশের সময় লেখকেরা ছিলেন তরুণ গবেষক। পরে Robert Floyd (১৯৭৮), Robert Tarjan (১৯৮৬), Manuel Blum (১৯৯৫) আর Ronald Rivest (২০০২) — চারজনই Turing Award পান, প্রত্যেকে ভিন্ন কাজের জন্য: Floyd প্রোগ্রাম যাচাই আর অ্যালগরিদমের জন্য, Tarjan গ্রাফ অ্যালগরিদম আর ডেটা স্ট্রাকচারের জন্য, Blum computational complexity আর cryptography-র জন্য, আর Rivest RSA public-key cryptography-র জন্য (Shamir ও Adleman-এর সাথে). Vaughan Pratt-ও Knuth–Morris–Pratt string matching-এর (পর্ব ৫৮) সহ-আবিষ্কারক। একটি প্রবন্ধে এমন সমাবেশ বিরল।

প্রবন্ধটির আসল প্রশ্ন ছিল তাত্ত্বিক: selection কি sorting-এর চেয়ে মৌলিকভাবে সহজ? Sorting-এ তুলনার নিম্নসীমা Ω(n log n) (পর্ব ২৪). Quickselect গড়ে O(n) দেখিয়েছিল, কিন্তু worst case-এ Θ(n²). BFPRT প্রমাণ করে যে worst case-এও O(n) সম্ভব — অর্থাৎ হ্যাঁ, median খোঁজা সাজানোর চেয়ে প্রকৃতপক্ষে সহজ। তাঁরা তুলনার সংখ্যার ওপর ও নিচের সীমাও দেন, আর পরবর্তী গবেষণায় median খোঁজার জন্য ঠিক কতগুলো তুলনা লাগে, তা নিয়ে দীর্ঘ প্রতিযোগিতা চলে — যা আজও পুরোপুরি মীমাংসিত নয়।

কেন বাস্তবে কম ব্যবহৃত? প্রতিটি স্তরে n/5 টি দলের median বের করা, তাদের রিকার্সিভ median, তারপর partition — ধ্রুবকটি বড়, আর দুটি রিকার্শনের ব্যবস্থাপনা ঝামেলার। Random pivot-এর quickselect গড়ে অনেক কম কাজ করে। তাই বাস্তব লাইব্রেরি “আশা করো ভালো, কিন্তু প্রস্তুত থাকো খারাপের জন্য” নীতি নেয়: quickselect চালানো, খারাপ চলছে ধরা পড়লে নিরাপদ পদ্ধতিতে যাওয়া। ১৯৭৫ সালে Floyd আর Rivest নিজেরাই একটি sampling-ভিত্তিক পদ্ধতি দেন, যা গড়ে প্রায় n + min(k, n − k) তুলনায় কাজ সারে — বাস্তবে আরও দ্রুত।

তবু BFPRT-এর ধারণা — “পুরো ডেটা না দেখে একটি প্রতিনিধি নমুনার প্রতিনিধি বেছে নিশ্চিত ভালো pivot পাওয়া” — বহু জায়গায় ফিরে আসে: deterministic অ্যালগরিদমে, parallel আর streaming পরিবেশে, আর T(n) ≤ T(an) + T(bn) + O(n) যেখানে a + b < 1 — এই ধাঁচের recurrence বিশ্লেষণের ক্লাসিক উদাহরণ হিসেবে।

মূল উপলব্ধি: কিছু অ্যালগরিদমের মূল্য দ্রুততায় নয়, সম্ভাবনার সীমা প্রমাণে। “Worst case-এ linear সম্ভব” — এই জ্ঞানই বাস্তব পদ্ধতিগুলোকে নিরাপদ বিকল্প দেয়।

প্র ০৫Java কেন int[]-এর জন্য quick sort কিন্তু object অ্যারের জন্য merge-ধাঁচের sort ব্যবহার করে? Stability-র সাথে এর সম্পর্ক কী?

Java 7 (২০১১) থেকে Arrays.sort(int[])-এর মতো primitive অ্যারের sort হলো Vladimir Yaroslavskiy-র dual-pivot quicksort, যা Jon Bentley আর Joshua Bloch-এর সহযোগিতায় JDK-তে আসে। দুটি pivot P1 ≤ P2 নিয়ে অ্যারে তিন ভাগ হয়: P1-এর ছোট, দুটির মাঝে, P2-এর বড়। পরের গবেষণায় দেখা গেছে, এর সুবিধার বড় অংশ তুলনা কমানো থেকে নয়, মেমরি থেকে কম পড়া (ভালো cache আচরণ) থেকে আসে। অন্য দিকে Arrays.sort(Object[]) আর Collections.sort ব্যবহার করে TimSort — Python-এর জন্য Tim Peters-এর লেখা stable merge-ভিত্তিক sort.

কেন দুই রকম? কারণ stability-র অর্থ শুধু তখনই আছে, যখন সমান key-এর দুটি উপাদানকে আলাদা করে চেনা যায়। দুটি int 5 একেবারে অভিন্ন — কোনটি আগে ছিল, সেই প্রশ্নই ওঠে না। তাই primitive-এর জন্য দ্রুততম unstable sort নিলে কারও ক্ষতি নেই। কিন্তু object-এ দুটি “সমান” রেকর্ড আলাদা হতে পারে: একই নম্বরের দুজন পরীক্ষার্থী, একই তারিখের দুটি bKash লেনদেন। ব্যবহারকারী প্রায়ই আগে নাম অনুযায়ী, পরে নম্বর অনুযায়ী সাজান, আর আশা করেন একই নম্বরের ভেতরে নামের ক্রম থাকবে — এটি শুধু stable sort দেয়। অংশ ৭-এর Anik–Bithi উদাহরণে quick sort ঠিক এই আশাটিই ভেঙেছিল।

দ্বিতীয় কারণ comparator-এর খরচ। Object তুলনা করতে একটি method ডাকতে হয়, যা primitive তুলনার চেয়ে অনেক ব্যয়বহুল; TimSort প্রায়-সাজানো বাস্তব ডেটায় খুব কম তুলনা করে। আর merge-ধাঁচের sort-এর বাড়তি Θ(n) মেমরি object-এর ক্ষেত্রে শুধু reference-এর অ্যারে, যা তুলনামূলক সস্তা।

C++ পছন্দটি প্রোগ্রামারের হাতে দেয়: std::sort (introsort, unstable, দ্রুত) আর std::stable_sort (সাধারণত merge sort, stable). Python সহজ পথ নিয়েছে — list.sort সবসময় stable, তাই ব্যবহারকারীকে কখনো ভাবতে হয় না।

মূল উপলব্ধি: “সবচেয়ে ভালো sort” বলে কিছু নেই — আছে ডেটার ধরন অনুযায়ী সবচেয়ে উপযুক্ত। Stability, মেমরি, তুলনার খরচ আর গ্যারান্টি — চারটিই নকশার সিদ্ধান্ত, শুধু Big-O নয়।

অনুশীলন

খাতা-কলম নিন। প্রথম তিনটি হাতে করার, শেষটিতে কোড লিখতে হবে।

  1. হাতে trace করুন: [3, 8, 2, 5, 1, 4, 7, 6]-এ Lomuto partition চালান (pivot = শেষ উপাদান). প্রতিটি swap-এর পর অ্যারে লিখুন। শেষে p কত, আর কয়টি তুলনা হলো?

    Pivot 6, i = −1. j = 0: 3 ≤ 6 → i = 0 (নিজের সাথে swap). j = 1: 8 বড়। j = 2: 2 → i = 1, swap 8 ⇄ 2: 3 2 8 5 1 4 7 6. j = 3: 5 → i = 2, swap 8 ⇄ 5: 3 2 5 8 1 4 7 6. j = 4: 1 → i = 3, swap 8 ⇄ 1: 3 2 5 1 8 4 7 6. j = 5: 4 → i = 4, swap 8 ⇄ 4: 3 2 5 1 4 8 7 6. j = 6: 7 বড়। শেষ swap A[5] ⇄ A[7]: 3 2 5 1 4 [6] 7 8.

    p = 5, তুলনা ঠিক ৭টি (n − 1). লক্ষ করুন, 8 প্রতিটি ছোট উপাদানের সাথে একবার করে ডানে সরেছে — Lomuto-র বাড়তি swap-এর উৎস এটাই।

  2. হিসাব করুন: ১০টি উপাদানের সাজানো অ্যারেতে শেষ উপাদানকে pivot ধরে সরল quick sort (দুই দিকেই রিকার্শন) চালালে মোট কয়টি তুলনা হয়, আর রিকার্শনের সর্বোচ্চ গভীরতা কত? ছোট ভাগে রিকার্শন করার রূপে গভীরতা কত হবে?

    প্রতিটি partition-এ pivot সবচেয়ে বড়, ভাগ (n − 1) আর 0. তুলনা 9 + 8 + … + 1 = 45 = 10 · 9 / 2. গভীরতা: অংশের মাপ 10, 9, …, 1 — ১০টি স্তর (মাপ 1-এর কলটি base case, যা partition করে না; সেটি বাদ দিলে ৯).

    ছোট-ভাগ রূপে প্রতিবার ছোট ভাগটি খালি — তার রিকার্সিভ কল সাথে সাথে ফেরে — আর বড় ভাগ চলে লুপে। তাই গভীরতা থাকে মাত্র ২ (মূল কল আর একটি খালি কল). কিন্তু তুলনা এখনো 45 — জায়গার সমস্যা মিটেছে, সময়ের নয়; সময়ের জন্য pivot বাছাই বদলাতে হবে।

  3. Quickselect হাতে চালান: [6, 1, 9, 4, 7, 3, 8]-এ তৃতীয় ছোট উপাদান (0-based k = 2) খুঁজুন, Lomuto আর শেষ-উপাদান pivot দিয়ে। প্রতিটি রাউন্ডে অংশ, pivot আর p লিখুন।

    রাউন্ড ১: [0..6], pivot 8 → 6 1 4 7 3 [8] 9, p = 5. k = 2 < 5 → hi = 4. রাউন্ড ২: [0..4] = 6 1 4 7 3, pivot 3 → 1 [3] 4 7 6, p = 1. k = 2 > 1 → lo = 2. রাউন্ড ৩: [2..4] = 4 7 6, pivot 6 → 4 [6] 7, p = 3. k = 2 < 3 → hi = 2. এখন lo = hi = 2 — একটিমাত্র উপাদান, উত্তর 4.

    যাচাই: সাজালে 1 3 4 6 7 8 9. তুলনা 6 + 4 + 2 = ১২টি। প্রথম রাউন্ডে pivot 8 প্রায় সবচেয়ে বড় ছিল — খারাপ ভাগ, তাই মাত্র দুটি উপাদান বাদ গেল।

  4. কোড লিখুন: 3-way partition (অংশ ৬) আর random pivot দিয়ে একটি quick sort লিখুন, যা ছোট ভাগে রিকার্শন করে। তারপর ব্যাখ্যা করুন: সব উপাদান সমান এমন 106 উপাদানের অ্যারেতে এটি কত সময় নেবে, আর Lomuto-ভিত্তিক সরল quick sort কত?
    mt19937 rng(12345);
    void quickSort3(vector<int>& a, int lo, int hi) {
        while (lo < hi) {
            int r = uniform_int_distribution<int>(lo, hi)(rng);
            int pivot = a[r], lt = lo, i = lo, gt = hi;
            while (i <= gt) {
                if (a[i] < pivot) swap(a[lt++], a[i++]);
                else if (a[i] > pivot) swap(a[i], a[gt--]);
                else ++i;
            }                                   // a[lt..gt] == pivot: final
            if (lt - lo < hi - gt) { quickSort3(a, lo, lt - 1); lo = gt + 1; }
            else                   { quickSort3(a, gt + 1, hi); hi = lt - 1; }
        }
    }

    সব-সমান অ্যারেতে প্রথম partition-এই সব উপাদান “সমান” অংশে যায়, lt = lo আর gt = hi; দুই পাশ খালি — এক পাসে শেষ, Θ(n), প্রায় 106 তুলনা। Lomuto-তে প্রতিবার সব উপাদান ≤ pivot, ভাগ (n − 1) আর 0 — Θ(n²), প্রায় 5 × 1011 তুলনা, আর সরল রিকার্শনে গভীরতা 106, অর্থাৎ stack overflow. Random pivot এখানে বাঁচাত না — সব মানই তো সমান। ছোট ভাগে রিকার্শনের ফলে stack সবসময় O(log n).

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমLomuto partition-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. অংশের শেষ উপাদানকে pivot ধরা আর i = lo − 1 রাখা
  2. j দিয়ে lo থেকে hi − 1 পর্যন্ত প্রতিটি উপাদান দেখা
  3. A[j] ≤ pivot হলে i এক বাড়িয়ে A[i] আর A[j] swap করা
  4. লুপ শেষে A[i + 1] আর A[hi] swap করে pivot-কে বসানো
  5. i + 1 ফেরত দেওয়া — pivot-এর চূড়ান্ত index

শেষ উপাদানকে pivot ধরে Lomuto partition চালালে [4, 7, 2, 6, 1, 8, 3, 5] অ্যারেটি partition-এর পর কেমন দেখায়?

  • 1 2 3 4 5 6 7 8
  • 4 2 1 3 5 8 6 7
  • 4 2 1 3 5 7 8 6
  • 3 1 2 4 6 7 8 5

আগে থেকেই সাজানো n উপাদানের অ্যারেতে প্রথম বা শেষ উপাদানকে pivot ধরে সরল quick sort-এর সময় কত?

  • Θ(n), কারণ অ্যারে আগেই সাজানো
  • Θ(n log n)
  • Θ(n²)
  • Θ(2ⁿ)

Randomized quicksort-এর “expected O(n log n)” দাবিটির সঠিক অর্থ কোনটি?

  • ইনপুট এলোমেলো হলে গড়ে O(n log n)
  • প্রতিটি চালানোয় নিশ্চিতভাবে O(n log n)
  • শুধু সাজানো নয় এমন ইনপুটে O(n log n)
  • যেকোনো নির্দিষ্ট ইনপুটে, অ্যালগরিদমের নিজের এলোমেলো pivot বাছাইয়ের ওপর গড় সময় O(n log n)

C++-এর std::sort-এ ব্যবহৃত introsort কোন তিনটি অ্যালগরিদম মেশায়?

  • Quick sort, heapsort আর insertion sort
  • Quick sort, merge sort আর bubble sort
  • Merge sort, Timsort আর insertion sort
  • Quick sort, counting sort আর radix sort

Quick sort-এ partition-এর পর দুই ভাগের মধ্যে ছোটটিতে রিকার্শন আর বড়টিতে লুপ করলে কী লাভ হয়?

  • Worst case সময় Θ(n log n) হয়ে যায়
  • রিকার্শনের stack worst case-এও O(log n) থাকে
  • Sort stable হয়ে যায়
  • তুলনার সংখ্যা অর্ধেক হয়

Median of medians (BFPRT) অ্যালগরিদমের worst case সময়ের recurrence T(n) ≤ T(n/5) + T(7n/10) + O(n)-এর সমাধান কী, আর মূল কারণ কী?

  • O(n log n), কারণ দুটি রিকার্সিভ কল
  • O(n²), কারণ pivot খারাপ হতে পারে
  • O(n), কারণ দুই অংশের মাপের যোগফল 9n/10, যা n-এর চেয়ে কম
  • O(log n), কারণ প্রতিবার অংশ ছোট হয়

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Quick sort = pivot বাছাই → partition → দুই ভাগে রিকার্শন; partition-এর পর (Lomuto-তে) pivot চূড়ান্ত ঘরে, আর জোড়া দেওয়ার কোনো কাজ নেই।
  2. Lomuto: শেষ উপাদান pivot, ঠিক n − 1 তুলনা, invariant দিয়ে সহজ প্রমাণ। Hoare: দুই প্রান্ত থেকে pointer, কম swap, রিকার্শন [lo..j] আর [j+1..hi]-এ।
  3. সময়: best/average Θ(n log n), worst Θ(n²) (প্রতিবার প্রান্তের pivot). Random pivot যেকোনো ইনপুটে প্রত্যাশিত O(n log n) দেয়।
  4. বাস্তবের কৌশল: ডুপ্লিকেটে 3-way partition, ছোট ভাগে রিকার্শনে O(log n) stack, আর introsort (quick + heap + insertion) — std::sort. Quick sort in-place, stable নয়।
  5. Quickselect এক দিকে রিকার্শন করে k-তম ছোট দেয় প্রত্যাশিত O(n)-এ; median of medians (১৯৭৩) worst case-এও O(n), কারণ n/5 + 7n/10 < n.

পরের পর্বে: Introsort যে heapsort-এর ওপর ভরসা করে, সেটি কীভাবে কাজ করে? Heap — একটি অ্যারের ভেতরে লুকোনো tree, যার মূলে সবসময় সবচেয়ে ছোট (বা বড়) উপাদান; তা দিয়ে heapsort আর priority queue.

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান