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

Amortized Analysis — মাঝে মাঝে দামি, গড়ে সস্তা

Amortized Analysis

Amortized analysis দামি অপারেশনের খরচ সস্তাদের ওপর ছড়িয়ে দেয় — dynamic array-র n append-এর মোট খরচ 3n প্রমাণ করে দেখায় যে প্রতি-append গড়ে constant, যদিও একক append মাঝে মাঝে linear।

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

  • Dynamic array doubling-এর উপর aggregate, accounting, potential — তিনটা পদ্ধতিতেই n append-এর মোট খরচ 3n-এর প্রমাণ নিজের হাতে লিখতে পারবেন
  • কেন per-operation worst case misleading হতে পারে, আর amortized bound ঠিক কী promise করে (sequence guarantee, কোনো probability না) তা ব্যাখ্যা করতে পারবেন
  • Multipop stack আর binary counter-এ একই তিন পদ্ধতি প্রয়োগ করে amortized cost বের করতে পারবেন
  • Geometric বনাম arithmetic expansion-এর পার্থক্য দেখিয়ে বলতে পারবেন কেন fixed-size increment করা array-র মোট খরচ quadratic হয়
  • Amortized bound আর average-case bound-এর মধ্যে ফারাক — একটা deterministic guarantee, আরেকটা distribution assumption — concrete উদাহরণে আলাদা করতে পারবেন

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

আগে এটা বুঝি

গত লেসনে আমরা growth-এর ভাষা শিখলাম। সেই ভাষায় একটা বিখ্যাত বিরোধ আছে, যেটা প্রায় প্রতিটা নতুন student-কে থামিয়ে দেয়। Python-এর documentation বলে list.append-এর complexity O(1)। আপনি গত লেসনের জ্ঞানে বই খুলে দেখলেন: append করতে গিয়ে যখন array ভর্তি, Python-কे নতুন বড় block allocate করে পুরো পুরনো content copy করতে হয় — অর্থাৎ ওই একটা append-এর খরচ তো current size-এর সমান, মানে O(n)! Documentation কি মিথ্যা বলছে?

না। দুই বাক্যই সত্য — কারণ তারা দুই ভিন্ন প্রশ্নের উত্তর। “একটা append সবচেয়ে খারাপ কত খরচ করতে পারে?” — উত্তর O(n)। “append-গুলোর একটা long sequence-এ গড়ে প্রতি append কত?” — উত্তর O(1)। দ্বিতীয়টাই amortized analysis: দামি মুহূর্তগুলোর খরচ সস্তা মুহূর্তগুলোর ওপর ছড়িয়ে দেওয়া। কফি shop-এর loyalty card-এর মতো — নয়টা coffee সস্তা, দশমটা “ফ্রি” না, দশটার খরচই গড়ে ভাগ হয়ে গেছে।

আজকের প্রশ্ন তিনটা: (১) sequence-এর মোট খরচ ঠিকভাবে upper-bound করা যায় কীভাবে? (২) প্রমাণের জন্য তিনটা আলাদা হিসাব-রীতি — aggregate, accounting, potential — আসলে কী ভিন্ন চোখ? (৩) এই guarantee কোথায় ভাঙে, আর per-operation latency কখন amortized গড়কে চ্যাটার করে?

মূল ধারণা

Dynamic array — সমস্যার সেটআপ

আমাদের running example: capacity c-র একটা array, size s। Append-এর নিয়ম: slot খালি থাকলে লিখে দাও — খরচ 1। Array ভর্তি হলে দ্বিগুণ capacity-র নতুন array বানাও, সব পুরনো element copy করো, তারপর লেখো — খরচ s + 1। এখন n টা append করলে মোট খরচ কত?

Copy হয় শুধু capacity 1, 2, 4, 8, …, অর্থাৎ সব power of two-তে, n-এর আগ পর্যন্ত। মোট copied elements:

1+2+4+⋯+2⌊log⁡2(n−1)⌋≤2n−11 + 2 + 4 + \cdots + 2^{\lfloor \log_2 (n-1) \rfloor} \le 2n - 1

Geometric series-এর ক্লাসিক identity — sum সবসময় last term-এর দ্বিগুণের কম। Slot-write ঠিক n বার। সুতরাং মোট খরচ ≤ n + (2n−1) + resizes-এর খুদ্র overhead ≈ 3n। Per-append amortized খরচ ≤ 3 — একটা constant! দামি O(n) append এখনও ঘটে সত্যি, কিন্তু তাদের সংখ্যা মাত্র log₂ n টা — গড়ের হিসাবে সব ডুবে যায়।

পদ্ধতি ১ — Aggregate: সবার মোট হিসাব

সবচেয়ে direct: সবচেয়ে খারাপ sequence-এর মোট খরচ T(n) bound করো, তারপর per-op গড় = T(n)/n। Dynamic array-তে T(n) ≤ 3n, তাই amortized ≤ 3। একটাও probability লাগে না — এটা প্রতিটা possible sequence-এর উপর absolute দাবি।

আরেকটা উদাহরণ — multipop stack: push খরচ 1, pop খরচ 1, আর multipop(k) স্ট্যাকের top-এর k টা element ফেলে — খরচ min(k, s)। একটা multipop একাই O(n) হতে পারে! কিন্তু n টা operation-এর যেকোনো sequence-এ মোট খরচ কত? প্রতিটা element স্ট্যাকে ঢুকেছে ঠিক একবার (push), আর বেরোবার আগে ঢুকতে হয় — তাই total pops ≤ total pushes ≤ n। মোট খরচ ≤ 2n → প্রতিটা অপারেশন amortized O(1)। দামি multipop আসলে আগের push-গুলোর ঋণ শোধ মাত্র।

পদ্ধতি ২ — Accounting: প্রতিটাকে বেশি টাকা দাও

প্রতিটা append-কে charge করো ৩ unit (আসল খরচ 1 হোক বা s+1 হোক):

  • 1 unit যায় নিজের slot-write-এ
  • বাকি 2 unit element-টার savings account-এ জমে

Resize-এর দিন প্রতিটা moved element-এর খরচ 1 — আর তার account-এ তখন 2 জমা (এই জীবনে দ্বিতীয়বার move হওয়ার আগে আবার 2 করে জমবে)। Account কখনো negative হয় না — এটাই accounting method-এর validity condition। যে খরচ আজকের charge-এ ঢেকে যায় না, তার জন্য আগের কারো জমানো টাকা আছেই। মজার observation: আমরা ঠিক 3 charge করলাম কারণ aggregate বলে গড় ≤ 3 — accounting method নিজে থেকে গড় আবিষ্কার করে না, সে aggregate-এর জ্ঞানকে per-element হিসাবে রূপ দেয়।

পদ্ধতি ৩ — Potential: গোটা structure-এর ঋণ-পত্র

Accounting প্রতি element-এ হিসাব রাখে; potential পুরো data structure-এর একটা scalar “ঋণ” function রাখে:

Φ(D)=2⋅size(D)−capacity(D)\Phi(D) = 2 \cdot \text{size}(D) - \text{capacity}(D)

Amortized cost = actual cost + Φ(after) − Φ(before)। তিনটা কেস:

কেসActualΔΦAmortized
Normal append (slot ছিল)1+23
Resize append (s == c ছিল)s + 1(2(s+1) − 2s) − (2s − s) = 2 − s(s+1) + (2−s) = 3
প্রথম append (capacity 0 → 1)1+12

সব কেসে amortized cost constant — potential নিজেই দামি মুহূর্তের খরচ absorb করে নিচ্ছে, কারণ resize-এর আগে Φ ছিল বিশাল (s), মানে গোটা structure ততক্ষণ ঋণ জমিয়ে বসে ছিল। Validity শর্ত: Φ ≥ 0 সবসময় — convention নিই শূন্য array-র capacity 0, চাইলেই grow (তাই শুরুতে Φ = 0); এরপর capacity কখনো size-এর দ্বিগুণের বেশি হয় না, অর্থাৎ 2s ≥ c বজায় থাকে। তাহলে মোট actual ≤ মোট amortized = 3n-এর নিচে।

Binary counter — আরেকটা ক্লাসিক

k-bit binary counter, increment অপারেশন: নিচের বিট থেকে carry propagate। 000111-কে increment করলে ৩টা bit flip (111→000 carry, 0→1)। Worst case একটা increment-এ k টা flip — O(k)। কিন্তু n টা increment-এ মোট? Bit 0 প্রতিটা increment-এ ফ্লিপ করে, bit 1 প্রতি দ্বিতীয়টায়, bit i প্রতি 2^i-তম increment-এ। মোট flip:

∑i=0k−1n2i≤2n\sum_{i=0}^{k-1} \frac{n}{2^i} \le 2n

Per-increment amortized ≤ 2 flips — যদিও একক increment log n বিট ঘুরিয়ে দিতে পারে। একই geometric-series স্বাদ, নতুন costume-এ।

Average case বনাম amortized — সূক্ষ্ম কিন্তু অনড়

দুটোই “গড়”-এর কথা বলে, কিন্তু ভিন্ন জিনিসের গড়:

AmortizedAverage-case
গড়টা কীসের উপরoperation-এর ক্রমের উপর (time)input-এর distribution-এর উপর
Assumptionকোনোটাই না — সব sequence-এ সত্যএকটা specific probability model
Quicksort—O(n log n) average (random pivot assumption)
list.appendO(1) amortized, unconditional—

Adversary আপনার input ইচ্ছেমতো দুষ্ট করলেও amortized bound টিকে থাকে — quicksort-এর average bound ভেঙে পড়ে (sorted input + খারাপ pivot rule)। Production system-এর ভাষায়: amortized হলো SLA, average হলো hope।

ভেতরে কী ঘটছে

CPython আসলে কী করে

CPython-এর list pure doubling করে না। Growth pattern আনুমানিক ~1.125× (নতুন capacity ≈ old + old/8 + constant)। কেন এত লোভী না? কারণ over-allocated slot-গুলো বাস্তব memory খায় — doubling গড়ে ~২৫% slot খালি রাখে, 1.125 রাখে ~৬%। Copy-র মোট সংখ্যা বেড়ে যায় (factor f হলে total copies ≈ n/(f−1)), কিন্তু C-স্তরের copy এত দ্রুত (memcpy) যে সেই লস মেমরি-saving-এর কাছে মূল্যবান না। Java ArrayList বেছেছে 1.5, C++ vector standard-এ 2 (implementation-defined)। তিনটা ecosystem, একই theory, তিন রকম trade-off point — geometric constraint-এর ভেতরের স্বাধীনতা এটুকুই।

Latency — যখন গড় যথেষ্ট না

Audio callback প্রতি ৫ ms-এ চলবেই, গড়ে না। Frame budget, trading system deadline, request tail-latency SLO — সবখানে প্রশ্নটা “worst single moment” — amortized গড় সেখানে প্রায় অর্থহীন। Dynamic array-র resize spike production system-এ বাস্তব সমস্যা: এক কোটি element-এর vector-এর একটা append হঠাৎ করে ১০⁷ element copy শুরু করলে latency graph-এ স্পাইক ধরা পড়বেই। প্রফেশনাল প্রতিকার: preallocation (reserve upfront), incremental flush/compaction strategies, বা chunked structure (deque — পরের লেসনগুলোর বিষয়)। Amortized analysis throughput-এর ভাষা; latency-র প্রশ্নে আপনাকে আলাদা করে ভাবতেই হবে।

একটা append-এর জীবন — amortized চশমায়
  1. Call sitelist.append(v) — user দেখছে একটা অপারেশন, খরচ যা-ই হোক
  2. Capacity checksize == capacity ? এই তুলনাই fast/slow path-এর দারোয়ান
  3. Fast pathslot-write + size++ — খরচ 1, potential 2 unit জমে — এরাই resize-এর প্রিমিয়াম জমায়
  4. Slow pathrealloc + s টা copy — খরচ s+1, কিন্তু প্রতিটা element-এর জমানো credit এখানেই খরচ হয়
  5. LedgerΦ = 2s − c আপডেট — গোটা history মিলিয়ে প্রতি append গড়ে 3-এর বেশি কখনো না

উদাহরণ

হাতে-হাতে: ১০টা append-এর পূর্ণ লেজার

Capacity 1 থেকে শুরু, doubling। প্রতিটা append-এর actual খরচ, জমা credit (accounting view, প্রতি append চার্জ 3), আর balance:

Append #Capacity আগে/পরেCopiesActualChargeBank after
11 → 21231
22 → 42331
34 → 40133
44 → 84531
58 → 80133
68 → 80135
78 → 80137
88 → 168931
916 → 160133
1016 → 160135

মোট actual = 27, মোট charge = 30, bank কখনোই negative হয়নি — minimum 1। Per-append actual গড় = 2.7 — এবং n বাড়ালে এই গড় 3-এর দিকে তাক করে, কখনো পার করে না। Bank-এর balance কখনো না কখনো resize-এর পর 1-এ নেমে আসে — ঠিক ততটাই credit জমেছিল যতটা দরকার ছিল। এটাই accounting method- এর সৌন্দর্য: abstract inequality-টা একটা দৃশ্যমান হিসাবের বই হয়ে যায়।

ছবিতে

Doubling timeline — খরচ কোথায় জমে কোথায় ফোটে

appends:   1    2    3    4    5    6    7    8    9  ...  16
capacity:  1→2  2→4   4         4         4   8→16     16
cost:      2    3    1    5    1    1    1    9    1  ...  17
           ▄▄   ▀▀   ▄    ██   ▄    ▄    ▄    ████ ▄       ████████
                ↑         ↑                   ↑          ↑
              resize     resize             resize     resize
           সস্তা append-গুলোর জমানো credit প্রতিটা spike-এই খরচ হয়

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

EXPERIMENT

CPython-এর গোপন resize — sizeof trace আর per-append spike

Linux/macOS, Python 3.8+ (CPython)· ১৫ মিনিট
# amortize_lab.py — list.append-এর hidden cost moments
# চালান: python3 amortize_lab.py
import sys, time

N = 100_000
xs = []
sizes_seen = {}
jump_at = []
spike_ns = []

for i in range(N):
    t0 = time.perf_counter_ns()
    xs.append(i)
    dt = time.perf_counter_ns() - t0
    if dt > 500:                        # গড়ের অনেক ওপরে — candidate spike
        spike_ns.append((i, dt))
    raw = sys.getsizeof(xs)
    if raw not in sizes_seen:
        sizes_seen[raw] = i             # প্রথমবার এই capacity — এটাই resize moment
        jump_at.append((i, raw))

print(f'{N:,} appends, distinct capacities seen: {len(jump_at)}')
print('first 10 capacity jumps:')
for idx, sz in jump_at[:10]:
    print(f'  append #{idx:>7,} -> capacity block {sz:>9,} bytes')

print('top 5 slowest appends:')
for idx, dt in sorted(spike_ns, key=lambda p: p[1], reverse=True)[:5]:
    print(f'  append #{idx:>7,}: {dt:>9,} ns')

mean = sum(d for _, d in spike_ns) / max(len(spike_ns), 1)
print(f'spike count: {len(spike_ns)}, typical append well below 500 ns, '
      f'spikes reach {max(d for _, d in spike_ns):,} ns')
python3 amortize_lab.py

Typical আকৃতি (সংখ্যা machine/version-ভেদে বদলাবে, pattern বদলাবে না):

100,000 appends, distinct capacities seen: 42
first 10 capacity jumps:
  append #      0 -> capacity block       120 bytes
  append #      4 -> capacity block       184 bytes
  append #      8 -> capacity block       216 bytes
  append #     16 -> capacity block       280 bytes
  append #     24 -> capacity block       344 bytes
  ...
top 5 slowest appends:
  append #  98304: 1,204,318 ns
  append #  90112:   988,412 ns
  append #  81920:   871,006 ns
  append #  65536:   745,220 ns
  append #  73728:   512,904 ns
spike count: 61, typical append well below 500 ns, spikes reach 1,204,318 ns

তিনটা reading: (১) capacity blocks ধাপে ধাপে বাড়ছে — geometric, factor ~1.125, doubling না; (২) spike-গুলোর index বড় resize moment- গুলোর (৬৫৫৩৬, ৮১৯২০, ৯৮৩০৪ — প্রতিবার ~১.১২৫× গুণ) চারপাশে cluster করছে — একক append মাঝে মাঝে মাইক্রোসেকেন্ড খায় যখন গড় কয়েকশ nanosecond; (৩) মোট সময় এখনও N × constant — গড় ভাঙেনি।

যা প্রমাণিত: documentation-এর O(1) দাবি গড়ের দাবি — বাস্তব মেশিনে সেই গড়ের ভেতরে মাপা যায় এমন দামি মুহূর্ত থাকে, আর সেগুলোর অবস্থান theory (capacity schedule) থেকেই predict করা যায়।

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

Python list append-এর ভেতরে লুকানো capacity jump আছে — sizeof হঠাৎ লাফ দেয় নির্দিষ্ট ব্যবধানে, আর per-append timing-এ ঠিক সেই মুহূর্তগুলোতে latency spike ধরা পড়ে; গড় সময় constant থাকে, একক মুহূর্ত হাজারগুণ দামি হয় — amortized analysis-র দুই মুখ একসাথে।

নিজে বানান

BUILD IT

DynArr — নিজের dynamic array, সঙ্গে সততার লেজার

Python · ●●○○○
  1. Fixed buffer দিয়ে শুরু করুন — size, capacity, আর দুইটা counter: writes ও copies
  2. append-এ capacity full হলে doubling করে নতুন buffer-এ সব copy করুন — copies counter বাড়ান
  3. Slot-write প্রতিবার writes counter বাড়ান — এই দুই counter-ই আমাদের লেজার
  4. n টা append চালিয়ে মোট elementary op (writes + copies) প্রতি append গড়ে ছাপুন — 3-এর কাছাকাছি আসা উচিত
  5. Growth factor প্যারামিটার করে 1.25 বনাম 2 তুলনা করুন — copy count আর final slack memory দুই-ই দেখান

লক্ষ্য: theory-র 3n bound-টা নিজের structure নিজের counter দিয়ে empirically verify করা — আর growth factor-এর trade-off হাতে অনুভব করা।

# dynarr.py — manual dynamic array with honest bookkeeping
# চালান: python3 dynarr.py

class DynArr:
    __slots__ = ('_buf', '_size', 'writes', 'copies', 'resizes', 'growth')

    def __init__(self, growth=2.0):
        self._buf = [None] * 1
        self._size = 0
        self.writes = 0          # elementary slot-writes
        self.copies = 0          # elements moved during resizes
        self.resizes = 0
        self.growth = growth

    def __len__(self):
        return self._size

    def _capacity(self):
        return len(self._buf)

    def _resize(self):
        new_cap = int(self._capacity() * self.growth) + 1
        new_buf = [None] * new_cap
        for i in range(self._size):
            new_buf[i] = self._buf[i]
            self.copies += 1
        self._buf = new_buf
        self.resizes += 1

    def append(self, value):
        if self._size == self._capacity():
            self._resize()
        self._buf[self._size] = value
        self.writes += 1
        self._size += 1

    def get(self, i):
        if i >= self._size:
            raise IndexError('out of range')   # negative indexing omitted — exercise!
        return self._buf[i]


def report(name, arr, n):
    total = arr.writes + arr.copies
    slack = arr._capacity() - arr._size
    print(f'{name:<14} appends={n:>8,} writes={arr.writes:>8,} '
          f'copies={arr.copies:>8,} resizes={arr.resizes:>3} '
          f'total={total:>8,} per-append={total/n:5.2f} slack-slots={slack:,}')


if __name__ == '__main__':
    N = 200_000
    for g, name in [(2.0, 'factor 2.0'), (1.5, 'factor 1.5'), (1.25, 'factor 1.25')]:
        a = DynArr(growth=g)
        for i in range(N):
            a.append(i)
        report(name, a, N)

    # empirical amortized proof: per-append cost stays flat as n doubles
    print('\ndoubling check (factor 2.0):')
    prev = None
    for n in [50_000, 100_000, 200_000, 400_000]:
        a = DynArr()
        for i in range(n):
            a.append(i)
        total = (a.writes + a.copies) / n
        line = f'n={n:>7,}: total-ops-per-append = {total:4.2f}'
        if prev is not None:
            line += f'  (delta from previous: {total - prev:+.2f})'
        print(line)
        prev = total

প্রত্যাশিত output-এর আকৃতি:

factor 2.0     appends= 200,000 writes=  200,000 copies= 262,142 resizes= 18 total= 462,142 per-append= 2.31 slack-slots=161,056
factor 1.5     appends= 200,000 writes=  200,000 copies= 398,585 resizes= 27 total= 598,585 per-append= 2.99 slack-slots= 76,483
factor 1.25    appends= 200,000 writes=  200,000 copies= 800,004 resizes= 53 total=1,000,004 per-append= 5.00 slack-slots= 49,999

doubling check (factor 2.0):
n= 50,000: total-ops-per-append = 2.31  (delta from previous: +0.00)
n=100,000: total-ops-per-append = 2.31  (delta from previous: +0.00)
n=200,000: total-ops-per-append = 2.31  (delta from previous: +0.00)
n=400,000: total-ops-per-append = 2.32  (delta from previous: +0.01)

Reading: factor 2.0-এ per-append ≈ 2.31 (theory বলছে ≤ 3 ✓), আর doubling n করলেও per-append cost flat — এটাই amortized O(1)-এর empirical fingerprint। Factor ছোট করলে copy খরচ বাড়ছে (1.25-এ প্রায় দ্বিগুণ), কিন্তু slack memory কমছে — CPython ঠিক এই axis-এ 1.125 বেছেছে। লক্ষ্য করুন copies ≈ n/(g−1): g=2 দিলে ≈ n, g=1.25 দিলে ≈ 4n — formula আর measurement মিলে যাচ্ছে।

নিজে বাড়ান:

  1. pop() যোগ করুন — size quarter-এ নামলে capacity half করুন (hysteresis ছাড়া কেন খারাপ, oscillation বানিয়ে দেখুন)
  2. Iterator (__iter__) যোগ করুন buffer-এর active prefix-এর উপর
  3. Accounting-view assertion যোগ করুন — প্রতি append-এ 3 credit জমান, প্রতি copy-তে 1 খরচ করুন, প্রতিটা মুহূর্তে bank ≥ 0 assert করুন — theory-র inequality runtime-এ enforce হচ্ছে এটাই প্রমাণ

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

Amortized thinking বাস্তবে যেখানে দেখা যায়

প্রতিটা modern language-এর growable array। C++ vector, Java ArrayList, Go slice, Rust Vec, JavaScript array engine internals, CPython list — সবাই geometric growth, সবাই amortized O(1) append। Factor choice আলাদা (Rust 2×, Go ছোট slice-এ 2× বড়গুলোতে ~1.25×, CPython ~1.125×) কিন্তু principle এক।

Log-structured storage। LSM-tree (LevelDB, RocksDB, Cassandra) compaction মাঝে মাঝে পুরো dataset rewrite করে — দামি মুহূর্ত; write-এর গড় খরচ তবু constant — database world-এর বৃহত্তর dynamic array। Kafka segment rotation, git packfile rebuild — একই ছাঁচ।

Garbage collector-এর batch work। Generational GC minor collection মাঝে মাঝে চলে (দামি), mutation-এর মূল কাজ তবু per-object cheap — runtime designers স্পষ্টভাবে amortization কথা বলে design doc-এ। Pause-time problem-টা ঠিক এই লেসনের latency-vs-throughput tension-ই।

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

“Amortized O(1) মানে average case O(1) — একই কথা।”

ভিন্ন জিনিস। Average case input-এর উপর একটা probability distribution ধরে — সেই distribution বাস্তবের সঙ্গে না মিললে দাবিটা অর্থহীন। Amortized কোনো assumption-ই করে না: প্রতিটা সম্ভাব্য অপারেশন-sequence-এর মোট খরচ bound করে, adversary-ও ভাঙতে পারে না। list.append-এ দুষ্ট user specific pattern দিয়ে গড় খরচ বাড়াতে পারবে না; quicksort-এ পারবে (sorted input, fixed pivot) — এটাই পার্থক্যের প্রমাণ। একটা deterministic sequence-guarantee, আরেকটা statistical input-assumption।

“Amortized O(1) মানে প্রতিটা অপারেশন আসলে দ্রুত হয়ে গেছে।”

না — দামি অপারেশন আজও সমান দামি। Amortized bound বলছে মোট হিসাব ভালো, একটা নির্দিষ্ট call-এর খরচ সম্পর্কে কিছুই বলছে না। Real-time audio, frame-budgeted game loop, বা tail-latency SLO-তে এই ভুল বোঝাবুঝি production incident: গড় benchmark pass করেছে, কিন্তু প্রতি লক্ষ request-এ একটা ৪০ ms spike — user-এর কাছে সেটাই প্রোডাক্ট। গড় throughput-এর প্রশ্নে amortized analysis রাজা; single-moment deadline-এর প্রশ্নে আপনাকে worst-case বা preallocation-এর পথ নিতে হবে।

“Growth factor যত বড়, array তত ভালো — doubling সবসময় best।”

Bigger factor কম resize দেয়, কিন্তু বেশি memory অপচয় করে — factor f-এ গড়ে (f−1)/f fraction খালি: doubling-এ ৫০%, 4× growth-এ ৭৫% বরবাদ। Cache-এর দৃষ্টিতেও বড় block jump locality নষ্ট করে। আবার factor খুব ছোট (1.125) হলে copy-র মোট পরিমাণ বাড়ে n/(f−1) সূত্রে। CPython 1.125 নেয় কারণ তার list অনেক সময় small থাকে আর memory pressure বড় খরচ; Rust 2.0 নেয় systems context-এ। সঠিক প্রশ্ন: আপনার workload-এ memory tax আর copy tax-এর বিনিময় হার কী — universal optimum নেই।

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

1

তিনটা amortized method-এর নাম আর এক-এক বাক্যে মূল ভাব লিখুন। কোনটা probability ব্যবহার করে?

স্মরণ

Aggregate — পুরো sequence-এর মোট খরচ bound করে n দিয়ে ভাগ করা। Accounting — প্রতিটা অপারেশনকে বেশি charge দিয়ে surplus জমানো, ভবিষ্যতের দামি অপারেশন সেই জমা থেকে শোধ, bank কখনো negative না। Potential — structure-এর অবস্থার উপর একটা scalar ঋণ-function Φ, amortized cost = actual + ΔΦ। কোনোটাই probability ব্যবহার করে না — সব কটা deterministic; probability average-case analysis-এর জিনিস।

2

Capacity 8-এ পৌঁছানো পর্যন্ত (৮ম append পর্যন্ত) মোট copy কত হয় doubling scheme-এ? Formula n/(g−1) দিয়ে g=2 হলে estimate কত দেয়?

প্রয়োগ

Copy ঘটে capacity transition-গুলোতে: 1→2 (1 copy), 2→4 (2), 4→8 (4) — মোট 7 (৮ম append-এর নিজের কাজ শুধু slot-write)। General: 1+2+4+⋯+2^(k−1) = 2^k − 1 = n − 1 যখন n power of two। Formula n/(g−1) = 8/1 = 8 — approximation দেয় 8, আসল 7; বড় n-এ gap negligible (exact: n − 1)। এই formula-টাই factor-choice এর cost-side।

3

একটা teammate বলছে: “আমরা vector-এ reserve() আগে ডাকি না, তবু amortized O(1), তাই reserve অপ্রয়োজনীয়।” কোথায় ভুল — বিশেষত latency-sensitive service-এ?

যুক্তি

Throughput-এর হিসাবে সে ঠিক — মোট খরচ তবু 3n-এর মধ্যে। কিন্তু reserve ছাড়া resize spike গুলো রানটাইমে ঘটে: এক কোটি element-এ একটা append হঠাৎ ১ কোটি copy শুরু করে — p99.9 latency-তে স্পষ্ট স্পাইক। Latency-sensitive service-এ প্রশ্ন গড় না, worst moment — reserve করলে সেই মুহূর্তগুলো startup-এ সরিয়ে predictable করা যায়। Plus over-allocation schedule জানা থাকলে memory planning-ও সহজ। Amortized guarantee আর latency engineering দুই আলাদা লেন — একটার সত্যি হওয়া আরেকটাকে বাতিল করে না।

4

Binary counter-এর বদলে Gray code counter ব্যবহার করলে প্রতি increment-এ ঠিক ১টা bit flip হয়। তাহলে binary counter কেন standard? Amortized argument কী বলে?

ডিজাইন

Gray code increment-এ flip 1, কিন্তু পড়া দরকার হলে counter-এর মান decode করতে হয় O(k) — খরচ অপারেশন থেকে read-এ সরে গেল, মুछে যায়নি। Binary counter read O(1) (bits already are the value), increment amortized 2 flips। Total-work argument: workload এ কতবার read আর কতবার increment — সেই ratio সিদ্ধান্ত বদলায়। Amortized thinking শেখায়: একটা অপারেশনের খরচ একা না, পুরো operation-mix-এর মোট খরচ দেখে structure বাছতে হয়। (Hardware-এ বিশেষ ক্ষেত্রে Gray counter সত্যিই জেতে — async FIFO pointer-এ, যেখানে multi-bit simultaneous flip glitch তৈরি করে।)

5

Multipop stack: একটা multipop(k) worst case Θ(n)। কেন তবু n টা অপারেশনের যেকোনো sequence-এর মোট খরচ O(n)?

স্মরণ

কারণ প্রতিটা element বেরোতে পারে ঠিক একবার, আর বেরোনোর আগে ঢুকতেই হয়। Push সর্বোচ্চ n বার (প্রতি অপারেশন অন্তত একটা খরচ করে), সুতরাং pop+multipop মিলে সর্বোচ্চ n বার element remove করে — মোট removal work ≤ মোট insertion work ≤ n। দামি multipop-এর খরচ আসলে আগের push-গুলোর খরচের পুনঃপ্রদান — double counting এড়ালেই aggregate bound পরিষ্কার।

এরপর কী

এরপর কী

Amortized analysis আমাদের দিল sequence-এর সততার হিসাব — কিন্তু এখনো আমরা array-কে ভাবি abstract box হিসেবে। পরের লেসনে সেই box-টা খুলব: contiguous memory layout কীভাবে O(1) indexing-কে base + i×size arithmetic-এ নামায়, কেন spatial locality array-কে cache-এর প্রিয় বানায়, row-major traversal column-major-কে কেন বাস্তবে হারায় (নিজের মেশিনে মেপে), আর middle insertion-এর O(n) খরচ ঠিক কোথায় ঘটে। Theory আর silicon-এর প্রথম direct handshake — এটাই এই module-এর আসল স্বাদ।

পরের লেসন: Array ও Dynamic Array — memory layout থেকে performance পর্যন্ত।

আরও পড়ুন

  • Introduction to Algorithms (3rd edition), Chapter 17 — Amortized Analysis — Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, Clifford Stein · Aggregate, accounting, potential — তিন পদ্ধতির canonical treatment; dynamic table analysis এই অধ্যায়েই
  • "Amortized Computational Complexity" (SIAM Journal on Algebraic and Discrete Methods, 1985) — Robert Endre Tarjan · Amortized analysis-কে unified framework হিসেবে দাঁড় করানো মূল পেপার — potential method-এর জনক
  • Purely Functional Data Structures — Chris Okasaki · কোথায় amortized bound ভেঙে পড়ে (persistent structures, scheduling) — hood section-এর সতর্কতার উৎস