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

Array আর dynamic array — মেমরিতে পাশাপাশি

Arrays & Dynamic Arrays — Contiguous Memory, Shifting, Amortized Growth, Iterator Invalidation, 2D Layout & Classic Problems

Taught in Bangla, this lesson explains why array indexing is O(1) and middle insertion O(n), traces size, capacity and reallocation in dynamic arrays and proves amortized O(1) append. It covers growth factors, reserve, shrink_to_fit, iterator invalidation, row-major 2D layout and cache behaviour, and solves reversal, rotation, second maximum, duplicate removal and Kadane's algorithm in C++ and Python.

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

  • ঠিকানার সূত্র দিয়ে ব্যাখ্যা করা কেন array-এ index দিয়ে পড়া O(1), আর মাঝখানে insert O(n)
  • Dynamic array-এর size, capacity আর reallocation হাতে trace করা, এবং amortized O(1)-এর প্রমাণ দেওয়া
  • Growth factor 2 বনাম 1.5, reserve, shrink_to_fit আর iterator invalidation-এর বিপদ চেনা
  • 2D array-এর row-major বিন্যাস থেকে ঠিকানা বের করা, আর কোন লুপের ক্রম cache-বান্ধব তা বলা
  • উল্টানো, k ঘর rotate, max/second max, sorted array থেকে duplicate বাদ আর Kadane — O(1) বাড়তি জায়গায় লেখা

১ · Array কী — মেমরিতে পাশাপাশি ঘর

ধরুন পরীক্ষার হলে বেঞ্চে রোল নম্বর অনুযায়ী সবাই বসেছে, প্রতি বেঞ্চে একজন, বেঞ্চগুলো একটানা এক সারিতে। পরিদর্শক জানেন প্রথম বেঞ্চ দরজা থেকে ২ মিটার দূরে, আর প্রতিটি বেঞ্চ ১ মিটার চওড়া। রোল ৩৭-এর পরীক্ষার্থীকে খুঁজতে তাঁকে ১ থেকে ৩৬ পর্যন্ত সবাইকে পেরিয়ে গুনতে হয় না — তিনি মনে মনে হিসাব করেন “২ + ৩৬ × ১ = ৩৮ মিটার” আর সোজা সেখানে চলে যান। ১০০ জন হোক বা ১০,০০০ জন, হিসাব একটাই।

এটিই array-এর মূল ধারণা। Array হলো একই ধরনের উপাদানের সারি, যা মেমরিতে রাখা থাকে পাশাপাশি, ফাঁকহীন ঘরে. প্রথম ঘরের ঠিকানাকে বলে base address. প্রতিটি উপাদানের আকার যদি size বাইট হয়, তাহলে index i-এর ঠিকানা:

address(a[i]) = base + i × size

মনে আছে পর্ব ০২-এর RAM model? সেখানে ধরেছিলাম যেকোনো ঠিকানার মেমরি ঘর এক ধাপে পড়া যায়। তাহলে a[i] পড়তে লাগে একটি গুণ, একটি যোগ, আর একবার মেমরি পড়া — i যত বড়ই হোক। তাই array-এ index দিয়ে পড়া বা লেখা constant time, Θ(1). এই ক্ষমতার নাম random access: যেকোনো ঘরে সরাসরি যাওয়া, আগের ঘরগুলো না ঘুরেই। নিচে ৫টি int-এর একটি array (প্রতিটি ৪ বাইট, base 1000) দেখুন:

মেমরি^ = এখন যে ঘর পড়ছি

                
হিসাব
চাওয়া ঘর
সূত্র
ঠিকানা
পাওয়া মান
  1. বিন্যাস। int a[5] = {12, 7, 30, 4, 19}; — পাঁচটি ঘর পরপর, প্রতিটি ৪ বাইট। মোট ২০ বাইট, ঠিকানা 1000 থেকে 1019. array-এর নাম a আসলে শুধু base ঠিকানাটি মনে রাখে — দৈর্ঘ্য বা অন্য কিছু নয়।
  2. a[0]. 1000 + 0 × 4 = 1000, মান 12. index 0 মানে “base থেকে ০ ঘর দূরে” — এই কারণেই C, C++, Python, Java-তে index শূন্য থেকে শুরু।
  3. a[3]. 1000 + 3 × 4 = 1012, মান 4. a[0], a[1], a[2] ছুঁয়েও দেখতে হয়নি। a[3] হোক বা a[3000000] — একই একটি গুণ আর যোগ।
  4. a[4] — শেষ ঘর। 1016, মান 19. n টি উপাদানের বৈধ index হলো 0 থেকে n − 1. শেষ ঘরের ঠিকানা base + (n − 1) × size.
  5. a[5] — সীমার বাইরে! সূত্র নির্বিকারভাবে 1020 দেয়, কিন্তু সেখানে আমাদের array নেই — অন্য ভ্যারিয়েবল বা অন্য কিছুর ডেটা। C/C++ এটি যাচাই করে না: এটি out-of-bounds access, যা undefined behavior. Python দেয় IndexError, Java দেয় ArrayIndexOutOfBoundsException.

এখান থেকে array-এর তিনটি বৈশিষ্ট্য সরাসরি বেরিয়ে আসে, আর এই পর্বের বাকি সব কিছু এদেরই ফল:

⚡ সরাসরি যাওয়া সস্তা

a[i] পড়া বা লেখা Θ(1). binary search (পর্ব ১৮), heap (পর্ব ২৩), hash table (পর্ব ৩২) — সবাই এই ক্ষমতার ওপর দাঁড়িয়ে।

random access

🧱 সারির গঠন অনমনীয়

ঘরগুলো পাশাপাশি থাকতেই হবে। তাই মাঝখানে নতুন ঘর “ঢোকানো” যায় না — বাকিদের সরে বসতে হয়।

insert/delete ব্যয়বহুল

🚀 পরপর পড়া খুব দ্রুত

পাশাপাশি থাকায় CPU-র cache একবারে কয়েকটি ঘর নিয়ে আসে। একই Θ(n) লুপ array-এ linked list-এর (পর্ব ১২) চেয়ে বাস্তবে কয়েক গুণ দ্রুত চলে।

cache-বান্ধব

২ · Static array আর dynamic array

যে array-এর আকার তৈরির মুহূর্তেই ঠিক হয়ে যায়, তাকে বলে static array. C-তে int a[100]; বা C++-এ std::array<int, 100> — ১০০ মানে ১০০, পরে ১০১ নম্বর উপাদান রাখার উপায় নেই। ক্রিকেট স্টেডিয়ামের আসনের মতো: গ্যালারিতে আসন যতগুলো, দর্শক তার বেশি হলে আর বসানো যাবে না।

কিন্তু বাস্তব প্রোগ্রামে আমরা প্রায়ই জানি না কতগুলো উপাদান আসবে — ফাইলে কয়টি লাইন, কনটেস্টের ইনপুটে কয়টি সংখ্যা, bKash অ্যাপে আজ কয়টি লেনদেন। এর সমাধান dynamic array: বাইরে থেকে দেখতে এমন array যা ইচ্ছামতো বাড়ে, কিন্তু ভেতরে এটি একটি সাধারণ static array-ই, সাথে দুটি বাড়তি সংখ্যা:

  • size — এখন কতগুলো উপাদান আসলে আছে।
  • capacity — ভেতরের ব্লকে মোট কতগুলো ঘর নেওয়া আছে। সবসময় size ≤ capacity.

যতক্ষণ size < capacity, নতুন উপাদান শুধু পরের খালি ঘরে বসে। জায়গা ফুরালে dynamic array একটি বড় নতুন ব্লক নেয়, সব পুরোনো উপাদান সেখানে কপি করে, পুরোনো ব্লক ছেড়ে দেয় — একে বলে reallocation. এর বিস্তারিত হিসাব অংশ ৪-এ। ভাষাভেদে নাম আলাদা, ধারণা এক:

ভাষাStatic (আকার স্থির)Dynamic (আকার বাড়ে)লক্ষ করার বিষয়
Cint a[100];malloc + realloc নিজে লিখতে হয়দৈর্ঘ্য আলাদা ভ্যারিয়েবলে রাখতে হয়
C++std::array<int, N>, C-arraystd::vector<int>v[i] সীমা যাচাই করে না, v.at(i) করে
Python(সরাসরি নেই; tuple বদলানো যায় না)listঘরে থাকে object-এর reference, মান নয়
Javaint[] a = new int[100];ArrayList<Integer>ArrayList-এ primitive নয়, boxed object থাকে

Python list-এর ভেতরের চেহারা। CPython-এ list একটি dynamic array, কিন্তু তার প্রতিটি ঘরে থাকে একটি pointer — আসল সংখ্যা বা string মেমরির অন্য কোথাও আলাদা object হিসেবে বসে থাকে। তাই a[i] পড়া Θ(1) ঠিকই, কিন্তু মানগুলো নিজেরা পাশাপাশি নেই, ফলে C++-এর vector<int>-এর মতো cache-সুবিধা পুরোটা মেলে না। সংখ্যার বড় সারির সাথে দ্রুত কাজ করতে চাইলে Python-এ array মডিউল বা NumPy ব্যবহার করা হয়, যেখানে মানগুলো সত্যিই পাশাপাশি থাকে।

৩ · মাঝখানে insert আর delete — সবাইকে সরে বসতে হয়

ট্রেনের টিকিটের লাইনে কেউ যদি তৃতীয় স্থানে ঢুকতে চান (ধরুন তিনি সত্যিই একজন বয়স্ক যাত্রী, সবাই রাজি), তাহলে তৃতীয় থেকে শেষ পর্যন্ত প্রত্যেককে এক পা পিছিয়ে যেতে হয়। লাইনে ৫ জন হলে ৩ জন সরেন; ৫০০ জন হলে ৪৯৮ জন। Array-এর ক্ষেত্রেও ঠিক তাই — ঘরগুলো পাশাপাশি থাকতেই হবে, তাই index i-তে নতুন উপাদান বসাতে i থেকে শেষ পর্যন্ত সবাইকে এক ঘর ডানে সরাতে হয়। একে বলে shifting.

নিচে capacity ৮-এর একটি array-তে (ভরা আছে ৫টি) index 2-এ 25 বসানো, তারপর index 1-এর উপাদান মুছে ফেলা দেখুন। _ মানে খালি ঘর:

Array (capacity 8)[ ] = এই ধাপে লেখা ঘর

                
গণনা
কাজ
size
এই কাজে সরানো
  1. শুরু। size 5, capacity 8. লক্ষ্য: 25-কে index 2-এ বসানো, যাতে ফল হয় 10 20 25 30 40 50. index 2-এর ঘর এখন 30-এর দখলে, তাই আগে জায়গা খালি করতে হবে।
  2. ডান প্রান্ত থেকে শুরু। সবচেয়ে ডানের উপাদান 50 সরল খালি ঘর a[5]-এ। কেন ডান থেকে? কারণ a[5] খালি — কিছু মুছে যাওয়ার ভয় নেই।
  3. a[4] = a[3]. 40 এক ঘর ডানে। a[4]-এর পুরোনো 50 ইতিমধ্যে নিরাপদে a[5]-এ আছে, তাই ওপরে লেখা ঠিক আছে।
  4. a[3] = a[2]. 30 সরল। এখন a[2] আর a[3] দুটোতেই 30 — a[2] “খালি” ধরা যায়। মোট সরানো ৩টি = n − i = 5 − 2.
  5. লেখা। a[2] = 25, size 6. insert শেষ। সরাতে হয়েছে index 2-এর পরের সবাইকে; index 0-তে insert করলে সরাতে হতো ৫ জনকেই।
  6. এবার erase(1). 20 মুছতে তার ডানের সবাইকে এক ঘর বাঁয়ে টানতে হয়: a[1] = a[2], a[2] = a[3], … — এবার বাঁ দিক থেকে. সরানো ৪টি = size − 1 − i = 6 − 1 − 1. ফল: 10 25 30 40 50, size 5.

বিশ্লেষণ। size n-এর array-এ index i-তে insert করলে ঠিক n − i টি উপাদান সরে, erase করলে n − 1 − i টি। প্রতিটি সরানো Θ(1), তাই:

  • শেষে insert/erase (i = n বা n − 1): Θ(1) — কাউকে সরাতে হয় না।
  • শুরুতে insert/erase (i = 0): Θ(n) — সবাই সরে।
  • এলোমেলো অবস্থানে (i সমান সম্ভাবনায় 0…n): গড়ে n/2 টি সরানো, তাই average case-ও Θ(n) (পর্ব ০৫-এর হিসাব মনে করুন)।

বাড়তি জায়গা O(1) — কাজটি in-place. Python-এ a.insert(0, x) বা a.pop(0), C++-এ v.insert(v.begin(), x) বা v.erase(v.begin()) — দেখতে এক লাইন, কিন্তু প্রতিটি Θ(n). লুপের ভেতরে n বার করলে মোট Θ(n²).

দিক ভুল করলে তথ্য হারায়। insert-এর সময় কেউ যদি বাঁ থেকে সরান — for (j = i; j < n; ++j) a[j + 1] = a[j]; — তাহলে প্রথম ধাপেই a[i + 1]-এর পুরোনো মান মুছে যায়, আর শেষে পুরো ডান অংশে একই মান (a[i]) ভরে যায়। নিয়ম: insert-এ ডান থেকে বাঁয়ে, erase-এ বাঁ থেকে ডানে — সবসময় এমন দিকে যাতে লেখার ঘরটির পুরোনো মান আগেই নিরাপদে সরে গেছে।

ক্রম না লাগলে Θ(1) delete

উপাদানের ক্রম যদি গুরুত্বপূর্ণ না হয় (যেমন একটি ব্যাগে কোন কোন জিনিস আছে), তাহলে index i মুছতে শেষ উপাদানটিকে i-তে এনে বসান, তারপর শেষটি বাদ দিন: a[i] = a.back(); a.pop_back(); — কোনো shifting নেই, Θ(1). গেম প্রোগ্রামিং আর কনটেস্টে এই “swap and pop” কৌশল খুব প্রচলিত।

৪ · শেষে যোগ — capacity দ্বিগুণ আর amortized O(1)

এবার সবচেয়ে গুরুত্বপূর্ণ প্রশ্ন: dynamic array ভরে গেলে নতুন ব্লক কত বড় নেবে? সবচেয়ে সরল চিন্তা — “প্রতিবার ১টি ঘর বাড়াই।” তাহলে প্রতিটি push-ই reallocation: k-তম push-এ k − 1 টি উপাদান কপি। n বার push করলে মোট কপি 0 + 1 + 2 + … + (n − 1) = n(n − 1)/2 = Θ(n²). দশ লাখ push মানে প্রায় ৫০,০০০ কোটি কপি — অচল।

আসল কৌশল হলো গুণ করে বাড়ানো: জায়গা ফুরালেই capacity দ্বিগুণ। মেস বাড়ির কথা ভাবুন — প্রতিবার নতুন একজন সদস্য এলে বাসা বদলালে জীবন শেষ। তাই একবার বদলালে দ্বিগুণ বড় বাসা নিন; বাসা বদল (সব জিনিস টানা) দামি, কিন্তু ঘন ঘন হয় না। নিচে capacity 0 থেকে শুরু করে ৯ বার push দেখুন:

ভেতরের ব্লক_ = নেওয়া কিন্তু খালি ঘর

                
হিসাব
push করা মান
size
capacity
এই push-এ কপি
মোট কপি
  1. push 1. capacity 0, তাই প্রথম ব্লক নেওয়া হলো ১ ঘরের। কপি করার কিছু নেই। size 1, capacity 1.
  2. push 2 — প্রথম reallocation. ব্লক ভরা (size = capacity = 1). নতুন ব্লক ২ ঘরের, পুরোনো ১টি উপাদান কপি, তারপর 2 লেখা।
  3. push 3. আবার ভরা — capacity 4, ২টি কপি। এখন একটি খালি ঘর হাতে আছে।
  4. push 4 — সস্তা। খালি ঘরে শুধু লেখা, কোনো কপি নেই। মোট কপি এখনো ৩.
  5. push 5. ভরা ব্লক, capacity 4 → 8, ৪টি কপি। মোট ৭. লক্ষ করুন, প্রতিটি reallocation-এর কপি আগেরটির দ্বিগুণ — 1, 2, 4.
  6. push 6. খালি ঘর আছে, শুধু লেখা। এই দামি reallocation-এর পর এখন তিনটি সস্তা push পাওনা।
  7. push 7. আবার শুধু লেখা। সস্তা push-গুলো আগের দামি কপির খরচ “শোধ” করছে।
  8. push 8. ব্লক আবার ঠিক ভরা: size = capacity = 8. পরের push দামি হবে।
  9. push 9. capacity 8 → 16, ৮টি কপি। ৯টি push-এ মোট কপি 1 + 2 + 4 + 8 = ১৫, যা 2n = ১৮-এর কম। এখন ৭টি খালি ঘর — পরের ৭টি push কোনো কপি ছাড়াই হবে।

কেন মোট কপি 2n-এর কম — প্রমাণ

capacity 1 থেকে দ্বিগুণ হয়, n বার push করা হলো। reallocation হয় তখনই, যখন size ঠিক 1, 2, 4, …, 2k — যেখানে 2k হলো n-এর চেয়ে ছোট সবচেয়ে বড় দুইয়ের ঘাত (2k < n). প্রতিটি reallocation তখনকার size-এর সমান কপি করে, তাই মোট কপি:

1 + 2 + 4 + … + 2k = 2k+1 − 1 < 2 × n

এটি একটি geometric series (পর্ব ০৬): শেষ পদটিই প্রায় পুরো যোগফল, আর শেষ পদ 2k < n. সাথে n টি নতুন উপাদান লেখা — তাই n push-এর মোট কাজ < n + 2n = 3n = Θ(n). গড়ে প্রতিটি push-এর খরচ < ৩ ধাপ, অর্থাৎ O(1) amortized.

Amortized মানে কী — আর কী নয়

Amortized O(1) মানে: যেকোনো ক্রমের n টি push-এর মোট খরচ সবসময় O(n) — এটি সবচেয়ে খারাপ ক্ষেত্রের নিশ্চয়তা, কোনো সম্ভাবনা বা “গড় ইনপুট”-এর কথা নয়। কিন্তু একটি নির্দিষ্ট push (যেমন push 9) Θ(n) সময় নিতে পারে। রিয়েল-টাইম সিস্টেমে (যেমন প্রতিটি ফ্রেম ১৬ মিলিসেকেন্ডে আঁকতে হবে এমন গেম) এই হঠাৎ ধীর push সমস্যা হতে পারে। Aggregate, accounting আর potential — amortized analysis-এর তিনটি আনুষ্ঠানিক পদ্ধতি পর্ব ৬০-এ পুরোপুরি দেখবেন।

এবার নিজের হাতে একটি ছোট dynamic array বানিয়ে দেখি — প্রতিটি push-এ size, capacity আর কপির সংখ্যা ছাপবে। (বাস্তবে নিজে না লিখে std::vector ব্যবহার করবেন; এটি শুধু ভেতরটা বোঝার জন্য।)

#include <iostream>
using namespace std;

struct DynArray {
    int* data = nullptr;
    int size = 0, cap = 0;
    long long copies = 0;

    void push_back(int x) {
        int copied = 0;
        if (size == cap) {                              // full: grow
            int newCap = (cap == 0) ? 1 : 2 * cap;
            int* bigger = new int[newCap];
            for (int i = 0; i < size; ++i) bigger[i] = data[i];  // copy old items
            copied = size;
            delete[] data;
            data = bigger;
            cap = newCap;
        }
        data[size++] = x;
        copies += copied;
        cout << "push " << x << " -> size " << size << ", cap " << cap
             << ", copied " << copied << '\n';
    }
    ~DynArray() { delete[] data; }
};

int main() {
    DynArray a;
    const int n = 9;
    for (int x = 1; x <= n; ++x) a.push_back(x);
    cout << "total copies = " << a.copies << " (less than 2n = " << 2 * n << ")\n";
}

আউটপুট:

push 1 -> size 1, cap 1, copied 0
push 2 -> size 2, cap 2, copied 1
push 3 -> size 3, cap 4, copied 2
push 4 -> size 4, cap 4, copied 0
push 5 -> size 5, cap 8, copied 4
push 6 -> size 6, cap 8, copied 0
push 7 -> size 7, cap 8, copied 0
push 8 -> size 8, cap 8, copied 0
push 9 -> size 9, cap 16, copied 8
total copies = 15 (less than 2n = 18)

ঠিক ওপরের ডায়াগ্রামের সংখ্যাগুলো। কপির সারি 1, 2, 4, 8 — দ্বিগুণ হচ্ছে, কিন্তু তাদের মাঝের ফাঁকও দ্বিগুণ হচ্ছে; তাই দামি push ক্রমশ বিরল। n বদলে 1000 করে চালান: মোট কপি হবে 1 + 2 + … + 512 = 1023, যা 2000-এর কম। delete[] data; ভুলে গেলে প্রতিটি reallocation-এ পুরোনো ব্লক হারিয়ে যেত (memory leak). আর এই struct-কে কপি করা নিরাপদ নয় (দুটি কপি একই data দুবার delete করবে) — আসল std::vector এসব নিয়ম ঠিকঠাক সামলায়।

class DynArray:
    def __init__(self):
        self.data = []                   # fixed-size block (never .append to it)
        self.size = 0
        self.cap = 0
        self.copies = 0

    def push_back(self, x):
        copied = 0
        if self.size == self.cap:                    # full: grow
            new_cap = 1 if self.cap == 0 else 2 * self.cap
            bigger = [None] * new_cap
            for i in range(self.size):               # copy old items
                bigger[i] = self.data[i]
            copied = self.size
            self.data, self.cap = bigger, new_cap
        self.data[self.size] = x
        self.size += 1
        self.copies += copied
        print(f"push {x} -> size {self.size}, cap {self.cap}, copied {copied}")

a = DynArray()
n = 9
for x in range(1, n + 1):
    a.push_back(x)
print(f"total copies = {a.copies} (less than 2n = {2 * n})")

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

push 1 -> size 1, cap 1, copied 0
push 2 -> size 2, cap 2, copied 1
push 3 -> size 3, cap 4, copied 2
push 4 -> size 4, cap 4, copied 0
push 5 -> size 5, cap 8, copied 4
push 6 -> size 6, cap 8, copied 0
push 7 -> size 7, cap 8, copied 0
push 8 -> size 8, cap 8, copied 0
push 9 -> size 9, cap 16, copied 8
total copies = 15 (less than 2n = 18)

[None] * new_cap দিয়ে আমরা একটি “স্থির আকারের ব্লক” অনুকরণ করছি — এই ব্লকে কখনো append করা হয় না, শুধু index দিয়ে লেখা হয়। Python-এর নিজের list.append ভেতরে ঠিক এই কাজটিই C ভাষায় করে, শুধু দ্বিগুণের বদলে একটু কম হারে বাড়ায় (অংশ ৫)। তাই append amortized O(1), কিন্তু insert(0, x) Θ(n) — সামনে ঢোকাতে বাকি সবাইকে সরতে হয়।

৫ · Growth factor, reserve আর shrink_to_fit

capacity প্রতিবার কত গুণ বাড়বে — সেই সংখ্যাকে বলে growth factor (g). g যেকোনো ধ্রুবক > 1 হলেই push amortized O(1) থাকে, কিন্তু ধ্রুবকগুলো বদলায়। শেষ reallocation-এ কপি হয় n-এর কম, তার আগেরটিতে তার 1/g অংশ, তার আগে 1/g² — যোগ করলে মোট কপি < n × g/(g − 1).

বাড়ানোর নিয়মn push-এ মোট কপিসবচেয়ে বেশি খালি জায়গাফল
+1 ঘর (বা +10 ঘর)Θ(n²)প্রায় শূন্যpush Θ(n) — বর্জনীয়
× 1.5< 3nreallocation-এর ঠিক পরে capacity-র প্রায় ⅓বেশি কপি, কম অপচয়
× 2< 2nreallocation-এর ঠিক পরে capacity-র প্রায় ½কম কপি, বেশি অপচয়

যোগ করে বাড়ানো (+c) কেন ব্যর্থ? কারণ তখন reallocation হয় প্রতি c টি push-এ একবার — n/c বার — আর প্রতিবার গড়ে প্রায় n/2 টি কপি: মোট Θ(n²/c), যা c যত বড়ই হোক, শেষ পর্যন্ত Θ(n²). গুণ করে বাড়ালে reallocation-এর সংখ্যা মাত্র Θ(log n).

কোন লাইব্রেরি কোনটি বেছেছে, দেখুন — সবাই একই trade-off-এর ভিন্ন বিন্দুতে দাঁড়িয়ে:

  • GCC-র libstdc++ আর Clang-এর libc++ (std::vector): 2 গুণ।
  • Microsoft-এর MSVC STL (std::vector) আর Java-র ArrayList: 1.5 গুণ (ArrayList-এ নতুন capacity = পুরোনো + পুরোনো/2).
  • CPython-এর list: মাত্র প্রায় 1.125 গুণ, সাথে ছোট একটি ধ্রুবক — Python মেমরি বাঁচানোকে বেশি গুরুত্ব দেয়।

C++ standard নিজে কোনো নির্দিষ্ট g বলে না; শুধু দাবি করে push_back-এর খরচ “amortized constant” হতে হবে। তাই একই কোড ভিন্ন compiler-এ ভিন্ন capacity দেখাতে পারে — capacity()-এর নির্দিষ্ট মানের ওপর নির্ভর করে কোড লিখবেন না।

reserve — আগে থেকে জানলে আগেই জায়গা নিন

কতগুলো উপাদান আসবে তা আগে থেকে জানলে (যেমন কনটেস্টে প্রথম লাইনে n দেওয়া থাকে) reallocation পুরোপুরি এড়ানো যায়:

int n; cin >> n;
vector<int> v;
v.reserve(n);                   // capacity >= n now: no reallocation below
for (int i = 0; i < n; ++i) { int x; cin >> x; v.push_back(x); }

vector<int> w(n);               // different: size n, filled with zeros
v.clear();                      // size 0, but capacity stays (memory kept)
v.shrink_to_fit();              // a REQUEST to give the spare memory back
  • reserve(n) শুধু capacity বাড়ায়, size বদলায় না — তাই এরপর v[0] লেখা ভুল (size এখনো 0); push_back করতে হবে। আর vector<int> w(n) সাথে সাথে n টি শূন্য বসায় — তারপর push_back করলে সেগুলো n টি শূন্যের পরে যোগ হয়। এই দুটি গুলিয়ে ফেলা নতুনদের খুব পরিচিত ভুল।
  • clear() উপাদান মোছে কিন্তু মেমরি ফেরত দেয় না — একই vector বারবার ব্যবহার করলে এটি আসলে সুবিধা।
  • shrink_to_fit() হলো অনুরোধ, আদেশ নয় — standard অনুযায়ী লাইব্রেরি চাইলে উপেক্ষা করতে পারে। কাজ করলে এটি নিজেও একটি reallocation (Θ(n) কপি)।
  • Python-এ সমতুল্য কৌশল: আগে থেকে a = [0] * n বানিয়ে index দিয়ে লেখা, অথবা list comprehension.

৬ · Iterator invalidation — বাসা বদলালে পুরোনো ঠিকানা অচল

মেস বাড়ির উদাহরণে ফিরি। আপনি বন্ধুকে পুরোনো বাসার ঠিকানা দিয়ে রেখেছিলেন। এর মধ্যে আপনি বড় বাসায় উঠে গেছেন। বন্ধু পুরোনো ঠিকানায় গিয়ে দরজায় টোকা দিলেন — ভেতরে এখন অন্য কেউ থাকেন, বা ঘর ফাঁকা। Dynamic array-এর reallocation ঠিক এটাই করে: সব উপাদান নতুন ব্লকে চলে যায়, পুরোনো ব্লক ফেরত যায়। কিন্তু যে pointer, reference বা iterator আগে পুরোনো ব্লকের দিকে তাকিয়ে ছিল, সেটি জানে না যে বাসা বদলেছে। একে বলে iterator invalidation, আর এমন অচল pointer-কে বলে dangling pointer.

vector<int> v{1, 2, 3};
int& first = v[0];                  // reference into the current block
for (int i = 0; i < 100; ++i) v.push_back(i);   // block moves (several times)
cout << first;                      // UNDEFINED BEHAVIOUR: dangling reference

// the classic loop bug: growing the vector you are iterating over
for (auto it = v.begin(); it != v.end(); ++it)
    if (*it % 2 == 0) v.push_back(*it);   // may reallocate: 'it' now dangles

সবচেয়ে বিপজ্জনক দিক: এই কোড প্রায়ই ঠিকঠাক চলে বলে মনে হয়. ছোট ইনপুটে reallocation হয়তো হয়ই না (capacity যথেষ্ট ছিল), বা পুরোনো মেমরিতে তখনো পুরোনো মান পড়ে আছে। বড় ইনপুটে বা অন্য compiler-এ হঠাৎ ভুল উত্তর বা crash. std::vector-এর নিয়মগুলো মনে রাখুন:

কাজকোন iterator/pointer/reference অচল হয়
push_back, insert, emplace_back — যদি reallocation হয়সবগুলো
insert — reallocation না হলেinsert-এর জায়গা ও তার পরের সবগুলো
eraseমোছা জায়গা ও তার পরের সবগুলো (end()-সহ)
reserve, shrink_to_fit — যদি capacity বদলায়সবগুলো
v[i] পড়া/লেখা, pop_backকিছুই না (pop_back-এ শুধু মুছে যাওয়া শেষ উপাদানেরটি আর end())

নিরাপদ বিকল্প। (১) লুপে বদলাতে হলে iterator নয়, index ব্যবহার করুন — v[i] প্রতিবার নতুন করে base থেকে হিসাব হয়, তাই reallocation-এর পরেও ঠিক থাকে; আর শর্তে v.size() একবার আগে থেকে রেখে দিন, যাতে নতুন যোগ করা উপাদান আবার না ঘোরে। (২) মুছতে হলে it = v.erase(it); লিখুন (erase নতুন বৈধ iterator ফেরত দেয়), অথবা আরও ভালো — C++20-এর std::erase_if(v, pred), যা এক পাসে Θ(n)-এ সব মোছে। লুপে একটি একটি করে erase করা Θ(n²)!

Python-এও একই ফাঁদ, অন্য চেহারায়। for x in a: if x % 2 == 0: a.remove(x) — Python crash করে না, কিন্তু উপাদান বাদ পড়ে যায়। a = [2, 4, 6, 1] হলে ফল [4, 1]: 2 মোছার পর সবাই এক ঘর বাঁয়ে সরে, 4 চলে আসে index 0-তে, কিন্তু লুপ এগিয়ে যায় index 1-এ (এখন 6). সমাধান: নতুন list বানান — a = [x for x in a if x % 2 != 0], যা Θ(n).

৭ · 2D array, row-major আর cache — আর jagged array

সিনেমা হলের আসন ভাবুন — সারি A থেকে J, প্রতি সারিতে ১২টি আসন। এটি একটি 2D array. কিন্তু কম্পিউটারের মেমরি একমাত্রিক — একটিই লম্বা ঠিকানার সারি। তাই R সারি আর C কলামের int m[R][C]-কে কোনো এক নিয়মে এক সারিতে বিছাতে হয়। C, C++ আর Python-এর NumPy (ডিফল্টে) মানে row-major order: প্রথমে সারি 0-এর সব কলাম, তারপর সারি 1-এর সব, এভাবে। Fortran, MATLAB আর R মানে উল্টোটা — column-major order.

row-major: address(m[i][j]) = base + (i × C + j) × size

এখন প্রশ্ন: সব উপাদান যোগ করতে দুটি লুপ — বাইরে সারি, ভেতরে কলাম (for i … for j …), নাকি উল্টো? দুটিই Θ(R × C) কাজ। কিন্তু মেমরি থেকে ডেটা আসে টুকরো টুকরো — একেকটি cache line (বাস্তবে সাধারণত ৬৪ বাইট = ১৬টি int). একটি ঘর পড়লে পুরো line চলে আসে cache-এ। সময় না মেপে আমরা গুনব কতবার মেমরি থেকে নতুন line আনতে হয়। সরল মডেল: 3 × 4 matrix, এক line = ৪টি int, আর cache একসাথে মাত্র ১টি line ধরে রাখে। ঘরের নম্বর = i × 4 + j:

মেমরিতে (row-major)L0, L1, L2 = তিনটি cache line

                
গণনা
ক্রম
পড়া ঘর
line load
  1. বিন্যাস। সারি 0 (ঘর 0–3) পুরোটা একটি line L0-এ, সারি 1 L1-এ, সারি 2 L2-এ। m[1][2] হলো ঘর 1 × 4 + 2 = 6, তাই L1-এ।
  2. সারি ধরে, প্রথম সারি। ঘর 0 পড়তে L0 আনতে হলো (১টি load). ঘর 1, 2, 3 ততক্ষণে cache-এ — বিনা খরচে। পাশাপাশি পড়ার এই সুবিধার নাম spatial locality.
  3. সারি ধরে, শেষ। ১২টি ঘর পড়তে মাত্র ৩টি load — প্রতি line-এ একটি। সাধারণভাবে R × C ঘরে load ≈ R × C ÷ (line-এ যতগুলো উপাদান)।
  4. কলাম ধরে, প্রথম কলাম। ঘর 0, 4, 8 — তিনটি ভিন্ন line-এ। প্রতিটি পড়তেই নতুন load: ৩টি ঘরে ৩টি load.
  5. কলাম ধরে, শেষ। দ্বিতীয় কলামের ঘর 1 আবার L0-এ, কিন্তু cache তো ততক্ষণে L2 ধরে আছে — L0 চলে গেছে। ফলে প্রতিটি ঘরে নতুন load: ১২টি। একই কাজ, চার গুণ মেমরি আনা-নেওয়া।
  6. বাস্তবের মাপে। 1000 × 1000 int, line-এ ১৬টি: সারি ধরে ৬২,৫০০ load, কলাম ধরে যখন একটি কলামের ১০০০টি line cache-এ টিকে থাকে না, তখন প্রায় ১০ লাখ — ১৬ গুণ। Big-O একই Θ(n²), কিন্তু ধ্রুবকে বিশাল পার্থক্য।

শিক্ষা সরল: C/C++/Python-এ ভেতরের লুপে শেষ index (j) বদলান — for (i …) for (j …) sum += m[i][j];. matrix গুণের মতো তিন-লুপের কাজে শুধু লুপের ক্রম বদলে (যেমন i-j-k থেকে i-k-j) একই Θ(n³) অ্যালগরিদম বাস্তবে কয়েক গুণ দ্রুত হয়ে যায়। Big-O বলে কত কাজ; cache বলে কাজটি মেমরির সাথে কতটা মানিয়ে চলে.

Jagged array — প্রতিটি সারি আলাদা দৈর্ঘ্যের

সব সারির দৈর্ঘ্য সমান না হলে? যেমন প্রতিটি ছাত্রের আলাদা সংখ্যক বিষয়ের নম্বর, বা গ্রাফে প্রতিটি node-এর প্রতিবেশীর তালিকা (adjacency list, পর্ব ৩৯)। তখন ব্যবহার হয় jagged array: প্রতিটি সারি একটি আলাদা dynamic array.

vector<vector<int>> marks(3);      // 3 students, rows start empty
marks[0] = {78, 85};                   // row lengths can differ
marks[1] = {90, 66, 71, 88};
marks[2].push_back(55);
// each row is its own block somewhere in memory:
// marks[i][j] = two lookups (find row i's block, then j inside it)

vector<int> flat(R * C);              // one block, manual row-major index
flat[i * C + j] = 7;                   // same as m[i][j], one lookup

vector<vector<int>> লেখা সহজ, কিন্তু সারিগুলো মেমরিতে পাশাপাশি নাও থাকতে পারে, আর প্রতিটি সারির নিজস্ব বাড়তি তথ্য (pointer, size, capacity) থাকে। আয়তাকার বড় matrix-এ পারফরম্যান্স জরুরি হলে অনেকে একটিমাত্র vector<int> flat(R * C) নিয়ে নিজে i * C + j হিসাব করেন। Python-এ grid = [[0] * C for _ in range(R)] লিখুন — [[0] * C] * R লিখলে R টি সারিই একই list-এর reference হয়, এক সারি বদলালে সব বদলে যায়!

৮ · সব খরচ এক টেবিলে, আর উল্টানো ও k ঘর ঘোরানো

এ পর্যন্ত যা দেখলাম, এক জায়গায় — n উপাদানের dynamic array (std::vector বা Python list):

কাজC++ / Pythonসময়কেন
index দিয়ে পড়া/লেখাv[i] / a[i]Θ(1)base + i × size
শেষে যোগpush_back / appendamortized Θ(1), একটি push সবচেয়ে খারাপে Θ(n)capacity গুণ করে বাড়ে
শেষ থেকে বাদpop_back / pop()Θ(1)কাউকে সরাতে হয় না
index i-তে insertinsert / insert(i, x)Θ(n − i), সবচেয়ে খারাপে Θ(n)ডানের সবাই সরে
index i থেকে eraseerase / pop(i), del a[i]Θ(n − i)ডানের সবাই বাঁয়ে আসে
মান খোঁজা (sorted নয়)find / x in a, indexΘ(n)একে একে দেখতে হয় (linear search)
মান খোঁজা (sorted)lower_bound / bisectΘ(log n)binary search (পর্ব ১৮)
মান দিয়ে মোছাstd::erase / remove(x)Θ(n)খোঁজা + সরানো
পুরো কপি বা slicecopy / a[l:r]Θ(n) বা Θ(r − l)প্রতিটি উপাদান কপি
উল্টানোreverse / a.reverse()Θ(n), বাড়তি O(1)দুই প্রান্ত থেকে swap

উল্টানো — দুই প্রান্ত থেকে ভেতরে

বাজারের সারিতে সবাইকে উল্টো ক্রমে দাঁড় করাতে হবে, কিন্তু পাশে আরেকটি সারি বানানোর জায়গা নেই। উপায়: প্রথম আর শেষজন জায়গা বদলান, তারপর দ্বিতীয় আর শেষের আগেরজন — মাঝখানে পৌঁছালেই শেষ। দুটি index, lo শুরু থেকে আর hi শেষ থেকে, একে অপরের দিকে এগোয় — এটি two pointer কৌশলের সবচেয়ে সরল রূপ (পুরো আলোচনা পর্ব ১৬-এ)। n উপাদানে ঠিক ⌊n/2⌋ টি swap: সময় Θ(n), বাড়তি জায়গা O(1).

k ঘর ডানে ঘোরানো — তিনবার উল্টানোর কৌশল

Array rotation: [1 2 3 4 5 6 7]-কে ৩ ঘর ডানে ঘোরালে শেষের তিনটি সামনে চলে আসে: [5 6 7 1 2 3 4]. সরল উপায়গুলো দেখুন — (ক) একবারে এক ঘর ঘোরানো k বার: Θ(n·k); (খ) নতুন array-তে b[(i + k) % n] = a[i]: Θ(n) সময় কিন্তু Θ(n) বাড়তি জায়গা। Θ(n) সময় আর O(1) জায়গা কি একসাথে সম্ভব? হ্যাঁ — reversal algorithm:

Pseudocode (0-based; REVERSE(a, l, r) reverses a[l..r-1])

ROTATE-RIGHT(a, k):
    n = length(a)
    if n == 0: return
    k = k mod n                  // rotating by n changes nothing
    REVERSE(a, 0, n)             // whole array
    REVERSE(a, 0, k)             // first k elements
    REVERSE(a, k, n)             // the remaining n - k elements
Array[ ] = এই ধাপে উল্টানো অংশ

                
হিসাব
ধাপ
এই ধাপে swap
মোট swap
  1. শুরু। n = 7, k = 3. array-কে দুই ভাগে ভাবুন: A = প্রথম n − k = ৪টি (1 2 3 4), B = শেষ k = ৩টি (5 6 7). আমাদের দরকার AB থেকে BA.
  2. পুরোটা উল্টাও। AB উল্টালে হয় (B উল্টো)(A উল্টো) = 7 6 5 | 4 3 2 1. B এখন সামনে — ঠিক জায়গায়, শুধু ভেতরের ক্রম উল্টো। ⌊7/2⌋ = ৩টি swap.
  3. প্রথম k ঘর উল্টাও। 7 6 5 → 5 6 7. B এখন সোজা। ১টি swap (মাঝের 6 নিজের জায়গায়)।
  4. বাকি n − k ঘর উল্টাও। 4 3 2 1 → 1 2 3 4. ফল 5 6 7 1 2 3 4 — ঠিক BA. মোট swap 3 + 1 + 2 = ৬, যা কখনো n-এর বেশি নয়। বাড়তি জায়গা শুধু swap-এর একটি ভ্যারিয়েবল।

কেন ঠিক — প্রমাণ। অক্ষর দিয়ে লিখি: XR মানে X উল্টো। যেকোনো দুই অংশের জন্য (XY)R = YRXR (শেষটি প্রথমে আসে, প্রতিটি অংশ উল্টো), আর (XR)R = X. তাহলে প্রথম ধাপে AB → BRAR; দ্বিতীয় ধাপে প্রথম k ঘর, অর্থাৎ BR, উল্টে হয় B; তৃতীয় ধাপে AR উল্টে A. ফল BA — ঠিক যা চেয়েছিলাম। সময়: তিনটি reverse মিলে ⌊n/2⌋ + ⌊k/2⌋ + ⌊(n − k)/2⌋ ≤ n swap, তাই Θ(n); বাড়তি জায়গা O(1). বাঁয়ে ঘোরাতে একই কৌশল, শুধু ক্রম বদলে: আগে প্রথম k, তারপর বাকিটা, শেষে পুরোটা।

#include <iostream>
#include <string>
#include <utility>
#include <vector>
using namespace std;

int swaps = 0;
void rev(vector<int>& a, int lo, int hi) {        // reverse a[lo..hi-1]
    for (--hi; lo < hi; ++lo, --hi) { swap(a[lo], a[hi]); ++swaps; }
}

void show(const string& label, const vector<int>& a) {
    cout << label;
    for (int x : a) cout << ' ' << x;
    cout << '\n';
}

void rotateRight(vector<int>& a, int k, bool trace) {
    int n = (int)a.size();
    if (n == 0) return;
    k %= n;                                        // k = 10, n = 7 -> 3
    rev(a, 0, n); if (trace) show("reverse all   :", a);
    rev(a, 0, k); if (trace) show("reverse [0,k) :", a);
    rev(a, k, n); if (trace) show("reverse [k,n) :", a);
}

int main() {
    vector<int> a{1, 2, 3, 4, 5, 6, 7};
    show("start         :", a);
    rotateRight(a, 3, true);
    cout << "swaps = " << swaps << " (never more than n = 7)\n";
    vector<int> b{1, 2, 3, 4, 5, 6, 7};
    rotateRight(b, 10, false);
    show("k = 10        :", b);
}

আউটপুট:

start         : 1 2 3 4 5 6 7
reverse all   : 7 6 5 4 3 2 1
reverse [0,k) : 5 6 7 4 3 2 1
reverse [k,n) : 5 6 7 1 2 3 4
swaps = 6 (never more than n = 7)
k = 10        : 5 6 7 1 2 3 4

প্রথম চার লাইন ঠিক ওপরের ডায়াগ্রামের চারটি ধাপ। শেষ লাইনে k = 10: যেহেতু 7 ঘর ঘোরালে array আগের অবস্থায় ফেরে, 10 ঘর ঘোরানো আর 10 mod 7 = 3 ঘর ঘোরানো একই — তাই একই ফল। k %= n না লিখলে rev(a, 0, 10) সীমার বাইরে লিখত (undefined behavior); আর n == 0-এর পরীক্ষা না থাকলে k % 0-এ প্রোগ্রাম crash করত। এই দুটি edge case ইন্টারভিউতে প্রায় সবসময় জিজ্ঞেস করা হয়। লাইব্রেরিতে তৈরি সমাধানও আছে: std::rotate.

swaps = 0

def rev(a, lo, hi):                      # reverse a[lo..hi-1]
    global swaps
    hi -= 1
    while lo < hi:
        a[lo], a[hi] = a[hi], a[lo]
        swaps += 1
        lo += 1
        hi -= 1

def show(label, a):
    print(label, *a)

def rotate_right(a, k, trace):
    n = len(a)
    if n == 0:
        return
    k %= n                               # k = 10, n = 7 -> 3
    rev(a, 0, n)
    if trace: show("reverse all   :", a)
    rev(a, 0, k)
    if trace: show("reverse [0,k) :", a)
    rev(a, k, n)
    if trace: show("reverse [k,n) :", a)

a = [1, 2, 3, 4, 5, 6, 7]
show("start         :", a)
rotate_right(a, 3, True)
print(f"swaps = {swaps} (never more than n = 7)")
b = [1, 2, 3, 4, 5, 6, 7]
rotate_right(b, 10, False)
show("k = 10        :", b)

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

start         : 1 2 3 4 5 6 7
reverse all   : 7 6 5 4 3 2 1
reverse [0,k) : 5 6 7 4 3 2 1
reverse [k,n) : 5 6 7 1 2 3 4
swaps = 6 (never more than n = 7)
k = 10        : 5 6 7 1 2 3 4

Python-এ অনেকে এক লাইনে লেখেন a = a[-k:] + a[:-k] — সুন্দর, Θ(n) সময়, কিন্তু দুটি slice আর একটি নতুন list মিলে Θ(n) বাড়তি জায়গা। আর k = 0 হলে a[-0:] মানে a[0:] — পুরো list! ফলে a[-0:] + a[:-0] = পুরো list + খালি list, এখানে ফল কাকতালীয়ভাবে ঠিক হয়, কিন্তু একই ধাঁচের slicing অন্য সমস্যায় নিঃশব্দে ভুল দেয়। In-place সংস্করণে এমন ফাঁদ নেই।

৯ · আরও ক্লাসিক — max, second max, duplicate বাদ আর Kadane

সবচেয়ে বড় আর দ্বিতীয় বড় — এক পাসে

সবচেয়ে বড় মান খুঁজতে n − 1 টি তুলনা লাগে, আর এর কমে সম্ভব নয় — প্রতিটি “পরাজিত” উপাদানকে অন্তত একবার কারও কাছে হারতে হবে, আর n − 1 জন পরাজিত। দ্বিতীয় বড় মান? সাধারণ ভুল: sort করে a[n-2] নেওয়া — Θ(n log n), আর duplicate থাকলে ([9, 9, 4]) ভুল উত্তর 9. এক পাসের সমাধান:

// largest and second largest DISTINCT values (LLONG_MIN = "none yet")
long long mx = LLONG_MIN, second = LLONG_MIN;
for (int x : a) {
    if (x > mx) { second = mx; mx = x; }          // new champion: old one drops to 2nd
    else if (x < mx && x > second) second = x;   // x < mx skips duplicates of mx
}

সময় Θ(n), বাড়তি জায়গা O(1). x < mx শর্তটিই duplicate সামলায়; “distinct নয়, সাধারণ দ্বিতীয় অবস্থান” চাইলে শর্তটি x > second করলেই হয়। সব উপাদান সমান হলে second থেকে যায় LLONG_MIN — উত্তর নেই, সেটি আলাদা করে জানাতে হবে।

আরও গভীরে: ওপরের কোড সবচেয়ে খারাপে প্রায় 2n তুলনা করে। কিন্তু নকআউট টুর্নামেন্টের মতো খেলালে (জোড়ায় জোড়ায় তুলনা, বিজয়ীরা পরের রাউন্ডে) চ্যাম্পিয়ন পেতে লাগে n − 1 তুলনা, আর দ্বিতীয় সেরা অবশ্যই এমন কেউ যে শুধু চ্যাম্পিয়নের কাছে হেরেছে — এমন খেলোয়াড় মাত্র ⌈log₂ n⌉ জন। তাদের মধ্যে সেরাটি খুঁজতে আরও ⌈log₂ n⌉ − 1 তুলনা: মোট n + ⌈log₂ n⌉ − 2. এটি শুধু একটি ভালো কৌশল নয় — প্রমাণিত নিম্নসীমাও; তুলনা-ভিত্তিক কোনো অ্যালগরিদম এর কমে দ্বিতীয় সেরা নিশ্চিতভাবে বের করতে পারে না। Knuth-এর The Art of Computer Programming-এর তৃতীয় খণ্ডে (selection-এর আলোচনায়) এর বিশ্লেষণ আছে।

Sorted array থেকে duplicate বাদ — in-place

[1 1 2 3 3 3 4] থেকে [1 2 3 4] চাই, নতুন array ছাড়া। Sorted হওয়ায় একই মানগুলো পাশাপাশি থাকে। দুটি index: r (read) প্রতিটি উপাদান পড়ে, w (write) পরের distinct মান কোথায় লেখা হবে তা মনে রাখে।

// a is sorted; keeps the distinct values in a[0..w-1], returns w
int dedup(vector<int>& a) {
    if (a.empty()) return 0;
    int w = 1;                                   // a[0] is always kept
    for (int r = 1; r < (int)a.size(); ++r)
        if (a[r] != a[w - 1]) a[w++] = a[r];     // new value: keep it
    return w;                                    // then a.resize(w) if you like
}

শুদ্ধতার যুক্তি (loop invariant, পর্ব ০৩): প্রতিটি ঘুর্ণনের শুরুতে a[0..w−1]-এ আছে a[0..r−1]-এর সব distinct মান, ছোট থেকে বড় ক্রমে, প্রতিটি একবার। শুরুতে (r = 1, w = 1) এটি সত্য। a[r] যদি a[w−1]-এর সমান হয়, এটি পুরোনো মান (sorted, তাই সমান মান শুধু শেষ রাখা মানের সমান হতে পারে) — বাদ। আলাদা হলে এটি নতুন এবং বড় — a[w]-এ লেখা হয়। w ≤ r সবসময়, তাই লেখা কখনো না-পড়া ঘর মুছে দেয় না। লুপ শেষে r = n, তাই a[0..w−1] = সব distinct মান। সময় Θ(n), বাড়তি O(1). Array sorted না হলে এই কোড ভুল ([1 2 1] থেকে [1 2 1]) — তখন আগে sort, বা hash set (পর্ব ৩২)।

Kadane — সবচেয়ে বড় যোগফলের subarray

একটি দোকানের ৯ দিনের লাভ-ক্ষতি (হাজার টাকায়): [−2, 1, −3, 4, −1, 2, 1, −5, 4]. টানা কোন কয়েক দিনে মোট লাভ সবচেয়ে বেশি? এটি maximum subarray problem. সব (l, r) জোড়া চেষ্টা করলে Θ(n²) (প্রতিটির যোগফল চলতে চলতে হিসাব করে), প্রতিবার নতুন করে যোগ করলে Θ(n³). Divide and conquer দিয়ে Θ(n log n) হয়। কিন্তু Θ(n)-এর একটি সুন্দর সমাধান আছে — Kadane's algorithm.

মূল প্রশ্নটি একটু বদলে নিন: “index i-তে শেষ হওয়া সেরা যোগফল কত?” একে বলি cur(i). i-তে শেষ হওয়া subarray হয় শুধু a[i] নিজে, নয়তো (i − 1)-এ শেষ হওয়া কোনো subarray-এর সাথে a[i] জুড়ে। দ্বিতীয় ক্ষেত্রে সেরাটি অবশ্যই cur(i − 1) + a[i]. তাই:

cur(i) = max(a[i], cur(i − 1) + a[i])    best = সব cur(i)-এর সর্বোচ্চ

সহজ ভাষায়: আগের যোগফল ঋণাত্মক হলে সেটি বোঝা — ফেলে দিয়ে নতুন করে শুরু করুন। চলুন ধাপে ধাপে দেখি:

Array[ ] = এখন i-তে শেষ হওয়া সেরা subarray

                
অবস্থা
i, a[i]
cur
best
  1. i = 0. শুরুতে cur = best = a[0] = −2. একটি মাত্র subarray, তাই এটিই এখন সেরা।
  2. i = 1, a = 1. max(1, −2 + 1 = −1) = 1. আগের −2 বোঝা ছিল — ফেলে দিয়ে নতুন শুরু। best = 1.
  3. i = 2, a = −3. max(−3, 1 − 3 = −2) = −2. এখানে জুড়ে রাখাই ভালো (−2 > −3), যদিও cur ঋণাত্মক হয়ে গেল। best বদলায় না: 1.
  4. i = 3, a = 4. max(4, −2 + 4 = 2) = 4. ঋণাত্মক cur ফেলে দিয়ে 4 থেকে নতুন শুরু। best = 4.
  5. i = 4, a = −1. max(−1, 4 − 1 = 3) = 3. একটু কমল, কিন্তু এখনো ধনাত্মক — জুড়ে রাখি। best 4-ই।
  6. i = 5, a = 2. max(2, 3 + 2 = 5) = 5. best = 5.
  7. i = 6, a = 1. max(1, 5 + 1 = 6) = 6. best = 6 — subarray a[3..6] = [4, −1, 2, 1].
  8. i = 7, a = −5. max(−5, 6 − 5 = 1) = 1. বড় ক্ষতি, কিন্তু cur এখনো ধনাত্মক — পরের দিনের জন্য রেখে দিই। best 6.
  9. i = 8, a = 4. max(4, 1 + 4 = 5) = 5 — তবু 6-এর কম। উত্তর: best = 6, ৯টি ধাপে, প্রতিটিতে O(1) কাজ। সময় Θ(n), বাড়তি জায়গা O(1).
long long kadane(const vector<int>& a) {     // a must be non-empty
    long long cur = a[0], best = a[0];
    for (size_t i = 1; i < a.size(); ++i) {
        cur = max<long long>(a[i], cur + a[i]);   // extend, or start fresh at i
        best = max(best, cur);
    }
    return best;
}

কেন ঠিক: সর্বোচ্চ subarray-টি কোনো না কোনো index r-এ শেষ হয়, তাই উত্তর = সব r-এর জন্য “r-এ শেষ হওয়া সেরা”-র সর্বোচ্চ। আর cur(r) যে সত্যিই r-এ শেষ হওয়া সেরা, তা induction-এ প্রমাণ হয় (ওপরের দুই-ক্ষেত্রের যুক্তি)। লক্ষ করুন, শুরু a[0] দিয়ে, 0 দিয়ে নয় — সব সংখ্যা ঋণাত্মক হলে ([−3, −1, −2]) সঠিক উত্তর −1, অথচ cur = best = 0 দিয়ে শুরু করলে ভুল উত্তর 0 আসে (খালি subarray অনুমোদিত না হলে)। এই যে “i-তে শেষ হওয়া সেরা উত্তর” — আগের উপসমস্যার উত্তর থেকে পরেরটি গড়া — এটিই dynamic programming-এর ভাষা। পর্ব ৫২-তে ঠিক এই চোখ দিয়ে Kadane-কে আবার দেখবেন।

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

  • Array-এ index দিয়ে পড়া O(1) কেন, linked list-এ O(n) কেন? — array-এ ঠিকানা = base + i × size, এক হিসাবে; list-এ i টি node একে একে পেরোতে হয়।
  • vector-এর push_back-এর time complexity? — amortized O(1) (capacity গুণ করে বাড়ে, n push-এ মোট কপি < 2n); একটি push সবচেয়ে খারাপে O(n).
  • int m[5][8], base 1000, int 4 বাইট, row-major — m[3][5]-এর ঠিকানা? — 1000 + (3 × 8 + 5) × 4 = 1116.
  • O(1) বাড়তি জায়গায় array k ঘর rotate করুন। — k %= n; পুরোটা, প্রথম k, বাকিটা উল্টান (ডানে ঘোরাতে); Θ(n).
  • Maximum subarray sum O(n)-এ? — Kadane: cur = max(x, cur + x), best = max(best, cur); শুরু a[0] দিয়ে, যাতে সব-ঋণাত্মক ইনপুট ঠিক থাকে।

১০ · AI-এর যুগে array — এক লাইনের কোডের লুকানো খরচ

Array-এর সমস্যা AI খুব দ্রুত সমাধান করে — rotate, dedup, Kadane প্রায় মুখস্থের মতো লিখে দেয়। কিন্তু array-এর ভুলগুলো সাধারণত ছোট ইনপুটে দেখা দেয় না: k > n, খালি array, সব-ঋণাত্মক সংখ্যা, এক লাইনের Python যা ভেতরে Θ(n) কপি করে, বা লুপের ভেতরে erase. AI প্রায়ই সময়ের দাবিও ভুল করে — “list.insert(0, x) O(1)” বা “slicing দিয়ে rotate, O(1) জায়গা”।

Array-এ AI সাধারণত যেসব ভুল করে

AI-এর ভুলকীভাবে ধরবেন
লুপে insert(0, x), pop(0), remove বা erase — অথচ মোট O(n) দাবিপ্রতিটি লাইনের খরচ টেবিল থেকে মেলান; n দ্বিগুণ করে কাজের গণনা (counter) চার গুণ হয় কি না দেখুন
Rotate-এ k %= n নেই, বা n = 0 সামলায়নিk = 0, k = n, k = 3n + 1 আর খালি array দিয়ে চালান
Kadane-এ cur = best = 0 দিয়ে শুরু[−3, −1, −2] দিন — উত্তর −1 আসা উচিত
Dedup কোড sorted নয় এমন array-তে ব্যবহার[1, 2, 1] দিয়ে চালান; প্রশ্নে “sorted” লেখা আছে কি না দেখুন
iterator ধরে লুপ চলাকালীন push_back/erase-D_GLIBCXX_DEBUG দিয়ে compile করুন, বা AddressSanitizer (-fsanitize=address) চালান
২D লুপে ভেতরে সারির index বদলানো (কলাম ধরে ঘোরা)ভেতরের লুপে শেষ index (j) বদলাচ্ছে কি না দেখুন
Off-by-one: i <= n, a[n], rev(a, 0, n)-এ hi = n ধরে swapn = 1 আর n = 2 দিয়ে হাতে trace করুন; C++-এ v.at(i) দিয়ে পরীক্ষা

AI-কে সমাধান লিখতে না বলে আপনার সমাধান পরীক্ষা করতে ব্যবহার করুন। নিচের prompt-টি জোর দেয় খরচ আর কিনারার ইনপুটের ওপর — যেখানে AI নিজেও ভুল করে, তাই উত্তর আপনাকেই মিলিয়ে দেখতে হবে:

আমি DSA শিখছি, এখন array আর dynamic array. নিচে একটি array সমস্যার জন্য আমার লেখা কোড দিলাম: [এখানে আপনার কোড পেস্ট করুন] পুরো সমাধান লিখে দিও না। শুধু এগুলো করো: ১. প্রতিটি লাইনের খরচ বলো (বিশেষ করে insert, erase, slice, in, remove), আর মোট time ও extra space complexity কত — যুক্তিসহ। ২. এমন ৫টি edge case ইনপুট দাও যেগুলোতে কোডটি ভাঙতে পারে (খালি array, এক উপাদান, k > n, সব ঋণাত্মক, সব সমান)। ৩. কোথাও iterator invalidation বা index সীমার বাইরে যাওয়ার সম্ভাবনা আছে কি? ৪. একটি brute force সংস্করণ কেমন হবে তার শুধু রূপরেখা দাও, যাতে আমি নিজে stress test লিখে ফল মিলাতে পারি।

AI-এর (বা নিজের) array কোড যাচাইয়ের চেকলিস্ট

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

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

প্র ০১বেশিরভাগ ভাষায় array-এর index শূন্য থেকে শুরু হয় কেন — আর কিছু ভাষা ১ থেকে কেন শুরু করে?

সবচেয়ে সরাসরি কারণ ঠিকানার সূত্রেই লুকিয়ে আছে: address = base + i × size. index যদি “base থেকে কত ঘর দূরে” বোঝায়, তাহলে প্রথম ঘরের দূরত্ব শূন্য। ১ থেকে শুরু করলে সূত্র হতো base + (i − 1) × size — প্রতিটি access-এ একটি বাড়তি বিয়োগ (অথবা compiler-কে base আগে থেকে এক ঘর পিছিয়ে রাখার কৌশল করতে হতো)। C ভাষা এই চিন্তা নিয়েছে তার পূর্বসূরি BCPL থেকে, যেখানে array আর pointer প্রায় একই জিনিস। C-তে a[i] আক্ষরিক অর্থেই *(a + i)-এর সংক্ষিপ্ত রূপ — এ কারণে C-তে i[a] লিখলেও কাজ করে, যা একটি বিখ্যাত কৌতুক।

কিন্তু শুধু যন্ত্রের সুবিধাই কারণ নয়। ১৯৮২ সালে Edsger Dijkstra একটি ছোট হাতে-লেখা নোট লেখেন, “Why numbering should start at zero” (EWD831). তাঁর যুক্তি: একটি পরিসর লেখার সবচেয়ে পরিচ্ছন্ন উপায় হলো অর্ধ-খোলা পরিসর, যেমন 0 ≤ i < n — নিচের সীমা অন্তর্ভুক্ত, ওপরেরটি নয়। এতে পরিসরের দৈর্ঘ্য সরাসরি ওপরের সীমা থেকে নিচেরটি বাদ দিলেই পাওয়া যায়, খালি পরিসর লেখা যায় (0 ≤ i < 0), আর দুটি পাশাপাশি পরিসর (0 ≤ i < k এবং k ≤ i < n) কোনো ফাঁক বা ওভারল্যাপ ছাড়াই জোড়া লাগে। n টি উপাদানের জন্য এই নিয়মে শূন্য থেকে শুরু করলেই পরিসর হয় 0 ≤ i < n — সবচেয়ে সুন্দর রূপ। এই পর্বের rev(a, lo, hi) আর তিনবার উল্টানোয় [0, k) আর [k, n) এভাবেই নির্ভুলভাবে জোড়া লেগেছে। Python-এর range(n) আর slicing a[l:r], C++-এর begin()/end() — সবই এই অর্ধ-খোলা দর্শনে তৈরি।

অন্য দিকে Fortran (১৯৫৭), MATLAB, R, Lua আর Julia ১ থেকে গোনে — কারণ এদের মূল ব্যবহারকারী গণিতবিদ ও বিজ্ঞানী, আর গণিতের বইয়ে matrix-এর উপাদান লেখা হয় a₁₁ থেকে। Fortran আর Pascal-এ চাইলে নিচের সীমা নিজেই বেছে নেওয়া যায় (যেমন -5 থেকে 5). অর্থাৎ এটি কোনো “সঠিক বনাম ভুল” প্রশ্ন নয়, বরং কার সুবিধাকে অগ্রাধিকার দেওয়া হবে সেই সিদ্ধান্ত। তবে দুই রীতির মধ্যে কোড অনুবাদ করার সময় (যেমন MATLAB থেকে Python-এ, বা পাঠ্যবইয়ের 1-based pseudocode থেকে C++-এ) off-by-one ভুল সবচেয়ে বেশি হয় — CLRS-এর মতো বিখ্যাত বইও pseudocode-এ 1-based array ব্যবহার করে।

মূল উপলব্ধি: ০ থেকে গোনা ঠিকানার সূত্র আর অর্ধ-খোলা পরিসরের যুক্তি থেকে আসে; ১ থেকে গোনা আসে গণিতের প্রচলিত লেখা থেকে। কোন রীতিতে কাজ করছেন তা সচেতনভাবে জানলেই off-by-one ভুলের বড় অংশ এড়ানো যায়।

প্র ০২কিছু লাইব্রেরি growth factor 2-এর বদলে 1.5 বেছে নেয় কেন? এর পেছনে কোন সূক্ষ্ম যুক্তি আছে?

অংশ ৫-এর টেবিল থেকে স্পষ্ট বাণিজ্য: 2 গুণে মোট কপি কম (< 2n), 1.5 গুণে বেশি (< 3n) কিন্তু খালি জায়গার অপচয় কম। তবে এর চেয়েও সূক্ষ্ম একটি যুক্তি আছে, যা মেমরি allocator-এর আচরণ নিয়ে।

ধরুন vector প্রতিবার বড় ব্লক নেয় আর পুরোনোটা ছেড়ে দেয়, এবং allocator ছেড়ে দেওয়া পাশাপাশি ব্লকগুলো জুড়ে নতুন অনুরোধে আবার ব্যবহার করতে পারে। growth factor 2 হলে capacity-র ধারা 1, 2, 4, 8, 16, … এবং প্রতিটি নতুন ব্লক আগের সব ছেড়ে দেওয়া ব্লকের যোগফলের চেয়ে বড়: 1 + 2 + 4 + 8 = 15 < 16. অর্থাৎ পুরোনো ছেড়ে দেওয়া জায়গাগুলো জুড়েও কখনো নতুন ব্লকের সমান হয় না — vector সবসময় মেমরির নতুন অংশে সরে যেতে থাকে, আর পেছনে ব্যবহার-অযোগ্য ফাঁক রেখে যায়। গাণিতিকভাবে দেখানো যায়, growth factor যদি golden ratio φ ≈ 1.618-এর কম হয়, তাহলে কয়েক ধাপ পর আগে ছেড়ে দেওয়া ব্লকগুলোর যোগফল নতুন অনুরোধের চেয়ে বড় হয়ে যায় — তখন পুরোনো জায়গা পুনর্ব্যবহারের সুযোগ তৈরি হয়। 1.5 এই সীমার নিচে, আর গুণটিও সস্তা (x + x/2, শুধু একটি shift আর যোগ)।

Facebook (এখন Meta)-এর ওপেন-সোর্স C++ লাইব্রেরি folly-র fbvector-এর ডকুমেন্টেশন ঠিক এই যুক্তিতে 1.5 বেছে নেওয়ার কথা লিখেছে। Microsoft-এর STL আর Java-র ArrayList-ও 1.5. অন্য দিকে GCC আর Clang-এর লাইব্রেরি 2 রেখেছে — কারণ বাস্তবে আধুনিক allocator সাধারণত বিভিন্ন আকারের ব্লক আলাদা “তাক”-এ রাখে, বড় ব্লক সরাসরি অপারেটিং সিস্টেম থেকে নেয়, তাই তাত্ত্বিক পুনর্ব্যবহারের সুবিধা সবসময় মেলে না, অথচ কম কপির সুবিধা নিশ্চিত। CPython মাত্র প্রায় 1.125 গুণ বাড়ায়, কারণ Python প্রোগ্রামে লক্ষ লক্ষ ছোট list থাকতে পারে, আর প্রতিটিতে অর্ধেক জায়গা খালি থাকলে মোট অপচয় বিশাল হতো। আর Go ভাষার slice ছোট অবস্থায় দ্বিগুণ হয়, বড় হলে ক্রমশ কম হারে বাড়ে।

লক্ষ করুন, এই সব সিদ্ধান্তেই push amortized O(1) থাকে — Big-O কোনো পার্থক্য দেখায় না। পার্থক্য ধ্রুবকে, মেমরি ব্যবহারে আর allocator-এর সাথে মানিয়ে চলায়।

মূল উপলব্ধি: অ্যালগরিদমের বিশ্লেষণ বলে কোন নকশা সম্ভব (গুণ করে বাড়াতে হবে, যোগ করে নয়); প্রকৌশলের সিদ্ধান্ত ঠিক করে সেই সম্ভাব্য নকশাগুলোর মধ্যে কোনটি নির্দিষ্ট বাস্তবতায় সবচেয়ে ভালো।

প্র ০৩একই Big-O-র দুটি লুপের গতিতে এত পার্থক্য হয় কেন? মেমরির স্তরবিন্যাস DSA-কে কীভাবে বদলে দেয়?

RAM model (পর্ব ০২) ধরে নেয় প্রতিটি মেমরি পড়ার খরচ সমান। বাস্তবে তা নয়। আধুনিক প্রসেসরে মেমরি কয়েক স্তরে সাজানো: CPU-র register, তারপর ছোট কিন্তু অত্যন্ত দ্রুত L1 cache (সাধারণত কয়েক ডজন কিলোবাইট), তারপর বড় ও একটু ধীর L2, L3, আর শেষে মূল RAM. L1 থেকে একটি মান পড়তে লাগে কয়েকটি clock cycle; RAM থেকে আনতে লাগে কয়েকশো cycle — প্রায় শতগুণ পার্থক্য। একে অনেকে বলেন “memory wall”: গত কয়েক দশকে CPU-র গতি যত দ্রুত বেড়েছে, RAM-এর সাড়া দেওয়ার সময় তত কমেনি।

এই ব্যবধান লুকাতে হার্ডওয়্যার দুটি কৌশল নেয়। প্রথমত, একটি ঘর চাইলে পুরো cache line (সাধারণত ৬৪ বাইট) আনে — spatial locality-র বাজি। দ্বিতীয়ত, প্রসেসরের prefetcher লক্ষ করে আপনি কি নিয়মিত ধাপে (যেমন পরপর) পড়ছেন; তাহলে আপনি চাওয়ার আগেই পরের line আনতে শুরু করে। Array পরপর ঘুরলে দুটি কৌশলই নিখুঁতভাবে কাজ করে। কলাম ধরে বড় matrix ঘুরলে, বা linked list-এর মতো মেমরিতে ছড়ানো node-এ লাফালে (পর্ব ১২), প্রায় প্রতিটি পড়াই RAM পর্যন্ত যায়।

এই উপলব্ধি পুরো ক্ষেত্রকে প্রভাবিত করেছে। ১৯৯৯ সালে Frigo, Leiserson, Prokop ও Ramachandran “cache-oblivious algorithms” ধারণা দেন — এমন অ্যালগরিদম যা cache-এর আকার না জেনেও সব স্তরে দক্ষ। ডেটাবেসের B-tree (পর্ব ৩০) পুরোটাই এই চিন্তায় তৈরি: একটি node একটি ডিস্ক পৃষ্ঠার সমান, যাতে প্রতিটি ধীর পড়ায় যতটা সম্ভব কাজের তথ্য আসে। গেম ইঞ্জিনে “array of structs” ছেড়ে “struct of arrays”-এ যাওয়া, NumPy-র vectorized হিসাব, matrix গুণের লাইব্রেরি (BLAS)-তে ছোট ব্লকে ভাগ করে গুণ — সবই একই নীতি: যা একসাথে লাগবে, তা পাশাপাশি রাখা।

কনটেস্টেও এর প্রভাব আছে। একই Θ(n²) DP, শুধু লুপের ক্রম বা টেবিলের মাত্রা (dp[i][j] না dp[j][i]) বদলে, time limit-এর ভেতরে চলে যেতে পারে। অনেক অভিজ্ঞ প্রতিযোগী vector<vector<int>>-এর বদলে একটি সমতল array ব্যবহার করেন ঠিক এই কারণে।

মূল উপলব্ধি: Big-O কাজের পরিমাণের মাপ, কাজের ধরনের নয়। দুটি Θ(n) অ্যালগরিদমের মধ্যে যেটি মেমরি পরপর পড়ে, সেটি প্রায়ই কয়েক গুণ দ্রুত — আর array-এর পাশাপাশি বিন্যাসই এই সুবিধার উৎস।

প্র ০৪Maximum subarray সমস্যার Θ(n) সমাধান কীভাবে আবিষ্কৃত হয়েছিল?

এই গল্পটি অ্যালগরিদম ডিজাইনের সবচেয়ে পরিচিত শিক্ষামূলক কাহিনিগুলোর একটি, আর এটি জনপ্রিয় করেছেন Jon Bentley, তাঁর Communications of the ACM-এর “Programming Pearls” কলামে (১৯৮৪), যা পরে একই নামের বইয়ে আসে। Bentley-র বর্ণনা অনুযায়ী, সমস্যাটির উৎস Brown University-র Ulf Grenander-এর কাজ — ১৯৭০-এর দশকে ডিজিটাল ছবিতে প্যাটার্ন খোঁজার জন্য তিনি একটি দ্বিমাত্রিক সংস্করণ নিয়ে ভাবছিলেন: ছবির কোন আয়তাকার অংশের উজ্জ্বলতার যোগফল সবচেয়ে বেশি। দ্বিমাত্রিক রূপ কঠিন হওয়ায় তিনি একমাত্রিক সংস্করণে মন দেন।

Grenander-এর প্রথম সমাধান ছিল Θ(n³) — সব (l, r) জোড়ার যোগফল নতুন করে হিসাব। তারপর তিনি Θ(n²)-এ নামান, যোগফল চলতে চলতে হিসাব করে। Bentley লিখেছেন, Michael Shamos এই সমস্যা শুনে divide and conquer দিয়ে Θ(n log n) সমাধান বের করেন — array-কে অর্ধেক করা, দুই অর্ধেকের উত্তর রিকার্শনে, আর মাঝখান পেরোনো সেরা subarray আলাদা করে Θ(n)-এ, ঠিক merge sort-এর মতো (পর্ব ২১)। Shamos ও Bentley মনে করেছিলেন সম্ভবত এর চেয়ে ভালো হয় না। কিছুদিন পর Carnegie Mellon University-র একটি সেমিনারে পরিসংখ্যানবিদ Jay Kadane সমস্যাটি শোনেন এবং — Bentley-র ভাষায় — প্রায় এক মিনিটের মধ্যেই Θ(n) সমাধানটি দেন।

এর চেয়ে ভালো সম্ভব নয়: প্রতিটি উপাদান অন্তত একবার না দেখলে সেটি উত্তরের অংশ কি না জানা যায় না, তাই Ω(n). একই সমস্যার চারটি সমাধান — Θ(n³), Θ(n²), Θ(n log n), Θ(n) — পাশাপাশি রেখে Bentley দেখিয়েছিলেন, n = ১০ লাখে প্রথমটি অসম্ভব আর শেষটি চোখের পলকে। পর্ব ০৪-এর বৃদ্ধির হারের শিক্ষাটি এর চেয়ে নাটকীয়ভাবে দেখানো কঠিন।

মজার ব্যাপার, Grenander-এর আসল দ্বিমাত্রিক সমস্যাটি (n × n matrix-এ সর্বোচ্চ যোগফলের আয়তক্ষেত্র) এখনো এত সহজ নয়। Kadane-কে কলামের প্রতিটি জোড়ার ওপর চালিয়ে Θ(n³)-এ করা যায়, আর এর চেয়ে উল্লেখযোগ্যভাবে দ্রুত সমাধান খুঁজে পাওয়া আজও একটি গবেষণার প্রশ্ন — fine-grained complexity-র গবেষকেরা দেখিয়েছেন এটি all-pairs shortest path-এর মতো কঠিন বলে বিশ্বাস করা সমস্যার সাথে সম্পর্কিত।

মূল উপলব্ধি: সঠিক প্রশ্ন বেছে নেওয়াই অর্ধেক সমাধান। “সেরা subarray কোনটি?” থেকে “i-তে শেষ হওয়া সেরা subarray কোনটি?”-তে যাওয়াই Θ(n log n)-কে Θ(n)-এ নামিয়েছে — আর এই কৌশলটিই dynamic programming-এর হৃৎপিণ্ড।

প্র ০৫Array-এর সীমার বাইরে লেখা কতটা বিপজ্জনক হতে পারে — আর আধুনিক ভাষাগুলো এটি কীভাবে ঠেকায়?

অংশ ১-এ দেখেছেন, C/C++-এ a[5] লিখলে প্রোগ্রাম শুধু ঠিকানা হিসাব করে, কোনো যাচাই ছাড়া। ছোট প্রোগ্রামে এর ফল ভুল উত্তর বা crash. কিন্তু ইন্টারনেটের সাথে যুক্ত সফটওয়্যারে এটি নিরাপত্তার সবচেয়ে বড় ছিদ্রগুলোর একটি — আক্রমণকারী ইচ্ছা করে লম্বা ইনপুট পাঠিয়ে buffer-এর পরের মেমরি (যেমন ফাংশনের return address, পর্ব ০৭) নিজের পছন্দমতো লিখে ফেলতে পারে। একে বলে buffer overflow.

ইতিহাসে এর উদাহরণ অনেক। ১৯৮৮ সালের Morris worm, যা তখনকার ইন্টারনেটের উল্লেখযোগ্য অংশ অচল করে দিয়েছিল, অন্যান্য কৌশলের সাথে Unix-এর fingerd প্রোগ্রামের একটি buffer overflow ব্যবহার করেছিল — সেখানে C-র gets() ফাংশন ইনপুটের দৈর্ঘ্য যাচাই না করেই array-এ লিখত। ২০১৪ সালের Heartbleed বাগে OpenSSL লাইব্রেরি একটি অনুরোধে দাবি করা দৈর্ঘ্য যাচাই না করে array-এর সীমার বাইরে থেকে পড়ে ফেরত পাঠাত — এতে সার্ভারের মেমরি থেকে পাসওয়ার্ড বা গোপন key ফাঁস হওয়ার ঝুঁকি তৈরি হয়েছিল, আর পৃথিবীজুড়ে বহু ওয়েবসাইটকে তড়িঘড়ি আপডেট করতে হয়েছিল। Microsoft আর Google-এর Chromium দল প্রকাশ্যে জানিয়েছে, তাদের গুরুতর নিরাপত্তা-ত্রুটির প্রায় ৭০ শতাংশ এ ধরনের memory safety সমস্যা — সীমার বাইরে access, dangling pointer, ছেড়ে দেওয়া মেমরি ব্যবহার।

সমাধানের পথ কয়েকটি। Python, Java, C# প্রতিটি access-এ সীমা যাচাই করে exception দেয় — সামান্য গতির দাম দিয়ে নিরাপত্তা। C++ দুটোই দেয়: v[i] দ্রুত কিন্তু যাচাইহীন, v.at(i) যাচাইসহ; আর debug-এর সময় GCC-র -D_GLIBCXX_ASSERTIONS বা AddressSanitizer লুকানো ভুল ধরে ফেলে। Rust আরও এক ধাপ এগিয়েছে: এর “borrow checker” compile-এর সময়েই ধরে ফেলে যদি কোনো reference বেঁচে থাকা অবস্থায় vector বদলানোর চেষ্টা হয় — অর্থাৎ অংশ ৬-এর iterator invalidation বাগ Rust-এ সাধারণ কোডে compile-ই হয় না। Java-র ArrayList-ও লুপ চলাকালীন list বদলালে অনেক ক্ষেত্রে ConcurrentModificationException দিয়ে সতর্ক করে।

কনটেস্টে এর একটি ব্যবহারিক দিক আছে: C++-এ সীমার বাইরে লেখা অনেক সময় “Wrong Answer” দেয়, “Runtime Error” নয় — কারণ প্রোগ্রাম crash না করে পাশের ভ্যারিয়েবলের মান নষ্ট করে। তাই array-এর আকার সবসময় সর্বোচ্চ সীমার চেয়ে একটু বড় রাখা আর লোকাল টেস্টে sanitizer চালানো অভিজ্ঞদের অভ্যাস।

মূল উপলব্ধি: base + i × size সূত্রের গতি আসে যাচাই না করার মধ্য দিয়ে; সেই যাচাইয়ের দায়িত্ব তখন প্রোগ্রামারের। যে ভাষা বা টুল এই দায়িত্ব যন্ত্রের হাতে দেয়, সে লক্ষ লক্ষ ব্যবহারকারীর নিরাপত্তা রক্ষা করে।

অনুশীলন

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

  1. ঠিকানা হিসাব করুন: int m[5][8] (int ৪ বাইট) মেমরিতে base 1000 থেকে শুরু। (ক) row-major বিন্যাসে m[3][5]-এর ঠিকানা কত? (খ) একই matrix column-major হলে? (গ) পুরো matrix শেষ হয় কোন ঠিকানায়?

    (ক) Row-major: অবস্থান = i × C + j = 3 × 8 + 5 = 29, ঠিকানা = 1000 + 29 × 4 = 1116.

    (খ) Column-major: অবস্থান = j × R + i = 5 × 5 + 3 = 28, ঠিকানা = 1000 + 28 × 4 = 1112. লক্ষ করুন, column-major-এ গুণ হয় সারির সংখ্যা (R = 5) দিয়ে, কলামের নয়।

    (গ) মোট 5 × 8 = 40টি int = 160 বাইট, তাই ঠিকানা 1000 থেকে 1159 পর্যন্ত; শেষ উপাদান m[4][7]-এর ঠিকানা 1000 + 39 × 4 = 1156.

  2. Push trace করুন: capacity 0 থেকে শুরু (প্রথমবার 1, তারপর প্রতিবার দ্বিগুণ) একটি dynamic array-তে ১৭ বার push করা হলো। কোন কোন push-এ reallocation হয়? মোট কতটি কপি? শেষে capacity কত? growth factor 2-এর বদলে “প্রতিবার +4 ঘর” হলে (capacity 0 থেকে 4, 8, 12, …) মোট কপি কত হতো?

    দ্বিগুণ নিয়মে reallocation হয় push 1 (0 → 1, কপি 0), push 2 (কপি 1), push 3 (কপি 2), push 5 (কপি 4), push 9 (কপি 8), push 17 (কপি 16). মোট কপি 1 + 2 + 4 + 8 + 16 = ৩১, যা 2 × 17 = ৩৪-এর কম। শেষে capacity ৩২ (১৫টি ঘর খালি — প্রায় অর্ধেক, ঠিক যেমন বলা হয়েছিল)।

    +4 নিয়মে reallocation হয় push 1, 5, 9, 13, 17-এ, কপি 0 + 4 + 8 + 12 + 16 = ৪০. ১৭-তে পার্থক্য সামান্য মনে হয়, কিন্তু n = 10⁶ হলে +4 নিয়মে কপি প্রায় n²/8 ≈ ১২,৫০০ কোটি, আর দ্বিগুণে ২০ লাখের কম।

  3. Kadane হাতে চালান: [2, −1, 2, 3, −9, 4]-এ প্রতিটি ধাপে cur আর best লিখুন। সর্বোচ্চ যোগফল কত, আর কোন subarray? একই কোড [−3, −1, −2]-এ কী দেয়?

    শুরু: cur = best = 2. তারপর −1: cur = max(−1, 1) = 1, best 2. 2: cur = max(2, 3) = 3, best 3. 3: cur = max(3, 6) = 6, best 6. −9: cur = max(−9, −3) = −3, best 6. 4: cur = max(4, 1) = 4, best 6.

    উত্তর 6, subarray [2, −1, 2, 3] (index 0..3). শেষের 4 একা 4 দেয়, যা 6-এর কম। [−3, −1, −2]-এ: cur = best = −3; −1: cur = max(−1, −4) = −1, best −1; −2: cur = max(−2, −3) = −2, best −1. উত্তর −1 — শুধু [−1]. শুরু 0 দিয়ে করলে ভুল উত্তর 0 আসত।

  4. কোড লিখুন: একটি array-এর সব শূন্য শেষে পাঠান, বাকি উপাদানের আপেক্ষিক ক্রম ঠিক রেখে — in-place, Θ(n) সময়, O(1) বাড়তি জায়গায়। যেমন [0, 3, 0, 5, 7] → [3, 5, 7, 0, 0]. (ইঙ্গিত: duplicate বাদ দেওয়ার read/write কৌশল মনে করুন।)

    একটি write index w রাখুন: a[0..w−1]-এ এ পর্যন্ত দেখা সব অশূন্য মান, মূল ক্রমে। প্রতিটি অশূন্য মান a[w]-এ লিখে w বাড়ান; শেষে a[w..n−1]-এ শূন্য বসান।

    void moveZeros(vector<int>& a) {
        int w = 0;                                // a[0..w-1]: non-zeros, in order
        for (int x : a) if (x != 0) a[w++] = x;   // w never passes the read position
        while (w < (int)a.size()) a[w++] = 0;     // fill the tail with zeros
    }

    Python-এ কাঠামো এক: for x in a: লুপে x != 0 হলে a[w] = x আর w += 1, তারপর শেষে শূন্য। প্রতিটি উপাদান একবার পড়া, সর্বোচ্চ একবার লেখা (আর শেষে শূন্য বসানো), তাই Θ(n); বাড়তি শুধু w, তাই O(1). লেখার index কখনো পড়ার index-কে ছাড়িয়ে যায় না, তাই না-পড়া মান মুছে যায় না। ভুল পথ: প্রতিটি শূন্য পেলে erase করে শেষে push_back(0) — প্রতিটি erase Θ(n), মোট Θ(n²), আর লুপ চলাকালীন erase-এ iterator invalidation-এর বিপদও আছে।

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

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

ক্রমভরা dynamic array-তে push_back(x) হলে ভেতরে যা ঘটে, সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. size আর capacity সমান কি না পরীক্ষা করা
  2. পুরোনো capacity-র গুণিতক (যেমন দ্বিগুণ) আকারের নতুন ব্লক নেওয়া
  3. পুরোনো সব উপাদান নতুন ব্লকে কপি করা
  4. পুরোনো ব্লক ছেড়ে দেওয়া (এখন থেকে পুরোনো pointer ও iterator অচল)
  5. x-কে index size-এ লিখে size এক বাড়ানো

একটি int array-এর base address 1000, আর প্রতিটি int ৪ বাইট। a[6]-এর ঠিকানা কত?

  • 1006
  • 1020
  • 1024
  • 1028

খালি একটি dynamic array, যা ভরে গেলেই capacity দ্বিগুণ করে, তাতে n বার push_back করা হলো। পুরো ক্রমের মোট খরচ আর প্রতি push-এর amortized খরচ কত?

  • মোট Θ(n²), প্রতি push Θ(n)
  • মোট Θ(n), প্রতি push amortized O(1)
  • মোট Θ(n log n), প্রতি push Θ(log n)
  • মোট Θ(1), প্রতি push Θ(1/n)

n উপাদানের একটি dynamic array-এর একেবারে শুরুতে (index 0) একটি নতুন উপাদান insert করার time complexity কত?

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

C-তে int m[4][10] row-major বিন্যাসে রাখা। প্রথম উপাদান m[0][0]-এর অবস্থান 0 ধরলে m[2][3]-এর অবস্থান (উপাদানের হিসাবে, বাইটে নয়) কত?

  • 23
  • 14
  • 6
  • 32

n উপাদানের array-কে O(1) বাড়তি জায়গায় k ঘর ডানে ঘোরাতে (0 < k < n) কোন তিনবার উল্টানোর ক্রম সঠিক?

  • প্রথম k ঘর, তারপর বাকি n − k ঘর, শেষে পুরোটা
  • পুরোটা, তারপর প্রথম k ঘর, তারপর আবার পুরোটা
  • পুরোটা, তারপর প্রথম k ঘর, তারপর বাকি n − k ঘর
  • প্রথম k ঘর আর বাকি n − k ঘর — শুধু দুবার

Kadane's algorithm (cur = max(x, cur + x), best = max(best, cur)) প্রথম উপাদান দিয়ে শুরু করলে [−3, −1, −2]-এ (খালি subarray অনুমোদিত নয়) কী উত্তর দেয়?

  • 0
  • −1
  • −6
  • −3

C++-এ একটি vector<int> v-এর উপাদানের দিকে একটি reference int& r = v[0]; নেওয়া হলো, তারপর অনেকবার v.push_back(...) করা হলো। এরপর r পড়লে কী হয়?

  • reallocation হয়ে থাকলে r অচল (dangling) — পড়া undefined behavior
  • r স্বয়ংক্রিয়ভাবে নতুন ব্লকের v[0]-কে নির্দেশ করে
  • Compiler কোডটি compile করতে অস্বীকার করে
  • প্রোগ্রাম নিশ্চিতভাবে একটি exception ছোড়ে

শব্দকোষ

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

মনে রাখুন

  1. Array = পাশাপাশি ঘর; a[i] = base + i × size, তাই index দিয়ে পড়া Θ(1), আর বৈধ index 0 থেকে n − 1.
  2. index i-তে insert/erase Θ(n − i) — shifting; শেষে Θ(1), শুরুতে Θ(n).
  3. Capacity গুণ করে বাড়ালে n push-এ মোট কপি < 2n (g = 2) — push_back amortized O(1); যোগ করে বাড়ালে Θ(n²).
  4. Reallocation, insert, erase-এর পর পুরোনো pointer/reference/iterator অচল; লুপে বদলাতে index বা erase_if.
  5. 2D array row-major — ভেতরের লুপে শেষ index; ক্লাসিক: তিনবার উল্টে rotate, read/write index-এ dedup, Kadane cur = max(x, cur + x).

পরের পর্বে: String আসলে অক্ষরের একটি array — কিন্তু তার নিজস্ব কিছু চমক আছে: Python-এ string বদলানো যায় না কেন, লুপে s += c কখন Θ(n²) হয়ে যায়, palindrome, anagram আর অক্ষর গোনার প্রচলিত সমস্যাগুলো।

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

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