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

best, worst আর average case — সময় ও জায়গার বিশ্লেষণ

Best, Worst & Average Case — Analysing Time and Space

Taught in Bangla, this lesson defines best, worst and average case and why average case needs a probability distribution. It derives all three cases for linear search and insertion sort, uses inversions to explain the n²/4 average, contrasts expected time and amortized cost, and covers space complexity, recursion stack space, in-place algorithms, time-space trade-offs and benchmarking pitfalls.

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

  • best, worst আর average case-এর সংজ্ঞা — আর কেন average case-এ একটি সম্ভাবনা-বণ্টন লাগে
  • Linear search ও insertion sort-এর তিনটি case হাতে-কলমে গুনে বের করা, প্রমাণসহ
  • Inversion দিয়ে insertion sort-এর গড় খরচ ≈ n²/4 কেন, তা বোঝা
  • Average case, expected time আর amortized খরচের পার্থক্য
  • Space complexity, recursion-এর stack, in-place অ্যালগরিদম, সময়–জায়গার দরদাম আর সঠিকভাবে benchmark করা

১ · একই অ্যালগরিদম, ভিন্ন ইনপুট, ভিন্ন সময়

একটি পরীক্ষার হলের দৃশ্য ভাবুন। পরীক্ষা শেষে শিক্ষক ৬০টি খাতা রোল নম্বর অনুযায়ী সাজাবেন। তিনি একটি একটি করে খাতা হাতে নেন, আর আগের সাজানো স্তূপে সেটির ঠিক জায়গা খুঁজে ঢুকিয়ে দেন। এখন দুটি দিন কল্পনা করুন:

  • প্রথম দিন: ছাত্রছাত্রীরা রোল নম্বর অনুযায়ী লাইন ধরে খাতা জমা দিয়েছে। প্রতিটি নতুন খাতা স্তূপের একদম ওপরে যায় — একবার তাকালেই কাজ শেষ।
  • দ্বিতীয় দিন: খাতা জমা পড়েছে ঠিক উল্টো ক্রমে — ৬০ থেকে ১। এবার প্রতিটি নতুন খাতাকে পুরো স্তূপের নিচে নিয়ে যেতে হয়, সবগুলোর সাথে মিলিয়ে।

খাতার সংখ্যা দুই দিনই ৬০টি। শিক্ষকের পদ্ধতিও একই। অথচ খাটুনি একেবারে আলাদা। পর্ব ৪-এ আমরা চলার সময়কে T(n) লিখেছিলাম, যেন শুধু আকার n জানলেই সময় ঠিক হয়ে যায়। এই পর্বের মূল কথাটি হলো — অনেক অ্যালগরিদমের জন্য একই আকারের ভিন্ন ভিন্ন ইনপুটে খরচ ভিন্ন হয়। ৮টি ভিন্ন সংখ্যা সাজানোর ইনপুটই আছে 8! = 40320 রকম ক্রমে।

ঢাকার রাস্তায় উত্তরা থেকে মতিঝিল যেতে কত সময় লাগে? এক কথায় উত্তর নেই। শুক্রবার সকালে হয়তো ৩০ মিনিট, বৃষ্টির দিনে অফিস ছুটির সময় হয়তো আড়াই ঘণ্টা, আর সাধারণ দিনে গড়ে এক ঘণ্টার মতো। পথ একই, দূরত্ব একই — কিন্তু “রাস্তার অবস্থা” নামের ইনপুট বদলালেই সময় বদলে যায়। অ্যালগরিদমের বেলাতেও আমরা ঠিক এই তিনটি প্রশ্ন করি: সবচেয়ে ভালো দিনে, সবচেয়ে খারাপ দিনে, আর গড়ে।

প্রশ্নগুলো নিখুঁত করতে প্রথমে ঠিক করতে হয় কী গুনব। একে বলে cost model. পর্ব ২-এর RAM model-এ প্রতিটি সরল কাজের খরচ একক ধরা হয়। সর্টিং আর সার্চিংয়ে সাধারণত গোনা হয় দুটি উপাদানের তুলনা (key comparison) — কারণ বাকি কাজের পরিমাণ মোটামুটি তুলনার সংখ্যার সমানুপাতিক। ইনপুটের আকার (input size) n মানে array-তে উপাদানের সংখ্যা।

মূল সংজ্ঞা

ধরুন In হলো আকার n-এর সব ইনপুটের সেট, আর ইনপুট I-তে অ্যালগরিদমের খরচ T(I). তাহলে —

  • Worst case: Tworst(n) = max { T(I) : I ∈ In } — আকার n-এর সবচেয়ে খারাপ ইনপুটে খরচ।
  • Best case: Tbest(n) = min { T(I) : I ∈ In } — সবচেয়ে সুবিধাজনক ইনপুটে খরচ।
  • Average case: Tavg(n) = Σ P(I) · T(I) — ইনপুটগুলোর ওপর একটি probability distribution P ধরে নিয়ে খরচের expected value বা প্রত্যাশিত মান।

খেয়াল করুন: worst আর best case প্রতিটি n-এর জন্য একটি নির্দিষ্ট সংখ্যা — কোনো অনুমান লাগে না। Average case-এ কিন্তু একটি অনুমান ঢুকে পড়ে: কোন ইনপুট কত ঘন ঘন আসে।

Best case

সবচেয়ে সুবিধাজনক ইনপুট। শুনতে ভালো, কিন্তু একা প্রায় কিছুই বলে না — প্রায় যেকোনো অ্যালগরিদমকে কোনো এক বিশেষ ইনপুটে দ্রুত বানানো যায়।

“শুক্রবার সকালের রাস্তা”

Worst case

সবচেয়ে খারাপ ইনপুট। এটি একটি গ্যারান্টি — কোনো ইনপুটেই এর বেশি লাগবে না।

“বৃষ্টির দিনের অফিস ছুটি”

Average case

ইনপুটগুলো কোন নিয়মে আসে, তা ধরে নিয়ে গড় খরচ। অনুমান ঠিক হলে খুব কাজের, ভুল হলে বিভ্রান্তিকর।

“সাধারণ একটি দিন”

২ · Linear search — এক, n, আর (n+1)/2

সবচেয়ে সরল উদাহরণ দিয়ে শুরু করি। Linear search একটি array-র শুরু থেকে একটি একটি করে উপাদান দেখে, যতক্ষণ না লক্ষ্যটি (target) মেলে বা array শেষ হয়। ফোনবুকের নামগুলো যদি কোনো ক্রমে সাজানো না থাকে, একটি নাম খুঁজতে আপনিও ঠিক এভাবেই ওপর থেকে নিচে পড়বেন।

LINEAR-SEARCH(a[0..n-1], x)
    for i = 0 to n-1
        if a[i] == x          // একটি তুলনা
            return i
    return NOT-FOUND

এখানে খরচ = a[i] == x তুলনার সংখ্যা। নিচের array-তে ৮টি রোল নম্বর আছে। চারটি ভিন্ন লক্ষ্য খুঁজে দেখুন — প্রতিবার কোন কোন ঘরের সাথে তুলনা হলো, লাল দাগে দেখাবে:

42i = 0
17i = 1
8i = 2
93i = 3
25i = 4
61i = 5
4i = 6
70i = 7
তুলনার হিসাবn = 8
target = 42
  1. Best case — প্রথম ঘরেই। লক্ষ্য 42, আর সেটি আছে a[0]-তে। একটিমাত্র তুলনা করেই খোঁজা শেষ। n যত বড়ই হোক, best case-এ খরচ ১ — অর্থাৎ Θ(1).
  2. মাঝামাঝি কোথাও। লক্ষ্য 25, আছে i = 4-এ। আগের চারটি ঘরের সাথে মেলেনি, পঞ্চমটিতে মিলেছে — মোট ৫টি তুলনা। সাধারণভাবে, লক্ষ্য i নম্বর ঘরে থাকলে (0-based) তুলনা হয় i + 1টি।
  3. শেষ ঘরে। লক্ষ্য 70, আছে একদম শেষে। সব ৮টি ঘর দেখতে হলো — ৮টি তুলনা। লক্ষ্য তালিকায় থাকলে এটিই সবচেয়ে খারাপ অবস্থা।
  4. Worst case — লক্ষ্য নেই। লক্ষ্য 50 তালিকায় নেই। “নেই” নিশ্চিত হতে সব n = 8টি ঘরই দেখতে হয়। তাই worst case-এ খরচ n — অর্থাৎ Θ(n).
  5. Average case — একটি অনুমান লাগবে। ধরে নিই লক্ষ্য তালিকায় আছে, আর ৮টি ঘরের যেকোনোটিতে থাকার সম্ভাবনা সমান (প্রতিটি ১/৮)। তাহলে গড় তুলনা = (১+২+…+৮)/৮ = ৩৬/৮ = ৪.৫, যা ঠিক (n+1)/2. নিচে সাধারণ প্রমাণটি দেখুন।

গড়টি কেন (n+1)/2 — প্রমাণ

অনুমান: লক্ষ্যটি array-তে আছে, আর nটি অবস্থানের প্রতিটিতে থাকার সম্ভাবনা 1/n. লক্ষ্য k-তম অবস্থানে (১ থেকে গুনে) থাকলে তুলনা লাগে ঠিক kটি। তাহলে প্রত্যাশিত তুলনা —

E[C] = Σk=1..n k · (1/n) = (1/n) · n(n+1)/2 = (n+1)/2

মাঝখানে আমরা ব্যবহার করেছি 1 + 2 + … + n = n(n+1)/2 — গাউসের সেই বিখ্যাত যোগফল, যা পর্ব ৬-এ বিস্তারিত দেখবেন। আর লক্ষ্য তালিকায় না থাকলে প্রতিবারই nটি তুলনা লাগে। সব মিলিয়ে:

Caseকখন ঘটেতুলনাক্রম
Bestলক্ষ্য প্রথম ঘরে1Θ(1)
Worstলক্ষ্য শেষ ঘরে বা একেবারেই নেইnΘ(n)
Average (আছে, সমান সম্ভাবনা)লক্ষ্য যেকোনো ঘরে(n+1)/2Θ(n)
Average (নেই)লক্ষ্য তালিকার বাইরেnΘ(n)

লক্ষ করুন, গড়টি worst case-এর প্রায় অর্ধেক — কিন্তু বৃদ্ধির হার একই, Θ(n). পর্ব ৪-এর ভাষায়, ধ্রুবক ১/২ বৃদ্ধির হার বদলায় না। ১০ লাখ উপাদানে গড়ে ৫ লাখ তুলনা আর সবচেয়ে খারাপে ১০ লাখ — দুটোই লাখের ঘরে।

Average case-এর ফল পুরোপুরি অনুমানের ওপর দাঁড়িয়ে আছে। ধরুন একটি bKash-জাতীয় অ্যাপ সাম্প্রতিক লেনদেনগুলো তালিকার শুরুতে রাখে, আর ব্যবহারকারীরা সাধারণত গত কয়েকটি লেনদেনই খোঁজেন। তখন “সব জায়গায় সমান সম্ভাবনা” অনুমানটি ভুল, আর বাস্তবের গড় (n+1)/2-এর চেয়ে অনেক কম। উল্টোটাও হতে পারে — যদি কেউ ইচ্ছা করে সবসময় শেষের জিনিস খোঁজে। তাই average case বলার সময় সবসময় বলুন: কোন বণ্টন ধরে নিয়ে।

৩ · Insertion sort — সাজানো, উল্টো আর এলোমেলো ইনপুট

শুরুর সেই শিক্ষকের পদ্ধতিটিরই নাম insertion sort. তাস খেলার সময় হাতে নতুন কার্ড এলে আমরাও এটাই করি — ডান দিক থেকে বাঁয়ে তাকিয়ে কার্ডটিকে ঠিক জায়গায় ঢুকিয়ে দিই। পর্ব ২০-এ এই সর্টটি বিস্তারিত দেখবেন; এখানে আমরা শুধু এর খরচ গুনব, কারণ তিন case বোঝার জন্য এর চেয়ে ভালো উদাহরণ কম আছে।

INSERTION-SORT(a[0..n-1])
    for i = 1 to n-1
        key = a[i]
        j = i
        while j > 0 and a[j-1] > key     // প্রতিবার একটি তুলনা
            a[j] = a[j-1]                 // বড়টিকে এক ঘর ডানে সরাও
            j = j - 1
        a[j] = key

প্রতিটি পাস i-তে আমরা a[0..i-1] অংশটি আগে থেকেই সাজানো পাই (এটিই loop invariant — পর্ব ৩), আর key = a[i]-কে তার জায়গায় বসাই। খরচ = a[j-1] > key তুলনার সংখ্যা। একই আকারের (n = 5) দুটি ইনপুট পাশাপাশি চালিয়ে দেখুন — একটি আগে থেকেই সাজানো, অন্যটি পুরো উল্টো:

প্রতি পাসের পরে array আর তুলনা+k = এই পাসে k-টি তুলনা
sorted   start      [1 2 3 4 5]
  1. শুরু। ওপরের সারিতে সাজানো ইনপুট [1 2 3 4 5], নিচে উল্টো ইনপুট [5 4 3 2 1]. দুটোরই আকার ৫, দুটোতেই একই কোড চলবে। এখনো কোনো তুলনা হয়নি।
  2. পাস i = 1. সাজানো ইনপুটে key = 2, বাঁয়ের 1-এর সাথে একটি তুলনা — 1 > 2 মিথ্যা, তাই থামুন। উল্টো ইনপুটে key = 4, 5-এর সাথে তুলনা — 5 > 4 সত্য, 5 ডানে সরল, j হলো 0, লুপ শেষ। দুই দিকেই ১টি করে তুলনা।
  3. পাস i = 2. সাজানো দিকে আবার ১টি তুলনাতেই থামা। উল্টো দিকে key = 3-কে 5 আর 4 দুজনকেই পেরিয়ে একদম সামনে যেতে হলো — ২টি তুলনা। মোট: ২ বনাম ৩।
  4. পাস i = 3. সাজানো দিকে ১টি। উল্টো দিকে key = 2 তিনটি বড় সংখ্যাকেই পেরোল — ৩টি তুলনা। ব্যবধান বাড়ছে: মোট ৩ বনাম ৬।
  5. পাস i = 4. সাজানো দিকে আবার ১টি। উল্টো দিকে key = 1 চারজনকেই পেরিয়ে সামনে গেল — ৪টি তুলনা। দুটি array-ই এখন সাজানো, কিন্তু খরচ ৪ বনাম ১০।
  6. সাধারণ সূত্র। সাজানো ইনপুটে প্রতিটি পাসে ঠিক ১টি তুলনা, মোট n−1 — best case, Θ(n). উল্টো ইনপুটে পাস i-তে iটি তুলনা, মোট 1+2+…+(n−1) = n(n−1)/2 — worst case, Θ(n²). n = 5-এ ৪ আর ১০, ঠিক যেমন দেখলেন।

কেন এগুলোই best আর worst — যুক্তি

নিম্নসীমা: প্রতিটি পাস i ≥ 1-এ অন্তত একটি তুলনা হয় (j = i > 0, তাই while-এর শর্ত অন্তত একবার যাচাই হয়)। তাই যেকোনো ইনপুটে তুলনা ≥ n−1, আর সাজানো ইনপুটে ঠিক এটুকুই হয় — অর্থাৎ best case ঠিক n−1.

ঊর্ধ্বসীমা: পাস i-তে j সর্বোচ্চ i বার কমতে পারে, তাই তুলনা সর্বোচ্চ iটি। মোট ≤ Σi=1..n−1 i = n(n−1)/2. উল্টো ইনপুটে প্রতিটি key তার আগের সবার চেয়ে ছোট, তাই প্রতিটি পাসে ঠিক iটি তুলনা হয় — worst case ঠিক n(n−1)/2.

এলোমেলো ইনপুট — inversion-এর হিসাব

সাজানো আর উল্টো ইনপুট দুটি চরম প্রান্ত। মাঝখানের সব ইনপুটের খরচ মাপার জন্য একটি সুন্দর ধারণা আছে — inversion. একটি array-তে i < j কিন্তু a[i] > a[j] — এমন প্রতিটি জোড়া (i, j) একটি inversion. অর্থাৎ যে দুটি উপাদান একে অপরের “ভুল পাশে” আছে। [2 4 1 3]-এ inversion তিনটি: (2,1), (4,1), (4,3). সাজানো array-তে inversion ০, উল্টো array-তে সব জোড়াই inversion — n(n−1)/2টি।

মূল পর্যবেক্ষণ (প্রমাণসহ)

Insertion sort-এ প্রতিবার a[j] = a[j-1] সরানো (shift) ঠিক একটি inversion দূর করে। কারণ: সরানোর সময় a[j-1] > key, আর এরা পাশাপাশি অবস্থানে আছে। পাশাপাশি দুটি উল্টো উপাদানের জায়গা বদলালে শুধু ওই একটি জোড়ার ক্রম বদলায়, বাকি সব জোড়ার আপেক্ষিক ক্রম একই থাকে। শেষে array সাজানো, অর্থাৎ inversion ০। তাই মোট shift = ইনপুটের inversion সংখ্যা I.

প্রতিটি পাসে তুলনা = সেই পাসের shift + ১ (যে তুলনাটি থামায়), শুধু key একদম সামনে পৌঁছালে (j = 0) সেই শেষ তুলনাটি হয় না। তাই I ≤ তুলনা ≤ I + (n−1).

এখন average case. ধরে নিই ইনপুটটি nটি ভিন্ন সংখ্যার একটি সম্পূর্ণ এলোমেলো বিন্যাস — n!টি বিন্যাসের প্রতিটি সমান সম্ভাব্য। যেকোনো একটি জোড়া (i, j) নিন। এর দুটি উপাদান সঠিক ক্রমে থাকার সম্ভাবনা আর উল্টো ক্রমে থাকার সম্ভাবনা সমান — প্রতিটি ১/২ (প্রতিটি বিন্যাসে ওই দুটির জায়গা বদলে আরেকটি বিন্যাস পাওয়া যায়, এভাবে জোড়ায় জোড়ায় মেলে)। জোড়া আছে n(n−1)/2টি। প্রত্যাশার যোগফলের নিয়ম (linearity of expectation) অনুযায়ী —

E[I] = n(n−1)/2 · 1/2 = n(n−1)/4
তাই গড় তুলনা ≈ n²/4 — worst case-এর প্রায় অর্ধেক, কিন্তু তবুও Θ(n²)

সবচেয়ে ছোট উদাহরণে নিজের চোখে মিলিয়ে নিন। {1, 2, 3}-এর ৬টি বিন্যাসের প্রতিটিতে insertion sort চালিয়ে তুলনা আর inversion গুনুন:

[1 2 3]inv 0
[1 3 2]inv 1
[2 1 3]inv 1
[2 3 1]inv 2
[3 1 2]inv 2
[3 2 1]inv 3
এই বিন্যাসের হিসাবn = 3, মোট 3! = 6টি
[1 2 3]  inversions: none (0)
  1. [1 2 3] — সাজানো। কোনো inversion নেই। দুই পাসেই ১টি করে তুলনা, মোট ২ = n−1. এটিই best case.
  2. [1 3 2]. একটি inversion: (3,2). পাস ২-এ key = 2 প্রথমে 3-এর সাথে তুলনা করে (সরায়), তারপর 1-এর সাথে (থামে) — ২টি। মোট ৩। চলমান যোগফল ৫।
  3. [2 1 3]. একটি inversion: (2,1). পাস ১-এ key = 1 একটি তুলনায় 2-কে সরিয়ে সামনে চলে যায় — j = 0, তাই থামানোর তুলনা লাগে না। পাস ২-এ key = 3 একটিতেই থামে। মোট ২, চলমান যোগফল ৭। খেয়াল করুন: inversion ১ হলেও তুলনা ২ — সূত্র I + (n−1) − (সামনে পৌঁছানো key-এর সংখ্যা) = ১ + ২ − ১ = ২।
  4. [2 3 1]. দুটি inversion: (2,1) আর (3,1). পাস ২-এ key = 1 দুজনকেই পেরিয়ে সামনে যায় — ২টি তুলনা। মোট ৩, চলমান যোগফল ১০।
  5. [3 1 2]. দুটি inversion: (3,1), (3,2). পাস ১-এ ১টি তুলনা (1 সামনে যায়), পাস ২-এ key = 2 আগে 3-কে সরায়, তারপর 1-এ থামে — ২টি। মোট ৩, চলমান যোগফল ১৩।
  6. [3 2 1] — উল্টো। তিনটি জোড়াই inversion. মোট ৩ তুলনা = n(n−1)/2 — worst case. চলমান যোগফল ১৬।
  7. গড়। সব বিন্যাস সমান সম্ভাব্য ধরলে গড় তুলনা ১৬/৬ ≈ ২.৬৭, আর গড় inversion ৯/৬ = ১.৫ — ঠিক n(n−1)/4. ছোট n-এ “+ (n−1)” অংশটি বেশ চোখে পড়ে; বড় n-এ n²/4 অংশটিই প্রধান হয়ে ওঠে।

নিজে চালিয়ে দেখুন — n = 8 আর n = 16

নিচের প্রোগ্রামটি insertion sort-এর প্রতিটি তুলনা গোনে, আর তিনটি ইনপুটে চালায়: সাজানো 1..n, উল্টো n..1, আর একটি নির্দিষ্ট এলোমেলো বিন্যাস (প্রতিবার একই ফল পেতে এটি কোডেই লিখে দেওয়া)। পাশে তিনটি সূত্রের মানও ছাপে। আপনার ভাষার ট্যাবটি বেছে নিন:

#include <iostream>
#include <numeric>
#include <vector>
using namespace std;
// insertion sort on a copy; returns the number of a[j-1] > key comparisons
long long countComparisons(vector<int> a) {
    long long cmp = 0;
    for (size_t i = 1; i < a.size(); ++i) {
        int key = a[i];
        size_t j = i;
        while (j > 0) {
            ++cmp;                          // one comparison
            if (a[j - 1] <= key) break;
            a[j] = a[j - 1];                // shift right
            --j;
        }
        a[j] = key;
    }
    return cmp;
}
int main() {
    vector<vector<int>> shuffled = {{5, 2, 7, 1, 8, 3, 6, 4},
        {11, 3, 14, 7, 1, 9, 16, 5, 12, 2, 8, 15, 6, 10, 4, 13}};
    for (const auto& sh : shuffled) {
        int n = (int)sh.size();
        vector<int> up(n);
        iota(up.begin(), up.end(), 1);              // 1, 2, ..., n
        vector<int> down(up.rbegin(), up.rend());   // n, ..., 2, 1
        cout << "n=" << n << "  sorted=" << countComparisons(up)
             << "  reversed=" << countComparisons(down)
             << "  shuffled=" << countComparisons(sh) << '\n';
        cout << "     n-1=" << n - 1 << "  n(n-1)/2=" << n * (n - 1) / 2
             << "  n(n-1)/4=" << n * (n - 1) / 4 << '\n';
    }
}

আউটপুট: n=8 sorted=7 reversed=28 shuffled=18, তার নিচে n-1=7 n(n-1)/2=28 n(n-1)/4=14; তারপর n=16 sorted=15 reversed=120 shuffled=70 আর n-1=15 n(n-1)/2=120 n(n-1)/4=60. countComparisons array-টি মান হিসেবে (copy) নেয়, তাই মূল ইনপুট বদলায় না।

def count_comparisons(a):
    """Insertion sort on a copy; count a[j-1] > key comparisons."""
    a = list(a)
    cmp = 0
    for i in range(1, len(a)):
        key, j = a[i], i
        while j > 0:
            cmp += 1                     # one comparison
            if a[j - 1] <= key:
                break
            a[j] = a[j - 1]              # shift right
            j -= 1
        a[j] = key
    return cmp


shuffled = [[5, 2, 7, 1, 8, 3, 6, 4],
            [11, 3, 14, 7, 1, 9, 16, 5, 12, 2, 8, 15, 6, 10, 4, 13]]
for sh in shuffled:
    n = len(sh)
    up = list(range(1, n + 1))          # 1, 2, ..., n
    down = up[::-1]                     # n, ..., 2, 1
    print(f"n={n}  sorted={count_comparisons(up)}"
          f"  reversed={count_comparisons(down)}"
          f"  shuffled={count_comparisons(sh)}")
    print(f"     n-1={n - 1}  n(n-1)/2={n * (n - 1) // 2}"
          f"  n(n-1)/4={n * (n - 1) // 4}")

আউটপুট C++ সংস্করণের সাথে হুবহু এক: n=8 sorted=7 reversed=28 shuffled=18 / n-1=7 n(n-1)/2=28 n(n-1)/4=14, তারপর n=16 sorted=15 reversed=120 shuffled=70 / n-1=15 n(n-1)/2=120 n(n-1)/4=60. list(a) একটি নতুন কপি বানায়, তাই ডাকার পরেও মূল তালিকা অপরিবর্তিত থাকে।

ফলটি পড়ুন: সাজানো ইনপুটে তুলনা ঠিক n−1 (৭ আর ১৫), উল্টো ইনপুটে ঠিক n(n−1)/2 (২৮ আর ১২০) — প্রমাণের সাথে হুবহু মিলেছে। এলোমেলো ইনপুট দুটিতে inversion যথাক্রমে ১৩ আর ৫৭; তুলনা ১৮ আর ৭০, যা I + (n−1) − 2 — কারণ দুটি ইনপুটেই ঠিক দুটি key (৮-এর ইনপুটে 2 আর 1; ১৬-এর ইনপুটে 3 আর 1) একদম সামনে পৌঁছায়। খেয়াল করুন n দ্বিগুণ হলে best case প্রায় দ্বিগুণ (৭ → ১৫), কিন্তু worst case প্রায় চার গুণ (২৮ → ১২০) — Θ(n) আর Θ(n²)-এর পার্থক্য সংখ্যাতেই দেখা যাচ্ছে।

একটি এলোমেলো ইনপুট কেবল একটি নমুনা। আসল average case হলো সব n! বিন্যাসের গড়। ছোট n-এর জন্য কম্পিউটার সেটি সরাসরি গুনে ফেলতে পারে। নিচের ব্রাউজার-টুলটি n = 2 থেকে 8 পর্যন্ত প্রতিটি বিন্যাসে insertion sort চালিয়ে best, worst আর সঠিক গড় বের করে (৮-এর জন্য ৪০,৩২০টি বিন্যাস!):

// insertion sort: n-এর সব বিন্যাসে তুলনা গুনে best, worst, average
function countCmp(arr) {
  const a = arr.slice(); let c = 0;
  for (let i = 1; i < a.length; i++) {
    const key = a[i]; let j = i;
    while (j > 0) { c++; if (a[j - 1] <= key) break; a[j] = a[j - 1]; j--; }
    a[j] = key;
  }
  return c;
}
function eachPermutation(n, visit) {     // Heap's algorithm, iterative
  const a = Array.from({ length: n }, (_, i) => i + 1), c = new Array(n).fill(0);
  visit(a);
  let i = 1;
  while (i < n) {
    if (c[i] < i) {
      const k = i % 2 === 0 ? 0 : c[i];
      [a[k], a[i]] = [a[i], a[k]];
      visit(a); c[i]++; i = 1;
    } else { c[i] = 0; i++; }
  }
}
for (let n = 2; n <= 8; n++) {
  let lo = Infinity, hi = 0, sum = 0, cnt = 0;
  eachPermutation(n, p => { const x = countCmp(p); lo = Math.min(lo, x); hi = Math.max(hi, x); sum += x; cnt++; });
  console.log(`n=${n} perms=${cnt} best=${lo} worst=${hi} average=${(sum / cnt).toFixed(2)} n(n-1)/4=${(n * (n - 1) / 4).toFixed(2)}`);
}

আউটপুটের শেষ লাইন: n=8 perms=40320 best=7 worst=28 average=19.28 n(n-1)/4=14.00. প্রতিটি লাইনে best = n−1, worst = n(n−1)/2. n = 3-এর লাইনে average = 2.67 — ঠিক যা ওপরের stepper-এ হাতে গুনেছিলেন। গড়টি n(n−1)/4-এর চেয়ে একটু বেশি, কারণ তুলনা = inversion + প্রায় nটি “থামানোর” তুলনা; বড় n-এ এই বাড়তি অংশ n²/4-এর তুলনায় নগণ্য। eachPermutation হলো Heap's algorithm (B. R. Heap, ১৯৬৩) — প্রতিবার একটিমাত্র জোড়া অদলবদল করে পরের বিন্যাস বানায়। লুপের সীমা n <= 8 বদলে ৯ করে দেখুন — ৩,৬২,৮৮০টি বিন্যাস, একটু সময় নেবে।

আরও গভীরে: গড় তুলনার সঠিক সূত্র হলো n(n−1)/4 + n − Hn, যেখানে Hn = 1 + 1/2 + … + 1/n ≈ ln n (harmonic সংখ্যা — পর্ব ৬)। “−Hn” আসে সেই key-গুলো থেকে, যারা একদম সামনে পৌঁছায় বলে শেষ তুলনাটি লাগে না — একটি এলোমেলো বিন্যাসে এমন key গড়ে Hn − 1টি। n = 8-এ সূত্রটি দেয় ১৪ + ৮ − ২.৭১৮ ≈ ১৯.২৮, ওপরের আউটপুটের সাথে হুবহু। আর যেহেতু খরচ Θ(n + I), প্রায়-সাজানো ডেটায় (যেখানে I ছোট) insertion sort প্রায় linear — একে বলে adaptive সর্টিং।

৪ · কোন case জানাবেন — worst case কেন সাধারণত

কেউ যখন বলেন “এই অ্যালগরিদম O(n log n)”, আর কিছু না বলেন, তখন প্রায় সবসময় তিনি worst case বোঝান। এর পেছনে কয়েকটি শক্ত কারণ আছে:

  1. গ্যারান্টি: worst case একটি প্রতিশ্রুতি — “যেকোনো ইনপুটে এর বেশি লাগবে না।” পরীক্ষার আগের রাতে ঢাকার জ্যামে বাসা থেকে কেন্দ্রে যাওয়ার সময় আপনি গড় দেখে নয়, সবচেয়ে খারাপ অবস্থা ধরে বের হন।
  2. অনুমানের দরকার নেই: average case বলতে ইনপুটের বণ্টন জানতে হয়। বাস্তবের ডেটা প্রায়ই “সম্পূর্ণ এলোমেলো” নয় — প্রায়-সাজানো, অনেক পুনরাবৃত্তি, বা নির্দিষ্ট ধাঁচের।
  3. শত্রুভাবাপন্ন ইনপুট: ইন্টারনেটে খোলা সার্ভারে কেউ ইচ্ছা করে সবচেয়ে খারাপ ইনপুট পাঠাতে পারে — একে বলে adversarial input. তখন গড়ের হিসাব কোনো কাজে আসে না (ভাবনার প্রশ্ন ২ দেখুন)।
  4. সময়-সংবেদনশীল সিস্টেম: মেট্রোরেলের ব্রেক-নিয়ন্ত্রণ, হৃদযন্ত্রের pacemaker বা বিমানের সফটওয়্যারে “গড়ে দ্রুত” যথেষ্ট নয় — প্রতিবারই নির্দিষ্ট সময়ের মধ্যে কাজ শেষ হতে হবে।
  5. সাধারণত হিসাব সহজ: worst case বের করতে একটি খারাপ ইনপুট আর একটি ঊর্ধ্বসীমার যুক্তিই যথেষ্ট; average case-এ সম্ভাবনার হিসাব লাগে।

তবু average case গুরুত্বহীন নয়। Quick sort-এর worst case Θ(n²), অথচ বাস্তবে এটি প্রায়ই সবচেয়ে দ্রুত সর্টগুলোর একটি — কারণ গড়ে (এবং এলোমেলো pivot বেছে নিলে প্রত্যাশিতভাবে) এটি Θ(n log n) (পর্ব ২২)। Hash table-এ খোঁজার worst case Θ(n), কিন্তু ভালো hash function থাকলে গড়ে O(1) (পর্ব ৩২)। এসব জায়গায় average বা expected খরচই আসল গল্প বলে।

আর best case? একা এটি প্রায় অর্থহীন। যেকোনো সর্টিং অ্যালগরিদমের শুরুতে “array আগেই সাজানো কি না” যাচাই (n−1টি তুলনা) জুড়ে দিলেই best case Θ(n) হয়ে যায় — অ্যালগরিদম একটুও ভালো না হয়েই! তবে best case-এর একটি ব্যবহার আছে: insertion sort-এর মতো কোনো অ্যালগরিদম যদি স্বাভাবিকভাবেই প্রায়-সাজানো ডেটায় দ্রুত হয়, আর আপনার ডেটা সত্যিই প্রায়-সাজানো হয়, তাহলে সেটি কাজের তথ্য।

সবচেয়ে প্রচলিত বিভ্রান্তি: case বনাম O, Ω, Θ

অনেকে ভাবেন “best case মানে Ω, worst case মানে O.” এটি ভুল। দুটি সম্পূর্ণ আলাদা প্রশ্ন:

  • Case বলে কোন ইনপুটের খরচ দেখছি — প্রতিটি n-এর জন্য একটি ফাংশন পাই: Tbest(n), Tworst(n), Tavg(n).
  • Big-O, Ω, Θ (পর্ব ৪) বলে সেই ফাংশনটি কত দ্রুত বাড়ে — ওপর থেকে, নিচ থেকে, নাকি দুই দিক থেকেই আঁটসাঁট।

তাই প্রতিটি case-এর জন্যই O, Ω, Θ তিনটিই বলা যায়। Insertion sort-এর worst case Θ(n²) — অর্থাৎ worst case O(n²) এবং Ω(n²). Best case Θ(n).

দাবিসত্য?কারণ
“Insertion sort O(n²)”সত্যসব ইনপুটে খরচ ≤ worst case = n(n−1)/2
“Insertion sort Ω(n)”সত্যসব ইনপুটে খরচ ≥ best case = n−1
“Insertion sort Θ(n²)” (সব ইনপুটের জন্য)মিথ্যাসাজানো ইনপুটে খরচ মাত্র Θ(n)
“Insertion sort-এর worst case Θ(n²)”সত্যউল্টো ইনপুটে ঠিক n(n−1)/2
“Insertion sort-এর worst case Ω(n)”সত্য, কিন্তু দুর্বলঠিক, তবে আঁটসাঁট নয় — Ω(n²)-ও সত্য

পরীক্ষা ও ইন্টারভিউতে — এই ধরনের প্রশ্ন প্রায়ই আসে:

  • Linear search-এর average case কত, কোন অনুমানে? — লক্ষ্য তালিকায় আছে আর সব অবস্থান সমান সম্ভাব্য ধরলে (n+1)/2 তুলনা, অর্থাৎ Θ(n).
  • Insertion sort-এর best ও worst case ইনপুট কোনটি? — আগেই সাজানো (n−1 তুলনা) আর উল্টো সাজানো (n(n−1)/2 তুলনা)।
  • “Best case = Ω, worst case = O” — ঠিক? — না; case বলে কোন ইনপুট, O/Ω/Θ বলে বৃদ্ধির সীমা; প্রতিটি case-এর জন্যই তিনটি বলা যায়।
  • Array [2, 4, 1, 3]-এ insertion sort কতগুলো shift করবে? — inversion সংখ্যার সমান: ৩টি।
  • Recursive ফাংশনের space complexity কীভাবে বের করবেন? — সর্বোচ্চ recursion গভীরতা × প্রতিটি frame-এর জায়গা, সাথে অন্যান্য বাড়তি জায়গা।

৫ · আরও দুই রকম “গড়” — expected time আর amortized খরচ

“গড়ে দ্রুত” কথাটি অ্যালগরিদমের জগতে তিনটি আলাদা অর্থে ব্যবহৃত হয়। গুলিয়ে ফেললে পরীক্ষায় নম্বর কাটা যায়, আর ইন্টারভিউতে ভুল বার্তা যায়।

Average case বনাম expected time

Average case-এ এলোমেলোতা থাকে ইনপুটে — আমরা ধরে নিই ইনপুট কোনো একটি বণ্টন থেকে আসছে। কিন্তু একটি randomized algorithm নিজেই কয়েন ছোড়ে — যেমন quick sort-এ pivot হিসেবে এলোমেলো একটি উপাদান বেছে নেওয়া। তখন ইনপুট যা-ই হোক, অ্যালগরিদমের নিজের এলোমেলো সিদ্ধান্তের ওপর গড় করা খরচকে বলে expected running time.

ক্রিকেট ম্যাচের টসের কথা ভাবুন। আম্পায়ার নিজে কয়েন ছোড়েন — কোন দল কী চাইবে, তাতে কয়েনের ফল বদলায় না। Randomized অ্যালগরিদমও তেমনি: প্রতিপক্ষ (বা দুর্ভাগ্যজনক ইনপুট) আগে থেকে জানে না অ্যালগরিদম কোন pivot বাছবে। তাই এমন কোনো “খারাপ ইনপুট” নেই যা সবসময় তাকে ধীর করে দেবে।

এই পার্থক্যটি গুরুত্বপূর্ণ: random pivot-এর quick sort-এর expected time প্রতিটি ইনপুটের জন্য O(n log n) — সাজানো, উল্টো, যেকোনো ইনপুট। অন্যদিকে প্রথম উপাদানকে pivot ধরা quick sort-এর average case O(n log n) হলেও, সাজানো ইনপুটে সেটি নির্ভরযোগ্যভাবে Θ(n²). বিস্তারিত দেখবেন পর্ব ২২ (quick sort) আর পর্ব ৬১ (randomized অ্যালগরিদম)-এ।

Amortized খরচ — একটি সংক্ষিপ্ত পরিচয়

তৃতীয় অর্থটিতে কোনো সম্ভাবনাই নেই। Amortized analysis বলে: অনেকগুলো অপারেশনের একটি পুরো ক্রমের worst-case মোট খরচ কত, আর সেটিকে অপারেশনের সংখ্যা দিয়ে ভাগ করলে প্রতি অপারেশনে কত পড়ে।

সবচেয়ে পরিচিত উদাহরণ dynamic array — C++-এর std::vector বা Python-এর list. জায়গা ফুরিয়ে গেলে এটি দ্বিগুণ বড় নতুন জায়গা নেয়, আর পুরোনো সব উপাদান কপি করে। একটি push-এর খরচ ধরি “লেখা ১ + কপি করা উপাদানের সংখ্যা”, শুরুর capacity ১:

push নম্বরজায়গা ভরা ছিল?কপিএই push-এর খরচমোট খরচcapacity পরে
১না০১১১
২হ্যাঁ (১/১)১২৩২
৩হ্যাঁ (২/২)২৩৬৪
৪না০১৭৪
৫হ্যাঁ (৪/৪)৪৫১২৮
৬, ৭, ৮না০১ করে১৩, ১৪, ১৫৮

একটি একক push-এর worst case Θ(n) (৫ নম্বর push-এ ৪টি কপি)। কিন্তু ৮টি push-এর মোট খরচ ১৫ — গড়ে ২-এরও কম। সাধারণভাবে nটি push-এ মোট কপি 1 + 2 + 4 + … < 2n, আর লেখা nটি — মোট < 3n. তাই প্রতি push-এর amortized খরচ O(1) — এবং এটি একটি গ্যারান্টি, কোনো ভাগ্যের ব্যাপার নয়। বাস্তবে বৃদ্ধির গুণক সবসময় ২ নয় (C++-এর কোনো কোনো বাস্তবায়ন ১.৫ গুণ বাড়ায়), কিন্তু যেকোনো ধ্রুবক গুণে বাড়ালেই ফল O(1) amortized থাকে। বিস্তারিত পর্ব ১০ (dynamic array) আর পর্ব ৬০ (amortized analysis)-এ।

ধারণাকিসের ওপর গড়/সর্বোচ্চএলোমেলোতা কোথায়গ্যারান্টি
Worst caseসব ইনপুটের সর্বোচ্চকোথাও নেইপ্রতিটি ইনপুটে, প্রতিবার
Average caseইনপুটের বণ্টনের ওপর গড়ইনপুটে (ধরে নেওয়া)শুধু বণ্টন-অনুমান ঠিক হলে
Expected (randomized)অ্যালগরিদমের কয়েন-ছোড়ার ওপর গড়অ্যালগরিদমের ভেতরেপ্রতিটি ইনপুটে, গড়ে
Amortizedএকটি অপারেশন-ক্রমের মোট ÷ সংখ্যাকোথাও নেইপ্রতিটি ক্রমে, মোট হিসেবে

৬ · জায়গার হিসাব — input space, auxiliary space আর recursion-এর stack

সময়ের মতো জায়গাও একটি সম্পদ। ফোনের মেমরি, সার্ভারের RAM, বা কনটেস্টে ২৫৬ MB-এর সীমা — সবখানেই জায়গা সীমিত। Space complexity বলে অ্যালগরিদম চলার সময় সর্বোচ্চ কতটা মেমরি লাগে, n-এর ফাংশন হিসেবে।

এখানে দুটি অংশ আলাদা করে দেখা দরকার:

  • Input space: ইনপুট নিজেই যে জায়গা নেয় — nটি উপাদানের array-তে Θ(n). এটি অ্যালগরিদমের দোষ নয়, ইনপুট তো আসবেই।
  • Auxiliary space: ইনপুটের বাইরে অ্যালগরিদম বাড়তি যে জায়গা নেয় — অস্থায়ী array, ভ্যারিয়েবল, আর recursion-এর stack.

অ্যালগরিদম তুলনা করার সময় সাধারণত auxiliary space-ই বলা হয়, কারণ সেখানেই পার্থক্য। Linear search আর insertion sort দুটোরই auxiliary space Θ(1) — কয়েকটি ভ্যারিয়েবল (i, j, key) ছাড়া কিছু লাগে না। Merge sort (পর্ব ২১) একটি অস্থায়ী array নেয়, তাই Θ(n).

পরীক্ষার খাতা সাজানোর সময় শিক্ষক যদি টেবিলের ওপর শুধু হাতের স্তূপটুকু নিয়েই কাজ সারেন, তাহলে বাড়তি জায়গা লাগে না — এটি in-place পদ্ধতি। আর যদি খাতাগুলো দুই ভাগ করে পাশের আরেকটি টেবিলে নতুন সাজানো স্তূপ বানান, তাহলে খাতার সমান বাড়তি টেবিল-জায়গা লাগে। খাতা (input) দুই ক্ষেত্রেই একই; পার্থক্য বাড়তি টেবিলে।

Recursion-এর লুকানো খরচ: call stack

নতুনরা প্রায়ই বলেন, “আমার recursive ফাংশনে তো কোনো array নেই, তাই জায়গা O(1).” কিন্তু প্রতিটি ফাংশন-ডাক call stack-এ একটি frame রাখে — প্যারামিটার, লোকাল ভ্যারিয়েবল, ফেরার ঠিকানা। ডাকটি শেষ না হওয়া পর্যন্ত frame-টি থাকে। তাই recursion-এর auxiliary space অন্তত সর্বোচ্চ recursion depth × একটি frame-এর আকার। নিচে a = [3, 1, 4, 1]-এর যোগফল recursive-ভাবে বের করা দেখুন:

1// a = {3, 1, 4, 1}; call sum(a, 4)2int sum(const vector<int>& a, int n) {3    if (n == 0) return 0;4    return sum(a, n - 1) + a[n - 1];5}
call stackওপরেরটি এখন চলছে
sum(a, 4)   waiting
হিসাব
stack-এ frame
সর্বোচ্চ এ পর্যন্ত
  1. প্রথম ডাক। sum(a, 4) শুরু হলো। n ০ নয়, তাই লাইন ৪-এ সে sum(a, 3)-কে ডাকে — আর উত্তর না আসা পর্যন্ত অপেক্ষা করে। তার frame stack-এ থেকে যায়।
  2. দ্বিতীয় স্তর। sum(a, 3) আবার sum(a, 2)-কে ডাকে। এখন stack-এ ২টি frame, দুটোই অপেক্ষমাণ।
  3. তৃতীয় স্তর। sum(a, 2) ডাকে sum(a, 1)-কে। ৩টি frame.
  4. চতুর্থ স্তর। sum(a, 1) ডাকে sum(a, 0)-কে। ৪টি frame — কেউই এখনো একটি যোগও করেনি।
  5. Base case — সর্বোচ্চ গভীরতা। sum(a, 0) লাইন ৩-এ সরাসরি 0 ফেরত দেয়। এই মুহূর্তে stack-এ ৫টি frame — n + 1. এটিই সর্বোচ্চ গভীরতা।
  6. ফেরার পথ শুরু। sum(a, 0)-এর frame মুছে গেল। sum(a, 1) হিসাব করে 0 + a[0] = 0 + 3 = 3, তারপর ফেরত দেয়। stack-এ এখন ৪টি।
  7. আরও দুই ধাপ ফেরা। sum(a, 2) = 3 + a[1] = 3 + 1 = 4 ফেরত দেয়; sum(a, 3) = 4 + a[2] = 4 + 4 = 8 ফেরত দেয়। শুধু sum(a, 4) বাকি।
  8. শেষ। sum(a, 4) = 8 + a[3] = 8 + 1 = 9. Stack খালি। সর্বোচ্চ ৫টি frame একসাথে ছিল, তাই auxiliary space Θ(n). একই কাজ একটি সাধারণ for লুপে করলে জায়গা লাগত মাত্র Θ(1) — একটি total আর একটি i.

এই হিসাবটির বাস্তব পরিণতি আছে। Call stack-এর আকার সীমিত (অনেক সিস্টেমে ডিফল্ট কয়েক MB), আর Python ডিফল্টভাবে প্রায় ১০০০ স্তরের বেশি recursion করতে দেয় না। তাই ১০ লাখ উপাদানের array-তে ওপরের recursive যোগফল চালালে stack overflow হবে — C++-এ প্রোগ্রাম crash করবে, Python-এ RecursionError আসবে। কনটেস্টে ১ লাখ node-এর একটি লম্বা শিকলের মতো গ্রাফে recursive DFS চালালে ঠিক এই সমস্যাই হয় (পর্ব ৪১)। অন্যদিকে recursive binary search প্রতিবার অর্ধেক অংশে যায়, তাই তার গভীরতা মাত্র ⌈log₂(n+1)⌉ — জায়গা Θ(log n), যা কখনো সমস্যা করে না।

In-place অ্যালগরিদম

যে অ্যালগরিদম ইনপুটের জায়গাতেই কাজ সারে, আর বাড়তি জায়গা নেয় O(1) (অনেক বইয়ে O(log n) পর্যন্তও গ্রহণযোগ্য ধরা হয়), তাকে বলে in-place. Insertion sort, selection sort, heapsort in-place; সাধারণ merge sort নয়। Quick sort নিজের array-তেই ভাগাভাগি করে, কিন্তু recursion-এর stack লাগে — শুধু ছোট অংশে recursion করে বড় অংশটি লুপে সামলালে গভীরতা O(log n)-এ বাঁধা থাকে।

জায়গারও best, worst, average case থাকতে পারে। Insertion sort-এর জায়গা সব ইনপুটেই Θ(1), কিন্তু ইনপুটের ধরন অনুযায়ী quick sort-এর stack-এর গভীরতা বদলায়। সাধারণভাবে “space complexity” বললে worst-case auxiliary space বোঝানো হয়।

৭ · সময় বনাম জায়গা — দরদামের খেলা

প্রায়ই জায়গা খরচ করে সময় কেনা যায়, আবার সময় খরচ করে জায়গা বাঁচানো যায়। একে বলে time–space trade-off. আপনার ফোনের কনট্যাক্ট লিস্ট এর সহজ উদাহরণ: প্রতিটি নম্বর একবার সেভ করতে কিছু জায়গা যায়, কিন্তু বারবার মনে করার বা কাগজ খোঁজার সময় বাঁচে।

Lookup table

যে উত্তর বারবার লাগে, তা আগেই হিসাব করে একটি lookup table-এ রাখুন। ধরুন একটি ৩২-bit সংখ্যায় কয়টি 1-bit আছে গুনতে হবে। প্রতিটি bit দেখলে ৩২টি ধাপ। কিন্তু ০–২৫৫ পর্যন্ত প্রতিটি byte-এর উত্তর ২৫৬ ঘরের একটি টেবিলে রাখলে, ৪টি byte-এর জন্য ৪টি টেবিল-দেখা আর ৩টি যোগেই কাজ শেষ।

২৫৬ ঘর জায়গা → ৮ গুণ কম ধাপ

Memoization

একটি ফাংশন একই ইনপুটে বারবার ডাকা হলে প্রথমবারের ফল মনে রাখুন — এটিই memoization. শব্দটি ১৯৬৮ সালে Donald Michie-র দেওয়া। পর্ব ৫২-এ dynamic programming-এর ভিত্তি হিসেবে বিস্তারিত দেখবেন।

Θ(n) জায়গা → সূচকীয় থেকে linear সময়

উল্টো দিকে: আবার হিসাব

জায়গা কম থাকলে সংরক্ষণ না করে দরকারের সময় আবার হিসাব করুন। ছোট মেমরির যন্ত্রে (যেমন মাইক্রোকন্ট্রোলার) বা বিশাল ডেটায় এটিই একমাত্র পথ হতে পারে।

বেশি সময় → কম জায়গা

Fibonacci সংখ্যা দিয়ে দরদামটি স্পষ্ট দেখা যায়। সরল recursive fib(n) = fib(n−1) + fib(n−2) একই মান বারবার হিসাব করে — fib(30) বের করতে মোট ২৬,৯২,৫৩৭টি ফাংশন-ডাক হয়, সময় সূচকীয় (exponential). Memoization দিয়ে প্রতিটি fib(k) একবারই হিসাব হয় — ৩১টি মান, Θ(n) সময়, বিনিময়ে Θ(n) জায়গা (টেবিল আর recursion-এর stack). আর একটু বুদ্ধি খাটালে দেখা যায় শুধু শেষ দুটি মান মনে রাখলেই চলে — একটি লুপে Θ(n) সময় আর মাত্র Θ(1) জায়গা। একই সমস্যা, তিনটি ভিন্ন সময়–জায়গার মিশ্রণ।

পদ্ধতিসময়Auxiliary spacefib(30)-এ কাজ
সরল recursionসূচকীয় (Θ(φⁿ), φ ≈ ১.৬১৮)Θ(n) (stack)২৬,৯২,৫৩৭টি ডাক
MemoizationΘ(n)Θ(n) (টেবিল + stack)৩১টি মান একবার করে
দুটি ভ্যারিয়েবলের লুপΘ(n)Θ(1)২৯টি যোগ

কনটেস্টেও এই চিন্তা সবখানে: অনেক প্রশ্নের উত্তর দিতে হলে factorial বা prime-এর তালিকা একবার আগেই বানিয়ে রাখা (sieve — পর্ব ৬২), বা prefix sum-এর array রেখে প্রতিটি রেঞ্জ-যোগফল O(1)-এ দেওয়া (পর্ব ১৭)। প্রশ্ন সবসময় একই — মেমরির সীমার মধ্যে কতটা আগে থেকে রেখে দিলে সময়ের সীমা পার হওয়া যায়?

৮ · বাস্তবে মাপা — benchmark-এর ফাঁদ

তাত্ত্বিক বিশ্লেষণ বলে খরচ কীভাবে বাড়ে; কিন্তু “আমার মেশিনে ১০ লাখ উপাদানে কত মিলিসেকেন্ড” — তা জানতে মাপতেই হয়। এই মাপাকে বলে benchmarking. কাজটি সহজ শোনায়, কিন্তু ভুল করা আরও সহজ। প্রচলিত ফাঁদগুলো:

  • একবার মেপেই সিদ্ধান্ত: অপারেটিং সিস্টেম, অন্য প্রোগ্রাম আর CPU-র গতি-পরিবর্তনের কারণে প্রতিবার সময় একটু আলাদা আসে। কয়েকবার চালিয়ে মধ্যমা (median) বা সর্বনিম্ন মান নিন।
  • Compiler কাজটাই মুছে দিল: -O2-এ ফলাফল কোথাও ব্যবহার না হলে compiler পুরো লুপ বাদ দিতে পারে — সময় আসবে প্রায় শূন্য। ফলটি ছাপুন বা অন্যভাবে ব্যবহার করুন।
  • Debug build মাপা: optimization ছাড়া (-O0) মাপা সময় বাস্তবের প্রতিনিধিত্ব করে না।
  • প্রথম চালানো (warm-up): প্রথমবার ডেটা cache-এ থাকে না, মেমরি বরাদ্দ হয়; পরেরবার দ্রুত। আগে একবার “গরম” করে নিন।
  • I/O-সহ মাপা: ইনপুট পড়া বা আউটপুট ছাপার সময় প্রায়ই আসল অ্যালগরিদমের চেয়ে বেশি। মাপার অংশ থেকে I/O বাদ দিন।
  • খুব ছোট n: ১০টি উপাদানে সবকিছুই মাইক্রোসেকেন্ডে শেষ — বৃদ্ধির হার দেখা যায় না, ঘড়ির নিখুঁততার সীমাও এসে পড়ে।
  • শুধু একরকম ইনপুট: এই পর্বের মূল শিক্ষা! সাজানো ইনপুটে মেপে insertion sort-কে “linear” ভাবা, বা এলোমেলো ইনপুটে মেপে quick sort-এর worst case ভুলে যাওয়া — দুটোই ভুল সিদ্ধান্তে নিয়ে যায়।

সময় মাপার জন্য C++-এ std::chrono::steady_clock (যে ঘড়ি কখনো পেছনে যায় না) আর Python-এ time.perf_counter() ব্যবহার করুন। কাঠামোটি দেখতে এমন (এগুলো নিজের কম্পিউটারে চালানোর জন্য — সময় প্রতিবার আলাদা আসবে):

#include <chrono>
// ... countComparisons(...) আর ইনপুট a আগের মতো ...
auto t0 = std::chrono::steady_clock::now();
long long c = countComparisons(a);          // ফল ব্যবহার করতে হবে
auto t1 = std::chrono::steady_clock::now();
auto us = std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
std::cout << "n=" << a.size() << " cmp=" << c << " time=" << us << " us\n";
import time
# ... count_comparisons(...) আর ইনপুট a আগের মতো ...
t0 = time.perf_counter()
c = count_comparisons(a)
t1 = time.perf_counter()
print(f"n={len(a)} cmp={c} time={(t1 - t0) * 1e6:.0f} us")
# বারবার চালিয়ে সবচেয়ে ভালো মান নিতে: timeit মডিউল

একটি কার্যকর কৌশল হলো দ্বিগুণ-পরীক্ষা (doubling experiment): n, 2n, 4n-এ সময় মাপুন আর পরপর দুটির অনুপাত দেখুন। অনুপাত ২-এর কাছে হলে বৃদ্ধি linear, ৪-এর কাছে হলে quadratic, ২-এর একটু বেশি হলে সম্ভবত n log n. ওপরের প্রোগ্রামের তুলনা-সংখ্যাতেও এটি দেখা গেছে — উল্টো ইনপুটে ২৮ থেকে ১২০, প্রায় ৪ গুণ।

এই কোর্সের চালানো যায় এমন উদাহরণগুলো তাই ইচ্ছা করেই সময় নয়, গণনা ছাপে — তুলনা, ডাক বা ধাপের সংখ্যা। গণনা প্রতিবার, প্রতিটি মেশিনে একই আসে, আর সরাসরি তত্ত্বের সাথে মেলানো যায়। সময় মাপবেন নিজের কম্পিউটারে, ওপরের ফাঁদগুলো মাথায় রেখে।

৯ · AI-এর যুগে case analysis

AI-কে “এই কোডের time complexity কত?” জিজ্ঞেস করলে প্রায় সবসময় একটি আত্মবিশ্বাসী উত্তর আসে। সমস্যা হলো, উত্তরটি প্রায়ই অসম্পূর্ণ — কোন case, কোন অনুমান, কোন জায়গা, তা বাদ পড়ে যায়। কয়েকটি সাধারণ ভুল:

AI যা বলেসমস্যাযেভাবে ধরবেন
“Insertion sort O(n²)” — ব্যসbest case Θ(n) আর adaptive আচরণ বাদসাজানো ইনপুটে তুলনা গুনে দেখুন
“Quick sort O(n log n)”worst case Θ(n²) চেপে যাওয়া; average আর expected গুলিয়ে ফেলাpivot কীভাবে বাছা হয় দেখুন, সাজানো ইনপুটে চালান
“কোনো বাড়তি array নেই, তাই O(1) space”recursion-এর stack বাদসর্বোচ্চ recursion গভীরতা গুনুন
“গড়ে n/2 তুলনা”off-by-one: সঠিক মান (n+1)/2, আর অনুমানটি বলা হয়নিn = 1, 2, 3-এ হাতে মিলিয়ে নিন
সময় ছাপানো benchmark দিয়ে “প্রমাণ”এক রকম ইনপুট, একবার চালানো, debug buildকয়েক রকম ইনপুট আর আকারে, গণনাসহ মাপুন

AI-কে বিশ্লেষণ করতে বলার সময় কাঠামো নিজেই বেঁধে দিন — তাহলে সে case, অনুমান আর জায়গা এড়িয়ে যেতে পারবে না:

নিচের ফাংশনটির complexity বিশ্লেষণ করো (কোড এখানে পেস্ট করুন)। ১. Cost model কী ধরছ (কোন অপারেশন গুনছ) — আগে বলো। ২. Best, worst আর average case আলাদা করে বলো: প্রতিটির জন্য একটি নির্দিষ্ট ছোট ইনপুটের উদাহরণ (n = 4 বা 5) আর সেই ইনপুটে ঠিক কয়টি অপারেশন হয়। ৩. Average case-এর জন্য ইনপুটের কোন বণ্টন ধরে নিচ্ছ, স্পষ্ট করে লেখো। ৪. Auxiliary space বলো — recursion থাকলে সর্বোচ্চ গভীরতাসহ। ৫. যে কথার প্রমাণ দিতে পারছ না, সেখানে লিখে দাও "অনুমান".

AI-এর বিশ্লেষণ হাতে পেলে — যাচাইয়ের চেকলিস্ট

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

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

প্র ০১যে অ্যালগরিদমের worst case সূচকীয়, সেটি কীভাবে দশকের পর দশক বাস্তবে সবচেয়ে জনপ্রিয় থাকে? Worst case কি তাহলে সবসময় সঠিক মাপকাঠি?

সবচেয়ে বিখ্যাত উদাহরণ হলো linear programming সমাধানের simplex পদ্ধতি, যা George Dantzig ১৯৪০-এর দশকের শেষভাগে উদ্ভাবন করেন। কারখানার উৎপাদন-পরিকল্পনা, বিমানের ক্রু-সূচি, সরবরাহ-শৃঙ্খল — অগণিত বাস্তব সমস্যায় এটি দ্রুত কাজ করে। অথচ ১৯৭২ সালে Victor Klee আর George Minty এমন কিছু বিশেষ ইনপুট বানিয়ে দেখান, যেখানে simplex-এর (প্রচলিত pivot নিয়মে) ধাপের সংখ্যা মাত্রার সাথে সূচকীয় হারে বাড়ে। অর্থাৎ worst case-এর বিচারে এটি “খারাপ” অ্যালগরিদম।

এরপর ১৯৭৯ সালে Leonid Khachiyan-এর ellipsoid পদ্ধতি প্রমাণ করে যে linear programming polynomial সময়ে সমাধান করা যায় — তাত্ত্বিকভাবে বিশাল খবর। কিন্তু বাস্তবে ellipsoid পদ্ধতি simplex-এর চেয়ে সাধারণত অনেক ধীর ছিল। ১৯৮৪ সালে Narendra Karmarkar-এর interior-point পদ্ধতি এসে তত্ত্ব আর বাস্তব দুই দিকেই প্রতিযোগিতা করতে পারল, তবু simplex আজও ব্যাপকভাবে ব্যবহৃত।

তাহলে ব্যাখ্যা কী? ২০০১ সালে Daniel Spielman আর Shang-Hua Teng প্রস্তাব করেন smoothed analysis: worst-case ইনপুট নিন, তারপর তাতে সামান্য এলোমেলো “কাঁপুনি” (noise) যোগ করুন — তখন প্রত্যাশিত খরচ কত? তাঁরা দেখান simplex-এর smoothed খরচ polynomial. অর্থাৎ খারাপ ইনপুটগুলো এতই “ভঙ্গুর” যে বাস্তবের মাপজোখের সামান্য অনিশ্চয়তাতেই সেগুলো ভেঙে যায়। এই কাজের জন্য তাঁরা ২০০৮ সালে Gödel Prize পান।

শিক্ষাটি দুই দিকেই। Worst case একটি নিরাপদ গ্যারান্টি, আর অচেনা বা শত্রুভাবাপন্ন ইনপুটে সেটিই একমাত্র ভরসা। কিন্তু worst case যদি অতি বিরল, কৃত্রিম ইনপুট থেকে আসে, তাহলে সেটি বাস্তবের খরচ সম্পর্কে ভুল ধারণা দিতে পারে। ভালো প্রকৌশলী তাই একাধিক মাপকাঠি দেখেন — worst, average, smoothed, আর বাস্তব ডেটায় benchmark.

মূল উপলব্ধি: বিশ্লেষণের প্রতিটি মডেল একটি প্রশ্নের উত্তর দেয়। “কোনটি সঠিক মাপকাঠি?” না জিজ্ঞেস করে জিজ্ঞেস করুন “আমার ইনপুট কোথা থেকে আসে, আর ভুল হলে কত দাম দিতে হবে?”

প্র ০২Hash table গড়ে O(1) — তাহলে কীভাবে কেউ শুধু বিশেষ কিছু ইনপুট পাঠিয়ে একটি ওয়েব সার্ভার বসিয়ে দিতে পারে?

Hash table-এর “গড়ে O(1)” দাবিটির পেছনে একটি অনুমান লুকিয়ে আছে: চাবিগুলো (key) hash function-এর কাছে “এলোমেলো” আচরণ করবে, তাই বিভিন্ন ঘরে মোটামুটি সমানভাবে ছড়িয়ে পড়বে। কিন্তু hash function যদি নির্দিষ্ট আর সবার জানা হয়, তাহলে একজন আক্রমণকারী হিসাব করে এমন হাজার হাজার চাবি বানাতে পারে, যেগুলো সব একই ঘরে পড়ে। তখন প্রতিটি নতুন চাবি ঢোকাতে আগের সবগুলোর সাথে তুলনা লাগে — nটি চাবিতে মোট Θ(n²) কাজ।

২০০৩ সালে Scott Crosby আর Dan Wallach “algorithmic complexity attack” নামে এই ধরনের আক্রমণ বিস্তারিতভাবে দেখান — অর্থাৎ অ্যালগরিদমের worst case-কে অস্ত্র বানানো। ২০১১ সালের শেষে Chaos Communication Congress-এ Alexander Klink আর Julian Wälde দেখান যে PHP, Java, Python, Ruby-সহ বহু জনপ্রিয় ওয়েব প্ল্যাটফর্ম এই আক্রমণের ঝুঁকিতে আছে: একটি ওয়েব ফর্মে বিশেষভাবে বানানো প্যারামিটার-নাম পাঠালেই সার্ভারের CPU দীর্ঘ সময় আটকে থাকত। একে বলে hash flooding.

সমাধান এসেছে দুই দিক থেকে। প্রথমত, randomization: প্রোগ্রাম চালু হওয়ার সময় একটি গোপন এলোমেলো বীজ (seed) বেছে নেওয়া, যাতে আক্রমণকারী আগে থেকে জানতে না পারে কোন চাবি কোথায় পড়বে। Python 3.3 থেকে string hash-এর এলোমেলোকরণ ডিফল্টভাবে চালু, আর Python 3.4 থেকে SipHash নামের একটি keyed hash function ব্যবহৃত হয়। দ্বিতীয়ত, worst-case সীমা: Java 8-এর HashMap একটি ঘরে অনেক চাবি জমে গেলে সেই ঘরের তালিকাকে একটি balanced tree-তে বদলে ফেলে, ফলে সেই ঘরে খোঁজা O(log n)-এ বাঁধা থাকে।

এখানে এই পর্বের প্রায় সব ধারণা একসাথে দেখা যায়: average case যে বণ্টন-অনুমানের ওপর দাঁড়িয়ে, শত্রু ইচ্ছা করে সেটি ভেঙে দেয়; randomization সেই গড়কে ইনপুটের অনুমান থেকে অ্যালগরিদমের নিজের কয়েন-ছোড়ায় সরিয়ে আনে (expected time); আর worst-case সীমা একটি শেষ রক্ষাকবচ দেয়।

মূল উপলব্ধি: ইনপুট যখন অবিশ্বস্ত উৎস থেকে আসে, তখন “গড়ে দ্রুত” মানে “শত্রু চাইলে ধীর”। এমন জায়গায় হয় worst-case গ্যারান্টি, নয়তো অ্যালগরিদমের ভেতরে randomization লাগবে।

প্র ০৩Quick sort-এর worst case Θ(n²) — তাহলে C++-এর std::sort কীভাবে সব ইনপুটে O(n log n) প্রতিশ্রুতি দেয়?

Quick sort (Tony Hoare, ১৯৫৯–৬১) গড়ে খুব দ্রুত: cache-বান্ধব, in-place, ভেতরের লুপ ছোট। কিন্তু pivot বারবার খারাপ পড়লে — যেমন প্রথম উপাদানকে pivot ধরে সাজানো ইনপুট দিলে — প্রতিবার ভাগ হয় ০ আর n−1-এ, ফলে Θ(n²). “মাঝের তিনটির median” নেওয়ার মতো চালাক নিয়মও নিরাপদ নয়: ১৯৯৯ সালে Doug McIlroy “A Killer Adversary for Quicksort” নামের লেখায় দেখান, একটি প্রোগ্রাম তুলনার ফল চালাকি করে ঠিক করে যেকোনো নির্ধারিত (deterministic) pivot-নিয়মের quick sort-কে quadratic সময়ে ঠেলে দিতে পারে।

সমাধানের একটি পথ হলো randomization — এলোমেলো pivot নিলে প্রতিটি ইনপুটে expected time O(n log n) (পর্ব ২২, ৬১)। কিন্তু expected মানে তবুও খুব ছোট সম্ভাবনায় ধীর হওয়ার সুযোগ থাকে। ১৯৯৭ সালে David Musser প্রস্তাব করেন introsort: quick sort চালান, কিন্তু recursion-এর গভীরতা গুনতে থাকুন; গভীরতা 2·log n-এর মতো একটি সীমা পেরোলেই সেই অংশে heapsort-এ চলে যান, যার worst case O(n log n). আর একদম ছোট অংশে (যেমন ১৬টির কম উপাদান) insertion sort, কারণ সেখানে তার ছোট ধ্রুবকই জেতে।

ফলে গড়ে quick sort-এর গতি, worst case-এ heapsort-এর গ্যারান্টি। GCC-র libstdc++-সহ বহু C++ লাইব্রেরির std::sort introsort-ধাঁচের। C++11 মানদণ্ড থেকে std::sort-এর জন্য O(n log n) তুলনার সীমা বাধ্যতামূলক — এর আগের মানদণ্ডে কেবল গড়ের কথা বলা ছিল। পরে pattern-defeating quicksort (pdqsort)-এর মতো আরও উন্নত সংকর পদ্ধতি এসেছে; Go ভাষা ১.১৯ সংস্করণ থেকে তার সর্টিংয়ে pdqsort ব্যবহার করে।

মূল উপলব্ধি: বাস্তবের সেরা অ্যালগরিদম প্রায়ই একটি “বীমা করা” সংকর — গড়ে দ্রুত অংশটি সাধারণ কাজ করে, আর খারাপ লক্ষণ দেখা দিলেই worst-case-নিরাপদ অংশ দায়িত্ব নেয়। Case analysis জানলেই এমন নকশা বোঝা ও বানানো যায়।

প্র ০৪Insertion sort-এর worst case Θ(n²) — তবু Python আর C++-এর দ্রুততম সর্টের ভেতরে কেন এটি লুকিয়ে থাকে?

কারণ দুটি: ছোট ধ্রুবক আর adaptive আচরণ।

প্রথমত, asymptotic বিশ্লেষণ বড় n-এর কথা বলে। ১০–২০টি উপাদানে n²/4 আর n log n-এর পার্থক্য সামান্য, আর insertion sort-এর ভেতরের লুপ অত্যন্ত সরল: একটি তুলনা, একটি সরানো, পাশাপাশি মেমরিতে — CPU-র cache আর branch prediction-এর জন্য আদর্শ। Merge sort বা quick sort-এর recursion আর হিসাবের বাড়তি খরচ ছোট অংশে লাভের চেয়ে বেশি। তাই introsort ছোট অংশগুলো insertion sort-কে দিয়ে দেয়।

দ্বিতীয়ত, এই পর্বেই দেখেছেন insertion sort-এর খরচ Θ(n + I), যেখানে I হলো inversion সংখ্যা। বাস্তবের ডেটা প্রায়ই প্রায়-সাজানো — আগের দিনের সাজানো তালিকায় কয়েকটি নতুন এন্ট্রি, সময় অনুযায়ী আসা লগ, আংশিক আপডেট হওয়া র‍্যাংকিং। এমন ডেটায় I ছোট, আর insertion sort প্রায় linear.

Tim Peters ২০০২ সালে Python-এর জন্য Timsort লেখেন ঠিক এই পর্যবেক্ষণ মাথায় রেখে। Timsort ডেটার ভেতরে আগে থেকেই সাজানো টুকরো (run) খোঁজে; ছোট run-গুলোকে binary insertion sort দিয়ে একটি ন্যূনতম দৈর্ঘ্য (সাধারণত ৩২ থেকে ৬৪-এর মধ্যে) পর্যন্ত বাড়ায়, তারপর merge sort-এর মতো চালাকি করে জোড়া দেয়। সম্পূর্ণ সাজানো ইনপুটে Timsort n−1 তুলনাতেই শেষ — best case linear, worst case O(n log n). Java 7 থেকে object-এর array সাজাতে Java-ও Timsort ব্যবহার করে।

এখানে best case বিশ্লেষণের আসল মূল্য দেখা যায়: শুধু “best case কত” নয়, বরং “কোন ধরনের ইনপুটে অ্যালগরিদম দ্রুত, আর আমার ডেটা কি সেই ধরনের?” — এই প্রশ্নের উত্তরই নকশা ঠিক করে দেয়।

মূল উপলব্ধি: asymptotic ক্রম একটি গল্পের শুরু, শেষ নয়। ছোট ইনপুটে ধ্রুবক জেতে, আর ইনপুটের গঠন (যেমন inversion কম) জানলে “ধীর” অ্যালগরিদমও সেরা হয়ে উঠতে পারে।

প্র ০৫সময়–জায়গার দরদাম কি শুধু প্রোগ্রামারের কৌশল? পাসওয়ার্ড চুরি আর তা ঠেকানোর লড়াইয়ে এটি কীভাবে কাজে লাগে?

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

১৯৮০ সালে Martin Hellman “A Cryptanalytic Time-Memory Trade-Off” প্রবন্ধে দেখান মাঝামাঝি একটি পথ: hash-এর শিকল বানিয়ে শুধু প্রতিটি শিকলের শুরু আর শেষ জমা রাখা; খোঁজার সময় কিছু হিসাব আবার করতে হয়, কিন্তু জায়গা অনেক কম লাগে। ২০০৩ সালে Philippe Oechslin এর উন্নত রূপ rainbow table প্রস্তাব করেন, যা একসময় পুরোনো Windows-এর দুর্বল LM hash-এর মতো পাসওয়ার্ড-hash ভাঙতে ব্যাপকভাবে ব্যবহৃত হয়েছে।

প্রতিরক্ষাও একই দরদামের ভাষায় লেখা। Salt — প্রতিটি ব্যবহারকারীর জন্য আলাদা এলোমেলো মান যোগ করে hash করা — আগে থেকে বানানো টেবিলকে অকেজো করে দেয়, কারণ প্রতিটি salt-এর জন্য আলাদা টেবিল লাগবে। তারপর ইচ্ছা করে hash-কে ধীর করা: ১৯৯৯ সালে Niels Provos আর David Mazières-এর bcrypt সময়-খরচ বাড়ানোর একটি সমন্বয়যোগ্য মাত্রা রাখে। ২০০৯ সালে Colin Percival-এর scrypt আরও এক ধাপ এগিয়ে জায়গা-খরচও বাধ্যতামূলক করে (memory-hard), যাতে প্রচুর সমান্তরাল বিশেষ হার্ডওয়্যার দিয়েও সস্তায় আক্রমণ চালানো না যায়। ২০১৫ সালে Password Hashing Competition জেতে Argon2, যা একই চিন্তার আধুনিক রূপ।

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

মূল উপলব্ধি: সময়–জায়গার বিশ্লেষণ শুধু প্রোগ্রাম দ্রুত করার কৌশল নয়; নিরাপত্তায় এটি আক্রমণ আর প্রতিরক্ষা দুই পক্ষেরই অস্ত্র। কে কোন সম্পদে সস্তা, তা বুঝলেই কৌশল বোঝা যায়।

অনুশীলন

খাতা-কলম নিয়ে বসুন — হাতে গুনে মেলানোই এই পর্বের আসল দক্ষতা।

  1. হাতে trace করুন: [3, 1, 4, 2] array-তে insertion sort চালান। প্রতিটি পাসে কয়টি তুলনা হলো, আর পাসের পরে array কেমন দাঁড়াল, লিখুন। মোট তুলনা কত? Array-টির inversion কয়টি? সূত্র I + (n−1) − (সামনে পৌঁছানো key) দিয়ে মিলিয়ে নিন।

    পাস i = 1: key = 1; 3 > 1, সরান; j = 0 — ১টি তুলনা। Array: [1, 3, 4, 2].

    পাস i = 2: key = 4; 3 > 4 মিথ্যা — ১টি তুলনা। Array অপরিবর্তিত: [1, 3, 4, 2].

    পাস i = 3: key = 2; 4 > 2 সরান, 3 > 2 সরান, 1 > 2 মিথ্যা, থামুন — ৩টি তুলনা। Array: [1, 2, 3, 4].

    মোট তুলনা = ১ + ১ + ৩ = ৫. Inversion: (3,1), (3,2), (4,2) — ৩টি, আর shift-ও ঠিক ৩টি। সামনে পৌঁছানো key একটি (1). সূত্র: ৩ + ৩ − ১ = ৫ — মিলেছে।

  2. অনুমান বদলালে গড় বদলায়: n উপাদানের array-তে linear search. লক্ষ্যটি তালিকায় থাকার সম্ভাবনা p, আর থাকলে সব অবস্থান সমান সম্ভাব্য। গড় তুলনার সূত্র বের করুন। n = 100, p = 0.5 হলে মান কত?

    দুটি ঘটনা আলাদা করে ভাবুন। তালিকায় থাকলে (সম্ভাবনা p) গড় তুলনা (n+1)/2; না থাকলে (সম্ভাবনা 1−p) ঠিক nটি। প্রত্যাশার নিয়মে:

    E[C] = p · (n+1)/2 + (1−p) · n

    n = 100, p = 0.5: ০.৫ × ৫০.৫ + ০.৫ × ১০০ = ২৫.২৫ + ৫০ = ৭৫.২৫. খেয়াল করুন, p = 1 হলে সূত্রটি (n+1)/2, আর p = 0 হলে n — দুই প্রান্তেই আগের ফল ফিরে আসে। সব ক্ষেত্রে ক্রম Θ(n).

  3. কোড লিখুন: একটি ফাংশন লিখুন যা একটি array-র inversion সংখ্যা O(n²) সময়ে গোনে (সব জোড়া দেখে), আর আরেকটি ফাংশন যা গোনে কয়টি উপাদান তার আগের সবার চেয়ে ছোট (প্রথমটি বাদে)। তারপর এই পর্বের দুটি এলোমেলো ইনপুটে যাচাই করুন যে insertion sort-এর তুলনা = I + (n−1) − m.
    long long inversions(const vector<int>& a) {
        long long inv = 0;
        for (size_t i = 0; i < a.size(); ++i)
            for (size_t j = i + 1; j < a.size(); ++j)
                if (a[i] > a[j]) ++inv;
        return inv;
    }
    int newMinima(const vector<int>& a) {        // keys that reach the front
        int m = 0, best = a[0];
        for (size_t i = 1; i < a.size(); ++i)
            if (a[i] < best) { best = a[i]; ++m; }
        return m;
    }
    // check: countComparisons(a) == inversions(a) + (n - 1) - newMinima(a)

    ৮-এর ইনপুট {5,2,7,1,8,3,6,4}: I = 13, m = 2 (2 আর 1), তাই ১৩ + ৭ − ২ = ১৮ ✓. ১৬-এর ইনপুট: I = 57, m = 2 (3 আর 1), তাই ৫৭ + ১৫ − ২ = ৭০ ✓. ভিন্ন সংখ্যার ক্ষেত্রে সূত্রটি সবসময় খাটে; সমান সংখ্যা থাকলে “ছোট” মানে কঠোরভাবে ছোট (<) ধরতে হবে, কারণ কোডে a[j-1] <= key হলে থামে।

  4. জায়গা গুনুন: প্রতিটির auxiliary space বলুন — (ক) iterative binary search, (খ) recursive binary search, (গ) একটি array-র উল্টো কপি বানিয়ে ফেরত দেওয়া, (ঘ) memoization-সহ recursive fib(n).

    (ক) Θ(1) — শুধু lo, hi, mid.

    (খ) Θ(log n) — প্রতিটি ডাকে অংশ অর্ধেক হয়, তাই গভীরতা প্রায় log₂ n, প্রতিটি frame ধ্রুবক আকারের। (কিছু compiler tail call মুছে দিতে পারে, কিন্তু ভাষার নিয়ম তা নিশ্চিত করে না — Python কখনোই করে না।)

    (গ) Θ(n) — নতুন array-টিই n ঘরের। (ইনপুটের জায়গায় উল্টালে, দুই প্রান্ত থেকে অদলবদল করে, Θ(1) — in-place.)

    (ঘ) Θ(n) — n+1 ঘরের memo টেবিল, আর প্রথমবার নিচে নামার সময় recursion-এর গভীরতা প্রায় n. দুটি যোগ করেও Θ(n).

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

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

ক্রমএকটি অ্যালগরিদমের average case বিশ্লেষণের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. কোন অপারেশন গুনবেন (cost model) ঠিক করা — যেমন তুলনা
  2. আকার n-এর ইনপুটগুলো চিহ্নিত করা
  3. ইনপুটের ওপর একটি সম্ভাবনা-বণ্টন ধরে নেওয়া — যেমন সব বিন্যাস সমান সম্ভাব্য
  4. প্রত্যাশিত মান Σ P(I)·T(I) হিসাব করা
  5. ফলকে সরল করে Θ-ক্রমে প্রকাশ করা

n উপাদানের একটি array-তে linear search করা হচ্ছে। লক্ষ্যটি array-তে আছে, আর প্রতিটি অবস্থানে থাকার সম্ভাবনা সমান। গড়ে কয়টি তুলনা লাগে?

  • n/2
  • (n+1)/2
  • n
  • log₂ n

আগে থেকেই ছোট থেকে বড় ক্রমে সাজানো nটি ভিন্ন সংখ্যায় insertion sort মোট কয়টি তুলনা করে?

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

Array [4, 3, 2, 1]-এ কয়টি inversion আছে (অর্থাৎ i < j কিন্তু a[i] > a[j], এমন জোড়া)?

  • ৩
  • ৪
  • ১২
  • ৬

Randomized অ্যালগরিদমের expected running time আর একটি অ্যালগরিদমের average-case running time-এর মূল পার্থক্য কী?

  • কোনো পার্থক্য নেই, দুটি একই জিনিসের দুই নাম
  • Expected time হলো worst case, আর average case হলো best case
  • Average case ইনপুটের বণ্টন ধরে নেয়; expected time প্রতিটি ইনপুটে অ্যালগরিদমের নিজের এলোমেলো পছন্দের ওপর গড় করে
  • Expected time অপারেশনের ক্রমের ওপর গড়, সম্ভাবনা ছাড়াই

একটি recursive ফাংশন n উপাদানের array-র যোগফল বের করে — প্রতিবার n−1 আকারে নিজেকে ডাকে, কোনো বাড়তি array বানায় না। এর auxiliary space কত?

  • Θ(1)
  • Θ(n)
  • Θ(log n)
  • Θ(n²)

Insertion sort সম্পর্কে নিচের কোন দাবিটি সঠিক?

  • সব ইনপুটে এর চলার সময় Θ(n²)
  • Best case মানেই Ω, আর worst case মানেই O
  • এর worst case Θ(n²) আর best case Θ(n)
  • এর average case আর worst case-এ তুলনার সংখ্যা সবসময় সমান

শব্দকোষ

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

মনে রাখুন

  1. একই আকারের ভিন্ন ইনপুটে খরচ ভিন্ন হতে পারে — তাই best, worst আর average case আলাদা করে বলতে হয়; আগে ঠিক করুন কী গুনছেন।
  2. Linear search: ১, n, আর (আছে ও সমান সম্ভাবনা ধরে) (n+1)/2 তুলনা। Insertion sort: n−1, n(n−1)/2, আর গড়ে ≈ n²/4 — খরচ Θ(n + inversion).
  3. Worst case গ্যারান্টি দেয়; average বণ্টন-অনুমানের ওপর দাঁড়ায়; expected আসে অ্যালগরিদমের নিজের কয়েন থেকে; amortized হলো ক্রমের মোট ÷ সংখ্যা।
  4. জায়গায় auxiliary space গুনুন — recursion-এর গভীরতাও জায়গা; in-place মানে O(1) বাড়তি জায়গা।
  5. জায়গা দিয়ে সময় কেনা যায় (lookup table, memoization), উল্টোটাও; মাপার সময় benchmark-এর ফাঁদ এড়ান, আর যাচাইয়ের জন্য গণনা ছাপুন।

পরের পর্বে: এই পর্বে বারবার 1+2+…+n, harmonic সংখ্যা আর log দেখলেন। পর্ব ৬-এ এই গণিতগুলো গুছিয়ে শিখব — log-এর নিয়ম, যোগফলের সূত্র আর ধারা, যা ছাড়া কোনো বিশ্লেষণ সম্পূর্ণ হয় না।

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

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