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

সরল সর্টিং — bubble, selection আর insertion

Elementary Sorting — Bubble, Selection, Insertion & Shell Sort, Stability and Loop Invariants

Taught in Bangla, this lesson defines the sorting problem and the terms stable, in-place and adaptive. It traces bubble, selection and insertion sort by hand, counts comparisons and swaps, proves each with a loop invariant, explains best, average and worst cases through inversions, includes a visual race of the three sorts, introduces Shell sort's gap sequence and advises when to pick each.

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

  • সর্টিং সমস্যার নিখুঁত সংজ্ঞা, আর stable, in-place, adaptive শব্দগুলোর মানে
  • Bubble, selection আর insertion sort হাতে trace করা, আর তুলনা ও swap গুনে মেলানো
  • প্রতিটির loop invariant লিখে শুদ্ধতা প্রমাণ করা
  • Best, average, worst case আর inversion দিয়ে তিনটির খরচের পার্থক্য ব্যাখ্যা করা
  • Shell sort-এর gap-এর ধারণা, আর কোন পরিস্থিতিতে কোন সরল সর্ট বেছে নেবেন

১ · সর্টিং কেন — পরীক্ষার খাতা থেকে মেধাতালিকা

পরীক্ষা শেষে শিক্ষকের টেবিলে একশো খাতার একটি এলোমেলো স্তূপ। নম্বর তোলার আগে তিনি খাতাগুলো রোল নম্বর অনুযায়ী সাজিয়ে নেন। কেন? কারণ সাজানো স্তূপে রোল ৫৭-এর খাতা খুঁজতে মাঝখান থেকে শুরু করে কয়েকবারেই পৌঁছানো যায়, আর কোন রোলের খাতা নেই, তা-ও এক নজরে ধরা পড়ে। এই সাজানোর কাজটিরই নাম sorting (সর্টিং) — কিছু উপাদানকে একটি নির্দিষ্ট ক্রমে, যেমন ছোট থেকে বড়, বসানো।

সর্টিং নিজে প্রায়ই শেষ লক্ষ্য নয়; এটি অন্য কাজকে সহজ করার প্রস্তুতি। কয়েকটি উদাহরণ:

  • খোঁজা: সাজানো অ্যারেতে binary search একটি মান O(log n) সময়ে খুঁজে দেয় (পর্ব ১৮)। একবার সাজাতে খরচ হয়, কিন্তু তারপর হাজারটি প্রশ্নের প্রতিটি দ্রুত।
  • গ্রুপ করা: একটি দোকানের সারা মাসের bKash লেনদেন গ্রাহকের নম্বর অনুযায়ী সাজালে একই গ্রাহকের সব লেনদেন পাশাপাশি চলে আসে। তখন একবার হেঁটেই প্রত্যেকের মোট বের করা যায়।
  • ডুপ্লিকেট বাদ দেওয়া: সাজানোর পর একই মানগুলো পাশাপাশি থাকে, তাই শুধু আগের উপাদানের সাথে মিলিয়েই deduplication হয় — O(n) এক যাত্রায়।
  • র‍্যাংকিং: ভর্তি পরীক্ষা বা চাকরির পরীক্ষার মেধাতালিকা মানে প্রাপ্ত নম্বর অনুযায়ী বড় থেকে ছোট ক্রমে সাজানো। সমান নম্বর হলে কার নাম আগে যাবে — এই প্রশ্ন থেকেই আসে stability, যা অংশ ২-এ দেখবেন।
  • আরও অনেক: median বের করা, দুটি তালিকায় মিল খোঁজা, সময়সূচি বানানো, সবচেয়ে কাছের দুটি সংখ্যা খোঁজা — সব শুরু হয় সাজানো দিয়ে।

Donald Knuth তাঁর বিখ্যাত বই The Art of Computer Programming-এর তৃতীয় খণ্ড (Sorting and Searching) শুরু করেছেন একটি চমকপ্রদ তথ্য দিয়ে: ১৯৬০-এর দশকের কম্পিউটার নির্মাতাদের হিসাবে, সব গ্রাহকের কাজ মিলিয়ে তাঁদের মেশিনের মোট চলার সময়ের এক-চতুর্থাংশের বেশি খরচ হতো সর্টিংয়ে। এজন্যই সর্টিং কম্পিউটার বিজ্ঞানের সবচেয়ে বেশি গবেষণা হওয়া সমস্যাগুলোর একটি।

সর্টিং সমস্যা — নিখুঁত সংজ্ঞা

পর্ব ০২-এ দেখেছেন, একটি সমস্যা বলা মানে ইনপুট আর আউটপুটের সম্পর্ক বলা। সর্টিংয়ের জন্য:

  • ইনপুট: n টি উপাদানের একটি ক্রম ⟨a1, a2, …, an⟩, আর উপাদানগুলোর মধ্যে তুলনার একটি নিয়ম ≤, যাতে যেকোনো দুটি উপাদান তুলনা করা যায় আর নিয়মটি সংগতিপূর্ণ (a ≤ b আর b ≤ c হলে a ≤ c).
  • আউটপুট: ইনপুটেরই একটি permutation (পুনর্বিন্যাস) ⟨a′1, a′2, …, a′n⟩, যাতে a′1 ≤ a′2 ≤ … ≤ a′n.

দুটি শর্তই জরুরি। শুধু “ক্রম বাড়ছে” যথেষ্ট নয় — সব উপাদান কোনো কিছু না হারিয়ে, নতুন কিছু না যোগ করে থাকতে হবে। [3, 1, 2]-এর আউটপুট [1, 1, 2] ক্রমে বাড়ছে, কিন্তু 3 হারিয়ে গেছে — এটি ভুল সর্ট। AI বা নিজের লেখা কোডে swap-এর বদলে ভুল করে শুধু একটি দিকে কপি করলে ঠিক এই বাগ হয়।

বাস্তবে আমরা প্রায়ই পুরো রেকর্ড সাজাই, শুধু সংখ্যা নয়। একজন ছাত্রের রেকর্ডে আছে রোল, নাম, নম্বর। যে অংশ দেখে সাজানো হয় (যেমন নম্বর), তার নাম sort key; বাকি অংশ key-এর সাথে সাথে চলে। তুলনার নিয়মটি কোডে দেওয়া হয় একটি comparator দিয়ে (পর্ব ১৫)।

এই পর্বের সব অ্যালগরিদম comparison sort: উপাদান সম্পর্কে তারা কেবল একটি প্রশ্নই করতে পারে — “a কি b-এর চেয়ে বড়?” তাই খরচ মাপব দুটি সংখ্যায়: তুলনার সংখ্যা আর উপাদান সরানোর সংখ্যা (swap বা shift). পর্ব ০২-এর RAM model-এ দুটিই primitive operation. পর্ব ২৪-এ দেখবেন, যেকোনো comparison sort-এর worst case-এ অন্তত প্রায় n log2 n তুলনা লাগে; এই পর্বের সরল সর্টগুলো সাধারণত n² পর্যায়ের — কিন্তু তাদের নিজস্ব শক্তি আছে, যা পুরো পর্বে দেখবেন।

মূল ধারণা

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

২ · দুটি জরুরি গুণ — in-place আর stable

দুটি সর্টিং অ্যালগরিদম একই ফল দিলেও তাদের আচরণে পার্থক্য থাকতে পারে। চারটি শব্দ পুরো মডিউলে বারবার আসবে:

গুণমানেদৈনন্দিন উপমা
In-placeইনপুটের অ্যারের ভেতরেই সাজায়; বাড়তি জায়গা O(1)খাতাগুলো টেবিলেই সাজানো, আরেকটি টেবিল না নিয়ে
Stableসমান key-এর উপাদানগুলোর আগের আপেক্ষিক ক্রম বদলায় নাসমান নম্বর পাওয়া দুজনের মধ্যে যার রোল আগে, তালিকায় সে আগেই থাকে
Adaptiveইনপুট যত বেশি সাজানো, তত কম কাজ করেপ্রায় সাজানো খাতার স্তূপ কয়েক মিনিটেই ঠিক হয়
Onlineপুরো ইনপুট আগে না পেয়েও, একটি করে আসার সাথে সাথে সাজাতে পারেতাস খেলায় একটি করে কার্ড হাতে নিয়ে সাজানো

Stability প্রথমে অপ্রয়োজনীয় মনে হতে পারে — সমান তো সমানই! কিন্তু রেকর্ড সাজালে “সমান key” মানে “সমান রেকর্ড” নয়। ধরুন একটি ক্লাসের তালিকা আগে থেকেই রোল অনুযায়ী সাজানো: রোল ১ রহিম (৭০), রোল ২ করিম (৮০), রোল ৩ সুমি (৭০), রোল ৪ নাদিয়া (৯০)। এখন নম্বর অনুযায়ী বড় থেকে ছোট সাজিয়ে মেধাতালিকা বানাতে চাই, আর নিয়ম হলো: সমান নম্বরে ছোট রোল আগে। একটি stable সর্ট এই নিয়ম আপনাআপনি মানে, কারণ রহিম শুরুতে সুমির আগে ছিল, শেষেও আগে থাকবে। নিচে দুটি পদ্ধতি পাশাপাশি দেখুন (দুটির ভেতরের কাজ অংশ ৪ ও ৫-এ বিস্তারিত):

প্রতিটি রেকর্ড = রোল:নাম(নম্বর)
ধাপ
selection sort
insertion sort
  1. শুরু. তালিকা রোল অনুযায়ী। রহিম আর সুমি দুজনেই ৭০ পেয়েছে; রহিমের রোল ছোট, তাই মেধাতালিকায় সে আগে থাকা উচিত।
  2. প্রথম ধাপ. Selection sort পুরো তালিকায় সবচেয়ে বেশি নম্বর (নাদিয়া, ৯০) খুঁজে প্রথম ঘরের রহিমের সাথে জায়গা বদল করে। এক লাফে রহিম চলে গেল একেবারে শেষে — সুমিকে টপকে। Insertion sort শুধু করিমকে (৮০) রহিমের আগে ঢোকায়; রহিম এক ঘর সরে, কিন্তু কাউকে টপকায় না।
  3. দ্বিতীয় ধাপ. Selection: বাকি তিনজনের মধ্যে সর্বোচ্চ করিম, সে নিজের জায়গাতেই আছে — কোনো swap নেই। Insertion: সুমির (৭০) বাঁয়ে রহিম (৭০) — সমান, তাই থামে। “বাঁয়ের উপাদান কঠোরভাবে ছোট হলেই সরাও” — এই নিয়মই stability-র চাবি।
  4. তৃতীয় ধাপ. Selection: সুমি আর রহিমের নম্বর সমান, প্রথম সর্বোচ্চ হিসেবে সুমিই থাকে — swap নেই। Insertion: নাদিয়া (৯০) তিনজনকে ডানে সরিয়ে একেবারে সামনে বসে; রহিম–সুমির আপেক্ষিক ক্রম বদলায়নি।
  5. ফল. দুটি তালিকাই নম্বর অনুযায়ী ঠিকভাবে সাজানো — কিন্তু selection sort-এ সুমি রহিমের আগে চলে গেছে, যা নিয়ম ভাঙে। Insertion sort stable, selection sort (swap-সহ) stable নয়। কারণ একটিই: দূরের swap সমান উপাদানকে টপকে যেতে পারে।

মাল্টি-key সর্টিংয়ের একটি সুন্দর কৌশল stability-র ওপর দাঁড়িয়ে: আগে দ্বিতীয় মাপকাঠিতে (রোল) সাজান, তারপর প্রথম মাপকাঠিতে (নম্বর) একটি stable সর্ট চালান। ফল হয় “নম্বর অনুযায়ী, সমান হলে রোল অনুযায়ী”। পর্ব ২৪-এর radix sort ঠিক এই নীতিতে কাজ করে, আর পর্ব ২৫-এ লাইব্রেরির stable sort নিয়ে বিস্তারিত দেখবেন।

৩ · Bubble sort — বড়টি বুদবুদের মতো ওপরে ওঠে

কোমল পানীয়ের গ্লাসে বুদবুদ নিচ থেকে ধীরে ধীরে ওপরে ওঠে। Bubble sort-এ বড় সংখ্যাগুলো ঠিক এভাবে অ্যারের ডান প্রান্তের দিকে “ভেসে” যায়। নিয়মটি সরল: বাঁ থেকে ডানে পাশাপাশি প্রতিটি জোড়া দেখুন; বাঁয়েরটি বড় হলে দুটির জায়গা বদল (swap) করুন। একবার পুরো অ্যারে ঘুরে আসাকে বলে একটি পাস। প্রথম পাস শেষে সবচেয়ে বড় উপাদানটি নিশ্চিতভাবে শেষ ঘরে পৌঁছায় — কারণ যেখানেই থাকুক, প্রতিটি তুলনায় সে জিতে ডানে এগোয়।

Pseudocode — bubble sort with early exit (0-based)

for i = 0 .. n-2:                       // pass i; the last i cells are already final
    swapped = false
    for j = 0 .. n-2-i:
        if a[j] > a[j+1]:
            swap a[j], a[j+1]
            swapped = true
    if not swapped: stop                // a pass with no swap: the array is sorted

ভেতরের লুপ কেন n-2-i পর্যন্ত? কারণ i টি পাসের পর শেষের i টি ঘরে সবচেয়ে বড় i টি উপাদান ঠিক জায়গায় বসে গেছে; সেগুলো আর দেখার দরকার নেই। [5, 2, 4, 1, 3]-এ চালিয়ে দেখুন (বন্ধনীর ভেতরের অংশ = চূড়ান্ত জায়গায় বসা):

প্রতিটি ধাপের পর
কোথায়
কী হলো
অ্যারে
মোট তুলনা
মোট swap
  1. পাস ১, প্রথম জোড়া. 5 আর 2 উল্টো ক্রমে, তাই swap. 5 এক ঘর ডানে এগোল।
  2. পরের জোড়া. 5 আর 4 — আবার swap. লক্ষ করুন, 5 প্রতিটি তুলনায় জিতে ডানে চলছে, বুদবুদের মতো।
  3. পরের জোড়া. 5 আর 1 — swap. অ্যারে: 2 4 1 5 3.
  4. পাস ১ শেষ. 5 আর 3 — swap. সবচেয়ে বড় 5 এখন শেষ ঘরে, চিরতরে। এই পাসে ৪টি তুলনা আর ৪টি swap.
  5. পাস ২. ভেতরের লুপ এবার শুধু j = 0..2 — শেষ ঘর আর দেখতে হয় না। ৩টি তুলনা, ২টি swap; দ্বিতীয় বড় 4 তার জায়গায় পৌঁছাল।
  6. পাস ৩. ২টি তুলনা, ১টি swap (2 আর 1). অ্যারে এখন আসলে সাজানো, কিন্তু অ্যালগরিদম তা এখনো জানে না, কারণ এই পাসে একটি swap হয়েছে।
  7. পাস ৪. ১টি তুলনা, কোনো swap নেই — flag বলে “সাজানো”। মোট ১০টি তুলনা = n(n − 1)/2, আর ৭টি swap. ইনপুটে inversion-ও ঠিক ৭টি: (5,2), (5,4), (5,1), (5,3), (2,1), (4,1), (4,3). কাকতালীয় নয় — নিচে কারণ দেখুন।

Early exit — সাজানো ইনপুটে Θ(n)

swapped flag না থাকলে bubble sort সবসময় n − 1টি পাস চালায়, ইনপুট আগে থেকে সাজানো হলেও — অর্থাৎ সবসময় n(n − 1)/2 তুলনা, Θ(n²). Flag থাকলে একটি পাসে কোনো swap না হলেই থামা যায়। কেন নিরাপদ? কারণ swap না হওয়া মানে ওই পাসের প্রতিটি পাশাপাশি জোড়া a[j] ≤ a[j+1], আর পেছনের চূড়ান্ত অংশ আগে থেকেই সাজানো ও বড় — পুরো অ্যারেই তাহলে সাজানো। সাজানো ইনপুটে প্রথম পাসেই কোনো swap নেই: মাত্র n − 1 তুলনা, best case Θ(n).

বিশ্লেষণ

  • Swap: প্রতিটি swap পাশাপাশি একটি উল্টো জোড়া ঠিক করে, তাই ঠিক একটি inversion কমায় (পর্ব ০৫)। শেষে inversion শূন্য — অতএব মোট swap = I, ইনপুটের inversion সংখ্যা। উল্টো ইনপুটে I = n(n − 1)/2; এলোমেলো ইনপুটে গড়ে n(n − 1)/4.
  • তুলনা: worst case (উল্টো ইনপুট) n(n − 1)/2 = Θ(n²); best case n − 1; এলোমেলো ইনপুটেও সাধারণত প্রায় সব পাস লাগে, তাই গড়ে Θ(n²).
  • জায়গা: কয়েকটি ভ্যারিয়েবল মাত্র — in-place, O(1) auxiliary space.
  • Stable: হ্যাঁ — শুধু কঠোরভাবে বড় হলে (>) swap করে, আর শুধু পাশাপাশি; সমান দুটি উপাদান কখনো একে অপরকে টপকায় না। >= লিখলে stability নষ্ট হয়।

“Adaptive” কথাটি আংশিক সত্য। Bubble sort-এ বড় উপাদান এক পাসেই অনেক দূর ডানে যেতে পারে (“খরগোশ”), কিন্তু ছোট উপাদান প্রতি পাসে মাত্র এক ঘর বাঁয়ে আসে (“কচ্ছপ”)। [2, 3, 4, 5, 1]-এ inversion মাত্র ৪টি, তবু 1-কে সামনে আনতে ৪টি পাস লাগে — মোট ১০টি তুলনা, পুরো n(n − 1)/2. অথচ insertion sort একই ইনপুটে মাত্র ৭টি তুলনা করে (অংশ ৫) — n বড় হলেও এমন ইনপুটে তার কাজ Θ(n), bubble sort-এর Θ(n²). Cocktail shaker sort (দুই দিকে পালা করে পাস) এই কচ্ছপ সমস্যা কিছুটা কমায় — অনুশীলন ৪ দেখুন।

কেন সঠিক — loop invariant

পর্ব ০৩-এর তিন ধাপের loop invariant পদ্ধতি এখানে প্রয়োগ করি। বাইরের লুপের invariant: পাস i শুরুর সময় শেষের i টি ঘর a[n−i..n−1]-এ অ্যারের সবচেয়ে বড় i টি উপাদান সাজানো ক্রমে আছে।

  • শুরু (i = 0): শেষের ০টি ঘর — বক্তব্যটি ফাঁকা অর্থে সত্য।
  • বজায় থাকা: পাস চলার সময় ভেতরের invariant হলো “j-তম তুলনার পর a[j+1] হলো a[0..j+1]-এর সর্বোচ্চ”। তাই পাস শেষে a[n−1−i]-তে বসে বাকি অংশের সর্বোচ্চ উপাদান, যা আগের i টির চেয়ে বড় নয় — শেষের i + 1টি ঘর এখন সবচেয়ে বড় i + 1টি উপাদান, সাজানো।
  • শেষ: n − 1টি পাস শেষে শেষের n − 1টি ঘর ঠিক; বাকি একটি ঘরে সবচেয়ে ছোটটি — পুরো অ্যারে সাজানো। আগে থামলে (early exit) কেন সঠিক, তা ওপরে দেখেছেন। আর প্রতিটি কাজ একটি swap, তাই আউটপুট ইনপুটেরই permutation.

৪ · Selection sort — প্রতিবার সবচেয়ে ছোটটি বেছে নেওয়া

স্কুলের অ্যাসেম্বলিতে শিক্ষক ছাত্রদের উচ্চতা অনুযায়ী লাইনে দাঁড় করাচ্ছেন। একটি সহজ পদ্ধতি: পুরো লাইনে তাকিয়ে সবচেয়ে খাটো জনকে খুঁজে প্রথম জায়গায় আনুন (সেখানে যে ছিল, সে চলে যাবে খাটো জনের পুরোনো জায়গায়)। তারপর বাকিদের মধ্যে সবচেয়ে খাটোকে দ্বিতীয় জায়গায়, এভাবে চলতে থাকুক। এটিই selection sort: প্রতি ধাপে বাকি অংশ থেকে সর্বনিম্নটি বেছে নেওয়া (select) আর একটি swap-এ তাকে ঠিক জায়গায় বসানো।

Pseudocode — selection sort (0-based)

for i = 0 .. n-2:
    m = i                              // index of the smallest seen so far
    for j = i+1 .. n-1:
        if a[j] < a[m]: m = j
    if m != i: swap a[i], a[m]         // at most one swap per position
প্রতিটি i-এর পর (বন্ধনী = চূড়ান্ত সাজানো অংশ)
i
যা দেখা হলো
swap
অ্যারে
মোট তুলনা
মোট swap
  1. i = 0. পাঁচটি উপাদানের সর্বনিম্ন খুঁজতে ৪টি তুলনা — 1 পাওয়া গেল index 3-এ। a[0] = 5-এর সাথে swap. 1 এখন চিরতরে প্রথম ঘরে, আর 5 এক লাফে চলে গেল index 3-এ।
  2. i = 1. বাকি চারটিতে ৩টি তুলনা; সর্বনিম্ন 2 আগে থেকেই index 1-এ, তাই swap লাগে না।
  3. i = 2. ২টি তুলনায় সর্বনিম্ন 3 (index 4); a[2] = 4-এর সাথে swap.
  4. i = 3. শেষ দুটির ১টি তুলনা; 4 < 5, তাই swap. মোট তুলনা 4 + 3 + 2 + 1 = 10.
  5. শেষ. n − 1 = ৪টি ধাপের পর শেষ ঘরে যা থাকে, তা নিশ্চিতভাবে সবচেয়ে বড় — আলাদা ধাপ লাগে না। bubble sort-এর মতোই ১০টি তুলনা, কিন্তু swap মাত্র ৩টি (bubble-এ ছিল ৭টি)।

বিশ্লেষণ — তুলনা স্থির, swap কম

i-তম ধাপে ভেতরের লুপ n − 1 − i বার তুলনা করে, ইনপুট যেমনই হোক। তাই মোট তুলনা

(n − 1) + (n − 2) + … + 1 = n(n − 1)/2

— best, average, worst সব case-এ হুবহু এক, Θ(n²). অর্থাৎ selection sort adaptive নয়: সাজানো অ্যারে দিলেও প্রতিটি সর্বনিম্ন খুঁজতে পুরো বাকি অংশ দেখে, কারণ না দেখলে নিশ্চিত হওয়ার উপায় নেই।

কিন্তু swap প্রতি ধাপে সর্বোচ্চ একটি, মোট সর্বোচ্চ n − 1 — Θ(n). উল্টো অ্যারেতে আরও মজার: [8, 7, …, 1]-এ প্রথম swap 8 আর 1 দুজনকেই ঠিক জায়গায় পাঠায়, তাই মাত্র n/2 = ৪টি swap লাগে। Bubble বা insertion sort-এ সরানোর সংখ্যা inversion-এর সমান, যা Θ(n²) পর্যন্ত যায়। তাই যেখানে লেখা দামি — বড় রেকর্ড সরানো, বা এমন মেমরি যেখানে প্রতিটি লেখা হার্ডওয়্যার ক্ষয় করে (flash memory) — সেখানে selection sort-এর এই গুণ কাজে লাগে। গুদামে ভারী চালের বস্তা সাজানোর মতো ভাবুন: চোখে দেখা (তুলনা) সস্তা, কিন্তু প্রতিটি বস্তা তোলা (swap) কষ্টের — তাই বেশি দেখে কম তোলাই বুদ্ধিমানের।

কেন সঠিক — loop invariant

Invariant: ধাপ i শুরুর সময় a[0..i−1]-এ অ্যারের সবচেয়ে ছোট i টি উপাদান সাজানো ক্রমে আছে, আর a[0..i−1]-এর প্রতিটি উপাদান a[i..n−1]-এর প্রতিটির চেয়ে ছোট বা সমান।

  • শুরু (i = 0): বাঁয়ের অংশ ফাঁকা — সত্য।
  • বজায় থাকা: ভেতরের লুপ শেষে a[m] হলো a[i..n−1]-এর সর্বনিম্ন (ভেতরের invariant: “m হলো a[i..j−1]-এর সর্বনিম্নের index”). swap-এ সেটি a[i]-তে বসে। এটি বাকি সবার চেয়ে ছোট বা সমান, আর বাঁয়ের অংশের সবার চেয়ে বড় বা সমান (দ্বিতীয় শর্ত থেকে) — তাই a[0..i] এখন সবচেয়ে ছোট i + 1টি, সাজানো।
  • শেষ (i = n − 1): a[0..n−2] সাজানো আর a[n−1]-এর চেয়ে ছোট বা সমান — পুরো অ্যারে সাজানো।

Stable নয় — নিজের চোখে দেখুন

অংশ ২-এর stepper-এ দেখেছেন, দূরের swap সমান উপাদানকে টপকে যায়। সবচেয়ে ছোট উদাহরণ [2a, 2b, 1]: প্রথম ধাপে 1 আর 2a-এর swap → [1, 2b, 2a] — দুটি 2-এর ক্রম উল্টে গেল। নিচের প্রোগ্রামে সেই মেধাতালিকা তিনভাবে বানানো হয়েছে, C++-এর লাইব্রেরি std::stable_sort-সহ:

#include <cstdio>
#include <vector>
#include <utility>
#include <algorithm>
using namespace std;
struct Student { int roll; const char* name; int marks; };
void show(const char* title, const vector<Student>& v) {
    printf("%-11s", title);
    for (const auto& s : v) printf(" %d:%s(%d)", s.roll, s.name, s.marks);
    printf("\n");
}
int main() {
    const vector<Student> byRoll = {{1, "Rahim", 70}, {2, "Karim", 80}, {3, "Sumi", 70}, {4, "Nadia", 90}};
    vector<Student> a = byRoll;                       // selection sort, highest marks first
    for (size_t i = 0; i + 1 < a.size(); ++i) {
        size_t m = i;
        for (size_t j = i + 1; j < a.size(); ++j) if (a[j].marks > a[m].marks) m = j;
        swap(a[i], a[m]);                             // the long jump that can break ties
    }
    show("selection", a);
    vector<Student> b = byRoll;                       // insertion sort, highest marks first
    for (size_t i = 1; i < b.size(); ++i) {
        Student key = b[i]; size_t j = i;
        while (j > 0 && b[j - 1].marks < key.marks) { b[j] = b[j - 1]; --j; }   // strict <
        b[j] = key;
    }
    show("insertion", b);
    vector<Student> c = byRoll;
    stable_sort(c.begin(), c.end(), [](const Student& x, const Student& y) { return x.marks > y.marks; });
    show("stable_sort", c);
}

আউটপুট:

selection   4:Nadia(90) 2:Karim(80) 3:Sumi(70) 1:Rahim(70)
insertion   4:Nadia(90) 2:Karim(80) 1:Rahim(70) 3:Sumi(70)
stable_sort 4:Nadia(90) 2:Karim(80) 1:Rahim(70) 3:Sumi(70)

তিনটি তালিকাই নম্বর অনুযায়ী সঠিক — সর্টিংয়ের সংজ্ঞা তিনটিই মানে। পার্থক্য শুধু সমান নম্বরের দুজনের ক্রমে: selection sort-এর প্রথম swap (রহিম ↔ নাদিয়া) রহিমকে সুমির পেছনে পাঠিয়ে দিয়েছে। insertion sort আর std::stable_sort রোলের আগের ক্রম রেখেছে। লক্ষ করুন, insertion sort-এর তুলনা < (কঠোর); <= লিখলে সমান নম্বরের উপাদানও সরত, আর stability হারাত। std::sort দিলে কী আসত? Standard কোনো নিশ্চয়তা দেয় না — এই ছোট ইনপুটে হয়তো ঠিক আসবে, বড় ইনপুটে নাও আসতে পারে। Selection sort-কে stable বানানো যায়: swap-এর বদলে সর্বনিম্নটিকে বের করে মাঝের সবাইকে এক ঘর ডানে সরান — কিন্তু তখন লেখা Θ(n²) হয়ে যায়, আর তার মূল সুবিধাই চলে যায়।

৫ · Insertion sort — তাসের মতো ঠিক জায়গায় ঢোকানো

তাস খেলায় একটি করে কার্ড তুলে আমরা হাতের আগের সাজানো কার্ডগুলোর মধ্যে তার ঠিক জায়গা খুঁজে ঢুকিয়ে দিই। লাইব্রেরিয়ান ফেরত আসা বই তাকের ঠিক জায়গায় রাখেন — ডানের বইগুলো একটু সরিয়ে ফাঁক বানিয়ে। Insertion sort এটাই করে: অ্যারের বাঁ অংশ a[0..i−1] সবসময় সাজানো; নতুন উপাদান a[i]-কে (key) সেখানে তার জায়গায় বসাতে, key-এর চেয়ে বড় উপাদানগুলোকে এক ঘর করে ডানে সরানো (shift) হয়।

Pseudocode — insertion sort (0-based)

for i = 1 .. n-1:
    key = a[i]; j = i
    while j > 0 and a[j-1] > key:     // strict >: equal keys are never passed
        a[j] = a[j-1]                  // shift one cell to the right
        j = j - 1
    a[j] = key
প্রতিটি i-এর পর (বন্ধনী = সাজানো বাঁ অংশ, এখনো চূড়ান্ত নয়)
i, key
কী হলো
অ্যারে
মোট তুলনা
মোট shift
  1. শুরু. একটি উপাদানের অংশ [5] নিজেই সাজানো। বাকি চারটি একে একে ঢোকানো হবে।
  2. key = 2. 5 > 2, তাই 5 এক ঘর ডানে সরল। j = 0 — সামনে পৌঁছে গেছে, আর তুলনা লাগে না। ১টি তুলনা, ১টি shift.
  3. key = 4. 5 > 4 — shift. তারপর 2 > 4? না — থামুন, 4 বসল 2-এর পরে। ২টি তুলনা (একটি সরানোর, একটি থামার), ১টি shift.
  4. key = 1. 5, 4, 2 তিনটিই 1-এর চেয়ে বড় — তিনটি shift, তারপর সামনে পৌঁছানো। ৩টি তুলনা, ৩টি shift.
  5. key = 3. 5 আর 4 সরল, 2 ≤ 3 — থামল। ৩টি তুলনা, ২টি shift. মোট ৯টি তুলনা আর ৭টি shift — আর ইনপুটের inversion ৭টি। Bubble sort-এর ১০টি তুলনার চেয়ে কম।

বিশ্লেষণ — Θ(n + I)

পর্ব ০৫-এর অংশ ৩-এ প্রমাণ করা হয়েছে: প্রতিটি shift পাশাপাশি একটি উল্টো জোড়া ঠিক করে, তাই ঠিক একটি inversion কমায়। ফলে মোট shift = I. প্রতিটি key-এর জন্য তুলনা = তার shift + ১ (থামার তুলনা), যদি না key সামনে পৌঁছে যায় (তখন থামার তুলনা লাগে না)। তাই

I ≤ তুলনা ≤ I + (n − 1), আর মোট সময় Θ(n + I).

ওপরের উদাহরণে: I + (n − 1) − (সামনে পৌঁছানো key) = 7 + 4 − 2 = 9 ✓. এখান থেকে তিন case সরাসরি:

  • সাজানো ইনপুট: I = 0 → n − 1 তুলনা, ০ shift — Θ(n).
  • উল্টো ইনপুট: I = n(n − 1)/2 → Θ(n²).
  • এলোমেলো ইনপুট: গড় I = n(n − 1)/4 (প্রতিটি জোড়া অর্ধেক সম্ভাবনায় উল্টো), তাই গড়ে প্রায় n²/4 তুলনা — average case Θ(n²), কিন্তু bubble আর selection-এর প্রায় n²/2-এর অর্ধেক।
  • প্রায়-সাজানো ইনপুট: প্রতিটি উপাদান তার আসল জায়গা থেকে সর্বোচ্চ k ঘর দূরে থাকলে I ≤ nk, তাই O(nk) — k ছোট হলে প্রায় রৈখিক।

এই কারণেই insertion sort হলো আসল অর্থে adaptive: খরচ ইনপুটের “এলোমেলোতার” (inversion) সাথে সরাসরি সমানুপাতিক। এটি stable (কঠোর >), in-place (O(1) বাড়তি জায়গা), আর online — নতুন উপাদান এলে শুধু তাকে ঢোকালেই চলে। আরেকটি বাস্তব সুবিধা: shift মানে একটি লেখা, কিন্তু swap মানে তিনটি (temp-এর মাধ্যমে)। তাই একই I-তে insertion sort bubble sort-এর চেয়ে প্রায় তিন গুণ কম লেখে।

কেন সঠিক — loop invariant

Invariant: বাইরের লুপের ধাপ i শুরুর সময় a[0..i−1]-এ ঠিক সেই উপাদানগুলো আছে যা শুরুতে a[0..i−1]-এ ছিল, কিন্তু সাজানো ক্রমে। (খেয়াল করুন, selection sort-এর মতো “চূড়ান্ত” নয় — পরে আসা ছোট উপাদান এদের ডানে ঠেলে দিতে পারে।)

  • শুরু (i = 1): a[0..0] একটি উপাদান — সাজানো।
  • বজায় থাকা: while লুপ key-এর চেয়ে বড় সব উপাদানকে এক ঘর ডানে সরায়, আর থামে প্রথম ছোট-বা-সমান উপাদানে বা শুরুতে। key সেই ফাঁকে বসে — বাঁয়ের সবাই ≤ key < ডানের সরানো সবাই। a[0..i] তাই সাজানো, আর উপাদানগুলো একই (শুধু সরানো হয়েছে)।
  • শেষ (i = n): a[0..n−1] পুরো অ্যারে, সাজানো। পর্ব ০৩-এ এই প্রমাণটি আরও বিস্তারিতভাবে করা আছে।

Binary insertion sort. বাঁ অংশ যেহেতু সাজানো, key-এর জায়গা binary search দিয়ে O(log i) তুলনায় পাওয়া যায় — মোট তুলনা O(n log n). কিন্তু ফাঁক বানাতে shift এখনো I টি, worst case Θ(n²). তাই binary insertion sort কাজে লাগে যখন তুলনা দামি (যেমন লম্বা string বা জটিল comparator) কিন্তু সরানো সস্তা। Python-এর Timsort ছোট টুকরোয় ঠিক এটিই ব্যবহার করে।

৬ · তিনটি সর্ট, একই ইনপুট — তুলনা আর swap গুনে দেখা

তত্ত্বে যা বললাম, এবার মেপে দেখি। নিচের প্রোগ্রাম তিনটি সর্টকে চারটি স্থির ৮-উপাদানের অ্যারেতে চালায় — এলোমেলো-দেখতে, সাজানো, উল্টো আর প্রায়-সাজানো (দুটি পাশাপাশি জোড়া উল্টো)। প্রতিটি তুলনা যায় gt() ফাংশনের ভেতর দিয়ে, যা একটি counter বাড়ায় — নিজের কোডে অ্যালগরিদমের খরচ মাপার এটিই সবচেয়ে সৎ উপায় (benchmarking-এর মতো সময় মাপলে মেশিনের গতি মিশে যায়, গুনলে শুধু অ্যালগরিদম)।

#include <cstdio>
#include <vector>
using namespace std;
int c, s;  bool gt(int x, int y) { ++c; return x > y; }    // c counts every comparison, s every move
void bubble(vector<int> a) {
    for (int i = 0, n = a.size(); i < n - 1; ++i) {
        bool swapped = false;
        for (int j = 0; j < n - 1 - i; ++j)
            if (gt(a[j], a[j + 1])) { swap(a[j], a[j + 1]); ++s; swapped = true; }
        if (!swapped) break;                     // a pass with no swap: already sorted
    }
}
void selection(vector<int> a) {
    for (int i = 0, n = a.size(); i < n - 1; ++i) {
        int m = i; for (int j = i + 1; j < n; ++j) if (gt(a[m], a[j])) m = j;
        if (m != i) { swap(a[i], a[m]); ++s; }
    }
}
void insertion(vector<int> a) {
    for (int i = 1, n = a.size(); i < n; ++i) {
        int key = a[i], j = i;
        while (j > 0 && gt(a[j - 1], key)) { a[j] = a[j - 1]; --j; ++s; }   // shift right
        a[j] = key;
    }
}
void run(void (*f)(vector<int>), const vector<int>& a, const char* label, const char* what) {
    c = s = 0; f(a); printf("  %-9s  comparisons = %2d  %s = %d\n", label, c, what, s); }
int main() {
    vector<pair<const char*, vector<int>>> tests = {{"random", {5, 1, 4, 8, 2, 7, 3, 6}},
        {"sorted", {1, 2, 3, 4, 5, 6, 7, 8}}, {"reversed", {8, 7, 6, 5, 4, 3, 2, 1}}, {"nearly", {1, 2, 4, 3, 5, 6, 8, 7}}};
    for (const auto& [name, a] : tests) {
        printf("%s\n", name); run(bubble, a, "bubble", "swaps");
        run(selection, a, "selection", "swaps"); run(insertion, a, "insertion", "shifts");
    }
}

আউটপুট:

random
  bubble     comparisons = 25  swaps = 12
  selection  comparisons = 28  swaps = 6
  insertion  comparisons = 18  shifts = 12
sorted
  bubble     comparisons =  7  swaps = 0
  selection  comparisons = 28  swaps = 0
  insertion  comparisons =  7  shifts = 0
reversed
  bubble     comparisons = 28  swaps = 28
  selection  comparisons = 28  swaps = 4
  insertion  comparisons = 28  shifts = 28
nearly
  bubble     comparisons = 13  swaps = 2
  selection  comparisons = 28  swaps = 2
  insertion  comparisons =  9  shifts = 2

প্রতিটি সংখ্যা তত্ত্বের সাথে মেলান। Selection sort-এর তুলনা চারবারই 28 = 8 × 7 / 2 — ইনপুট তার কাছে অপ্রাসঙ্গিক; কিন্তু swap সবচেয়ে কম (উল্টো ইনপুটে মাত্র 4). Bubble-এর swap আর insertion-এর shift প্রতিবার সমান (12, 0, 28, 2) — দুটিই ইনপুটের inversion সংখ্যা I. সাজানো ইনপুটে early exit আর insertion দুটিই n − 1 = 7 তুলনায় শেষ। প্রায়-সাজানো ইনপুটে insertion মাত্র 9 তুলনা নেয়, bubble 13 — কারণ bubble-কে শেষে একটি পুরো “কিছু-বদলায়নি” পাস চালাতে হয়, আর ঠিক কতগুলো পাস লাগবে তা ঠিক করে সবচেয়ে দূরে বাঁয়ে যেতে হবে এমন উপাদান। এলোমেলো ইনপুটে insertion (18) সবার চেয়ে কম তুলনা করেছে। প্রতিটি সর্ট ফাংশন vector<int> মান হিসেবে নেয়, তাই প্রত্যেকে মূল অ্যারের নিজস্ব কপি সাজায় — তিনটির ইনপুট হুবহু এক থাকে।

cnt = [0, 0]                             # [comparisons, swaps or shifts]
def gt(x, y):                            # every comparison goes through here
    cnt[0] += 1
    return x > y
def bubble(a):
    n = len(a)
    for i in range(n - 1):
        swapped = False
        for j in range(n - 1 - i):
            if gt(a[j], a[j + 1]):
                a[j], a[j + 1] = a[j + 1], a[j]
                cnt[1] += 1; swapped = True
        if not swapped: break            # a pass with no swap: already sorted
def selection(a):
    n = len(a)
    for i in range(n - 1):
        m = i
        for j in range(i + 1, n):
            if gt(a[m], a[j]): m = j
        if m != i:
            a[i], a[m] = a[m], a[i]; cnt[1] += 1
def insertion(a):
    for i in range(1, len(a)):
        key, j = a[i], i
        while j > 0 and gt(a[j - 1], key):
            a[j] = a[j - 1]; j -= 1; cnt[1] += 1   # shift right
        a[j] = key
tests = [("random", [5, 1, 4, 8, 2, 7, 3, 6]), ("sorted", [1, 2, 3, 4, 5, 6, 7, 8]),
         ("reversed", [8, 7, 6, 5, 4, 3, 2, 1]), ("nearly", [1, 2, 4, 3, 5, 6, 8, 7])]
for name, a in tests:
    print(name)
    for f, what in ((bubble, "swaps"), (selection, "swaps"), (insertion, "shifts")):
        cnt[:] = [0, 0]; f(a[:])         # sort a copy
        print(f"  {f.__name__:9}  comparisons = {cnt[0]:2}  {what} = {cnt[1]}")

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

random
  bubble     comparisons = 25  swaps = 12
  selection  comparisons = 28  swaps = 6
  insertion  comparisons = 18  shifts = 12
sorted
  bubble     comparisons =  7  swaps = 0
  selection  comparisons = 28  swaps = 0
  insertion  comparisons =  7  shifts = 0
reversed
  bubble     comparisons = 28  swaps = 28
  selection  comparisons = 28  swaps = 4
  insertion  comparisons = 28  shifts = 28
nearly
  bubble     comparisons = 13  swaps = 2
  selection  comparisons = 28  swaps = 2
  insertion  comparisons =  9  shifts = 2

Python-এ list ফাংশনে পাঠালে কপি হয় না — একই list-এর reference যায়। তাই f(a[:]) লিখে একটি কপি পাঠানো হয়েছে; না লিখলে প্রথম সর্টটিই মূল list সাজিয়ে ফেলত, আর পরের দুটি পেত সাজানো ইনপুট (selection-এর তুলনা তবুও 28 আসত, কিন্তু বাকি সংখ্যা বদলে যেত)। cnt একটি list, কারণ ফাংশনের ভেতর থেকে list-এর ঘর বদলানো যায় global না লিখেই। a[j], a[j + 1] = a[j + 1], a[j] হলো Python-এর এক লাইনের swap. {f.__name__:9} ফাংশনের নামকে ৯ ঘর চওড়া বাঁ-ঘেঁষা লেখে, C++-এর %-9s-এর মতো।

৭ · চোখে দেখা — তিন সর্টের দৌড়

সংখ্যা দেখা এক জিনিস, চোখের সামনে উপাদান নড়তে দেখা আরেক। নিচের টুলে একই অ্যারে তিনটি সর্ট একসাথে সাজায় — প্রতি টিকে প্রত্যেকে একটি করে কাজ (একটি তুলনা বা একটি swap/shift) করে। তাই যে আগে শেষ করে, সে সত্যিই কম কাজ করেছে। হলুদ দণ্ড = এখন তুলনা হচ্ছে, লাল = এখন সরানো হলো, সবুজ = শেষ। ইনপুট বদলে “আবার দৌড়” চাপুন: সাজানো ইনপুটে selection sort কীভাবে একা পিছিয়ে পড়ে, আর উল্টো ইনপুটে কীভাবে সে-ই সবচেয়ে কম swap করে — দেখুন। (Insertion sort-এর shift এখানে key-কে বাঁয়ের প্রতিবেশীর সাথে জায়গা বদল হিসেবে আঁকা হয়েছে; গোনা আর ফল একই।)

// Bubble, selection and insertion race on one array: one comparison or one move per tick
const SETS = { fixed: [5, 1, 4, 8, 2, 7, 3, 6], sorted: [1, 2, 3, 4, 5, 6, 7, 8], reversed: [8, 7, 6, 5, 4, 3, 2, 1], nearly: [1, 2, 4, 3, 5, 6, 8, 7] };
const ALGS = ["bubble", "selection", "insertion"], $ = id => document.getElementById(id); let timer = 0;
function record(alg, src) {               // events: ["c" or "s", i, j, snapshot of the array]
  const a = src.slice(), ev = [], n = a.length;
  const gt = (i, j) => (ev.push(["c", i, j, a.slice()]), a[i] > a[j]);
  const sw = (i, j) => { [a[i], a[j]] = [a[j], a[i]]; ev.push(["s", i, j, a.slice()]); };
  if (alg === "bubble") for (let i = 0, any = true; any && i < n - 1; i++)   // early exit
    { any = false; for (let j = 0; j < n - 1 - i; j++) if (gt(j, j + 1)) { sw(j, j + 1); any = true; } }
  if (alg === "selection") for (let i = 0; i < n - 1; i++)
    { let m = i; for (let j = i + 1; j < n; j++) if (gt(m, j)) m = j; if (m !== i) sw(i, m); }
  if (alg === "insertion") for (let i = 1; i < n; i++) for (let j = i; j > 0 && gt(j - 1, j); j--) sw(j - 1, j);
  return ev;
}
function draw(k, kind, i, j, arr, cnt) {
  $("b" + k).innerHTML = arr.map((v, x) => `<i class="${kind === "d" || x === i || x === j ? kind : ""}" style="height:${8 + v * 7}px"></i>`).join("");
  $("t" + k).textContent = `${ALGS[k]}: comparisons ${cnt[0]}, ${k === 2 ? "shifts" : "swaps"} ${cnt[1]}`;
}
function race(arr) {
  clearInterval(timer); const evs = ALGS.map(g => record(g, arr)), pos = [0, 0, 0], cnt = [[0, 0], [0, 0], [0, 0]];
  ALGS.forEach((_, k) => draw(k, "", -1, -1, arr, cnt[k]));
  timer = setInterval(() => {
    let busy = false; evs.forEach((ev, k) => {
      if (pos[k] === ev.length) { pos[k]++; draw(k, "d", -1, -1, ev[ev.length - 1][3], cnt[k]); }
      if (pos[k] >= ev.length) return;
      const [kind, i, j, snap] = ev[pos[k]++]; cnt[k][kind === "c" ? 0 : 1]++; busy = true; draw(k, kind, i, j, snap, cnt[k]);
    });
    if (!busy) clearInterval(timer);
  }, 1100 - 100 * $("sp").value);
}
const shuffled = () => Array.from({ length: 12 }, (_, i) => i + 1).sort(() => Math.random() - 0.5);
$("go").onclick = () => race($("set").value === "random" ? shuffled() : SETS[$("set").value]);
for (const [nm, arr] of Object.entries(SETS))
  console.log(nm.padEnd(8), ALGS.map(g => { const ev = record(g, arr), c = ev.filter(e => e[0] === "c").length; return `${g} ${c}/${ev.length - c}`; }).join("  "));
race(SETS.fixed);

টুল চালু হওয়ার সাথে সাথে কনসোলে প্রতিটি স্থির ইনপুটের চূড়ান্ত হিসাব (তুলনা/সরানো) আসে:

fixed    bubble 25/12  selection 28/6  insertion 18/12
sorted   bubble 7/0  selection 28/0  insertion 7/0
reversed bubble 28/28  selection 28/4  insertion 28/28
nearly   bubble 13/2  selection 28/2  insertion 9/2

সংখ্যাগুলো অংশ ৬-এর C++ আর Python প্রোগ্রামের সাথে হুবহু এক — তিনটি ভাষা, একই অ্যালগরিদম, একই গণনা। record() প্রথমে পুরো সর্ট চালিয়ে প্রতিটি তুলনা ("c") আর প্রতিটি সরানো ("s") একটি তালিকায় লিখে রাখে, সাথে সেই মুহূর্তের অ্যারের ছবি (a.slice())। তারপর setInterval প্রতি টিকে প্রতিটি তালিকা থেকে একটি ঘটনা আঁকে। এই “আগে হিসাব, পরে অ্যানিমেশন” নকশার সুবিধা হলো সর্টিংয়ের কোড একেবারে সাধারণ থাকে। “নতুন এলোমেলো” বেছে কয়েকবার চালান: প্রায় প্রতিবার insertion sort প্রথম, bubble sort শেষে — আর selection sort-এর লাল দণ্ড দেখা যায় খুব কম।

৮ · Shell sort — দূরের উপাদানকে আগে কাছে আনা

Insertion sort-এর দুর্বলতা হলো এটি উপাদানকে একবারে মাত্র এক ঘর সরায়। উল্টো অ্যারের শেষের 1-কে সামনে আনতে n − 1টি shift. ১৯৫৯ সালে Donald L. Shell Communications of the ACM-এ “A High-Speed Sorting Procedure” প্রবন্ধে একটি সহজ সমাধান দেন: আগে দূরের উপাদানগুলোকে সাজিয়ে নিন। একটি gap h বেছে নিন, আর প্রতিটি h দূরত্বের উপাদানের সারিকে (a[0], a[h], a[2h], … একটি সারি; a[1], a[1+h], … আরেকটি) আলাদাভাবে insertion sort করুন — একে বলে h-sorting. তারপর ছোট gap, আরও ছোট, শেষে gap = 1, যা সাধারণ insertion sort. এই হলো Shell sort.

শেষ ধাপ যেহেতু পুরো insertion sort, ফল সবসময় সঠিক। লাভ হলো, বড় gap-এর ধাপগুলো এক লাফে অনেক inversion দূর করে, ফলে শেষ ধাপের কাছে আসে প্রায়-সাজানো অ্যারে — আর প্রায়-সাজানো অ্যারেতে insertion sort দ্রুত (অংশ ৫)। উল্টো অ্যারে [8, 7, 6, 5, 4, 3, 2, 1]-এ Shell-এর নিজের ধারা অনুযায়ী gap n/2 = 4, তারপর 2, তারপর 1 দিয়ে দেখুন:

প্রতিটি gap-এর ধাপ শেষে
ধাপ
সারিগুলো
অ্যারে
বাকি inversion
মোট তুলনা / সরানো
  1. শুরু. উল্টো অ্যারে — ২৮টি inversion. সাধারণ insertion sort-এ এটি লাগত ২৮টি তুলনা আর ২৮টি shift.
  2. gap = 4. চারটি দুই-উপাদানের সারি: (8, 4), (7, 3), (6, 2), (5, 1). প্রতিটি সারিতে একটি সরানো — মোট মাত্র ৪টি, অথচ inversion ২৮ থেকে নেমে ১২. একেকটি দূরের সরানো একসাথে অনেকগুলো inversion দূর করেছে।
  3. gap = 2. জোড় ঘরের সারি 4 2 8 6 সাজিয়ে 2 4 6 8, বিজোড় ঘরের 3 1 7 5 সাজিয়ে 1 3 5 7. আরও ৮টি তুলনা আর ৪টি সরানো; inversion এখন মাত্র ৪টি।
  4. gap = 1. সাধারণ insertion sort, কিন্তু প্রায়-সাজানো ইনপুটে: ১০টি তুলনা, ৪টি shift. মোট ২২টি তুলনা আর ১২টি সরানো — insertion sort-এর ২৮/২৮-এর চেয়ে কম। n বড় হলে পার্থক্য বিশাল হয়।
// Shell sort with Knuth's gaps 1, 4, 13, 40, ... (h = 3h + 1)
void shellSort(vector<int>& a) {
    int n = a.size(), h = 1;
    while (h < n / 3) h = 3 * h + 1;              // largest gap below n/3
    for (; h >= 1; h /= 3)                         // ..., 40, 13, 4, 1
        for (int i = h; i < n; ++i) {              // insertion sort on every h-chain
            int key = a[i], j = i;
            for (; j >= h && a[j - h] > key; j -= h) a[j] = a[j - h];
            a[j] = key;
        }
}

Gap-এর ধারা — কোনটি ভালো?

Shell sort-এর গতি পুরোপুরি নির্ভর করে gap sequence-এর ওপর, আর এটি আজও গবেষণার বিষয়:

ধারাgap-গুলোworst case
Shell (১৯৫৯)n/2, n/4, …, 1Θ(n²) — n যখন ২-এর ঘাত, জোড় আর বিজোড় ঘর শেষ ধাপের আগে মেশেই না
Hibbard (১৯৬৩)1, 3, 7, 15, … (2k − 1)Θ(n3/2)
Knuth1, 4, 13, 40, 121, … ((3k − 1)/2)O(n3/2)
Pratt (১৯৭১)2p3q আকারের সব সংখ্যা: 1, 2, 3, 4, 6, 8, 9, …Θ(n log² n), কিন্তু ধাপ অনেক বেশি
Sedgewick (১৯৮৬)1, 5, 19, 41, 109, …O(n4/3)
Ciura (২০০১)1, 4, 10, 23, 57, 132, 301, 701 (পরীক্ষা করে পাওয়া)প্রমাণিত সীমা নেই; বাস্তবে খুব ভালো

সংখ্যার একটি অনুভূতি: আমাদের একটি পরীক্ষায় ১০,০০০টি এলোমেলো সংখ্যায় Knuth-এর gap দিয়ে Shell sort প্রায় ২.৫ লাখ তুলনা করেছে, আর সাধারণ insertion sort প্রায় ২.৫ কোটি — প্রায় একশো গুণ পার্থক্য। Shell sort in-place (O(1) বাড়তি জায়গা), রিকার্শন নেই, কোড ছোট — তাই ছোট মেমরির embedded সিস্টেমে এটি এখনো বেছে নেওয়া হয়। তবে এটি stable নয়: দূরের লাফ সমান উপাদানকে টপকে যায়, ঠিক selection sort-এর মতো।

৯ · পাশাপাশি তুলনা — কখন কোনটি

বিষয়Bubble (early exit)SelectionInsertionShell (Knuth gap)
Best caseΘ(n) — সাজানো ইনপুটΘ(n²)Θ(n) — সাজানো ইনপুটΘ(n log n)
Average caseΘ(n²)Θ(n²)Θ(n²), প্রায় n²/4 তুলনানিখুঁত সূত্র অজানা; বাস্তবে n²-এর অনেক নিচে
Worst caseΘ(n²)Θ(n²)Θ(n²)O(n3/2)
তুলনাn − 1 থেকে n(n − 1)/2সবসময় n(n − 1)/2I থেকে I + n − 1gap-এর ওপর নির্ভর
Swap / সরানোঠিক I টি swapসর্বোচ্চ n − 1টি swapঠিক I টি shiftI-এর চেয়ে অনেক কম
Stableহ্যাঁনাহ্যাঁনা
In-place (বাড়তি জায়গা)হ্যাঁ, O(1)হ্যাঁ, O(1)হ্যাঁ, O(1)হ্যাঁ, O(1)
Adaptiveআংশিক (কচ্ছপে দুর্বল)নাহ্যাঁ, Θ(n + I)আংশিক
Onlineনানাহ্যাঁনা

তিনটি invariant এক নজরে

পর্ব ০৩-এর ভাষায়, তিনটি অ্যালগরিদমের পার্থক্য আসলে তাদের invariant-এর পার্থক্য। খাতায় লেখার মতো করে:

  • Bubble: i টি পাসের পর ডানের i টি ঘরে সবচেয়ে বড় i টি, চূড়ান্ত জায়গায়।
  • Selection: i টি ধাপের পর বাঁয়ের i টি ঘরে সবচেয়ে ছোট i টি, চূড়ান্ত জায়গায়।
  • Insertion: i টি ধাপের পর বাঁয়ের i + 1টি ঘরে শুরুর সেই একই উপাদানগুলো, সাজানো — কিন্তু চূড়ান্ত নয়।

একটি পরীক্ষার কৌশল: কোনো অজানা সর্টের মাঝপথের অবস্থা দেওয়া থাকলে এই invariant দিয়েই চেনা যায়, এটি কোন সর্ট। যেমন [2, 4, 5, 1, 3]-কে [5, 2, 4, 1, 3]-এর মাঝপথ হিসেবে দেখালে — বাঁয়ের 2 4 5 সাজানো কিন্তু ছোট উপাদান 1 এখনো ডানে — এটি insertion sort-এর দুই ধাপ পরের অবস্থা; selection sort হলে প্রথম ঘরে 1 থাকত।

কখন কোনটি বেছে নেবেন

Insertion sort

ছোট অ্যারে (কয়েক ডজন পর্যন্ত), প্রায়-সাজানো ডেটা, একটি করে আসা ডেটা (online), stable দরকার। বড় সর্টের ভেতরে ছোট টুকরো সামলাতেও এটিই।

Selection sort

লেখা খুব দামি — বড় রেকর্ড, বা লেখার সংখ্যা সীমিত এমন মেমরি। swap-এর সংখ্যা Θ(n)-এর নিশ্চয়তা দরকার হলে।

Shell sort

মাঝারি আকারের ডেটা, বাড়তি মেমরি বা রিকার্শন চলবে না, stable দরকার নেই।

Bubble sort

মূলত শেখানোর জন্য, আর “অ্যারে কি সাজানো?” যাচাই করতে এক পাস। বাস্তবের কাজে প্রায় কখনো সেরা পছন্দ নয়।

আর n বড় হলে (হাজার বা লাখ) এদের কোনোটিই নয় — তখন Θ(n log n)-এর merge sort (পর্ব ২১), quick sort (পর্ব ২২), heapsort (পর্ব ২৩) বা ভাষার লাইব্রেরির sort (পর্ব ২৫)। মজার বিষয়, সেই বড় অ্যালগরিদমগুলোর ভেতরেও insertion sort লুকিয়ে থাকে: GCC-র std::sort যে introsort ব্যবহার করে, তা ছোট টুকরো (১৬টি উপাদানের মতো) insertion sort-কে দিয়ে দেয়, আর Python-এর Timsort ছোট “run”-গুলো binary insertion sort দিয়ে বানায়। কারণ ছোট n-এ insertion sort-এর ধ্রুবক খুব ছোট: রিকার্শন নেই, বাড়তি মেমরি নেই, মেমরি পরপর পড়া হয় (cache-বান্ধব)।

আরও গভীরে: যে অ্যালগরিদম শুধু পাশাপাশি দুটি উপাদান বদলায় (bubble, insertion, cocktail shaker), সে প্রতি swap-এ সর্বোচ্চ একটি inversion কমাতে পারে — তাই তার অন্তত I টি swap লাগবেই। এলোমেলো ইনপুটে গড় I = n(n − 1)/4, অতএব এমন যেকোনো অ্যালগরিদমের average case Ω(n²) — নতুন কৌশল আবিষ্কার করেও এই দেয়াল ভাঙা যাবে না। Shell sort, merge sort বা quick sort এই দেয়াল পার হয় ঠিক একটি কারণে: তারা দূরের উপাদানকে এক লাফে সরায়, যা একসাথে অনেক inversion দূর করতে পারে।

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

  • কোন সরল সর্টে swap সবচেয়ে কম? — Selection sort: সর্বোচ্চ n − 1টি, যদিও তুলনা সবসময় n(n − 1)/2.
  • Bubble sort-এর best case কত? — early-exit flag থাকলে Θ(n) (সাজানো ইনপুটে n − 1 তুলনা); flag না থাকলে Θ(n²).
  • Insertion sort-এর সময় inversion দিয়ে লিখুন। — Θ(n + I); shift ঠিক I টি।
  • Selection sort কি stable? উদাহরণ দিন। — না; [2a, 2b, 1] → [1, 2b, 2a].
  • প্রায়-সাজানো বা খুব ছোট অ্যারের জন্য কোন সর্ট? — Insertion sort; এজন্যই Timsort আর introsort-এর ভেতরে এটি থাকে।

১০ · AI-এর যুগে সর্টিং — চেনা কোডে অচেনা বাগ

“Bubble sort লিখে দাও” — AI এক সেকেন্ডে লিখে দেবে, আর বেশিরভাগ সময় চলবেও। কিন্তু সরল সর্টের ভুলগুলো সূক্ষ্ম: একটি > বনাম >= stability নষ্ট করে, লুপের সীমায় এক ঘরের ভুল (off-by-one) অ্যারের বাইরে পড়ে, আর complexity-র দাবি প্রায়ই অর্ধসত্য — যেমন flag ছাড়া লেখা bubble sort-কে “best case O(n)” বলা, বা Shell sort-কে নির্দ্বিধায় “O(n log n)” বলা। ছোট সুন্দর উদাহরণে এর কোনোটিই ধরা পড়ে না।

সর্টিং কোডে AI সাধারণত যেসব ভুল করে

AI-এর ভুলকীভাবে ধরবেন
Flag ছাড়া bubble sort, অথচ দাবি “best case O(n)”সাজানো ইনপুটে তুলনা গুনুন — n − 1 আসে, নাকি n(n − 1)/2?
ভেতরের লুপ j < n - i, ফলে a[j+1] অ্যারের বাইরেn = 1, 2 দিয়ে চালান; C++-এ -fsanitize=address বা .at()
Insertion sort-এ a[j-1] >= key — stable দাবি করে, অথচ নয়সমান key-এর রেকর্ড (রোল-সহ) দিয়ে চালিয়ে ক্রম দেখুন
“Selection sort stable”[2a, 2b, 1] দিয়ে চালান
swap-এর বদলে এক দিকে কপি — উপাদান হারায়ফল যে ইনপুটের permutation, তা গণনা মিলিয়ে যাচাই করুন
Shell sort-এর complexity “O(n log n)”gap ধারা কোনটি জিজ্ঞেস করুন; প্রমাণিত সীমা চান
Insertion sort-এর shift-কে swap ধরে লেখার সংখ্যা তিন গুণ বলাকোডে প্রতিটি assignment গুনুন

সমাধান লিখিয়ে নেওয়ার বদলে AI-কে আপনার trace আর বিশ্লেষণের পরীক্ষক বানান:

আমি DSA শিখছি, এখন bubble, selection আর insertion sort. নিচে আমার লেখা কোড আর একটি ইনপুটে আমার হাতে করা trace দিলাম: [এখানে কোড আর trace পেস্ট করুন] পুরো সমাধান লিখে দিও না। শুধু এগুলো করো: ১. [3, 1, 2], [1, 2, 3] আর [2, 2, 1] ইনপুটে প্রতিটি পাসের পর অ্যারের অবস্থা আর মোট তুলনা ও swap দেখাও; আমার trace-এর সাথে কোথায় অমিল বলো। ২. আমার কোড stable কি না — সমান key-এর দুটি রেকর্ড দিয়ে উদাহরণসহ দেখাও। ৩. best, worst আর average case-এ তুলনার সংখ্যা সূত্রসহ বলো, আর প্রতিটি সূত্র কোন ইনপুটে হয় তা বলো। ৪. লুপের সীমায় কোনো off-by-one আছে কি? n = 0, 1, 2 দিয়ে যাচাই করো।

সর্টিং কোড যাচাইয়ের চেকলিস্ট

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

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

প্র ০১Bubble sort প্রায় কখনো সেরা পছন্দ নয় — তবু প্রায় সব বইয়ে প্রথমে এটিই কেন পড়ানো হয়?

Bubble sort-এর বদনাম পুরোনো আর বিখ্যাত। Donald Knuth The Art of Computer Programming-এর তৃতীয় খণ্ডে লিখেছেন, bubble sort-এর পক্ষে বলার মতো কিছুই প্রায় নেই — একটি আকর্ষণীয় নাম, আর এটি থেকে কিছু মজার তাত্ত্বিক প্রশ্ন ওঠে, এটুকুই। ২০০৭ সালে মার্কিন প্রেসিডেন্ট পদপ্রার্থী Barack Obama Google-এ এক অনুষ্ঠানে গেলে Eric Schmidt মজা করে জিজ্ঞেস করেন, দশ লাখ ৩২-bit পূর্ণসংখ্যা সাজানোর সবচেয়ে দক্ষ উপায় কী। Obama-র উত্তর ছিল, bubble sort সম্ভবত ভুল পথ হবে — শ্রোতারা হাততালি দেন। অর্থাৎ “bubble sort ধীর” কথাটি কম্পিউটার বিজ্ঞানের বাইরেও একটি চেনা কৌতুক।

নামের ইতিহাসও আকর্ষণীয়। Owen Astrachan ২০০৩ সালে “Bubble Sort: An Archaeological Algorithmic Analysis” নামে একটি প্রবন্ধে এর উৎস খুঁজেছেন। ১৯৫০-এর দশকের মাঝামাঝি প্রবন্ধে একে “sorting by exchange” বা “exchange sorting” বলা হতো; “bubble sort” নামটি ছাপা অবস্থায় পাওয়া যায় ১৯৬২ সালের দিকে, Kenneth Iverson-এর A Programming Language বইয়ে। Astrachan-এর যুক্তি ছিল, শিক্ষার্থীদের প্রথম সর্ট হিসেবে এটি না শিখিয়ে insertion sort শেখানোই ভালো, কারণ সেটি সমান সহজ কিন্তু বাস্তবে কাজের।

তবু বইগুলো bubble sort রাখে কয়েকটি শিক্ষণীয় কারণে। এক, এর নিয়ম মাত্র একটি বাক্যে বলা যায় — “পাশাপাশি উল্টো হলে বদলাও”। দুই, এটি inversion-এর ধারণা পরিষ্কার করে: প্রতিটি swap ঠিক একটি inversion দূর করে, তাই swap = I. তিন, early-exit flag দিয়ে best case আর worst case-এর পার্থক্য, আর loop invariant (“ডানের অংশ চূড়ান্ত”) — দুটোই এখানে সবচেয়ে সহজে দেখানো যায়। চার, এটি একটি সতর্কবার্তা: সহজে লেখা যায় মানেই ভালো অ্যালগরিদম নয়। একই সরলতায় insertion sort প্রায় সব দিক থেকে ভালো — কম তুলনা, কম লেখা, আসল adaptive.

বাস্তবে bubble sort-এর একটি ছোট ব্যবহার টিকে আছে: “অ্যারে কি সাজানো?” পরীক্ষা করতে একটি পাস, বা প্রায় সাজানো ডেটায় একটি-দুটি পাস দিয়ে ছোট ভুল ঠিক করা। কম্পিউটার গ্রাফিক্সে কখনো কখনো প্রতি ফ্রেমে প্রায়-সাজানো বস্তুর তালিকায় এমন সংক্ষিপ্ত পাস চালানো হয়।

মূল উপলব্ধি: Bubble sort শেখার উদ্দেশ্য এটি ব্যবহার করা নয় — inversion, invariant আর case analysis বোঝা, আর বোঝা যে “সবচেয়ে সহজ” আর “সবচেয়ে ভালো” এক জিনিস নয়।

প্র ০২পৃথিবীর সবচেয়ে দ্রুত sort লাইব্রেরিগুলোর ভেতরেও কেন Θ(n²)-এর insertion sort থাকে?

Big-O ধ্রুবক লুকিয়ে রাখে, আর ছোট n-এ ধ্রুবকই সব। Merge sort বা quick sort-এর প্রতিটি রিকার্সিভ ডাকে ফাংশন কলের খরচ, stack-এ ফ্রেম বানানো, সীমা হিসাব, merge-এর জন্য বাড়তি অ্যারে — এসব আছে। ১০টি উপাদানের টুকরোয় এই খরচ আসল সাজানোর কাজের চেয়ে বেশি হয়ে যায়। Insertion sort-এর ভেতরের লুপ কয়েকটি নির্দেশ মাত্র: একটি তুলনা, একটি লেখা, একটি বিয়োগ। মেমরি পরপর পড়া হয়, তাই CPU-র cache আর prefetcher দারুণ কাজ করে; ছোট টুকরোয় branch-ও বেশ অনুমানযোগ্য।

তাই প্রায় সব বাস্তব sort হাইব্রিড। ১৯৯৭ সালে David Musser যে introsort প্রস্তাব করেন, GCC-র libstdc++-এর std::sort তা ব্যবহার করে: quick sort দিয়ে ভাগ করতে থাকে, রিকার্শন বেশি গভীর হলে heapsort-এ চলে যায় (worst case O(n log n) নিশ্চিত করতে), আর টুকরো ১৬টি উপাদানের মতো ছোট হলে ভাগ থামিয়ে দেয়। শেষে পুরো অ্যারেতে একবার insertion sort চালানো হয় — অ্যারে তখন প্রায় সাজানো (প্রতিটি উপাদান নিজের ছোট টুকরোর ভেতরেই আছে), তাই এই শেষ ধাপ প্রায় রৈখিক সময়ে শেষ হয়। ঠিক অংশ ৫-এর O(nk) যুক্তি।

২০০২ সালে Tim Peters Python-এর জন্য যে Timsort লেখেন, সেখানেও একই চিন্তা। Timsort ডেটার ভেতরে আগে থেকে সাজানো টুকরো (run) খোঁজে; কোনো run খুব ছোট হলে (সাধারণত ৩২ থেকে ৬৪-এর মধ্যে একটি “minrun” পর্যন্ত) binary insertion sort দিয়ে তাকে বড় করে, তারপর merge sort-এর মতো run-গুলো জোড়া দেয়। Binary insertion কারণ Python-এ তুলনা তুলনামূলক দামি (যেকোনো অবজেক্টের __lt__ ডাকা লাগতে পারে), কিন্তু list-এ reference সরানো সস্তা। Java-র অবজেক্ট সর্ট আর V8-এর JavaScript sort-ও পরে Timsort গ্রহণ করে।

আরেকটি গভীর কারণ adaptivity. বাস্তবের ডেটা প্রায়ই আংশিক সাজানো — তারিখ অনুযায়ী লগ, আগে সাজানো তালিকায় কয়েকটি নতুন এন্ট্রি। Insertion sort-এর Θ(n + I) এমন ডেটায় সরাসরি লাভ দেয়, যা “সবসময় n log n” অ্যালগরিদম পারে না।

মূল উপলব্ধি: অ্যালগরিদম বেছে নেওয়া শুধু asymptotic শ্রেণির প্রশ্ন নয়। বড় n-এ সঠিক শ্রেণি, ছোট n-এ ছোট ধ্রুবক — সেরা প্রকৌশল দুটিকেই একসাথে কাজে লাগায়।

প্র ০৩Selection sort-এর “কম swap” সুবিধা কোথায় সত্যিই গুরুত্বপূর্ণ — আর এর থেকে heapsort কীভাবে জন্মাল?

বেশিরভাগ বিশ্লেষণে আমরা ধরে নিই তুলনা আর সরানোর খরচ সমান। কিন্তু সবসময় তা নয়। ধরুন প্রতিটি রেকর্ড কয়েক কিলোবাইটের — একজন গ্রাহকের পুরো প্রোফাইল। তুলনা করতে শুধু একটি সংখ্যা (key) দেখতে হয়, কিন্তু swap করতে পুরো রেকর্ড দুবার কপি। তখন n(n − 1)/2 তুলনা কিন্তু মাত্র n − 1টি swap-এর selection sort, I টি swap-এর bubble sort-এর চেয়ে অনেক দ্রুত হতে পারে। আরেকটি উদাহরণ flash memory বা EEPROM: এদের প্রতিটি ঘরে সীমিত সংখ্যক বার লেখা যায়, তারপর ঘরটি ক্ষয় হয়ে যায়। এমন মেমরিতে লেখা কমানো সরাসরি যন্ত্রের আয়ু বাড়ায়।

লেখা কমানোর চরম রূপ হলো cycle sort (B. K. Haddon, ১৯৯০): প্রতিটি উপাদান কোন ঘরে যাবে তা গুনে বের করে সরাসরি সেখানে লেখে, তাই প্রতিটি উপাদান সর্বোচ্চ একবার লেখা হয় — তাত্ত্বিকভাবে সম্ভাব্য সর্বনিম্ন লেখা, যদিও তুলনা Θ(n²)। বাস্তবে আরও সাধারণ কৌশল হলো রেকর্ডগুলো না সরিয়ে তাদের index বা pointer-এর একটি অ্যারে সাজানো; তখন প্রতিটি swap সস্তা।

Selection sort-এর আসল দুর্বলতা হলো “বাকিদের মধ্যে সর্বনিম্ন খোঁজা” প্রতিবার Θ(n). প্রশ্ন উঠল: সর্বনিম্ন খোঁজার কাজটি দ্রুত করা যায় এমন কোনো ডেটা স্ট্রাকচার আছে কি? ১৯৬৪ সালে J. W. J. Williams উত্তর দেন binary heap দিয়ে, আর তার ওপর heapsort; একই বছর Robert Floyd heap বানানোর একটি দ্রুততর (রৈখিক সময়ের) পদ্ধতি দেন। Heapsort আসলে selection sort-ই: প্রতিবার সবচেয়ে বড়টি বের করে শেষে বসানো — কিন্তু heap থেকে সর্বোচ্চ বের করা O(log n), তাই মোট O(n log n), আর তখনো in-place. পর্ব ২৩-এ এটি বিস্তারিত।

এখান থেকে DSA-র একটি কেন্দ্রীয় শিক্ষা: অনেক সময় অ্যালগরিদমের ধারণা ঠিকই থাকে, শুধু তার ভেতরের সবচেয়ে ব্যয়বহুল প্রশ্নটির জন্য একটি ভালো ডেটা স্ট্রাকচার লাগে। Dijkstra-র অ্যালগরিদমেও (পর্ব ৪৩) “পরের সবচেয়ে কাছের vertex বেছে নাও” — সেই একই selection-এর ধারণা, priority queue দিয়ে দ্রুত।

মূল উপলব্ধি: খরচের মডেল বদলালে সেরা অ্যালগরিদমও বদলায়। আর selection sort থেকে heapsort-এ যাত্রা দেখায়, ভালো ডেটা স্ট্রাকচার পুরোনো ধারণাকে নতুন শ্রেণির গতি দিতে পারে।

প্র ০৪Shell sort ১৯৫৯ সালের — তবু এর সময়ের নিখুঁত হিসাব আজও অজানা কেন?

Shell sort-এর কোড দশ লাইনের, কিন্তু এর বিশ্লেষণ কম্পিউটার বিজ্ঞানের সবচেয়ে একগুঁয়ে খোলা প্রশ্নগুলোর একটি। সমস্যার মূলে gap-এর ধারা। Donald Shell নিজে n/2, n/4, … ব্যবহার করেছিলেন। পরে দেখা গেল, n যখন ২-এর ঘাত, তখন সব gap জোড় হওয়ায় জোড় আর বিজোড় অবস্থানের উপাদান শেষ ধাপের আগে কখনো মেশে না; বড়গুলো সব জোড় ঘরে আর ছোটগুলো সব বিজোড় ঘরে থাকলে শেষ insertion sort-কে Θ(n²) কাজ করতে হয়।

১৯৬৩ সালে Thomas Hibbard দেখান, 2k − 1 ধারায় (1, 3, 7, 15, …) worst case Θ(n3/2). Knuth তাঁর বইয়ে (3k − 1)/2 ধারা (1, 4, 13, 40, …) জনপ্রিয় করেন, যেটিও O(n3/2). ১৯৭১ সালে Vaughan Pratt তাঁর গবেষণায় দেখান, 2p3q আকারের সব সংখ্যা gap হিসেবে নিলে worst case Θ(n log² n) — কিন্তু gap এত বেশি যে বাস্তবে এটি ধীর। ১৯৮৬ সালে Robert Sedgewick O(n4/3)-এর ধারা দেন। ২০০১ সালে Marcin Ciura তাত্ত্বিক সূত্রের বদলে কম্পিউটারে ব্যাপক পরীক্ষা চালিয়ে 1, 4, 10, 23, 57, 132, 301, 701 ধারাটি খুঁজে পান, যা বাস্তবে সেরাগুলোর একটি — কিন্তু এর কোনো প্রমাণিত সীমা নেই।

কেন এত কঠিন? কারণ প্রতিটি gap-এর ধাপ আগের ধাপগুলোর তৈরি জটিল কাঠামোর ওপর কাজ করে। একটি সুন্দর উপপাদ্য আছে — একটি অ্যারে h-sorted থাকলে তাকে k-sort করার পরও সেটি h-sorted থাকে, অর্থাৎ আগের কাজ নষ্ট হয় না — কিন্তু ঠিক কতটুকু কাজ বাকি থাকে, তা সংখ্যাতত্ত্বের কঠিন প্রশ্নে (একাধিক সংখ্যার যোগফল দিয়ে কোন কোন সংখ্যা লেখা যায়) গিয়ে ঠেকে। ২০০০ সালে Tao Jiang, Ming Li আর Paul Vitányi Kolmogorov complexity ব্যবহার করে দেখান, p টি পাসের যেকোনো Shell sort-এর average case-এর একটি নিম্নসীমা আছে, যা পাস কম হলে n1+1/p পর্যায়ের — তবু কোনো ধারায় O(n log n) গড় সম্ভব কি না, সেই প্রশ্ন খোলা।

মূল উপলব্ধি: সরল কোড মানেই সরল বিশ্লেষণ নয়। Shell sort মনে করিয়ে দেয়, অনেক বাস্তব অ্যালগরিদম আমরা ব্যবহার করি পরীক্ষালব্ধ প্রমাণে ভর করে — আর তাই মেপে দেখার অভ্যাস (counter, benchmark) প্রমাণের মতোই জরুরি।

প্র ০৫Stability কেন এত জরুরি যে বিভিন্ন প্রোগ্রামিং ভাষা আলাদা করে এর নিশ্চয়তা দেয়?

একটি বাস্তব দৃশ্য ভাবুন। একটি অনলাইন দোকানের অর্ডারের তালিকা অর্ডারের সময় অনুযায়ী সাজানো। ম্যানেজার এখন “জেলা অনুযায়ী” দেখতে চান, কিন্তু প্রতিটি জেলার ভেতরে সময়ের ক্রম ঠিক রেখে। Stable sort থাকলে কাজটি এক লাইনের: জেলা অনুযায়ী stable sort, ব্যস — প্রতিটি জেলার ভেতরে আগের সময়ের ক্রম অক্ষত থাকে। Spreadsheet-এ একটি কলামে ক্লিক করে সাজানোর পর আরেকটি কলামে ক্লিক করলে যা হয়, তা-ও এই নীতিতেই। Stable না হলে প্রতিবার সব মাপকাঠি মিলিয়ে একটি জটিল comparator লিখতে হতো।

তত্ত্বেও stability অপরিহার্য। পর্ব ২৪-এর LSD radix sort প্রথমে শেষ অঙ্ক, তারপর তার আগের অঙ্ক… এভাবে সাজায়। প্রতিটি ধাপের সর্ট stable না হলে আগের ধাপের কাজ নষ্ট হয়ে যেত, আর পুরো অ্যালগরিদম ভুল ফল দিত।

এজন্য ভাষাগুলো নিশ্চয়তা নিয়ে সতর্ক। Python-এর documentation স্পষ্টভাবে বলে, list.sort() আর sorted() stable, আর একাধিক মাপকাঠিতে পরপর সাজানোর এই কৌশলটিই সুপারিশ করে। JavaScript-এ বহু বছর Array.prototype.sort-এর stability ব্রাউজারভেদে আলাদা ছিল; ২০১৮ সালে V8 (Chrome-এর ইঞ্জিন) Timsort-এ চলে যায়, আর ECMAScript 2019 standard থেকে stable হওয়া বাধ্যতামূলক। Java-র Arrays.sort অবজেক্টের জন্য stable হওয়ার নিশ্চয়তা দেয় (ভেতরে Timsort), কিন্তু int-এর মতো primitive-এর জন্য dual-pivot quicksort ব্যবহার করে — সেখানে সমান দুটি int আলাদা করে চেনার উপায়ই নেই, তাই stability অর্থহীন। C++ দুটিই রাখে: std::sort (stable নয়, সাধারণত দ্রুত) আর std::stable_sort (stable, ভেতরে merge sort-ধরনের, বাড়তি মেমরি নিতে পারে)।

যেকোনো সর্টকে stable বানানোর একটি সাধারণ কৌশলও আছে: প্রতিটি উপাদানের সাথে তার মূল index জুড়ে দিন, আর key সমান হলে index দিয়ে তুলনা করুন। খরচ হলো প্রতি উপাদানে একটি বাড়তি সংখ্যা।

মূল উপলব্ধি: Stability কোনো বিলাসিতা নয় — এটি একটি সর্টকে অন্য সর্টের সাথে জোড়া লাগানোর নিশ্চয়তা। সরল সর্টগুলোর মধ্যে insertion আর bubble এই নিশ্চয়তা বিনা খরচে দেয়; selection আর Shell দেয় না।

অনুশীলন

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

  1. হাতে trace করুন: early-exit flag-সহ bubble sort [3, 1, 2, 4, 5]-এ চালান। প্রতিটি পাসের পর অ্যারে, তুলনা আর swap লিখুন। কয়টি পাস চলে? Flag না থাকলে মোট তুলনা কত হতো?

    পাস ১ (j = 0..3): 3 > 1 swap → 1 3 2 4 5; 3 > 2 swap → 1 2 3 4 5; 3, 4 ঠিক; 4, 5 ঠিক। ৪টি তুলনা, ২টি swap. পাস ২ (j = 0..2): তিনটি জোড়াই ঠিক — ৩টি তুলনা, কোনো swap নেই, তাই থামুন।

    মোট ২টি পাস, ৭টি তুলনা, ২টি swap. Swap = inversion: (3,1), (3,2) — ঠিক ২টি। Flag না থাকলে চারটি পাসই চলত: 4 + 3 + 2 + 1 = ১০টি তুলনা.

  2. Inversion দিয়ে আগেই বলুন: [3, 7, 4, 9, 5, 2]-এ insertion sort কয়টি shift আর কয়টি তুলনা করবে — চালানোর আগে সূত্র দিয়ে বের করুন, তারপর trace করে মিলিয়ে নিন।

    Inversion গুনুন: 3 → (3,2) = ১; 7 → (7,4), (7,5), (7,2) = ৩; 4 → (4,2) = ১; 9 → (9,5), (9,2) = ২; 5 → (5,2) = ১. মোট I = ৮, তাই shift = ৮. কোন key সামনে পৌঁছাবে? শুধু 2 (সবচেয়ে ছোট, আর শেষে আছে)। তুলনা = I + (n − 1) − 1 = 8 + 5 − 1 = ১২.

    Trace: key 7 → ১ তুলনা, ০ shift; key 4 → ২ তুলনা, ১ shift (3 4 7 9 5 2); key 9 → ১, ০; key 5 → ৩ তুলনা, ২ shift (3 4 5 7 9 2); key 2 → ৫ তুলনা, ৫ shift, সামনে পৌঁছায়। মোট তুলনা 1 + 2 + 1 + 3 + 5 = 12, shift 0 + 1 + 0 + 2 + 5 = 8 ✓.

  3. Stability ভাঙুন, তারপর সারান: [4a, 3, 4b, 1] (a, b দিয়ে দুটি সমান 4 আলাদা করা) selection sort-এ চালান। শেষে দুটি 4 কোন ক্রমে থাকে? Swap-এর বদলে কী করলে selection sort stable হবে, আর তাতে কী হারাবেন?

    i = 0: সর্বনিম্ন 1 (index 3), swap a[0] ↔ a[3] → [1, 3, 4b, 4a]. i = 1: সর্বনিম্ন 3 নিজের জায়গায়। i = 2: 4b আর 4a সমান, কঠোর < তুলনায় m = 2 থাকে, swap নেই। ফল [1, 3, 4b, 4a] — 4b এখন 4a-র আগে, stability ভেঙেছে। কারণ প্রথম swap-টি 4a-কে এক লাফে 4b-র পেছনে পাঠিয়েছে।

    সারানো: সর্বনিম্ন উপাদানটি বের করে নিয়ে i থেকে m − 1 পর্যন্ত সবাইকে এক ঘর ডানে সরান, তারপর a[i]-তে বসান (insertion sort-এর shift-এর মতো)। তখন কেউ কাউকে টপকায় না — stable. কিন্তু প্রতি ধাপে Θ(n) পর্যন্ত লেখা, মোট লেখা Θ(n²) — selection sort-এর একমাত্র বড় সুবিধা, “কম লেখা”, হারিয়ে যায়।

  4. কোড লিখুন: Cocktail shaker sort লিখুন — bubble sort-এর মতো, কিন্তু পালা করে একবার বাঁ থেকে ডানে, একবার ডান থেকে বাঁয়ে পাস; কোনো পাসে swap না হলে থামুন। তুলনা গুনে [2, 3, 4, 5, 6, 1]-এ bubble sort-এর সাথে মেলান।
    int cocktail(vector<int>& a) {                  // returns the number of comparisons
        int lo = 0, hi = (int)a.size() - 1, cmp = 0;
        bool swapped = true;
        while (swapped && lo < hi) {
            swapped = false;
            for (int j = lo; j < hi; ++j, ++cmp)        // left to right: largest goes to hi
                if (a[j] > a[j + 1]) { swap(a[j], a[j + 1]); swapped = true; }
            --hi;
            if (!swapped) break;
            swapped = false;
            for (int j = hi; j > lo; --j, ++cmp)        // right to left: smallest goes to lo
                if (a[j - 1] > a[j]) { swap(a[j - 1], a[j]); swapped = true; }
            ++lo;
        }
        return cmp;
    }

    [2, 3, 4, 5, 6, 1]-এ: প্রথম ডানমুখী পাস ৫টি তুলনা (শুধু 6 ↔ 1 swap) → 2 3 4 5 1 6. বাঁমুখী পাস ৪টি তুলনায় “কচ্ছপ” 1-কে এক যাত্রায় সামনে নিয়ে আসে → 1 2 3 4 5 6. পরের ডানমুখী পাসে ৩টি তুলনা, কোনো swap নেই — থামুন। মোট ১২টি তুলনা, ৫টি swap. সাধারণ bubble sort-এ 1-কে সামনে আনতে পাঁচটি পাস লাগে: ১৫টি তুলনা, swap সেই ৫টিই (swap সবসময় = I, যেকোনো পাশাপাশি-swap সর্টে)। Worst case তবু Θ(n²) — উল্টো অ্যারেতে দুটিই n(n − 1)/2.

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

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

ক্রমInsertion sort-এ একটি key-কে বাঁয়ের সাজানো অংশে ঢোকানোর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. key = a[i] আলাদা করে রাখা, আর j = i ধরা
  2. j > 0 আর a[j−1] > key কি না দেখা
  3. সত্য হলে a[j−1]-কে এক ঘর ডানে a[j]-তে সরানো আর j এক কমানো
  4. a[j−1] ≤ key পাওয়া গেলে বা j = 0 হলে থামা
  5. ফাঁকা ঘর a[j]-তে key বসানো

নিচের কোন সর্টিং পদ্ধতিটি (সাধারণ রূপে) stable?

  • Selection sort
  • Shell sort
  • Insertion sort
  • Heapsort

১০টি উপাদানের একটি আগে থেকেই সাজানো অ্যারেতে selection sort মোট কয়টি তুলনা করে?

  • 9
  • 45
  • 100
  • 0

Early-exit flag-সহ bubble sort-কে n উপাদানের একটি আগে থেকে সাজানো অ্যারে দিলে কয়টি তুলনা হয়?

  • n − 1
  • n(n − 1)/2
  • log₂ n
  • 0

অ্যারে [4, 1, 3, 2]-কে insertion sort দিয়ে সাজালে মোট কয়টি shift (এক ঘর ডানে সরানো) হয়?

  • 2
  • 3
  • 6
  • 4

Selection sort (ছোট থেকে বড়)-এর বাইরের লুপের i টি ধাপ শেষ হলে কোন বক্তব্যটি সবসময় সত্য?

  • প্রথম i টি ঘরে শুরুর প্রথম i টি উপাদান সাজানো ক্রমে আছে
  • প্রথম i টি ঘরে অ্যারের সবচেয়ে ছোট i টি উপাদান চূড়ান্ত ক্রমে আছে
  • শেষের i টি ঘরে সবচেয়ে বড় i টি উপাদান চূড়ান্ত ক্রমে আছে
  • ততক্ষণে ঠিক i টি swap হয়েছে

Shell sort-এর gap ধারার শেষ gap সবসময় 1 রাখা হয় কেন?

  • কারণ gap 1-এ সবচেয়ে কম তুলনা লাগে
  • কারণ এতে সর্টটি stable হয়
  • কারণ gap 1 ধাপটি সাধারণ insertion sort, যা ফল সাজানো হওয়ার নিশ্চয়তা দেয়
  • কারণ এতে worst case O(n log n) হয়

শব্দকোষ

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

মনে রাখুন

  1. সর্টের আউটপুট সাজানো আর ইনপুটের permutation — দুটিই যাচাই করুন। Stable = সমান key-এর আগের ক্রম অক্ষত; in-place = O(1) বাড়তি জায়গা।
  2. Bubble: swap = I; early-exit flag থাকলে best Θ(n), নইলে সবসময় Θ(n²). ডানের অংশ চূড়ান্ত।
  3. Selection: তুলনা সবসময় n(n − 1)/2, swap ≤ n − 1; stable নয়, adaptive নয়। বাঁয়ের অংশ চূড়ান্ত।
  4. Insertion: Θ(n + I), shift = I; stable, in-place, adaptive, online — ছোট ও প্রায়-সাজানো ডেটার রাজা, Timsort আর introsort-এর ভেতরে।
  5. Shell: বড় gap থেকে 1-এ নেমে insertion sort; শুধু পাশাপাশি swap করা সর্টের Ω(n²) গড়-দেয়াল ভাঙে, কিন্তু stable নয়।

পরের পর্বে: n² থেকে n log n-এ লাফ — merge sort. অ্যারেকে অর্ধেক করে প্রতিটি অর্ধেক সাজান, তারপর দুটি সাজানো অর্ধেক এক যাত্রায় জোড়া দিন। সাথে merge-এর invariant, recurrence দিয়ে বিশ্লেষণ আর inversion গোনার একটি দ্রুত কৌশল।

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

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