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

Amortized analysis — aggregate, accounting আর potential

Amortized Analysis — Aggregate, Accounting & Potential Methods, Dynamic Arrays, Binary Counters, Two-Stack Queues & Real-Time Guarantees

Taught in Bangla, this lesson explains why per-operation worst cases are too pessimistic and how amortized analysis fixes that. Using dynamic arrays, binary counters and multipop stacks, it applies the aggregate, accounting and potential methods, analyses shrinking arrays with the one-quarter rule and thrashing, builds a queue from two stacks, and contrasts amortized bounds with real-time guarantees.

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

  • কেন প্রতি কাজের worst case গুণ করে পাওয়া সীমা প্রায়ই অনেক বেশি হতাশাবাদী, আর amortized সীমা average case থেকে কোথায় আলাদা
  • Aggregate, accounting আর potential — তিন পদ্ধতিতে একই তিনটি উদাহরণ (dynamic array, binary counter, multipop) প্রমাণ করা
  • Growth factor ২ বা ১.৫ কেন চলে আর ধ্রুবক যোগ কেন চলে না, এবং ছোট করার ¼ নিয়মটি কেন
  • দুই stack দিয়ে queue, KMP আর union-find-এ amortized যুক্তি চেনা
  • Amortized আর real-time নিশ্চয়তার পার্থক্য, আর C++ ও Python-এ খরচ গুনে দাবি যাচাই করা

১ · প্রতিটি কাজের worst case কেন অতিরিক্ত হতাশাবাদী

পর্ব ১০-এ আপনি দেখেছেন, dynamic array (C++-এর std::vector, Python-এর list) জায়গা ফুরালে নতুন বড় ব্লক নিয়ে সব উপাদান কপি করে। তাহলে একটি push_back সবচেয়ে খারাপে Θ(n). এখন সরল হিসাব: n টি push, প্রতিটির worst case O(n), তাই মোট O(n²). হিসাবটি ভুল নয় — upper bound হিসেবে সত্য। কিন্তু অনেক বেশি হতাশাবাদী। বাস্তবে দশ লাখ push-এ মোট কপি মাত্র প্রায় দশ লাখ, দশ লাখ গুণ দশ লাখ নয়।

ভুলটি কোথায়? আমরা ধরে নিয়েছি প্রতিটি কাজ একসাথে সবচেয়ে খারাপ হতে পারে। অথচ দামি কাজটি (কপি) ঘটলে capacity দ্বিগুণ হয়, আর তার পরের অনেকগুলো push নিশ্চিতভাবে সস্তা। দামি কাজ নিজেই পরের সস্তা কাজের জায়গা বানিয়ে দেয়। কাজগুলো স্বাধীন নয় — তারা একটি ক্রমে আসে, আর ডেটা স্ট্রাকচারের অবস্থা তাদের জুড়ে রাখে।

উপমা ১ — মাসিক মোবাইল ডেটা প্যাক। ধরুন আপনি মাসের শুরুতে ৩০০ টাকায় ৩০ জিবির একটি প্যাক কিনলেন। যেদিন কিনলেন, সেদিনের খরচ ৩০০ টাকা — “প্রতিদিনের সবচেয়ে খারাপ খরচ ৩০০ টাকা, তাই মাসে ৩০ × ৩০০ = ৯,০০০ টাকা” বলা হাস্যকর। মাসের মোট খরচ ৩০০, অর্থাৎ দিনে গড়ে ১০ টাকা। বাকি ২৯ দিন আপনি কিছুই দেননি, কারণ প্রথম দিনের বড় খরচটি তাদের জন্য আগেই মেটানো।

উপমা ২ — ঈদের কেনাকাটার জন্য রোজ একটু জমানো। ঈদে একবারে ৬,০০০ টাকার কেনাকাটা হবে। আপনি দুই মাস ধরে রোজ ১০০ টাকা মাটির ব্যাংকে রাখলেন। ঈদের দিনের বড় খরচ পুরোটাই জমানো টাকায় মেটে, ঋণ লাগে না। প্রতিদিনের হিসাবে আপনার “খরচ” ১০০ টাকা। এটিই পরের অংশের accounting পদ্ধতির হুবহু ছবি — সস্তা কাজে একটু বেশি নিয়ে জমা রাখা, যাতে দামি কাজের দিন ব্যাংক যথেষ্ট থাকে।

শব্দটিও এসেছে অর্থনীতি থেকে: ঋণ amortize করা মানে বড় অঙ্কটিকে সমান কিস্তিতে ভাগ করে শোধ করা — যেমন বাড়ি বা মোটরসাইকেলের মাসিক কিস্তি। Amortized analysis-এ আমরা কয়েকটি দামি কাজের খরচ সব কাজের ওপর কিস্তিতে ভাগ করে দিই। সংজ্ঞা:

সংজ্ঞা

কোনো ডেটা স্ট্রাকচারের কাজগুলোর যেকোনো n দৈর্ঘ্যের ক্রমে মোট আসল খরচ যদি সবসময় ≤ T(n) হয়, তবে প্রতি কাজের amortized খরচ T(n)/n. সাধারণভাবে, প্রতিটি কাজের ধরনকে একটি amortized খরচ ĉ দেওয়া যায়, যদি যেকোনো ক্রমে Σ (আসল খরচ) ≤ Σ ĉ হয়।

Amortized বনাম average case — কোনো সম্ভাবনা নেই!

এখানে সবচেয়ে প্রচলিত বিভ্রান্তি: “amortized O(1) মানে গড়ে O(1), তাই ভাগ্য খারাপ হলে ধীর হতে পারে”. না। পর্ব ৫-এর average case ইনপুটের একটি সম্ভাবনা-বণ্টন ধরে নেয় — ইনপুট এলোমেলো হলে প্রত্যাশিত খরচ। Amortized সীমা কোনো বণ্টন ধরে না: প্রতিপক্ষ যত খারাপ ক্রমই বাছুক, পুরো ক্রমের মোট খরচ সীমার মধ্যে থাকবেই। এটি worst case-এরই একটি রূপ — শুধু একটি কাজের নয়, পুরো ক্রমের।

সীমার ধরনকিসের ওপর গড়?নিশ্চয়তাউদাহরণ
প্রতি কাজের worst caseগড় নেইপ্রতিটি কাজ আলাদাভাবে ≤ সীমাbalanced BST-এ insert O(log n)
Amortizedএকই ক্রমের কাজগুলোর ওপরযেকোনো ক্রমে মোট ≤ সীমা; একটি কাজ বড় হতে পারেvector-এর push_back O(1)
Average caseইনপুটের সম্ভাবনা-বণ্টনের ওপরশুধু প্রত্যাশিত মান; খারাপ ইনপুটে বড়এলোমেলো ইনপুটে quick sort O(n log n)
Expected (randomized)অ্যালগরিদমের নিজের এলোমেলো পছন্দের ওপরপ্রতিটি ইনপুটে প্রত্যাশিত মান (পর্ব ৬১)randomized quicksort

দুটি একসাথেও আসতে পারে — যেমন hash table-এর insert “expected amortized O(1)”: rehashing-এর খরচ amortized, আর collision-এর খরচ hash function-এর এলোমেলোতার ওপর expected (পর্ব ৩২). তবে এই পর্বের সব ফল সম্ভাবনা ছাড়া, নিশ্চিত।

২ · তিনটি উদাহরণ — dynamic array, binary counter আর multipop

পুরো পর্বে আমরা একই তিনটি ডেটা স্ট্রাকচার তিন পদ্ধতিতে বিশ্লেষণ করব, যাতে পদ্ধতিগুলোর পার্থক্য স্পষ্ট হয়। প্রতিটিতে খরচের একক ঠিক করা দরকার।

Dynamic array

push_back: খালি ঘর থাকলে লেখা (খরচ ১); ভরা থাকলে capacity দ্বিগুণ, সব উপাদান কপি, তারপর লেখা (খরচ size + ১).

এক কাজে সবচেয়ে খারাপ Θ(n)

Binary counter

k বিটের অ্যারে A, শুরুতে সব 0. INCREMENT: নিচের দিকের সব 1 উল্টে 0, তারপর প্রথম 0-টি 1. খরচ = কয়টি বিট উল্টাল (flip).

এক কাজে সবচেয়ে খারাপ Θ(k)

Multipop stack

সাধারণ push আর pop (খরচ ১), সাথে multipop(k): ওপরের min(k, size) টি উপাদান একে একে pop.

এক কাজে সবচেয়ে খারাপ Θ(n)

প্রথমটি দিয়ে শুরু করুন। খালি array (capacity ১) থেকে নয়টি push — প্রতিটির খরচ আর মোট খরচের গড় দেখুন:

Dynamic array-এর মেমরি ব্লকসবুজ = এই ধাপে লেখা বা কপি হওয়া ঘর

            
প্রতিটি ধাপের পর
এই ধাপ
size / capacity
এই push-এর খরচ (লেখা + কপি)
মোট কপি এ পর্যন্ত
মোট খরচ ÷ push-এর সংখ্যা
  1. শুরু. খালি dynamic array, capacity ১টি ঘর, size ০. এখনো কোনো খরচ হয়নি।
  2. push(1). ঘর খালি ছিল, শুধু লিখলাম — খরচ ১.
  3. push(2). Capacity ১ পুরো ভরা। নতুন ২-ঘরের ব্লক নিয়ে পুরোনো ১টি উপাদান কপি, তারপর লেখা — খরচ ১ + ১ = ২.
  4. push(3). আবার ভরা: ৪-ঘরের ব্লক, ২টি কপি আর ১টি লেখা — খরচ ৩. মোট কপি এখন ১ + ২ = ৩.
  5. push(4). খালি ঘর তৈরি ছিল — খরচ মাত্র ১. আগের দামি push-টি এই সস্তা push-এর জায়গা আগেই বানিয়ে রেখেছে।
  6. push(5). ৪টি কপি করে ৮-ঘরের ব্লক — এই একটি push-এর খরচ ৫. শুধু এটুকু দেখলে মনে হবে “push মানে O(n)”.
  7. push(6). সস্তা, খরচ ১. গড় ১২/৫ = ২.৪০ থেকে নেমে ১৩/৬ ≈ ২.১৭.
  8. push(7). খরচ ১, গড় ২.০০.
  9. push(8). খরচ ১; ব্লক আবার পুরো ভরা। মোট খরচ ১৫, গড় ১.৮৮ — দামি কপির পরের সস্তা push-গুলো গড় নামিয়ে আনছে।
  10. push(9). ৮টি কপি, ১৬-ঘরের ব্লক — খরচ ৯. মোট কপি ১ + ২ + ৪ + ৮ = ১৫ < ২ × ৯, আর মোট খরচ ২৪ ≤ ৩ × ৯ = ২৭. কোনো ধাপেই গড় ৩ ছাড়ায়নি — এটিই amortized O(1).

দ্বিতীয় উদাহরণটি binary counter — গাড়ির কিলোমিটার মিটারের মতো, শুধু দশমিকের বদলে দুই ভিত্তিতে। মিটার ০৯৯৯ থেকে ১০০০ হলে চারটি চাকা ঘোরে, কিন্তু বেশিরভাগ সময় শুধু শেষ চাকাটি। k বিটের counter-এ একটি increment সবচেয়ে খারাপে k টি flip, তাই সরল হিসাবে n increment-এ O(nk). আসলে কত?

Counter-এর বিট (A[0] সবচেয়ে ছোট)সবুজ = এই increment-এ উল্টানো বিট

            
প্রতিটি ধাপের পর
increment-এর সংখ্যা n
এই ধাপে flip
মোট flip / সীমা 2n
কতবার উল্টাল: A[0] / A[1] / A[2] / A[3]
counter-এ 1-এর সংখ্যা
  1. শুরু. সব বিট 0. INCREMENT মানে: নিচ থেকে যত 1 পাওয়া যায় সব 0 করুন, তারপর প্রথম 0-টিকে 1.
  2. 0 → 1. A[0] ছিল 0, সরাসরি 1 — এক flip.
  3. 1 → 2. A[0] = 1 তাই 0 হলো, তারপর A[1] 0 থেকে 1 — দুই flip. মোট ৩.
  4. 2 → 3. আবার শুধু A[0] — এক flip. মোট ৪.
  5. 3 → 4. 0011 → 0100: দুটি 1 মুছে একটি 1 বসল — তিন flip. মোট ৭, সীমা ৮.
  6. 4 → 5. এক flip. লক্ষ করুন, A[0] প্রতিটি increment-এ উল্টায়, A[1] প্রতি দ্বিতীয়টিতে।
  7. 5 → 6. দুই flip; মোট ১০.
  8. 6 → 7. এক flip; counter এখন 0111 — তিনটি 1 জমে আছে, পরের increment দামি হবে।
  9. 7 → 8. 0111 → 1000: চারটি flip, এক increment-এ k বিটের সবগুলো! তবু মোট ১৫ = ৮ + ৪ + ২ + ১ < ১৬. A[i] উল্টায় মোট ⌊n/2i⌋ বার — ধারাটিই সীমা দেয়।

তৃতীয় উদাহরণ multipop. একটি multipop(k) একাই n টি pop করতে পারে, তাই সরল হিসাবে n কাজে O(n²). কিন্তু একটু ভাবুন: যে উপাদান কখনো push হয়নি, তাকে pop করা যায় না। প্রতিটি উপাদান একবার ঢোকে, সর্বোচ্চ একবার বের হয়। পরের তিন অংশে এই তিনটি অন্তর্দৃষ্টিকে তিনটি আনুষ্ঠানিক পদ্ধতিতে রূপ দেব।

৩ · Aggregate method — মোট যোগ করে ভাগ

সবচেয়ে সরাসরি পদ্ধতি: n টি কাজের যেকোনো ক্রমের মোট খরচ T(n)-এর একটি সীমা প্রমাণ করুন, তারপর n দিয়ে ভাগ। একে বলে aggregate method. এখানে সব কাজের amortized খরচ একই — T(n)/n.

Multipop. pop (multipop-এর ভেতরেরগুলো সহ) কতবার হতে পারে? প্রতিটি pop একটি আগে push হওয়া উপাদান সরায়, তাই মোট pop ≤ মোট push ≤ n. সব কাজ মিলে মোট খরচ ≤ 2n = O(n), প্রতি কাজে amortized O(1). লক্ষ করুন, যুক্তিটি কোনো একটি multipop-এর আকার নিয়ে কিছু বলে না — শুধু পুরো ক্রমের মোট নিয়ে।

Binary counter. A[0] প্রতিটি increment-এ উল্টায়: n বার। A[1] প্রতি দ্বিতীয়টিতে: ⌊n/2⌋ বার। সাধারণভাবে A[i] উল্টায় ⌊n/2i⌋ বার। তাই মোট flip

Σi≥0 ⌊n/2i⌋ < n · (1 + ½ + ¼ + …) = 2n

অর্থাৎ k যত বড়ই হোক, প্রতি increment-এ amortized O(1) — ঠিক ২-এর কম flip. এটি পর্ব ৬-এর geometric series.

Dynamic array, growth factor ২. n টি push-এ কপি হয় শুধু size যখন ১, ২, ৪, …, 2j (যেখানে 2j < n). মোট কপি 1 + 2 + … + 2j = 2j+1 − 1 < 2n. সাথে n টি লেখা — মোট < 3n. তাই push_back amortized O(1), যদিও একটি push Θ(n).

যেকোনো growth factor g > 1 চলে, কিন্তু ধ্রুবক যোগ চলে না

Growth factor g হলে কপির আকারগুলো একটি গুণোত্তর ধারা, যার শেষ পদ < n: মোট < n · (1 + 1/g + 1/g² + …) = n · g/(g − 1). g = 2-এ < 2n, g = 1.5-এ < 3n — দুটোই Θ(n), পার্থক্য শুধু ধ্রুবকে (১.৫ কম মেমরি নষ্ট করে, কিন্তু বেশি কপি করে; বিস্তারিত পর্ব ১০-এ)। g যত ১-এর কাছে যায়, g/(g − 1) তত বড় — তবু ধ্রুবক।

এবার “প্রতিবার ১০০টি ঘর বাড়াও” ভাবুন — শুনতে মিতব্যয়ী। কপির আকার 100, 200, 300, …, প্রায় n পর্যন্ত: মোট ≈ 100 · (1 + 2 + … + n/100) ≈ n²/200. তাই n push-এ Θ(n²), প্রতি push-এ amortized Θ(n). ধ্রুবক c যত বড়ই হোক, n²/(2c) শেষে জিতবেই। নিচের প্রোগ্রাম তিনটি নিয়ম পাশাপাশি চালিয়ে মোট কপি গোনে:

#include <algorithm>
#include <cstdio>
#include <initializer_list>
#include <string>
#include <utility>
using namespace std;

// n times push_back into an empty dynamic array; returns {resizes, copies}
pair<long long, long long> simulate(long long n, const string& rule) {
    long long size = 0, cap = 1, copies = 0, resizes = 0;
    for (long long k = 0; k < n; ++k) {
        if (size == cap) {                      // full: allocate bigger, copy all
            copies += size;
            ++resizes;
            if (rule == "x2")        cap = 2 * cap;
            else if (rule == "x1.5") cap = max(cap + 1, cap + cap / 2);
            else                     cap = cap + 100;   // "+100": constant growth
        }
        ++size;                                 // write the new element
    }
    return {resizes, copies};
}

int main() {
    printf("rule       n   resizes        copies  copies/n\n");
    for (long long n : {1000LL, 100000LL, 1000000LL})
        for (const char* rule : {"x2", "x1.5", "+100"}) {
            auto [r, c] = simulate(n, rule);
            printf("%4s %8lld %9lld %13lld %9.2f\n", rule, n, r, c, double(c) / n);
        }
}

আউটপুট:

rule       n   resizes        copies  copies/n
  x2     1000        10          1023      1.02
x1.5     1000        17          2137      2.14
+100     1000        10          4510      4.51
  x2   100000        17        131071      1.31
x1.5   100000        29        276521      2.77
+100   100000      1000      49951000    499.51
  x2  1000000        20       1048575      1.05
x1.5  1000000        34       2099753      2.10
+100  1000000     10000    4999510000   4999.51

শেষ কলামটিই প্রতি push-এর amortized কপি। x2-এ সবসময় ২-এর নিচে (1023 = 210 − 1, 1048575 = 220 − 1 — ঠিক অংশ ৩-এর সূত্র), x1.5-এ ৩-এর নিচে (n · g/(g − 1) = 3n সীমা)। n ১০০০ গুণ বাড়লেও এই দুই কলাম প্রায় স্থির — amortized O(1)-এর চেহারা এটাই। x2-এর অনুপাত ১.০২ থেকে ১.৩১-এর মধ্যে ওঠানামা করে, কারণ n কোনো ঘাতের ঠিক পরে না আগে পড়েছে তার ওপর মোট কপি নির্ভর করে। আর +100: n যত গুণ বাড়ে, copies/n-ও প্রায় তত গুণ (n ১০০ গুণে ৪.৫১ → ৪৯৯.৫১, তারপর ১০ গুণে → ৪৯৯৯.৫১) — n/200-এর সাথে প্রায় হুবহু মেলে। দশ লাখ push-এ প্রায় ৫০০ কোটি কপি, যেখানে x2-এ মাত্র দশ লাখ। Resize-এর সংখ্যাও দেখুন: গুণ করলে Θ(log n) বার (২০ বা ৩৪), যোগ করলে n/100 বার। সময় O(n) প্রতি নিয়মে (শুধু গোনা হচ্ছে, কপি নয়), জায়গা O(1).

def simulate(n, rule):
    size, cap, copies, resizes = 0, 1, 0, 0
    for _ in range(n):                      # n times push_back
        if size == cap:                     # full: allocate bigger, copy all
            copies += size
            resizes += 1
            if rule == "x2":
                cap = 2 * cap
            elif rule == "x1.5":
                cap = max(cap + 1, cap + cap // 2)
            else:
                cap = cap + 100             # "+100": constant growth
        size += 1                           # write the new element
    return resizes, copies

print("rule       n   resizes        copies  copies/n")
for n in (1000, 100000, 1000000):
    for rule in ("x2", "x1.5", "+100"):
        r, c = simulate(n, rule)
        print(f"{rule:>4} {n:>8} {r:>9} {c:>13} {c / n:>9.2f}")

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

rule       n   resizes        copies  copies/n
  x2     1000        10          1023      1.02
x1.5     1000        17          2137      2.14
+100     1000        10          4510      4.51
  x2   100000        17        131071      1.31
x1.5   100000        29        276521      2.77
+100   100000      1000      49951000    499.51
  x2  1000000        20       1048575      1.05
x1.5  1000000        34       2099753      2.10
+100  1000000     10000    4999510000   4999.51

এখানে আসল ব্লক বানানো হয়নি — শুধু size আর capacity-র হিসাব রেখে কপি গোনা হয়েছে, তাই ৫০০ কোটি কপিও মুহূর্তে “গোনা” যায়। Python-এর পূর্ণসংখ্যা নিজে বড় হতে পারে, তাই overflow-এর ভয় নেই; C++-এ একই কারণে long long. max(cap + 1, cap + cap // 2) লাইনটি দরকার, কারণ cap = 1-এ cap // 2 = 0 — নইলে capacity কখনো বাড়ত না।

৪ · Accounting method — সস্তা কাজে কয়েন জমানো

Aggregate পদ্ধতি সব কাজকে একই amortized খরচ দেয়। কিন্তু কখনো আলাদা আলাদা দাম দিলে যুক্তি সহজ হয়: push-এর দাম ২, pop-এর দাম ০. Accounting method-এ প্রতিটি কাজের একটি amortized দাম ĉ ঠিক করা হয়। আসল খরচের চেয়ে বেশি নিলে বাড়তিটা credit (কয়েন) হিসেবে ডেটা স্ট্রাকচারের নির্দিষ্ট অংশের গায়ে জমা থাকে; কম নিলে ঘাটতি সেই জমা থেকে মেটে — ঠিক ঈদের মাটির ব্যাংক।

একমাত্র শর্ত

যেকোনো ক্রমের যেকোনো মুহূর্তে ব্যাংক ঋণাত্মক হবে না: Σ ĉ − Σ আসল খরচ ≥ ০. তাহলে মোট আসল খরচ ≤ মোট amortized খরচ, আর amortized দামগুলোই সীমা। প্রমাণের কাজ হলো একটি invariant খুঁজে বের করা — “কোন কয়েন কোথায় আছে”.

Multipop. push-এর দাম ২: ১ দিয়ে push-এর খরচ মেটে, বাকি ১ কয়েন উপাদানটির গায়ে থাকে। pop আর multipop-এর দাম ০: প্রতিটি সরানো উপাদান নিজের কয়েন দিয়ে নিজের pop মেটায়। Invariant: stack-এর প্রতিটি উপাদানের গায়ে ঠিক ১ কয়েন, তাই ব্যাংক = stack-এর আকার ≥ ০. ফল: n কাজে মোট ≤ 2n.

Stack আর জমানো কয়েন● = উপাদানের গায়ে রাখা ১ কয়েন

            
প্রতিটি ধাপের পর
এই ধাপের কাজ
আসল খরচ (actual)
নেওয়া হলো (amortized)
ব্যাংকে কয়েন = stack-এর আকার
মোট আসল / মোট নেওয়া
  1. শুরু. খালি stack, ব্যাংকে কয়েন ০. নিয়ম: push-এর জন্য ২ কয়েন নেব, pop আর multipop-এর জন্য ০.
  2. push(5). আসল খরচ ১, নেওয়া হলো ২ — বাড়তি ১ কয়েন 5-এর গায়ে রাখা হলো। এটি 5-এর ভবিষ্যৎ pop-এর অগ্রিম।
  3. push(8). একইভাবে: খরচ ১, কয়েন ১টি 8-এর গায়ে। ব্যাংক ২.
  4. push(2). ব্যাংক ৩ — stack-এর প্রতিটি উপাদানের গায়ে ঠিক একটি কয়েন।
  5. push(7). ব্যাংক ৪. এ পর্যন্ত আসল খরচ ৪, নেওয়া ৮.
  6. multipop(3). তিনটি pop, আসল খরচ ৩ — কিন্তু আমরা কিছু নিইনি! খরচ মেটাল 7, 2 আর 8-এর গায়ের কয়েন। ব্যাংক ৪ − ৩ = ১, কখনো ঋণাত্মক নয়।
  7. push(4). আবার ২ নিলাম, ১ কয়েন 4-এর গায়ে। ব্যাংক ২.
  8. multipop(5). চাওয়া ৫টি, কিন্তু আছে ২টি — আসল খরচ min(5, 2) = ২, মিটল দুই কয়েনে। মোট আসল ১০ ≤ মোট নেওয়া ১০. যত ক্রমই হোক, মোট আসল ≤ ২ × (push-এর সংখ্যা).

Binary counter. কোনো বিটকে 0 থেকে 1 করার দাম ২: ১ দিয়ে flip, ১ কয়েন সেই বিটের গায়ে। 1 থেকে 0 করার দাম ০ — বিটের গায়ের কয়েনই মেটায়। Invariant: প্রতিটি 1-বিটের গায়ে ১ কয়েন। একটি INCREMENT ঠিক একটি বিটকে 0 → 1 করে (counter উপচে না পড়লে), তাই তার দাম ২; সামনের সব 1 → 0 বিনামূল্যে। n increment-এ মোট flip ≤ 2n — অংশ ৩-এর ফলই, অন্য পথে।

Dynamic array. প্রতিটি push-এর দাম ৩: ১ দিয়ে নিজের লেখা, ১ কয়েন নিজের গায়ে (পরের resize-এ নিজের কপির জন্য), আর ১ কয়েন আগের অর্ধেকের এমন একটি উপাদানের গায়ে, যার কয়েন আগের resize-এ খরচ হয়ে গেছে। Capacity c থেকে 2c হওয়ার মুহূর্তে c টি পুরোনো উপাদান কপি হয়ে কয়েনহীন। এরপর যে c টি push (resize ঘটানোটি সহ) array-কে 2c পর্যন্ত ভরে, তারা মোট 2c কয়েন রাখে — পরের resize-এ ঠিক 2c টি উপাদান কপি করার জন্য যথেষ্ট। তাই ব্যাংক কখনো ঋণাত্মক নয়, আর push-এর amortized খরচ ৩. অংশ ২-এর প্রথম ডায়াগ্রামে “মোট খরচ ÷ push” কখনো ৩ ছাড়ায়নি — এই কারণেই।

কয়েন কল্পনার, খরচ আসল। প্রোগ্রামে কোনো কয়েন রাখা হয় না — এটি শুধু প্রমাণের হিসাবখাতা। আর দাম বাছাই ভুল হলে (যেমন dynamic array-এ push-এর দাম ২) কোনো এক মুহূর্তে ব্যাংক ঋণাত্মক হবে, এবং প্রমাণটি ভেঙে পড়বে — দাবিটি মিথ্যা নাও হতে পারে, কিন্তু আপনার প্রমাণ অচল।

৫ · Potential method — জমানো শক্তি Φ

Accounting-এ কয়েন থাকে নির্দিষ্ট উপাদানের গায়ে। জটিল ডেটা স্ট্রাকচারে কোন কয়েন কার গায়ে, তা মনে রাখা কঠিন। Potential method বলে: আলাদা কয়েন নয়, পুরো ডেটা স্ট্রাকচারের অবস্থাকে একটি সংখ্যা দিন — potential function Φ(D). পদার্থবিজ্ঞানের স্থিতিশক্তির মতো: ওপরে তোলা পাথরে শক্তি জমা থাকে, পড়ার সময় সেই শক্তি কাজ করে। সস্তা কাজে Φ বাড়ে (শক্তি জমে), দামি কাজে Φ কমে (জমা শক্তি খরচ হয়)।

ĉi = ci + Φ(Di) − Φ(Di−1)

এখানে ci হলো i-তম কাজের আসল খরচ, Di সেই কাজের পরের অবস্থা। সব যোগ করলে মাঝের Φ-গুলো কাটাকাটি হয়ে যায় — একে বলে telescoping sum:

Σ ĉi = Σ ci + Φ(Dn) − Φ(D0)

তাই যদি সবসময় Φ(Di) ≥ Φ(D0) হয় (সাধারণত Φ(D0) = 0 আর Φ ≥ 0), তবে Σ ci ≤ Σ ĉi — amortized খরচগুলো আসল মোটের সীমা। কাজ হলো ভালো একটি Φ খোঁজা: দামি কাজে Φ যেন ঠিক ততটা কমে, যতটা বাড়তি খরচ হলো।

  • Multipop: Φ = stack-এর আকার। push: c = 1, ΔΦ = +1 → ĉ = 2. multipop(k) যা k′ টি সরায়: c = k′, ΔΦ = −k′ → ĉ = 0.
  • Binary counter: Φ = counter-এ 1-এর সংখ্যা। যে increment t টি 1-কে 0 করে আর একটি 0-কে 1: c = t + 1, ΔΦ = 1 − t → ĉ = 2. (অংশ ২-এর ডায়াগ্রামের শেষ সারিটি এই Φ.)
  • Dynamic array: Φ = 2·size − capacity. Resize ছাড়া: c = 1, ΔΦ = +2 → ĉ = 3. size = capacity = m অবস্থায় resize: c = m + 1, Φ m থেকে 2(m + 1) − 2m = 2, ΔΦ = 2 − m → ĉ = 3.
Array আর তার potential Φ■ = Φ-এর প্রতিটি একক (জমানো শক্তি)

            
প্রতিটি ধাপের পর
এই ধাপ
আসল খরচ cᵢ
ΔΦ = Φ(পরে) − Φ(আগে)
amortized = cᵢ + ΔΦ
মোট আসল / মোট amortized
  1. শুরু. size ০, capacity ১, তাই Φ = −১. এটি শুধু শুরুর বিশেষ অবস্থা; প্রথম push-এর পর থেকে size ≥ capacity/২, তাই Φ ≥ ০ থাকে।
  2. push(1). আসল খরচ ১, Φ −১ থেকে ১: ΔΦ = +২. Amortized = ১ + ২ = ৩.
  3. push(2). Resize: আসল খরচ ২ (১ কপি + ১ লেখা). Φ ১ থেকে ২·২ − ২ = ২, ΔΦ = +১. Amortized ৩.
  4. push(3). Resize ২ → ৪: আসল খরচ ৩. Φ ২ থেকে ২·৩ − ৪ = ২, ΔΦ = ০. Amortized ৩.
  5. push(4). সস্তা push: খরচ ১, কিন্তু Φ ২ বাড়ল (২ → ৪) — আমরা ভবিষ্যতের জন্য শক্তি জমালাম। Amortized ১ + ২ = ৩.
  6. push(5). দামি push: খরচ ৫. কিন্তু জমানো শক্তি খরচ হলো — Φ ৪ থেকে ২·৫ − ৮ = ২, ΔΦ = −২. Amortized ৫ − ২ = ৩.
  7. push(6). খরচ ১, Φ ২ → ৪. Amortized ৩.
  8. push(7). খরচ ১, Φ ৪ → ৬. Amortized ৩.
  9. push(8). খরচ ১, Φ ৬ → ৮ = capacity. ব্লক ভরা, আর Φ ঠিক পরের কপির সমান শক্তি জমিয়েছে।
  10. push(9). খরচ ৯ (৮ কপি). Φ ৮ থেকে ২·৯ − ১৬ = ২, ΔΦ = −৬. Amortized ৯ − ৬ = ৩. মোট আসল ২৪ = মোট amortized ২৭ − (Φশেষ − Φশুরু) = ২৭ − ৩.

Φ = 2·size − capacity-র অর্থ: “পরের resize-এর আগে কত কপি জমা করে রাখতে হবে”. Resize-এর ঠিক পরে array অর্ধেক ভরা, Φ প্রায় ০ — শক্তি সব খরচ হয়েছে। Array ভরে উঠতে উঠতে Φ বেড়ে capacity-তে পৌঁছায় — ঠিক পরের কপির খরচ। Accounting-এর কয়েনগুলোর মোট সংখ্যাই আসলে Φ; potential method সেই যোগফলকে উপাদান-নিরপেক্ষ একটি সূত্রে লেখে।

Pseudocode — the potential-method proof recipe

1. Choose Phi(D) from the state of the structure (size, number of 1s, ...).
2. Check Phi(D0) = 0 and Phi(D) >= 0 for every reachable state D.
3. For every operation type, compute  amortized = actual + Phi(after) - Phi(before).
4. Bound each amortized cost (cheap case AND expensive case).
5. Conclude: total actual <= sum of amortized bounds   (telescoping).
পদ্ধতিমূল প্রশ্নকখন সুবিধা
Aggregateপুরো ক্রমের মোট কত?এক ধরনের কাজ, সরাসরি গোনা যায় (counter, multipop)
Accountingকোন কাজ কত দাম দেবে, কয়েন কার গায়ে?বিভিন্ন কাজের বিভিন্ন দাম; উপাদানভিত্তিক যুক্তি
Potentialঅবস্থার কোন সংখ্যা দামি কাজে কমে?জটিল স্ট্রাকচার — splay tree, Fibonacci heap, union-find

৬ · Array ছোট করা — ¼ নিয়ম আর thrashing

এখন pop-ও আছে, আর আমরা চাই অনেক উপাদান মুছে গেলে array মেমরি ফেরত দিক। সহজ চিন্তা: “বড় করি ভরা হলে দ্বিগুণ, তাহলে ছোট করি অর্ধেক খালি হলে অর্ধেক”. শুনতে প্রতিসম, কিন্তু ফাঁদ আছে। ধরুন size = capacity = n. একটি push: দ্বিগুণ, n কপি; এখন ঠিক অর্ধেকের একটু বেশি ভরা। একটি pop: ঠিক অর্ধেক — ছোট করা, আবার n কপি, আবার পুরো ভরা। push, pop, push, pop … প্রতিটি কাজ Θ(n). একে বলে thrashing.

একই ক্রম, দুই নিয়মের array■ = ভরা ঘর, □ = খালি ঘর

            
প্রতিটি ধাপের পর
এই ধাপের কাজ
নিয়ম ½: এই ধাপে কপি / মোট
নিয়ম ¼: এই ধাপে কপি / মোট
  1. শুরু. দুটি array, দুটিতেই ৪টি উপাদান, capacity ৪ — পুরো ভরা। বড় করার নিয়ম একই (দ্বিগুণ); পার্থক্য শুধু ছোট করার নিয়মে: বাঁয়েরটি size ≤ capacity/২ হলেই অর্ধেক করে, ডানেরটি size ≤ capacity/৪ হলে।
  2. push. দুটিই ভরা ছিল: ৮-ঘরের ব্লকে ৪টি কপি। এখন size ৫, capacity ৮.
  3. pop. size ৪ = ৮/২. নিয়ম ½ সাথে সাথে ব্লক ছোট করল (৪টি কপি) — এখন আবার ভরা! নিয়ম ¼ কিছু করল না: ৪ > ৮/৪ = ২.
  4. push. নিয়ম ½-এর array ভরা ছিল, তাই আবার বড় করা — আরও ৪ কপি। নিয়ম ¼-এ খালি ঘর তৈরি, খরচ শুধু লেখা।
  5. pop. নিয়ম ½ আবার ছোট করল। প্রতিটি কাজে Θ(n) কপি — একে বলে thrashing.
  6. push. পাঁচ কাজে নিয়ম ½-এর কপি ২০, নিয়ম ¼-এর ৪. n টি উপাদানে সীমানায় m বার push-pop করলে নিয়ম ½ Θ(mn), নিয়ম ¼ O(n + m).

সমাধান: ছোট করুন যখন array ¼ ভরা, আর ছোট করে capacity অর্ধেক করুন। তখন বড় বা ছোট করার ঠিক পরে array সবসময় অর্ধেক ভরা — পরের resize-এর আগে অন্তত capacity/৪ টি কাজ লাগবে, যারা কপির খরচ জমাবে। Potential দিয়ে প্রমাণ (α = size/capacity):

Φ = 2·size − capacity, যখন α ≥ ½  ·  Φ = capacity/2 − size, যখন α < ½

দুই অংশই অর্ধেক ভরা অবস্থায় ০, আর অর্ধেক থেকে যেকোনো দিকে সরলে বাড়ে। ভরা অবস্থায় Φ = capacity, ¼ ভরা অবস্থায় Φ = capacity/4 = size — দুই ক্ষেত্রেই ঠিক কপির খরচের সমান শক্তি জমা। হিসাব করলে প্রতিটি push আর pop-এর amortized খরচ ধ্রুবক (৩-এর মধ্যে) — CLRS-এর amortized analysis অধ্যায়ে পুরো হিসাবটি আছে। জায়গাও Θ(size): capacity কখনো size-এর ৪ গুণের বেশি নয় (খালি অবস্থা বাদে)।

বাস্তবে: C++-এর std::vector নিজে থেকে কখনো ছোট হয় না — pop_back বা clear-এর পরও capacity থাকে; ফেরত চাইলে shrink_to_fit() ডাকতে হয়, যা শুধু একটি অনুরোধ। Java-র ArrayList-ও নিজে ছোট হয় না (trimToSize() আছে)। অর্থাৎ বড় লাইব্রেরিগুলো thrashing-এর ঝুঁকি না নিয়ে মেমরির বদলে গতি বেছে নিয়েছে। নিজের ডেটা স্ট্রাকচারে ছোট করতে চাইলে ¼ নিয়ম মনে রাখুন।

৭ · দুই stack দিয়ে queue, আর পরিচিত মুখগুলো

ইন্টারভিউর একটি প্রিয় প্রশ্ন: শুধু দুটি stack দিয়ে একটি queue বানান। কৌশল: inbox-এ সব enqueue; dequeue-এর সময় outbox খালি হলে inbox-এর সব উপাদান একে একে outbox-এ ঢালুন — ক্রম উল্টে যায়, তাই সবচেয়ে পুরোনোটি এখন outbox-এর ওপরে। তারপর outbox থেকে pop. এটিই two-stack queue. ব্যাংকের লাইনের উপমায়: নতুন লোকেরা পেছনের ঘরে বসে; সামনের কাউন্টার ফাঁকা হলে পুরো ঘরের লোকদের একসাথে উল্টো ক্রমে সামনে সাজানো হয়।

একটি dequeue-তে inbox-এর n টি উপাদান সরতে পারে — Θ(n). কিন্তু প্রতিটি উপাদান জীবনে ঠিক একবারই inbox থেকে outbox-এ যায়। খরচের একক = একটি stack push বা pop. Accounting: enqueue-এর দাম ৩ (১ নিজের push, ২ কয়েন ভবিষ্যতের “inbox থেকে pop + outbox-এ push”-এর জন্য), dequeue-এর দাম ১. Potential: Φ = 2·|inbox|; enqueue: ১ + ২ = ৩; সাধারণ dequeue: ১ + ০ = ১; k টি ঢালার dequeue: (1 + 2k) − 2k = ১. তাই n কাজে মোট ≤ 3n.

নিচের প্রোগ্রাম দুটি দাবি গুনে দেখে: binary counter-এর মোট flip বনাম 2n, আর ৩টি enqueue + ২টি dequeue-এর একটি ধাঁচ ১০০০ বার (তারপর সব খালি) চালিয়ে two-stack queue-র মোট stack-ধাপ আর সবচেয়ে দামি একক কাজ।

#include <algorithm>
#include <cstdio>
#include <initializer_list>
#include <vector>
using namespace std;
long long counterFlips(long long n, int bits = 32) {
    vector<int> a(bits, 0);                     // a[0] = lowest bit
    long long flips = 0;
    for (long long k = 0; k < n; ++k) {         // n times INCREMENT
        int i = 0;
        while (i < bits && a[i] == 1) { a[i] = 0; ++flips; ++i; }   // trailing 1s -> 0
        if (i < bits) { a[i] = 1; ++flips; }    // first 0 -> 1
    }
    return flips;
}
vector<int> inbox, outbox;                      // queue built from two stacks
long long ops = 0, cost = 0, worst = 0;
void enqueue(int x) { inbox.push_back(x); ++ops; ++cost; worst = max(worst, 1LL); }
void dequeue() {
    long long c = 1;
    if (outbox.empty())                         // refill: move everything, reversing order
        while (!inbox.empty()) { outbox.push_back(inbox.back()); inbox.pop_back(); c += 2; }
    outbox.pop_back(); ++ops; cost += c; worst = max(worst, c);
}

int main() {
    printf("binary counter:   n   flips  2n-bound  flips/n\n");
    for (long long n : {8LL, 16LL, 1000LL, 1000000LL}) {
        long long f = counterFlips(n);
        printf("%21lld %7lld %9lld %8.3f\n", n, f, 2 * n, double(f) / n);
    }
    for (int i = 0; i < 1000; ++i) {            // pattern: 3 enqueues, then 2 dequeues
        for (int j = 0; j < 3; ++j) enqueue(3 * i + j);
        dequeue(); dequeue();
    }
    while (!inbox.empty() || !outbox.empty()) dequeue();
    printf("two-stack queue: ops = %lld, stack steps = %lld, worst single op = %lld, steps/op = %.2f\n",
           ops, cost, worst, double(cost) / ops);
}

আউটপুট:

binary counter:   n   flips  2n-bound  flips/n
                    8      15        16    1.875
                   16      31        32    1.938
                 1000    1994      2000    1.994
              1000000 1999993   2000000    2.000
two-stack queue: ops = 6000, stack steps = 12000, worst single op = 1747, steps/op = 2.00

Counter: n = 8-এ ১৫ flip — অংশ ২-এর ডায়াগ্রামের সাথে মেলে; n = 16-এ ৩১. সাধারণভাবে n = 2j-এ মোট 2n − 1, আর সবসময় < 2n; দশ লাখে 1999993, অনুপাত তিন দশমিকে ২.০০০-এ গোল হলেও সীমার নিচে। এখানে k = 32 বিট, তবু প্রতি increment-এ গড়ে ২-এর কম — k-এর ওপর নির্ভর করে না। Queue: ৬০০০টি কাজে মোট ১২০০০টি stack-ধাপ = ঠিক 3 × 3000 (enqueue) + 1 × 3000 (dequeue), কারণ এখানে প্রতিটি উপাদানই একবার ঢালা হয়েছে। কিন্তু worst single op = 1747: একটি dequeue একাই ৮৭৩টি উপাদান ঢেলেছে! প্রতি রাউন্ডে inbox-এ নিট একটি করে বাড়ে, তাই শেষ দিকের ঢালা অনেক বড়। Amortized ২, কিন্তু একটি কাজ প্রায় ৯০০ গুণ দামি — অংশ ৮-এর বিষয়।

def counter_flips(n, bits=32):
    a = [0] * bits                    # a[0] = lowest bit
    flips = 0
    for _ in range(n):                # n times INCREMENT
        i = 0
        while i < bits and a[i] == 1:
            a[i] = 0                  # trailing 1s become 0
            flips += 1
            i += 1
        if i < bits:
            a[i] = 1                  # first 0 becomes 1
            flips += 1
    return flips
print("binary counter:   n   flips  2n-bound  flips/n")
for n in (8, 16, 1000, 1000000):
    f = counter_flips(n)
    print(f"{n:>21} {f:>7} {2 * n:>9} {f / n:>8.3f}")

inbox, outbox = [], []                # queue built from two stacks
ops = cost = worst = 0
def enqueue(x):
    global ops, cost, worst
    inbox.append(x); ops += 1; cost += 1; worst = max(worst, 1)
def dequeue():
    global ops, cost, worst
    c = 1
    if not outbox:                    # refill: move everything, reversing order
        while inbox:
            outbox.append(inbox.pop()); c += 2
    outbox.pop(); ops += 1; cost += c; worst = max(worst, c)

for i in range(1000):                 # pattern: 3 enqueues, then 2 dequeues
    for j in range(3):
        enqueue(3 * i + j)
    dequeue(); dequeue()
while inbox or outbox:
    dequeue()
print(f"two-stack queue: ops = {ops}, stack steps = {cost}, "
      f"worst single op = {worst}, steps/op = {cost / ops:.2f}")

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

binary counter:   n   flips  2n-bound  flips/n
                    8      15        16    1.875
                   16      31        32    1.938
                 1000    1994      2000    1.994
              1000000 1999993   2000000    2.000
two-stack queue: ops = 6000, stack steps = 12000, worst single op = 1747, steps/op = 2.00

Python-এর list-এর append আর pop() (শেষ থেকে) নিজেরাই amortized O(1) — অর্থাৎ এই stack দুটি নিজেই অংশ ৩-এর dynamic array. দশ লাখ increment-এ Python লুপ কয়েক সেকেন্ডের মধ্যে চলে, C++ মিলিসেকেন্ডে; সময় দুটিতেই Θ(n) (মোট flip < 2n), জায়গা Θ(k) আর queue-তে Θ(সর্বোচ্চ উপাদান)। global দিয়ে counter রাখা শুধু উদাহরণের সরলতার জন্য; বাস্তবে একটি class লিখুন।

আগে দেখা amortized যুক্তি — নতুন চোখে

  • KMP (পর্ব ৫৮): matching-এর ভেতরের while লুপ দেখে O(nm) মনে হয়। Potential Φ = k (এখন পর্যন্ত মেলা দৈর্ঘ্য): প্রতিটি text-অক্ষরে k সর্বোচ্চ ১ বাড়ে, আর প্রতিটি prefix function-এর লাফ k-কে অন্তত ১ কমায়। k কখনো ঋণাত্মক নয়, তাই মোট লাফ ≤ মোট বৃদ্ধি ≤ n — KMP Θ(n + m). এটি হুবহু multipop-এর যুক্তি।
  • Two pointer আর monotonic stack (পর্ব ১৬, ১৩): প্রতিটি pointer শুধু সামনে যায়, প্রতিটি উপাদান একবার ঢোকে আর একবার বের হয় — aggregate যুক্তি, মোট O(n).
  • Hash table-এর rehashing (পর্ব ৩২): load factor সীমা ছাড়ালে bucket দ্বিগুণ — dynamic array-এর যুক্তিই।
  • Union-find (পর্ব ৩৩): union by rank + path compression-এ m কাজে O(m α(n)) — α(n) বাস্তবে ৪-এর বেশি নয়। প্রমাণটি একটি সূক্ষ্ম potential function দিয়ে।

“Amortized” শব্দটি অ্যালগরিদমের জগতে প্রতিষ্ঠিত করেন Robert Tarjan, ১৯৮৫ সালে SIAM Journal on Algebraic and Discrete Methods-এ প্রকাশিত “Amortized computational complexity” প্রবন্ধে। সেখানে তিনি দুটি দৃষ্টিভঙ্গি দেন — ব্যাংকারের (কয়েন, যা আজকের accounting) আর পদার্থবিদের (potential) — আর binary counter-এর মতো উদাহরণে দেখান কেন প্রতি কাজের worst case বিভ্রান্তিকর। একই বছরে Daniel Sleator ও Tarjan Journal of the ACM-এ “Self-adjusting binary search trees” প্রকাশ করে splay tree দেন, যার বিশ্লেষণ potential method ছাড়া প্রায় অসম্ভব। ১৯৮৬ সালে John Hopcroft-এর সাথে Tarjan Turing Award পান।

আরও গভীরে — potential-এর বড় সাফল্যগুলো: Splay tree-এ কোনো ভারসাম্যের নিয়ম নেই, তবু Φ = Σ log(প্রতিটি subtree-র আকার) নিয়ে প্রমাণ হয় প্রতিটি কাজ amortized O(log n). Fibonacci heap-এ (Fredman ও Tarjan, ১৯৮৪) Φ = tree-র সংখ্যা + 2 × চিহ্নিত node — তাতে decrease-key amortized O(1), যা Dijkstra-কে O(E + V log V) করে (পর্ব ৪৩). আর union-find-এর O(m α(n)) প্রমাণে প্রতিটি node-এর potential তার rank আর parent-এর rank-এর ফারাকের ওপর নির্ভর করে। তিনটিতেই কোনো একক কাজ দামি হতে পারে — শুধু যোগফলটি নিশ্চিত।

৮ · Amortized বনাম real-time নিশ্চয়তা

Amortized O(1) বলে “মোট দ্রুত” — প্রতিটি কাজ দ্রুত নয়। বেশিরভাগ প্রোগ্রামে মোটই আসল: একটি কনটেস্টের সমাধান বা রাতের ব্যাচ-কাজ শেষ পর্যন্ত কত সময় নিল, সেটাই মাপা হয়। কিন্তু কিছু সিস্টেমে প্রতিটি কাজের একটি সময়সীমা আছে, আর সেখানে একটি দামি কাজই বিপর্যয়।

  • গেম আর অ্যানিমেশন: সেকেন্ডে ৬০ ফ্রেম মানে প্রতি ফ্রেমে প্রায় ১৬.৭ মিলিসেকেন্ড। একটি বিশাল vector-এর resize সেই সীমা ছাড়ালে স্ক্রিন একবার আটকে যায় — গড় যতই ভালো হোক।
  • ওয়েব সার্ভার আর latency: লাখো অনুরোধের মধ্যে যে কয়টি দামি rehash-এর সময় পড়ল, তাদের উত্তর দেরিতে যায়। বড় সিস্টেম তাই গড়ের সাথে “সবচেয়ে ধীর ১%” (tail latency) মাপে।
  • যন্ত্র নিয়ন্ত্রণ (real-time): গাড়ির ব্রেক বা পেসমেকারের সফটওয়্যারে “মোট ঠিক আছে” গ্রহণযোগ্য নয় — প্রতিটি কাজের worst case-ই সীমা।

সমাধান deamortization: দামি কাজটিকে ছোট টুকরো করে পরের কাজগুলোর মধ্যে ছড়িয়ে দেওয়া। Dynamic array-এ: c ঘরের ব্লক ভরলে 2c-এর নতুন ব্লক নিন, কিন্তু সাথে সাথে কপি করবেন না — পরের প্রতিটি push-এ নতুন উপাদান লেখার সাথে পুরোনো ব্লক থেকে ২টি করে সরান। c/2 push-এর মধ্যে সব c উপাদান সরে যায় — তখন নতুন ব্লকে 1.5c টি উপাদান, ভরার আগেই। প্রতিটি push এখন worst case O(1) (ধরে নিচ্ছি ব্লক বরাদ্দ ধ্রুবক সময়ে), দাম হলো জটিল কোড আর কিছুক্ষণ দুটি ব্লক রাখা। Redis তার hash table ঠিক এভাবে ধাপে ধাপে rehash করে; আর Robert Hood ও Robert Melville ১৯৮১ সালে দেখান, stack দিয়েই প্রতিটি কাজে worst case O(1)-এর queue বানানো যায়।

আরেকটি ফাঁদ — পুরোনো অবস্থা আবার ব্যবহার: amortized যুক্তি ধরে নেয় ডেটা স্ট্রাকচার একটিই সময়রেখায় চলে, আর দামি কাজ জমানো শক্তি খরচ করে ফেলে। কিন্তু যদি কোনো পুরোনো সংস্করণ রেখে দিয়ে বারবার সেখান থেকে একই দামি কাজ করা যায় (persistent বা functional ডেটা স্ট্রাকচারে, অথবা undo-তে), তবে একই শক্তি বারবার খরচ হয়, আর সীমা ভেঙে পড়ে। দুই stack-এর queue-এর একটি কপি রেখে তার “ঢালার ঠিক আগের” অবস্থা থেকে দশবার dequeue করলে দশবারই Θ(n).

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

  • Amortized আর average case-এর পার্থক্য কী? — amortized সম্ভাবনা ছাড়াই যেকোনো ক্রমের মোট খরচের সীমা; average case ইনপুটের বণ্টনের ওপর প্রত্যাশিত মান।
  • capacity দ্বিগুণ করা vector-এ n push-এর মোট খরচ? — Θ(n): কপি 1 + 2 + … < 2n; প্রতি push amortized O(1), একটি push Θ(n).
  • k-বিট counter-এ n increment-এর মোট flip? — < 2n; A[i] উল্টায় ⌊n/2i⌋ বার, অথবা Φ = 1-এর সংখ্যা।
  • Potential method-এ amortized খরচের সূত্র আর শর্ত? — ĉ = c + ΔΦ; Φ(D0) = 0, Φ ≥ 0 হলে Σc ≤ Σĉ.
  • দুই stack-এর queue-তে dequeue-এর complexity? — worst case O(n), amortized O(1); প্রতিটি উপাদান একবারই ঢালা হয়।

৯ · AI-এর যুগে amortized analysis — “O(1)” শব্দটির পেছনে কী লুকানো

AI সহকারীকে জিজ্ঞেস করলে প্রায় সবসময় শুনবেন “vector push_back O(1)” বা “এই লুপ O(n²), কারণ ভেতরে while আছে”. দুটোই অর্ধেক সত্য বা ভুল। Amortized যুক্তি কোডের চেহারা থেকে দেখা যায় না — দেখতে হয় কোন রাশি শুধু বাড়ে বা কমে। আর AI প্রায়ই “amortized” শব্দটি বাদ দেয়, average case-এর সাথে গুলিয়ে ফেলে, বা Φ বেছে নিয়ে Φ ≥ 0 পরীক্ষা করতে ভুলে যায়।

AI-এর ভুলকীভাবে ধরবেন
“push_back প্রতিটি O(1)” — amortized বাদএকক push-এর worst case জিজ্ঞেস করুন; উত্তর Θ(n) হওয়া উচিত
Amortized-কে “গড়ে, সাধারণ ইনপুটে” বলে ব্যাখ্যাজিজ্ঞেস করুন: সবচেয়ে খারাপ ক্রমেও কি মোট সীমা সত্য? হ্যাঁ হওয়া উচিত
লুপের ভেতর লুপ দেখেই O(n²) (KMP, two pointer, monotonic stack)ভেতরের লুপ মোট কতবার চলে, পুরো রানে গুনে দেখুন — counter বসিয়ে
Potential method-এ Φ ঋণাত্মক হতে পারে, খেয়াল নেইপ্রতিটি সম্ভাব্য অবস্থায় Φ ≥ Φ(D0) প্রমাণ চান
“+k করে বাড়ালেও amortized O(1), k বড় হলে”অংশ ৩-এর প্রোগ্রাম চালান: copies/n n-এর সাথে বাড়ে
ছোট করা ½-এ, thrashing-এর কথা নেইসীমানায় push-pop পর্যায়ক্রমে চালিয়ে কপি গুনুন

AI-কে প্রমাণ লিখে দিতে না বলে, আপনার প্রমাণ আর খরচ-গোনা পরীক্ষা করতে বলুন:

আমি DSA শিখছি, এখন amortized analysis. নিচে আমার একটি ডেটা স্ট্রাকচার, তার জন্য বেছে নেওয়া potential function Φ আর প্রতিটি কাজের amortized খরচের হিসাব দিলাম: [এখানে কোড, Φ আর হিসাব পেস্ট করুন] পুরো প্রমাণ নতুন করে লিখে দিও না। শুধু এগুলো করো: ১. শুরুর অবস্থায় Φ কত, আর এমন কোনো অবস্থা কি আছে যেখানে Φ শুরুর চেয়ে কম? থাকলে উদাহরণ দাও। ২. প্রতিটি কাজের সস্তা আর দামি দুই ক্ষেত্রেই actual + ΔΦ আলাদা করে হিসাব করো, আর আমার হিসাবের সাথে মেলাও। ৩. একটি কাজের worst case আর amortized খরচ আলাদা করে লেখো — "average" শব্দটি ব্যবহার কোরো না। ৪. সবচেয়ে খারাপ একটি কাজের ক্রম বানাও, যেটিতে মোট খরচ সর্বোচ্চ হয়, আর দেখাও মোট তবু আমার সীমার মধ্যে।

Amortized দাবি যাচাইয়ের চেকলিস্ট

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

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

প্র ০১১৯৮৫ সালের আগেও তো সবাই জানত vector-এর মতো কৌশল দ্রুত — তাহলে Tarjan-এর “Amortized computational complexity” প্রবন্ধটি নতুন কী দিল?

সত্যিই, amortized ধাঁচের যুক্তি ১৯৮৫-র আগেই ছিল। ১৯৭০-এর দশকে union-find-এর বিশ্লেষণে (Hopcroft ও Ullman ১৯৭৩, Tarjan ১৯৭৫) পুরো ক্রমের মোট খরচই গোনা হয়েছিল, কারণ path compression-এ কোনো একটি find-এর খরচ আলাদা করে বলা যায় না। দ্বিগুণ করে বাড়ানো টেবিলের কৌশলও প্রোগ্রামাররা জানতেন। কিন্তু এগুলো ছিল আলাদা আলাদা কৌশল, প্রতিটি সমস্যার জন্য নতুন করে বানানো যুক্তি — কোনো সাধারণ নাম বা কাঠামো ছিল না।

Tarjan-এর প্রবন্ধ তিনটি কাজ করে। প্রথমত, ধারণাটির একটি নাম আর স্পষ্ট সংজ্ঞা দেয়: কাজের যেকোনো ক্রমে মোট খরচের সীমা, প্রতি কাজে ভাগ করে। দ্বিতীয়ত, দুটি সাধারণ প্রমাণ-পদ্ধতি দেয় — ব্যাংকারের দৃষ্টি (কয়েন জমা) আর পদার্থবিদের দৃষ্টি (potential function) — যা মূলত একই হিসাবের দুই রূপ। তৃতীয়ত, দেখায় যে এই দৃষ্টিতে কিছু ডেটা স্ট্রাকচার, যেগুলো worst case-এ খারাপ দেখায়, আসলে চমৎকার। “Self-adjusting” ডেটা স্ট্রাকচারের পুরো ধারা — splay tree, skew heap, move-to-front list — এই কাঠামো ছাড়া বিশ্লেষণ করাই যেত না।

একই সময়ে Sleator ও Tarjan-এর আরেকটি প্রবন্ধ (“Amortized efficiency of list update and paging rules”, ১৯৮৫) একটি ঘনিষ্ঠ ধারণা দেয়, যা পরে competitive analysis নামে পরিচিত হয় — একটি অনলাইন অ্যালগরিদমের মোট খরচকে সবকিছু আগে জানা একটি সর্বোত্তম অ্যালগরিদমের খরচের সাথে তুলনা। Cache-এর LRU নীতি কেন ভালো, তার তাত্ত্বিক ব্যাখ্যা সেখান থেকে আসে। অ্যালগরিদম আর ডেটা স্ট্রাকচারের ডিজাইন ও বিশ্লেষণে মৌলিক অবদানের জন্য ১৯৮৬ সালে Hopcroft ও Tarjan Turing Award পান।

আজ CLRS-সহ প্রায় সব পাঠ্যবইয়ে amortized analysis একটি পুরো অধ্যায়, আর C++ standard নিজেই push_back-এর জন্য “amortized constant time” দাবি করে — একটি গবেষণা-ধারণা ভাষার নিয়মে পরিণত হয়েছে।

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

প্র ০২Splay tree কোনো ভারসাম্যের তথ্য রাখে না, তবু কীভাবে amortized O(log n) দেয় — আর “dynamic optimality” প্রশ্নটি কী?

AVL বা red-black tree (পর্ব ২৮–২৯) প্রতিটি node-এ উচ্চতা বা রং রাখে আর প্রতিটি কাজের পর ভারসাম্য ঠিক করে, যাতে প্রতিটি কাজ worst case O(log n). Sleator ও Tarjan ১৯৮৫ সালে একটি সাহসী বিকল্প দেন: কিছুই রাখবেন না। যে node-এ প্রবেশ করলেন — খোঁজা, যোগ বা মোছা — তাকে একটি নির্দিষ্ট ধরনের rotation-এর ধারায় (zig, zig-zig, zig-zag) root-এ তুলে আনুন। একে বলে splaying. Tree কখনো কখনো একটি লম্বা শিকলের মতো হয়ে যায়, আর তখন একটি খোঁজা Θ(n). তাহলে লাভ কোথায়?

উত্তর potential function-এ। প্রতিটি node x-এর জন্য r(x) = log2(x-এর subtree-র আকার), আর Φ = সব node-এর r-এর যোগফল। লম্বা শিকলে Φ বড় (অনেক বড় subtree একটির নিচে আরেকটি), সুষম tree-তে ছোট। মূল প্রমাণ — “access lemma” — দেখায় যে x-কে splay করার amortized খরচ ≤ 3(r(root) − r(x)) + 1 = O(log n). দামি splay শিকলটিকে প্রায় অর্ধেক উচ্চতায় ভাঁজ করে Φ অনেক কমিয়ে দেয়; সেই কমা থেকেই দামি কাজের খরচ মেটে। তাই m কাজে মোট O((m + n) log n).

আরও আশ্চর্য, splay tree ঘনঘন ব্যবহৃত key-কে নিজে থেকেই root-এর কাছে রাখে। কয়েকটি key বারবার চাইলে (working set) তারা দ্রুত আসে; key-গুলো ক্রমানুসারে চাইলে (sequential access) মোট O(n). Sleator ও Tarjan অনুমান করেন যে splay tree যেকোনো BST অ্যালগরিদমের চেয়ে, এমনকি পুরো প্রশ্নের ক্রম আগে জেনে বসানো tree-র চেয়েও, সর্বোচ্চ একটি ধ্রুবক গুণ ধীর। এটিই dynamic optimality conjecture — ৪০ বছর পরও প্রমাণ বা খণ্ডন হয়নি, তাত্ত্বিক কম্পিউটার বিজ্ঞানের বিখ্যাত খোলা প্রশ্নগুলোর একটি।

বাস্তবে splay tree-র দুর্বলতাও amortized প্রকৃতি থেকে আসে: প্রতিটি খোঁজাও tree বদলায় (একসাথে অনেক thread পড়লে সমস্যা), আর একটি একক কাজ দামি হতে পারে। তবু কিছু compiler আর অপারেটিং সিস্টেমের ভেতরের টেবিলে splay tree ব্যবহৃত হয়েছে, যেখানে সাম্প্রতিক জিনিস আবার চাওয়ার সম্ভাবনা বেশি।

মূল উপলব্ধি: ভারসাম্য রক্ষার জন্য প্রতিটি ধাপে সাবধান হতে হয় না; খারাপ অবস্থাকে “শক্তি জমা” হিসেবে দেখলে দামি কাজ নিজেই অবস্থা ঠিক করে দেয়। Potential method এই চিন্তাকে প্রমাণে রূপ দেয়।

প্র ০৩Garbage collector-এর “থেমে যাওয়া” আর hash table-এর rehash — amortized ভালো হলেও বড় সিস্টেমগুলো কেন এদের ধাপে ধাপে করে?

Java, Go, C# বা JavaScript-এর মতো ভাষায় garbage collection অব্যবহৃত মেমরি ফেরত নেয়। সরলতম collector প্রোগ্রাম থামিয়ে পুরো heap ঘুরে দেখে — “stop the world”. Amortized হিসাবে এটি চমৎকার: প্রতি বরাদ্দ করা বাইটের জন্য গড় খরচ ছোট, কারণ একবারের বড় পরিষ্কার অনেক বরাদ্দের খরচ একসাথে মেটায়। কিন্তু কয়েক গিগাবাইটের heap-এ সেই একটি থামা কয়েকশো মিলিসেকেন্ড বা আরও বেশি হতে পারে। একটি শেয়ারবাজারের ট্রেডিং সার্ভার, ভিডিও কল বা গেমের জন্য সেটি অগ্রহণযোগ্য। তাই আধুনিক collector-গুলো incremental আর concurrent — কাজটি ছোট ছোট টুকরোয় ভাগ করে প্রোগ্রামের সাথে পাশাপাশি চালায়, যাতে প্রতিটি থামা ছোট থাকে। মোট কাজ প্রায়ই বেড়ে যায়, কিন্তু সবচেয়ে খারাপ থামা কমে।

Hash table-এ একই গল্প। Load factor সীমা ছাড়ালে সব key নতুন বড় টেবিলে বসাতে হয় — amortized O(1), কিন্তু কোটি key-র টেবিলে একটি insert হঠাৎ কয়েক সেকেন্ড। Redis-এর মতো in-memory ডেটাবেস, যেখানে প্রতিটি অনুরোধের উত্তর মিলিসেকেন্ডের নিচে আশা করা হয়, তাই দুটি টেবিল একসাথে রাখে আর প্রতিটি কাজের সাথে পুরোনো টেবিলের কয়েকটি bucket নতুনটিতে সরায়। এটিই অংশ ৮-এর deamortization.

লক্ষ করুন, deamortization সবসময় একটি বাণিজ্য। মোট কাজ আর মেমরি একটু বাড়ে (দুই টেবিল একসাথে), কোড জটিল হয়, আর খোঁজার সময় দুই জায়গায় দেখতে হয়। ব্যাচ-কাজ বা কনটেস্টের সমাধানে, যেখানে শুধু মোট সময় মাপা হয়, সরল amortized সংস্করণই ভালো। অর্থাৎ কোনটি “দ্রুত” তা নির্ভর করে আপনি কী মাপছেন তার ওপর: throughput (মোট কাজ ÷ সময়), নাকি latency (একটি অনুরোধের সবচেয়ে খারাপ অপেক্ষা)।

মূল উপলব্ধি: Amortized বিশ্লেষণ throughput-এর ভাষা, worst case latency-র। সিস্টেম ডিজাইনের আগে ঠিক করুন কোনটি আপনার ব্যবহারকারীর কাছে গুরুত্বপূর্ণ।

প্র ০৪Functional প্রোগ্রামিংয়ে (Haskell, OCaml) দুই stack-এর queue কেন amortized O(1) হারায়, আর Chris Okasaki এর কী সমাধান দেন?

Functional ভাষায় ডেটা সাধারণত immutable — একটি queue-তে enqueue করলে পুরোনো queue বদলায় না, নতুন একটি সংস্করণ তৈরি হয়, আর পুরোনোটিও ব্যবহারযোগ্য থাকে। এমন ডেটা স্ট্রাকচারকে বলে persistent. Linked list দিয়ে বানানো দুটি stack এখানে সহজ: নতুন সংস্করণ পুরোনোর বেশিরভাগ অংশ ভাগ করে নেয়। কিন্তু amortized বিশ্লেষণের একটি গোপন অনুমান ভেঙে যায় — যে প্রতিটি অবস্থা একবারই ব্যবহার হবে।

ধরুন q এমন একটি queue, যার outbox খালি আর inbox-এ n টি উপাদান। q থেকে একবার dequeue করলে ঢালা হয় — Θ(n). Ephemeral (সাধারণ, বদলানো যায় এমন) জগতে এর পরে q আর থাকে না; জমানো potential খরচ হয়ে গেছে। কিন্তু persistent জগতে প্রোগ্রাম q-কে রেখে দিয়ে তার ওপর আবার, আবার dequeue করতে পারে — প্রতিবার Θ(n), অথচ potential জমেছিল একবারই। ব্যাংকের ভাষায়, একই কয়েন বারবার খরচ করা হচ্ছে।

Chris Okasaki তাঁর ১৯৯৬ সালের PhD গবেষণা আর ১৯৯৮ সালের বই Purely Functional Data Structures-এ এর সুন্দর সমাধান দেখান: lazy evaluation আর memoization. দামি কাজটিকে আগে থেকে একটি “স্থগিত হিসাব” হিসেবে তৈরি করে রাখা হয়, যা প্রথমবার দরকার হলে চলে আর ফল মনে রাখে। একই পুরোনো সংস্করণ থেকে দশবার চাইলেও হিসাবটি একবারই হয়, বাকি নয়বার মনে রাখা ফল ফেরত আসে। তিনি ব্যাংকারের আর পদার্থবিদের পদ্ধতিকে এই “ঋণ” (debit) ধারণায় নতুন করে লেখেন, যাতে persistent ব্যবহারেও amortized সীমা টেকে। আরও এগিয়ে, স্থগিত কাজগুলো নিয়মিত একটু একটু করে আগাম চালিয়ে (scheduling) worst case O(1)-এর queue-ও পাওয়া যায় — Hood ও Melville-এর ১৯৮১ সালের real-time queue-এর আরও সরল রূপ।

মূল উপলব্ধি: প্রতিটি amortized প্রমাণের পেছনে একটি অনুমান আছে — অবস্থা একবারই ব্যবহার হয়। Undo, persistence বা backtracking-এ সেই অনুমান ভাঙে, তাই সীমাও ভাঙতে পারে।

প্র ০৫কনটেস্টে “ভেতরে while লুপ, তবু O(n)” — কীভাবে চিনবেন কোথায় amortized যুক্তি খাটে, আর কোথায় খাটে না?

Codeforces বা ICPC-ধাঁচের সমস্যায় n = 2 × 105 হলে O(n²) চলে না। অথচ অনেক সঠিক সমাধানে একটি for-এর ভেতরে একটি while থাকে, যা এক ধাপে অনেকবার ঘুরতে পারে। Sliding window-এর বাম pointer (পর্ব ১৬), monotonic stack-এর “ছোটগুলো pop করো” (পর্ব ১৩), KMP-র prefix function (পর্ব ৫৮), monotonic deque-এ sliding window minimum — সবগুলোতে মোট O(n). চেনার প্রশ্ন একটাই: ভেতরের লুপের প্রতিটি ঘোরা কোন রাশিকে স্থায়ীভাবে বদলায়, আর সেই রাশি মোট কতটা বদলাতে পারে?

Two pointer-এ বাম pointer শুধু ডানে যায়, সর্বোচ্চ n বার — এটি potential Φ = n − left. Monotonic stack-এ প্রতিটি উপাদান একবার push, সর্বোচ্চ একবার pop — multipop-এর হুবহু যুক্তি। KMP-তে k প্রতি অক্ষরে সর্বোচ্চ ১ বাড়ে, প্রতিটি লাফে কমে, আর ০-এর নিচে যায় না। সব ক্ষেত্রে একটি সীমিত “বাজেট” আছে, আর ভেতরের লুপ সেই বাজেট খরচ করে।

কোথায় খাটে না? যখন সেই রাশি আবার পেছনে যেতে পারে। যেমন sliding window-এ প্রতিটি নতুন ডান প্রান্তে বাম pointer-কে আবার শুরুতে ফিরিয়ে আনলে আর amortized কিছু নেই — Θ(n²). Binary counter-এ শুধু increment থাকলে ২n, কিন্তু increment আর decrement দুটো মিশালে 0111…1 আর 1000…0-এর মধ্যে আসা-যাওয়া করে প্রতিটি কাজ Θ(k) হয়। আর undo বা rollback থাকলে (প্রশ্ন ০৪) জমানো শক্তি বারবার খরচ হয়। Union-find-এ rollback দরকার হলে তাই path compression বাদ দেওয়া হয় (পর্ব ৩৩).

যাচাইয়ের সবচেয়ে নির্ভরযোগ্য উপায়: কোডে ভেতরের লুপের মোট ঘোরা গোনার একটি counter বসান, তারপর সবচেয়ে খারাপ ইনপুট বানান — সাজানো, উল্টো সাজানো, সব সমান, “aaaa…ab” ধাঁচের string. Counter যদি n-এর কয়েক গুণের মধ্যে থাকে, যুক্তি ঠিক; n²-এর মতো বাড়লে কোথাও pointer পেছনে যাচ্ছে।

মূল উপলব্ধি: Amortized O(n) চেনার চাবি হলো একটি একমুখী রাশি — যা শুধু বাড়ে বা শুধু কমে, আর যার মোট পরিবর্তন সীমিত। রাশিটি খুঁজে পেলে প্রমাণ এক লাইনের।

অনুশীলন

খাতা-কলম নিন। প্রথম দুটি হাতে trace করার, তৃতীয়টি ভাবার, শেষটিতে কোড লিখতে হবে।

  1. হাতে trace করুন: একটি ৫-বিটের binary counter 01011 (= ১১) থেকে শুরু। পরপর ৫টি INCREMENT চালান। প্রতিটির flip-সংখ্যা লিখুন, মোট বের করুন, আর potential Φ = 1-এর সংখ্যা দিয়ে মোটটি মিলিয়ে দেখান।

    01011 → 01100: ৩ flip. 01100 → 01101: ১. 01101 → 01110: ২. 01110 → 01111: ১. 01111 → 10000: ৫. মোট ১২.

    প্রতিটি increment-এর amortized খরচ ২, তাই Σĉ = ১০. সূত্র: Σc = Σĉ − (Φশেষ − Φশুরু) = ১০ − (১ − ৩) = ১২ ✓. লক্ষ করুন, এখানে মোট ১২ > 2n = ১০ — কারণ শুরুতে Φ = ৩, শূন্য নয়। শুরুর জমানো শক্তি (তিনটি 1) এই ক্রম খরচ করেছে। সাধারণ সীমা: Σc ≤ 2n + Φশুরু.

  2. হাতে trace করুন: খালি two-stack queue-তে চালান: enqueue 1, enqueue 2, enqueue 3, dequeue, enqueue 4, dequeue, dequeue, dequeue. প্রতিটি কাজের পর inbox আর outbox (নিচ থেকে ওপরে) লিখুন, আর stack-ধাপে প্রতিটির খরচ।

    enq 1, 2, 3: inbox [1, 2, 3], প্রতিটি খরচ ১. প্রথম dequeue: outbox খালি, তাই তিনটি ঢালা (৬ ধাপ) → outbox [3, 2, 1], তারপর pop → 1 বের হলো; খরচ ৭. enq 4: inbox [4], খরচ ১. dequeue → 2, খরচ ১. dequeue → 3, খরচ ১. dequeue: outbox খালি, 4 ঢালা (২) + pop → 4, খরচ ৩.

    মোট = ৩ + ৭ + ১ + ১ + ১ + ৩ = ১৬. Amortized সীমা: ৪টি enqueue × ৩ + ৪টি dequeue × ১ = ১৬ — ঠিক সমান, কারণ প্রতিটি উপাদানই ঢালা হয়েছে (প্রতিটির জীবনে ৪টি stack-ধাপ)। বের হওয়ার ক্রম 1, 2, 3, 4 — FIFO ঠিক আছে।

  3. ভেবে বলুন: (ক) binary counter-এ DECREMENT-ও যোগ করলে n কাজে মোট flip কি তবু O(n)? (খ) Dynamic array-এ pop আছে কিন্তু কখনো ছোট করা হয় না — Φ = 2·size − capacity কি তবু কাজ করে?

    (ক) না। counter-কে 0111…1-এ আনুন (k − 1 টি 1), তারপর পর্যায়ক্রমে increment আর decrement: 0111…1 → 1000…0 → 0111…1 … প্রতিটি কাজে k টি flip, তাই n কাজে Θ(nk). Φ = 1-এর সংখ্যা এখানে কাজ করে না, কারণ decrement 0-গুলোকে 1 বানিয়ে Φ বাড়ায়, সাথে আসল খরচও বড় — দুটি একসাথে বড়, মেটানোর শক্তি নেই।

    (খ) সরাসরি না: pop size কমায়, তাই ΔΦ = −2, আর pop-এর amortized খরচ ১ − ২ = −১ — সমস্যা নয়, কিন্তু অনেক pop-এর পর Φ ঋণাত্মক হতে পারে (size < capacity/2), আর Φ ≥ 0 শর্ত ভাঙে। ঠিক করতে Φ = max(0, 2·size − capacity) নিন। pop-এ ΔΦ ≤ 0, তাই ĉ ≤ ১; push-এ ĉ ≤ ৩ (Φ ০ থেকে শুরু হলেও বাড়ে সর্বোচ্চ ২)। ফল: push আর pop দুটিই amortized O(1), শুধু মেমরি ফেরত আসে না।

  4. কোড লিখুন: push আর pop সমর্থন করা একটি dynamic array-এর খরচ-সিমুলেটর লিখুন (দ্বিগুণে বড়, আর size ≤ capacity/s হলে capacity অর্ধেক, s = 2 বা 4). capacity ১ থেকে শুরু করে ১০২৪ বার push, তারপর ১০০০ বার (push, pop) — দুই নিয়মে মোট কপি তুলনা করুন।
    def run(s):                       # s = 2 (shrink at half) or 4 (at quarter)
        size, cap, copies = 0, 1, 0
        def push():
            nonlocal size, cap, copies
            if size == cap:
                copies += size; cap *= 2
            size += 1
        def pop():
            nonlocal size, cap, copies
            size -= 1
            if cap > 1 and size <= cap // s:
                cap //= 2; copies += size
        for _ in range(1024): push()
        for _ in range(1000): push(); pop()
        return copies
    
    print(run(2), run(4))             # 2049023 2047

    প্রথম ১০২৪ push-এ কপি 1 + 2 + … + 512 = ১০২৩, capacity ১০২৪, পুরো ভরা। নিয়ম ½: প্রতিটি push ১০২৪ কপি করে capacity ২০৪৮, প্রতিটি pop size ১০২৪ = ২০৪৮/২ দেখে আবার ১০২৪ কপি — মোট ১০২৩ + ১০০০ × ২০৪৮ = 2049023. নিয়ম ¼: প্রথম push একবার ১০২৪ কপি করে, তারপর size ১০২৪–১০২৫-এর মধ্যে থাকে, যা ২০৪৮/৪ = ৫১২-এর অনেক ওপরে — আর কোনো কপি নেই, মোট 2047. হাজার গুণের পার্থক্য, শুধু একটি সংখ্যা বদলে।

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

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

ক্রমPotential method দিয়ে একটি amortized সীমা প্রমাণের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. ডেটা স্ট্রাকচারের অবস্থা থেকে একটি potential function Φ বেছে নেওয়া
  2. দেখানো যে শুরুতে Φ = 0 আর প্রতিটি সম্ভাব্য অবস্থায় Φ ≥ 0
  3. প্রতিটি কাজের সস্তা আর দামি দুই ক্ষেত্রে আসল খরচ + ΔΦ হিসাব করা
  4. প্রতিটি কাজের ধরনের amortized খরচের একটি সীমা পাওয়া
  5. Telescoping যোগফল থেকে সিদ্ধান্ত: মোট আসল খরচ ≤ amortized খরচগুলোর যোগফল

একটি dynamic array প্রতিবার ভরে গেলে capacity ঠিক ১০০ ঘর বাড়ায় (গুণ নয়, যোগ)। খালি অবস্থা থেকে n টি push_back-এর প্রতি push-এর amortized খরচ কত?

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

৩২ বিটের একটি binary counter শূন্য থেকে শুরু করে n বার increment করা হলো (n < 232). মোট বিট-flip সম্পর্কে কোনটি সঠিক?

  • ঠিক 32n
  • Θ(n log n)
  • 2n-এর কম
  • n-এর কম

“একটি অপারেশনের amortized খরচ O(1)” — এই বক্তব্যের সঠিক অর্থ কোনটি?

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

Potential method-এ একটি অপারেশনের আসল খরচ ৯, অপারেশনের আগে Φ = 8 আর পরে Φ = 2. এর amortized খরচ কত?

  • 3
  • 15
  • 19
  • 9

একটি dynamic array ভরলে capacity দ্বিগুণ করে। ছোট করার নিয়ম “size ≤ capacity/2 হলে capacity অর্ধেক” না রেখে “size ≤ capacity/4 হলে অর্ধেক” রাখা হয় কেন?

  • মেমরি আরও কম লাগে বলে
  • সীমানায় পর্যায়ক্রমে push আর pop করলে বারবার বড়-ছোট হওয়া (thrashing) এড়াতে
  • প্রতিটি ছোট করায় কপি কম হয় বলে
  • Random access O(1) রাখতে

দুটি stack (inbox, outbox) দিয়ে বানানো queue-তে dequeue: outbox খালি হলে inbox-এর সব উপাদান outbox-এ ঢালা হয়। n টি উপাদান থাকলে dequeue-এর worst case আর amortized খরচ কত?

  • worst case O(1), amortized O(1)
  • worst case O(n), amortized O(n)
  • worst case O(n), amortized O(1)
  • worst case O(log n), amortized O(1)

শব্দকোষ

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

মনে রাখুন

  1. Amortized = যেকোনো ক্রমের মোট worst-case খরচ ÷ কাজের সংখ্যা; কোনো সম্ভাবনা নেই — average case নয়।
  2. Aggregate: মোট গুনুন; accounting: দাম ঠিক করে কয়েন জমান, ব্যাংক ≥ ০; potential: ĉ = c + ΔΦ, Φ(D0) = 0, Φ ≥ 0.
  3. Dynamic array: গুণ করে বাড়ালে (g = 2 বা 1.5) push amortized O(1), কপি < n·g/(g − 1); যোগ করে বাড়ালে Θ(n) প্রতি push. Φ = 2·size − capacity.
  4. Binary counter: n increment-এ flip < 2n (Φ = 1-এর সংখ্যা); multipop আর দুই stack-এর queue: প্রতিটি উপাদান একবার ঢোকে, একবার সরে — O(1) amortized.
  5. ছোট করুন ¼-এ, ½-এ নয় (thrashing); আর amortized ≠ real-time — latency-সংবেদনশীল সিস্টেমে deamortization লাগে।

পরের পর্বে: অ্যালগরিদম নিজেই যদি পয়সা ছুড়ে সিদ্ধান্ত নেয়? Randomized অ্যালগরিদম — Las Vegas আর Monte Carlo, আর expected সময়ের নিশ্চয়তা কীভাবে amortized থেকে আলাদা।

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

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