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

Prefix sum আর difference array

Prefix Sums & Difference Arrays — O(1) Range Sums, 2D Sums, Subarray Counting with Hashing, Range Updates

Taught in Bangla, this lesson builds prefix sum arrays with the P[0] = 0 convention for off-by-one-free range sums, extends them to 2D rectangle sums by inclusion-exclusion, and combines them with hash maps to count subarrays with sum k, find the longest zero-sum subarray and use prefix XOR and counts. It covers difference arrays for batch range updates, limits and overflow.

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

  • Prefix sum অ্যারে বানানো আর P[0] = 0 নিয়মে off-by-one ছাড়া রেঞ্জের যোগফল বের করা
  • 2D prefix sum দিয়ে যেকোনো আয়তক্ষেত্রের যোগফল O(1)-এ, আর এর পেছনের inclusion–exclusion
  • Hash map-এর সাথে prefix sum মিলিয়ে sum = k subarray গোনা, শূন্য-যোগফলের দীর্ঘতম subarray, prefix XOR আর prefix count
  • Difference array দিয়ে অনেকগুলো রেঞ্জ আপডেট একসাথে, আর 2D difference-এর মূল ধারণা
  • কখন prefix sum চলে না, overflow কীভাবে এড়াবেন, আর প্রতিটি দাবির শুদ্ধতার ছোট প্রমাণ

১ · একই যোগফল বারবার — সরল পদ্ধতি কেন ধীর

ধরুন মিরপুরের একটি মুদি দোকানের মালিক রফিক ভাই প্রতিদিনের বিক্রি একটি খাতায় লেখেন। মাসের শেষে তাঁর হিসাবরক্ষক প্রশ্ন করেন: “৩ তারিখ থেকে ১২ তারিখ পর্যন্ত মোট বিক্রি কত?” রফিক ভাই খাতা খুলে দশটি সংখ্যা যোগ করেন। পরের প্রশ্ন: “১০ থেকে ২৫ তারিখ?” আবার ষোলোটি যোগ। তারপর “১ থেকে ২৮?” — আবার প্রায় পুরো মাস। প্রতিটি প্রশ্নে একই সংখ্যাগুলো বারবার যোগ হচ্ছে।

রফিক ভাইয়ের বুদ্ধিমান মেয়ে একটি পরামর্শ দিল: খাতায় আরেকটি কলাম যোগ করুন — “আজ পর্যন্ত মোট বিক্রি”। প্রতিদিন শুধু আগের দিনের মোটের সাথে আজকের বিক্রি যোগ করলেই হয়। এরপর “৩ থেকে ১২ তারিখ” জানতে শুধু দুটি সংখ্যা লাগে: ১২ তারিখ পর্যন্ত মোট থেকে ২ তারিখ পর্যন্ত মোট বাদ দিন। দশটি যোগের বদলে একটি বিয়োগ! bKash-এর স্টেটমেন্টে প্রতিটি লেনদেনের পাশে যে “ব্যালান্স” কলাম থাকে, সেটিও ঠিক এই ধারণা।

এই পর্বের পুরো গল্প এই চলতি যোগফলের কলামকে ঘিরে। প্রথমে সমস্যাটি নিখুঁতভাবে লিখি। আমাদের কাছে n টি সংখ্যার একটি অ্যারে a[0..n−1] আছে (০ থেকে গোনা), আর q টি প্রশ্ন। প্রতিটি প্রশ্ন একটি range query: দুটি index l ও r দেওয়া আছে (0 ≤ l ≤ r < n), বলতে হবে a[l] + a[l + 1] + … + a[r] কত। এই টুকরোটিকে বলে একটি subarray — অ্যারের পাশাপাশি থাকা ঘরগুলোর একটি অবিচ্ছিন্ন অংশ।

Pseudocode (0-based): naive range sum, one query

RANGE-SUM-NAIVE(a, l, r):
    s = 0
    for i = l to r:          // r - l + 1 additions
        s = s + a[i]
    return s

একটি প্রশ্নে কাজ r − l + 1 টি যোগ, সবচেয়ে খারাপ ক্ষেত্রে (l = 0, r = n − 1) n টি। তাই q টি প্রশ্নে মোট সময় O(n·q). কনটেস্টে প্রায়ই n = q = 2 × 105 থাকে — তখন n·q = 4 × 1010, যা সাধারণ কম্পিউটারে কয়েক মিনিটের কাজ, অথচ সময়সীমা ১–২ সেকেন্ড। প্রশ্ন হলো: প্রতিটি প্রশ্নের আগে একই যোগগুলো আবার কেন করছি?

মূল ধারণা

অ্যারে যদি না বদলায়, তাহলে একবার একটু বাড়তি কাজ করে (preprocessing) এমন একটি সাহায্যকারী অ্যারে বানিয়ে রাখুন, যা থেকে প্রতিটি প্রশ্নের উত্তর ধ্রুবক সময়ে বের হয়। একে বলে “আগে খরচ, পরে লাভ” — আর এটি পর্ব ০৫-এ দেখা time–space trade-off-এর একটি সুন্দর উদাহরণ: n + 1 টি বাড়তি ঘরের বিনিময়ে প্রতিটি প্রশ্ন O(n) থেকে O(1).

২ · Prefix sum — একবার বানান, প্রতিটি প্রশ্ন O(1)

অ্যারের শুরু থেকে কোনো জায়গা পর্যন্ত টুকরোকে বলে prefix (উপসর্গ)। prefix sum অ্যারে P-এর প্রতিটি ঘরে রাখা হয় একটি prefix-এর যোগফল। আমরা যে নিয়ম ব্যবহার করব, সেটি মনে গেঁথে নিন:

P[0] = 0    এবং    P[i + 1] = P[i] + a[i]
অর্থাৎ P[i] = a[0] + a[1] + … + a[i − 1]   (প্রথম i টি ঘরের যোগফল)

লক্ষ করুন, P-এর আকার n + 1 — a-এর চেয়ে এক ঘর বেশি। P[0] হলো “শূন্য টি ঘরের যোগফল”, অর্থাৎ 0. এই একটি বাড়তি ঘরই পরে সব off-by-one ঝামেলা দূর করবে। এবার যেকোনো রেঞ্জের যোগফল:

sum(l..r) = a[l] + … + a[r] = P[r + 1] − P[l]

কারণ P[r + 1] হলো a[0] থেকে a[r] পর্যন্ত যোগফল। এর মধ্যে a[0] থেকে a[l − 1] অংশটুকু আমাদের দরকার নেই — আর ঠিক সেটিই P[l]. বড় prefix থেকে ছোট prefix বাদ দিলে মাঝের টুকরোটি থাকে। নিচে a = [3, 1, 4, 1, 5, 9, 2]-এর জন্য P বানানো আর একটি প্রশ্নের উত্তর ধাপে ধাপে দেখুন:

1vector<long long> P(n + 1, 0);   // P[0] = 02for (int i = 0; i < n; ++i)3    P[i + 1] = P[i] + a[i];45// sum(l..r) = P[r+1] - P[l]6long long ans = P[r + 1] - P[l];
এই ধাপের হিসাব
i
a[i]
নতুন ঘর
অ্যারে দুটি. = এখনো ভরা হয়নি

                    
  1. শুরু: P[0] = 0. P-এর আকার n + 1 = 8. প্রথম ঘর “কোনো ঘর না নেওয়ার যোগফল” — শূন্য। বাকি ঘরগুলো এখনো ভরা হয়নি।
  2. i = 0. P[1] = P[0] + a[0] = 0 + 3 = 3. অর্থাৎ প্রথম ১টি ঘরের যোগফল 3.
  3. i = 1. P[2] = 3 + 1 = 4 = a[0] + a[1]. প্রতিটি নতুন ঘর আগের ঘরের ওপর একটি মাত্র যোগ — পুরো prefix আবার যোগ করতে হয় না।
  4. i = 2. P[3] = 4 + 4 = 8. প্রথম তিনটি ঘর 3 + 1 + 4 = 8 — মিলে গেল।
  5. i = 3. P[4] = 8 + 1 = 9.
  6. i = 4. P[5] = 9 + 5 = 14.
  7. i = 5. P[6] = 14 + 9 = 23.
  8. i = 6, শেষ ঘর। P[7] = 23 + 2 = 25 = পুরো অ্যারের যোগফল। মোট ৭টি যোগ — n টি যোগে preprocessing শেষ, সময় Θ(n), বাড়তি জায়গা Θ(n).
  9. প্রশ্ন: sum(2..5). বন্ধনীর [ ] ঘরগুলো আমাদের রেঞ্জ: 4 + 1 + 5 + 9 = 19. সূত্রে: P[r + 1] − P[l] = P[6] − P[2] = 23 − 4 = 19. P[6]-এ আছে a[0..5], তা থেকে P[2] অর্থাৎ a[0..1] বাদ দিলাম — একটি বিয়োগ, O(1).

শুদ্ধতার প্রমাণ। দাবি: লুপ শেষে সব i-এর জন্য (0 ≤ i ≤ n) P[i] = a[0] + … + a[i − 1]. এটি একটি সরল loop invariant (পর্ব ০৩): “লুপের i-তম ঘূর্ণনের আগে P[0..i] সব ঠিক।” শুরুতে i = 0, আর P[0] = 0 = শূন্য টি ঘরের যোগফল — ঠিক। ঘূর্ণনে P[i + 1] = P[i] + a[i] = (a[0] + … + a[i − 1]) + a[i] — ঠিক, তাই invariant i + 1-এর জন্যও টেকে। লুপ শেষে i = n, তাই সব ঘর ঠিক। এরপর sum(l..r)-এর সূত্রটি শুধু যোগের সাজানো: P[r + 1] = (a[0] + … + a[l − 1]) + (a[l] + … + a[r]) = P[l] + sum(l..r).

Off-by-one — সূচকের নিয়ম একবারে ঠিক করুন

prefix sum-এর সবচেয়ে সাধারণ ভুল সূত্রে নয়, সূচকে। off-by-one ভুল এড়াতে প্রথমেই ঠিক করুন আপনি কোন নিয়ম মানছেন। তিনটি প্রচলিত নিয়ম পাশাপাশি দেখুন:

নিয়মP[i]-এর মানেsum(l..r)ঝুঁকি
০-ভিত্তিক a, P[0] = 0 (এই পর্বের নিয়ম)a[0..i−1]-এর যোগফলP[r + 1] − P[l]কোনো বিশেষ ক্ষেত্র নেই
১-ভিত্তিক a[1..n], P[0] = 0 (অনেক বই আর কনটেস্টে)a[1..i]-এর যোগফলP[r] − P[l − 1]০-ভিত্তিক অ্যারেতে এটি লিখলে ভুল ঘর
বাড়তি ঘর ছাড়া: S[i] = a[0..i]a[0..i]-এর যোগফলS[r] − S[l − 1], কিন্তু l = 0 হলে শুধু S[r]l = 0-তে S[−1] — C++-এ undefined behaviour, Python-এ নিঃশব্দে শেষ ঘর!

Python-এর নিঃশব্দ ফাঁদ। তৃতীয় নিয়মে l = 0 হলে Python-এ S[l - 1] মানে S[-1] — অর্থাৎ শেষ ঘর, পুরো অ্যারের যোগফল! কোনো error আসে না, শুধু উত্তর ভুল হয়। C++-এ একই কোড অ্যারের বাইরে পড়ে। P[0] = 0 রাখার নিয়মটি এই পুরো সমস্যাকে মুছে দেয়: সব l-এর জন্য একই সূত্র, কোনো if নেই।

এর পেছনে একটি গভীর নীতি আছে: রেঞ্জকে half-open interval [l, r + 1) হিসেবে ভাবা — শুরু অন্তর্ভুক্ত, শেষ বাদ। তখন সূত্র দাঁড়ায় “sum[lo, hi) = P[hi] − P[lo]”, দৈর্ঘ্য hi − lo, আর খালি রেঞ্জ মানে lo = hi, যোগফল 0. Edsger Dijkstra ১৯৮২ সালের একটি ছোট নোটে (EWD831, “Why numbering should start at zero”) ঠিক এই কারণেই ০ থেকে গোনা আর half-open রেঞ্জের পক্ষে যুক্তি দেন। C++-এর begin()/end() আর Python-এর range(lo, hi) ও slicing একই নিয়ম মানে।

লাইব্রেরিতে তৈরি আছে। C++-এ std::partial_sum (হেডার <numeric>) বা C++17-এর std::inclusive_scan চলতি যোগফল বানায় — তবে এগুলো বাড়তি ০ ঘর দেয় না, তাই P[0] = 0 নিজে রাখুন। Python-এ itertools.accumulate(a, initial=0) (Python 3.8 থেকে) সরাসরি আমাদের P দেয়। লাইব্রেরি ব্যবহার করুন, কিন্তু সূচকের মানে নিজে নিশ্চিত হয়ে।

৩ · 2D prefix sum — আয়তক্ষেত্রের যোগফল চার কোণা থেকে

এবার ধরুন ঢাকার একটি এলাকার মানচিত্র একটি গ্রিডে ভাগ করা — প্রতিটি ঘরে লেখা সেই ব্লকে কয়টি ফার্মেসি আছে। প্রশ্ন আসে: “এই আয়তাকার এলাকায় মোট কয়টি ফার্মেসি?” R × C গ্রিডে একটি আয়তক্ষেত্রে সরলভাবে যোগ করলে প্রতি প্রশ্নে O(R·C) কাজ। 1D-র কৌশলটিকে দুই মাত্রায় নিলে প্রতিটি প্রশ্ন আবার O(1).

2D prefix sum-এ P[i][j] = গ্রিডের ওপরের i টি সারি আর বাঁয়ের j টি কলামের আয়তক্ষেত্রের যোগফল, অর্থাৎ G[0..i−1][0..j−1]. 1D-র মতোই প্রথম সারি আর প্রথম কলাম পুরো শূন্য। একটি ঘর ভরার সূত্র:

P[i + 1][j + 1] = G[i][j] + P[i][j + 1] + P[i + 1][j] − P[i][j]

কেন বিয়োগ? ওপরের আয়তক্ষেত্র P[i][j + 1] আর বাঁয়ের আয়তক্ষেত্র P[i + 1][j] — দুটিতেই বাঁ-ওপরের কোণার অংশ P[i][j] আছে। যোগ করলে সেটি দুবার গোনা হয়, তাই একবার বাদ দিতে হয়। এই “যোগ করো, দুবার গোনা অংশ বাদ দাও” নীতির নাম inclusion–exclusion. প্রশ্নের উত্তরেও একই নীতি — সারি r1..r2 আর কলাম c1..c2-এর আয়তক্ষেত্র:

sum = P[r2 + 1][c2 + 1] − P[r1][c2 + 1] − P[r2 + 1][c1] + P[r1][c1]

বড় আয়তক্ষেত্র থেকে ওপরের পট্টি আর বাঁয়ের পট্টি বাদ দিলাম; কিন্তু বাঁ-ওপরের কোণাটি দুই পট্টিতেই ছিল, তাই দুবার বাদ গেছে — একবার ফেরত যোগ। নিচে 3 × 4 গ্রিডে পুরো P বানানো আর একটি প্রশ্ন দেখুন:

G (ফার্মেসির সংখ্যা) আর P. = এখনো ভরা হয়নি

                
এই ধাপের হিসাব
কাজ
সূত্র
  1. সীমানা শূন্য। P-এর আকার (R + 1) × (C + 1) = 4 × 5. সারি 0 আর কলাম 0 মানে “শূন্য সারি” বা “শূন্য কলাম”-এর আয়তক্ষেত্র — যোগফল 0. 1D-র P[0] = 0-এর মতোই এরা সীমানার সব বিশেষ ক্ষেত্র দূর করে।
  2. সারি 1. ওপরে কিছু নেই (সারি 0 শূন্য), তাই P[1][j + 1] হলো G-এর সারি 0-এর সাধারণ চলতি যোগফল: 2, 2 + 1 = 3, 3 + 3 = 6, 6 + 0 = 6.
  3. একটি ঘর খুঁটিয়ে: P[2][2]. প্রথমে P[2][1] = 1 + 2 + 0 − 0 = 3. তারপর P[2][2] = G[1][1] + P[1][2] + P[2][1] − P[1][1] = 4 + 3 + 3 − 2 = 8. পরীক্ষা: বাঁ-ওপরের 2 × 2 অংশ 2 + 1 + 1 + 4 = 8 — মিলে গেল। G[0][0] = 2 ওপর আর বাঁ দুটিতেই ছিল, তাই −2.
  4. সারি 2 শেষ। P[2][3] = 2 + 6 + 8 − 3 = 13, P[2][4] = 5 + 6 + 13 − 6 = 18. প্রতিটি ঘর ঠিক তিনটি আগে-ভরা ঘর থেকে O(1)-এ।
  5. সারি 3 শেষ। 6, 11, 17, আর শেষ ঘর P[3][4] = 2 + 18 + 17 − 13 = 24 = পুরো গ্রিডের যোগফল। মোট R·C = 12টি ঘর, preprocessing Θ(R·C) সময় ও জায়গা।
  6. প্রশ্ন: সারি 1..2, কলাম 1..3. [ ] ঘরগুলো: 4 + 2 + 5 + 0 + 1 + 2 = 14. সূত্রে চার কোণা (চিহ্ন + বা − মানে সূত্রে যোগ বা বিয়োগ): +P[3][4] − P[1][4] − P[3][1] + P[1][1] = 24 − 6 − 6 + 2 = 14. বারোটি ঘরের গ্রিডে লাভ সামান্য, কিন্তু ১০০০ × ১০০০ গ্রিডে প্রতি প্রশ্নে দশ লাখ যোগের বদলে চারটি পড়া।

কেন সূত্রটি ঠিক? প্রতিটি ঘর G[x][y] চার কোণার পদে কতবার গোনা হয়, দেখুন। যদি ঘরটি আয়তক্ষেত্রের ভেতরে থাকে, সেটি শুধু P[r2 + 1][c2 + 1]-এ আছে: +1. যদি ঘরটি ওপরের পট্টিতে থাকে (x < r1, কিন্তু কলাম সীমার ভেতরে), সেটি প্রথম আর দ্বিতীয় পদে আছে: +1 − 1 = 0. বাঁ-ওপরের কোণায় (x < r1, y < c1) থাকলে চারটি পদেই: +1 − 1 − 1 + 1 = 0. বাঁয়ের পট্টিতেও একইভাবে 0, আর আয়তক্ষেত্রের ডানে বা নিচে থাকলে কোনো পদেই নেই। তাই ফলাফলে ঠিক ভেতরের ঘরগুলো একবার করে থাকে। এই একই হিসাব-রক্ষার যুক্তি d মাত্রায়ও চলে, তখন 2d টি কোণা লাগে।

৪ · Prefix sum + hash map — যোগফল k এমন কয়টি subarray

এখন একটি বিখ্যাত ইন্টারভিউ-প্রশ্ন: একটি অ্যারে আর একটি সংখ্যা k দেওয়া আছে। কয়টি subarray-এর যোগফল ঠিক k? অ্যারেতে ঋণাত্মক সংখ্যাও থাকতে পারে — যেমন bKash অ্যাকাউন্টে টাকা ঢোকা (+) আর বের হওয়া (−), আর প্রশ্ন “কয়টি ধারাবাহিক সময়-খণ্ডে নিট পরিবর্তন ঠিক ৩ হাজার টাকা?”

Subarray আছে n(n + 1)/2 টি, তাই সবগুলো যাচাই করা brute force কমপক্ষে Θ(n²). prefix sum দিয়ে প্রশ্নটিকে নতুন ভাষায় লিখি। subarray a[j..i]-এর যোগফল = P[i + 1] − P[j]. তাই

a[j..i]-এর যোগফল = k  ⇔  P[j] = P[i + 1] − k

অর্থাৎ প্রতিটি শেষবিন্দু i-এর জন্য প্রশ্ন দাঁড়ায়: “আগের prefix sum-গুলোর (P[0] থেকে P[i]) মধ্যে কয়টির মান ঠিক S − k?”, যেখানে S = P[i + 1] হলো এখন পর্যন্ত চলতি যোগফল। এখন পর্যন্ত দেখা প্রতিটি prefix sum কতবার এসেছে, সেটি একটি hash table-এ (C++-এ unordered_map, Python-এ dict) গুনে রাখলে এই প্রশ্নের উত্তর গড়ে O(1)-এ মেলে। শুরুতে map-এ {0: 1} রাখতে হয় — এটিই P[0] = 0, অর্থাৎ “শুরু থেকেই শুরু” এমন subarray-গুলোর জন্য।

Pseudocode (0-based): count subarrays with sum exactly k

COUNT-SUM-K(a, k):
    seen = map with seen[0] = 1        // the empty prefix P[0] = 0
    S = 0, cnt = 0
    for i = 0 to n - 1:
        S = S + a[i]                   // S = P[i+1]
        cnt = cnt + seen[S - k]        // every earlier P[j] = S - k gives a[j..i]
        seen[S] = seen[S] + 1          // only AFTER counting
    return cnt

নিচে a = [1, 2, −1, 2, 1, −2, 3] আর k = 3-এ পুরো চলা দেখুন। map-এ লেখা আছে “prefix-এর মান : কতবার এসেছে”:

এই ধাপের অবস্থা (k = 3)
i
x = a[i]
S (চলতি যোগফল)
খুঁজছি S − k
map-এ পেলাম
cnt
map (মান:কতবার) আর পাওয়া subarrayধাপ শেষের অবস্থা

                
  1. শুরু। S = 0, cnt = 0, আর map-এ 0:1 — খালি prefix P[0] = 0 একবার “দেখা” হয়েছে ধরে নিলাম। এটি না রাখলে শুরু থেকে শুরু হওয়া subarray-গুলো (যেমন [0..1]) হারিয়ে যেত।
  2. i = 0, x = 1. S = 1. খুঁজছি 1 − 3 = −2 — map-এ নেই, কিছু যোগ হলো না। তারপর map-এ 1:1 যোগ।
  3. i = 1, x = 2. S = 3. খুঁজছি 0 — map-এ ১ বার আছে (P[0]). তাই a[0..1] = 1 + 2 = 3 একটি উত্তর। cnt = 1. তারপর 3:1.
  4. i = 2, x = −1. S = 2. খুঁজছি −1 — নেই। ঋণাত্মক সংখ্যায় S কমেও যায়; এই পদ্ধতিতে তাতে কোনো সমস্যা নেই। map-এ 2:1.
  5. i = 3, x = 2. S = 4. খুঁজছি 1 — আছে, P[1] = 1. তাই a[1..3] = 2 − 1 + 2 = 3. cnt = 2.
  6. i = 4, x = 1. S = 5. খুঁজছি 2 — আছে, P[3] = 2. তাই a[3..4] = 2 + 1 = 3. cnt = 3.
  7. i = 5, x = −2. S = 3. খুঁজছি 0 — P[0], তাই a[0..5] = 3. cnt = 4. এখন 3 মানটি দ্বিতীয়বার এলো, তাই map-এ 3:2.
  8. i = 6, x = 3. S = 6. খুঁজছি 3 — map-এ ২ বার (P[2] আর P[6]). তাই দুটি উত্তর একসাথে: a[2..6] = −1 + 2 + 1 − 2 + 3 = 3 আর a[6..6] = 3. cnt = 6. প্রতিটি উপাদানে O(1) গড় কাজ — মোট গড়ে O(n).

শুদ্ধতার প্রমাণ। প্রতিটি subarray a[j..i] একটি জোড় (j, i + 1)-এর সাথে এক-এক মিলে যায়, যেখানে 0 ≤ j ≤ i. যোগফল k হওয়া মানে P[i + 1] − P[j] = k. লুপের i-তম ধাপে seen[S − k] পড়ার মুহূর্তে map-এ ঠিক P[0], P[1], …, P[i]-এর গণনা আছে — P[i + 1] এখনো ঢোকেনি (ঢোকে পরের লাইনে)। তাই এই ধাপে ঠিক সেই j-গুলো গোনা হয় যাদের জন্য a[j..i]-এর যোগফল k, কোনোটি দুবার নয়, কোনোটি বাদ নয়। সব i-এর ওপর যোগ করলে প্রতিটি উপযুক্ত subarray ঠিক একবার।

লাইনের ক্রম বদলাবেন না। আগে seen[S]++ করে পরে seen[S − k] পড়লে k = 0-এর ক্ষেত্রে প্রতিটি i-তে নিজের সদ্য-ঢোকানো P[i + 1]-কেই গুনে ফেলবেন — অর্থাৎ j = i + 1, একটি খালি subarray! ফল n বেশি আসবে। k ≠ 0 হলে উত্তর ঠিকই আসে, তাই শুধু k ≠ 0-এর পরীক্ষায় ভুলটি ধরা পড়ে না।

Sliding window কেন এখানে চলে না

পর্ব ১৬-এ দেখেছেন, সব সংখ্যা ধনাত্মক হলে sliding window দিয়ে “যোগফল k” জাতীয় প্রশ্ন O(n)-এ হয়: যোগফল কম হলে ডানে বাড়ানো, বেশি হলে বাঁ থেকে ছাঁটা। এর ভিত্তি একটি একমুখিতা (monotonicity): window বড় করলে যোগফল কমে না, ছোট করলে বাড়ে না। ঋণাত্মক সংখ্যা এলেই এই ভিত্তি ভেঙে যায়।

ছোট একটি উদাহরণ: a = [2, −2, 3], k = 3. উত্তর দুটি: a[2..2] = 3 আর a[0..2] = 2 − 2 + 3 = 3. লক্ষ করুন, দুটিরই শেষবিন্দু একই (index 2), কিন্তু শুরু আলাদা। sliding window প্রতিটি শেষবিন্দুর জন্য একটিমাত্র বাঁ সীমা রাখে, তাই দুটির একটি হারাবেই। সব সংখ্যা ধনাত্মক হলে এমন হতে পারে না — তখন P কঠোরভাবে বাড়ে, তাই প্রতিটি শেষবিন্দুর জন্য P[j] = S − k এমন j সর্বোচ্চ একটি। prefix sum + hash map পদ্ধতি এই একমুখিতার ওপর নির্ভর করে না, তাই ঋণাত্মক, শূন্য — সব অবস্থায় ঠিক।

বিষয়Sliding window (পর্ব ১৬)Prefix sum + hash map
শর্তসব উপাদান ধনাত্মক (কিছু প্রশ্নে অঋণাত্মক হলেও চলে)কোনো শর্ত নেই
সময়O(n), নিশ্চিতগড়ে O(n); std::map দিয়ে O(n log n) নিশ্চিত
বাড়তি জায়গাO(1)O(n) — map-এ সর্বোচ্চ n + 1 টি key
কোন প্রশ্নে বেশি কাজে লাগে“যোগফল ≤ k এমন দীর্ঘতম window”“যোগফল ঠিক k এমন কয়টি / দীর্ঘতম”

এবার কোডে — প্রথমে range-sum প্রশ্ন, তারপর sum = k গণনা, আর ছোট ইনপুটে একটি brute force দিয়ে নিজের উত্তর মিলিয়ে নেওয়া:

#include <iostream>
#include <vector>
#include <unordered_map>
#include <utility>
#include <initializer_list>
using namespace std;

int main() {
    vector<long long> a = {3, 1, 4, 1, 5, 9, 2};
    int n = (int)a.size();
    vector<long long> P(n + 1, 0);                       // P[0] = 0
    for (int i = 0; i < n; ++i) P[i + 1] = P[i] + a[i];
    cout << "P =";
    for (long long x : P) cout << ' ' << x;
    cout << '\n';
    for (auto [l, r] : {pair{2, 5}, pair{0, 6}, pair{4, 4}})
        cout << "sum(" << l << ".." << r << ") = " << P[r + 1] - P[l] << '\n';

    vector<long long> b = {1, 2, -1, 2, 1, -2, 3};
    long long k = 3, s = 0, cnt = 0;
    unordered_map<long long, long long> seen{{0, 1}};     // the empty prefix
    for (long long x : b) {
        s += x;
        if (auto it = seen.find(s - k); it != seen.end()) cnt += it->second;
        ++seen[s];                                          // after counting
    }
    long long brute = 0;                                    // O(n^2) check
    for (int i = 0; i < (int)b.size(); ++i)
        for (long long t = 0, j = i; j < (int)b.size(); ++j)
            if ((t += b[j]) == k) ++brute;
    cout << "subarrays with sum " << k << ": " << cnt << " (brute force: " << brute << ")\n";
}

আউটপুট:

P = 0 3 4 8 9 14 23 25
sum(2..5) = 19
sum(0..6) = 25
sum(4..4) = 5
subarrays with sum 3: 6 (brute force: 6)

P ঠিক ওপরের প্রথম ডায়াগ্রামের মতো। তিনটি প্রশ্নই একই সূত্রে — l = 0 (পুরো অ্যারে) আর l = r (একটি ঘর) কোনো বিশেষ ক্ষেত্র নয়। sum = 3 গণনায় ৬টি উত্তর, তৃতীয় ডায়াগ্রামের সাথে মিলে যায়। seen.find ব্যবহার করা হয়েছে, কারণ seen[s - k] লিখলে না-থাকা key-ও map-এ ঢুকে যেত (মান 0) — উত্তর ঠিক থাকত, কিন্তু map অকারণে বড় হতো। শেষের দুই লুপের brute force Θ(n²): ছোট ইনপুটে দ্রুত সমাধানের সাথে মিলিয়ে দেখার এই অভ্যাসটিই stress testing-এর ভিত্তি। সব যোগফল long long-এ — কারণ অংশ ৭-এ দেখবেন।

a = [3, 1, 4, 1, 5, 9, 2]
P = [0]                                   # P[0] = 0
for x in a:
    P.append(P[-1] + x)                   # P[i+1] = P[i] + a[i]
print("P =", *P)
for l, r in [(2, 5), (0, 6), (4, 4)]:
    print(f"sum({l}..{r}) = {P[r + 1] - P[l]}")

b = [1, 2, -1, 2, 1, -2, 3]
k, s, cnt = 3, 0, 0
seen = {0: 1}                             # the empty prefix
for x in b:
    s += x
    cnt += seen.get(s - k, 0)
    seen[s] = seen.get(s, 0) + 1          # after counting
brute = sum(1 for i in range(len(b)) for j in range(i, len(b))
            if sum(b[i:j + 1]) == k)      # slow check, fine for tiny input
print(f"subarrays with sum {k}: {cnt} (brute force: {brute})")

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

P = 0 3 4 8 9 14 23 25
sum(2..5) = 19
sum(0..6) = 25
sum(4..4) = 5
subarrays with sum 3: 6 (brute force: 6)

P[-1] এখানে ইচ্ছাকৃত — “এখন পর্যন্ত শেষ ঘর”, অর্থাৎ আগের prefix. seen.get(s - k, 0) না-থাকা key-তে 0 দেয়, map-এ কিছু ঢোকায় না। একই কাজ collections.Counter বা defaultdict(int) দিয়েও লেখা যায়। brute force-টি আসলে Θ(n³), কারণ প্রতিটি sum(b[i:j + 1]) নিজেই একটি লুপ আর slice কপি — ৭টি উপাদানে কিছু যায় আসে না, কিন্তু এটিই দেখায় কেন prefix sum দরকার। Python-এর integer-এ overflow নেই, তাই টাইপ নিয়ে ভাবতে হয় না।

৫ · একই ছাঁচে আরও তিন কৌশল — শূন্য-যোগফল, prefix XOR, prefix count

“চলতি মান রাখো, দুটি prefix-এর পার্থক্য দিয়ে রেঞ্জ বের করো” — এই ছাঁচ যোগফলের বাইরেও বহু জায়গায় খাটে। তিনটি সবচেয়ে কাজের রূপ দেখি।

শূন্য যোগফলের দীর্ঘতম subarray

a[j..i]-এর যোগফল 0 মানে P[i + 1] = P[j] — দুটি prefix sum সমান। দীর্ঘতম চাই, তাই প্রতিটি মানের প্রথম উপস্থিতির সূচক map-এ রাখুন (পরে এলে আর বদলাবেন না)। একই মান আবার এলে দৈর্ঘ্য = বর্তমান সূচক − প্রথম সূচক। উদাহরণ a = [3, 4, −7, 1, 3, 3, 1, −4]:

t (P-এর সূচক)012345678
P[t]037014784
আগে দেখা?নতুননতুননতুন0 → t = 0, দৈর্ঘ্য 3নতুননতুন7 → t = 2, দৈর্ঘ্য 4নতুন4 → t = 5, দৈর্ঘ্য 3

দীর্ঘতম 4: P[2] = P[6] = 7, তাই a[2..5] = −7 + 1 + 3 + 3 = 0. সময় গড়ে O(n), জায়গা O(n). একই কৌশলে একটি জনপ্রিয় প্রশ্নও হয়ে যায়: “০ আর ১ সমান সংখ্যায় আছে এমন দীর্ঘতম subarray” — প্রতিটি 0-কে −1 ধরলেই সেটি শূন্য-যোগফলের প্রশ্ন (অনুশীলন ৪)। আর “যোগফল ঠিক k এমন দীর্ঘতম” চাইলে S − k-এর প্রথম উপস্থিতি খুঁজুন।

Prefix XOR

XOR (⊕, C++ ও Python-এ ^)-এর একটি বিশেষ গুণ আছে: x ⊕ x = 0, আর x ⊕ 0 = x. অর্থাৎ XOR নিজেই নিজের উল্টো — বিয়োগের কাজও XOR দিয়ে হয়। তাই prefix XOR X[0] = 0, X[i + 1] = X[i] ⊕ a[i] রাখলে:

a[l] ⊕ a[l + 1] ⊕ … ⊕ a[r] = X[r + 1] ⊕ X[l]

কারণ X[r + 1]-এ a[0..r] আছে, X[l]-এ a[0..l−1]; দুটি XOR করলে a[0..l−1] অংশ দুবার এসে নিজেকে বাতিল করে। “XOR ঠিক k এমন কয়টি subarray?” প্রশ্নেও অংশ ৪-এর পদ্ধতি হুবহু চলে — শুধু S − k-এর বদলে S ^ k খুঁজুন, কারণ X[j] ⊕ S = k ⇔ X[j] = S ⊕ k.

Prefix count — একটি রেঞ্জে কোনো মান কতবার

ধরুন একটি এলাকার ৩০ দিনের রেকর্ড: প্রতিদিন লোডশেডিং হয়েছে কি না। প্রশ্ন: “১০ থেকে ২০ তারিখের মধ্যে কত দিন লোডশেডিং?” প্রতিটি দিনকে 1 (হয়েছে) বা 0 (হয়নি) ধরলে এটি সাধারণ prefix sum: cnt[i + 1] = cnt[i] + (দিন i-এ লোডশেডিং ? 1 : 0). একই ধারণা যেকোনো শর্তে চলে — একটি string-এর s[l..r]-এ কয়টি স্বরবর্ণ, একটি পরীক্ষার ফলের তালিকায় একটি রোল-রেঞ্জে কতজন A+.

একাধিক মানের জন্য একসাথে চাইলে প্রতিটি মানের নিজের prefix count রাখুন: ছোট হাতের ২৬টি ইংরেজি অক্ষরের জন্য cnt[c][i] — জায়গা O(26·n), প্রতি প্রশ্ন O(1). “s[l..r]-এর অক্ষরগুলো সাজিয়ে palindrome বানানো যায় কি না?” জাতীয় প্রশ্নে এটি খুব কাজের: ২৬টি গণনা O(26)-এ বের করে দেখুন বিজোড় গণনা কয়টি। তবে মানের সংখ্যা (alphabet) বড় হলে এই জায়গা অসম্ভব হয়ে যায় — তখন অন্য কৌশল লাগে, যেমন পর্ব ৩৭-এর Mo’s algorithm.

কোন operation-এ prefix কৌশল চলে

যোগ, XOR, গণনা — সবগুলোর একটি মিল আছে: operation-টি সহযোজী (associative), আর তার একটি উল্টো আছে (যোগের উল্টো বিয়োগ, XOR-এর উল্টো XOR নিজেই)। উল্টো থাকলেই বড় prefix থেকে ছোট prefix “বাদ” দেওয়া যায়। min আর max-এর কোনো উল্টো নেই: [1..7]-এর minimum আর [1..2]-এর minimum জেনে [3..7]-এর minimum বের করা যায় না। তাই রেঞ্জ-minimum প্রশ্নে prefix sum অচল — সেখানে sparse table (পর্ব ৩৭) বা segment tree (পর্ব ৩৪)। গুণফলেরও সমস্যা: শূন্য থাকলে ভাগ করে “বাদ” দেওয়া যায় না।

৬ · Difference array — অনেকগুলো রেঞ্জ আপডেট, শেষে একবার হিসাব

এবার উল্টো সমস্যা। মিরপুর থেকে মতিঝিল পর্যন্ত রাস্তাকে ৮টি অংশে ভাগ করা হয়েছে (অংশ 0 থেকে 7). ট্রাফিক বিভাগের কাছে বাস রুটের তালিকা: “২টি বাস অংশ 1 থেকে 4 পর্যন্ত চলে”, “৩টি বাস অংশ 3 থেকে 6”, এমন অনেকগুলো। শেষে জানতে চাই প্রতিটি অংশে মোট কয়টি বাস — কোথায় জ্যাম সবচেয়ে বেশি হবে। একইভাবে একটি স্কুলে বিভিন্ন শাখার ছাত্রদের জন্য বেঞ্চ বরাদ্দ, বা একটি হলের আসন-সারিতে বুকিং — সব জায়গায় প্রশ্ন একই: অনেকগুলো “[l, r]-এর সব ঘরে v যোগ করো”, তারপর সব ঘরের মান।

সরলভাবে প্রতিটি range update-এ r − l + 1 টি ঘরে যোগ: q টি আপডেটে O(n·q). কিন্তু একটি পর্যবেক্ষণ সব বদলে দেয়। চূড়ান্ত অ্যারে A-এর পাশাপাশি দুটি ঘরের পার্থক্য দেখুন: D[i] = A[i] − A[i − 1] (D[0] = A[0]). “[l, r]-এ v যোগ” করলে কোন পার্থক্যগুলো বদলায়? রেঞ্জের ভেতরে পাশাপাশি দুটি ঘর দুটিই v বেড়েছে, তাই তাদের পার্থক্য একই। বদলায় শুধু দুটি সীমানা: l-এ ঢোকার মুহূর্তে মান v লাফ দেয় (D[l] += v), আর r পেরিয়ে বেরোনোর মুহূর্তে v নামে (D[r + 1] −= v). এই D-কে বলে difference array, আর A হলো D-এর prefix sum — prefix sum-এর ঠিক উল্টো কাজ।

Pseudocode (0-based): q range updates, then read every value

RANGE-ADD-ALL(n, updates):
    D = array of n + 1 zeros            // one extra cell for r + 1 = n
    for each (l, r, v) in updates:      // O(1) per update
        D[l] = D[l] + v
        D[r + 1] = D[r + 1] - v
    run = 0
    for i = 0 to n - 1:                 // one prefix pass, O(n)
        run = run + D[i]
        A[i] = run
    return A

রাস্তার উদাহরণে চারটি রুট: +2 অংশ [1, 4]-এ, +3 অংশ [3, 6]-এ, +1 অংশ [0, 2]-এ, আর +1 অংশ [5, 7]-এ। প্রতিটি আপডেটে D-তে শুধু দুটি ঘর বদলায় — দেখুন:

এই ধাপের কাজ
অপারেশন
D-তে বদল
D আর বাসের সংখ্যা. = এখনো হিসাব হয়নি

                
  1. শুরু। D-তে ৯টি শূন্য — রাস্তার অংশ ৮টি, কিন্তু D-তে এক ঘর বেশি, যাতে r = 7 হলেও D[r + 1] = D[8] লেখা যায়। কোনো রুট নেই, সব অংশে 0 বাস।
  2. রুট ১: +2 → [1, 4]. D[1] += 2 (“এখান থেকে ২টি বাস শুরু”), D[5] −= 2 (“অংশ 4-এর পরে ওরা নেই”)। মাঝের অংশ 2, 3, 4 ছোঁয়াই হলো না — রুট যত লম্বাই হোক, কাজ দুটি ঘর।
  3. রুট ২: +3 → [3, 6]. D[3] += 3, D[7] −= 3.
  4. রুট ৩: +1 → [0, 2]. D[0] += 1, আর D[3] −= 1. D[3]-এ আগে 3 ছিল, এখন 2 — একই ঘরে একাধিক রুটের শুরু আর শেষ মিশে যেতে পারে, যোগফলই আসল।
  5. রুট ৪: +1 → [5, 7]. D[5] += 1 (আগে −2, এখন −1), আর D[8] −= 1 — সেই বাড়তি ঘরটি এখানেই কাজে লাগল। চারটি আপডেটে মোট আটটি O(1) কাজ।
  6. Prefix pass, প্রথম চার ঘর। চলতি যোগফল: 1, 1 + 2 = 3, 3 + 0 = 3, 3 + 2 = 5. মিলিয়ে দেখুন: অংশ 3-এ রুট ১ (২টি) আর রুট ২ (৩টি) চলে — মোট ৫. রুট ৩ অংশ 2-এ শেষ, তাই D[3]-এর −1 তাকে বাদ দিয়েছে।
  7. Prefix pass শেষ। 5 + 0 = 5, 5 − 1 = 4, 4 + 0 = 4, 4 − 3 = 1. চূড়ান্ত বাস: [1, 3, 3, 5, 5, 4, 4, 1]. সবচেয়ে ব্যস্ত অংশ 3 ও 4 (৫টি করে বাস)। D[8]-কে prefix pass-এ ছোঁয়া হয় না — সে শুধু হিসাব মেলানোর জন্য। মোট সময় O(n + q).

শুদ্ধতার প্রমাণ। একটি আপডেট (l, r, v) D-তে যা করে, তার প্রভাব prefix sum A[i] = D[0] + … + D[i]-এ দেখি। i < l হলে দুটি বদলানো ঘরের কোনোটিই যোগে আসে না: প্রভাব 0. l ≤ i ≤ r হলে শুধু D[l]-এর +v আসে: প্রভাব v. i > r হলে দুটিই আসে: v − v = 0. অর্থাৎ ঠিক [l, r]-এর ঘরগুলোতে v যোগ হয়। আর যোগ রৈখিক (linear) — একাধিক আপডেটের প্রভাব আলাদা আলাদা যোগ হয়, কোনো ক্রমের ওপর নির্ভর করে না। তাই সব আপডেট শেষে একটি prefix pass-এ সবার মিলিত ফল।

কোডে — চারটি রুট আর সবচেয়ে ব্যস্ত অংশ:

#include <iostream>
#include <vector>
#include <tuple>
#include <initializer_list>
using namespace std;

int main() {
    int n = 8;                                     // road segments 0..7
    vector<long long> D(n + 1, 0);                 // one extra cell
    for (auto [l, r, v] : {tuple{1, 4, 2}, tuple{3, 6, 3}, tuple{0, 2, 1}, tuple{5, 7, 1}}) {
        D[l] += v;                                 // start adding v at l
        D[r + 1] -= v;                             // stop after r
    }
    cout << "D =";
    for (long long x : D) cout << ' ' << x;
    cout << '\n';
    vector<long long> bus(n);
    long long run = 0;
    for (int i = 0; i < n; ++i) bus[i] = run += D[i];   // one prefix pass
    cout << "buses =";
    for (long long x : bus) cout << ' ' << x;
    cout << '\n';
    int best = 0;
    for (int i = 1; i < n; ++i) if (bus[i] > bus[best]) best = i;
    cout << "busiest segment: " << best << " (" << bus[best] << " buses)\n";
}

আউটপুট:

D = 1 2 0 2 0 -1 0 -3 -1
buses = 1 3 3 5 5 4 4 1
busiest segment: 3 (5 buses)

D আর বাসের সংখ্যা চতুর্থ ডায়াগ্রামের শেষ ধাপের সাথে হুবহু এক। bus[i] = run += D[i] লাইনে আগে run-এ D[i] যোগ হয়, তারপর সেই নতুন মান bus[i]-এ যায়। অংশ 3 আর 4 দুটিতেই ৫টি বাস; > (সমান নয়, কঠোরভাবে বড়) ব্যবহার করায় প্রথমটি, অংশ 3, ছাপা হয়েছে। আপডেট q টি আর রাস্তা n অংশের হলে সময় O(n + q), বাড়তি জায়গা O(n).

n = 8                                     # road segments 0..7
D = [0] * (n + 1)                         # one extra cell
for l, r, v in [(1, 4, 2), (3, 6, 3), (0, 2, 1), (5, 7, 1)]:
    D[l] += v                             # start adding v at l
    D[r + 1] -= v                         # stop after r
print("D =", *D)
bus, run = [], 0
for i in range(n):
    run += D[i]                           # one prefix pass
    bus.append(run)
print("buses =", *bus)
best = max(range(n), key=lambda i: bus[i])   # first index of the maximum
print(f"busiest segment: {best} ({bus[best]} buses)")

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

D = 1 2 0 2 0 -1 0 -3 -1
buses = 1 3 3 5 5 4 4 1
busiest segment: 3 (5 buses)

max(..., key=...) সমান মান পেলে প্রথমটি ফেরত দেয়, তাই এখানেও অংশ 3. prefix pass-টি এক লাইনেও লেখা যায়: bus = list(itertools.accumulate(D[:n])). D-তে বাড়তি ঘর না রাখলে r = 7-এর রুটে D[8] লিখতে গিয়ে IndexError আসত — C++-এ সেটি কোনো error ছাড়াই মেমরি নষ্ট করত।

2D difference array — আয়তক্ষেত্রে আপডেট

গ্রিডে “সারি r1..r2, কলাম c1..c2-এর সব ঘরে v যোগ” — 2D prefix sum-এর উল্টো কাজ। চারটি কোণায় চিহ্ন দিন, তারপর শেষে একবার 2D prefix sum নিন:

Pseudocode (0-based): add v to rectangle (r1..r2, c1..c2); D is (R+1) x (C+1)

D[r1][c1]         += v
D[r1][c2 + 1]     -= v
D[r2 + 1][c1]     -= v
D[r2 + 1][c2 + 1] += v       // added back: it was subtracted twice
// after all updates: A = 2D prefix sum of D  (section 3), O(R*C) once

প্রতিটি আপডেট O(1), শেষে Θ(R·C). চিহ্নের ছক (+, −, −, +) 2D প্রশ্নের সূত্রের মতোই — একই inclusion–exclusion. বাস্তব উদাহরণ: একটি মানচিত্রে অনেকগুলো মোবাইল টাওয়ারের আয়তাকার কভারেজ এলাকা দেওয়া, জানতে হবে প্রতিটি ব্লক কতগুলো টাওয়ারের আওতায়। কনটেস্টে “অনেকগুলো আয়তক্ষেত্র আঁকা হলো, কতগুলো ঘর ঠিক k টি আয়তক্ষেত্রে ঢাকা” জাতীয় সমস্যা এভাবেই হয়।

৭ · সীমা — মান বদলালে কী হয়, আর overflow

Point update prefix sum ভেঙে দেয়

prefix sum-এর সব জাদু একটি শর্তে দাঁড়িয়ে: অ্যারে বদলায় না (static). এখন ধরুন প্রশ্নের মাঝে মাঝে আসে “a[i]-এর মান বদলে x করো” — একটি point update. তখন P[i + 1], P[i + 2], …, P[n] সবগুলো বদলে যায়, কারণ সবগুলোতেই a[i] আছে। সবচেয়ে খারাপ ক্ষেত্রে (i = 0) n টি ঘর ঠিক করতে হয়: আপডেট O(n). difference array-র সমস্যা উল্টো দিকে: আপডেট O(1), কিন্তু মাঝপথে একটি মান পড়তে পুরো prefix pass, O(n).

কাঠামোপ্রস্তুতিরেঞ্জ যোগফল প্রশ্নআপডেটকখন
সাধারণ অ্যারে—O(n)point: O(1)প্রশ্ন খুব কম
Prefix sumO(n)O(1)point: O(n)অ্যারে বদলায় না
Difference arrayO(n)সব আপডেট শেষে একবারে O(n)range: O(1)আগে সব আপডেট, পরে সব প্রশ্ন
Fenwick tree (পর্ব ৩৬)O(n)O(log n)point: O(log n)প্রশ্ন আর আপডেট মিশে আসে
Segment tree + lazy (পর্ব ৩৪–৩৫)O(n)O(log n)range: O(log n)রেঞ্জ আপডেট আর রেঞ্জ প্রশ্ন একসাথে; min/max-ও

একটি কাজের নিয়ম: সমস্যা পড়ে প্রথমেই জিজ্ঞেস করুন “আপডেট আর প্রশ্ন কি মিশে আসে?” না হলে prefix sum বা difference array — সবচেয়ে সরল, সবচেয়ে দ্রুত, ভুলের সুযোগ সবচেয়ে কম। মিশে এলে log n-এর কাঠামো লাগবে, যা মডিউল ৫-এ আসছে। সব আপডেট আর প্রশ্ন আগে থেকে জানা থাকলে কখনো কখনো এগুলোকে সাজিয়ে নিয়ে (offline) আবার সরল কৌশলেই কাজ সারা যায়।

Overflow — যোগফল সংখ্যার চেয়ে অনেক বড়

C++-এ int সাধারণত 32 bit, সর্বোচ্চ মান 2,147,483,647 (প্রায় 2.1 × 109). কনটেস্টে প্রায়ই n ≤ 2 × 105 আর প্রতিটি |a[i]| ≤ 109. প্রতিটি সংখ্যা int-এ ধরে, কিন্তু prefix sum পৌঁছাতে পারে 2 × 1014-এ — int-এর চেয়ে প্রায় এক লাখ গুণ বড়। signed int-এর integer overflow C++-এ undefined behaviour: সাধারণত মান ঘুরে ঋণাত্মক হয়ে যায়, কোনো error ছাড়া।

নিয়ম: prefix sum, difference array আর গণনা — সবসময় long long. 64-bit-এ সর্বোচ্চ প্রায় 9.2 × 1018, যা ওপরের হিসাবের জন্য যথেষ্ট। আর খেয়াল করুন হিসাবটি কোন টাইপে হচ্ছে: vector<int> a থেকে P[i + 1] = P[i] + a[i] ঠিক আছে যদি P long long হয় (যোগের আগে a[i] বড় টাইপে উঠে যায়), কিন্তু a[i] * a[j]-এর মতো দুটি int-এর গুণ আগেই overflow করবে। 2D prefix sum-এ গ্রিড 1000 × 1000 আর প্রতিটি মান 109 হলে মোট 1015 — সেখানেও long long. যোগফল mod M-এ রাখলে বিয়োগের পর ঋণাত্মক হতে পারে, তাই লিখুন (P[r + 1] - P[l] + M) % M.

আরও গভীরে: prefix কৌশলের বীজগণিত হলো এটি: operation সহযোজী আর উল্টো আছে এমন যেকোনো গঠনে (গণিতের ভাষায় group) রেঞ্জ = উল্টো(prefixl) ∘ prefixr+1; উল্টো না থাকলে (monoid, যেমন min) শুধু সহযোজিতা দিয়ে sparse table বা segment tree. আরেকটি বিস্ময়: prefix sum দেখতে সম্পূর্ণ ধারাবাহিক মনে হলেও সমান্তরালে O(n) মোট কাজ আর O(log n) গভীরতায় করা যায় (parallel scan) — GPU-তে radix sort আর stream compaction এর ওপর দাঁড়িয়ে। আর counting sort (পর্ব ২৪) আসলে গণনার prefix sum দিয়ে প্রতিটি মানের শেষ অবস্থান বের করে।

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

  • Prefix sum দিয়ে q টি range-sum প্রশ্নের মোট সময়? — preprocessing Θ(n), প্রতি প্রশ্ন Θ(1), মোট Θ(n + q); বাড়তি জায়গা Θ(n).
  • যোগফল k এমন subarray গোনা, অ্যারেতে ঋণাত্মক আছে — কোন পদ্ধতি? — prefix sum + hash map, {0: 1} দিয়ে শুরু; গড়ে O(n). sliding window চলবে না, কারণ যোগফল একমুখী নয়।
  • 2D-তে আয়তক্ষেত্রের যোগফলের সূত্রে শেষে কেন যোগ? — বাঁ-ওপরের কোণা দুবার বিয়োগ হয়েছে; inclusion–exclusion.
  • অনেকগুলো রেঞ্জ আপডেট, তারপর সব মান — কীভাবে O(n + q)? — difference array: D[l] += v, D[r + 1] −= v, শেষে prefix pass.
  • প্রশ্নের মাঝে point update এলে? — prefix sum O(n) আপডেট চায়; Fenwick tree বা segment tree দিয়ে দুটিই O(log n).

৮ · AI-এর যুগে prefix sum — সূত্র সহজ, সূচক কঠিন

“Subarray sum equals k সমাধান করো” লিখলে যেকোনো AI কয়েক সেকেন্ডে কোড দেয়, আর বেশিরভাগ সময় সেটি ঠিক — কারণ সমস্যাটি অত্যন্ত পরিচিত। বিপদ শুরু হয় যখন সমস্যা একটু বদলায়: ১-ভিত্তিক ইনপুট, “দীর্ঘতম” বনাম “কয়টি”, k = 0, 2D, বা মাঝে আপডেট। AI তখন প্রায়ই পরিচিত সমাধানটি সামান্য বদলে দেয় — সূচক এক ঘর সরে যায়, map-এর লাইনের ক্রম উল্টে যায়, বা টাইপ int থেকে যায়। কোড চলে, ছোট উদাহরণে ঠিক উত্তরও দেয়, তারপর লুকানো test-এ “Wrong Answer”.

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

AI-এর ভুলকীভাবে ধরবেন
০-ভিত্তিক অ্যারেতে ১-ভিত্তিক সূত্র (P[r] − P[l − 1]) মিশিয়ে ফেলাl = 0, l = r আর পুরো অ্যারে — তিনটি প্রশ্ন হাতে হিসাব করে মেলান
map-এ {0: 1} দিয়ে শুরু না করা, বা গণনার আগে map আপডেট করাশুরু থেকেই শুরু হওয়া একটি উত্তর আর k = 0 — দুটি test চালান
ঋণাত্মক সংখ্যায় sliding window দিয়ে “O(n) সমাধান”[2, −2, 3], k = 3 দিন — উত্তর 2 আসে কি না দেখুন
prefix sum int-এ রাখাn = 2 × 105, সব মান 109 — বড় ইনপুটে চালান
2D সূত্রে চিহ্ন বা সূচক ভুল (−P[r1 − 1][…])ছোট গ্রিডে প্রতিটি সম্ভাব্য আয়তক্ষেত্র brute force-এর সাথে মেলান
difference array-তে n + 1 ঘর না রাখা, বা r + 1 = n-এ সীমা পরীক্ষা ভুলে যাওয়াr = n − 1 এমন আপডেট দিয়ে চালান
মাঝে আপডেট থাকা সত্ত্বেও প্রতিবার পুরো P নতুন করে বানিয়ে “O(1) প্রশ্ন” দাবিআপডেটের খরচ আলাদা করে গুনুন — মোট O(n·q) কি না

AI-কে সমাধান লিখতে না বলে বরং আপনার নিজের সমাধান যাচাই আর stress test বানাতে ব্যবহার করুন:

আমি DSA শিখছি, এখন prefix sum আর difference array. নিচে একটি সমস্যা আর আমার লেখা সমাধান দিলাম: [সমস্যার বিবরণ] [আমার কোড] পুরো সমাধান নতুন করে লিখে দিও না। শুধু এগুলো করো: ১. আমার কোড কোন সূচক-নিয়ম মানছে (০-ভিত্তিক না ১-ভিত্তিক, P[0] = 0 আছে কি না) — এক লাইনে বলো, আর l = 0 ও r = n − 1-এ সূত্রটি ঠিক কি না দেখাও। ২. ৫টি edge case দাও: খালি বা এক উপাদানের অ্যারে, ঋণাত্মক সংখ্যা, k = 0, সব মান সর্বোচ্চ (overflow), আর r = n − 1 এমন আপডেট। ৩. Python-এ একটি ছোট brute force আর random test generator লিখে দাও, যা আমার সমাধানের সাথে ফল মেলাবে। ৪. সময় আর জায়গার complexity কত, preprocessing আর প্রতি প্রশ্ন আলাদা করে — যুক্তিসহ।

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

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

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

প্র ০১ক্যামেরায় মুখ চেনার প্রথম দ্রুত পদ্ধতি কীভাবে 2D prefix sum-এর ওপর দাঁড়িয়ে ছিল?

2D prefix sum-এর ধারণা কম্পিউটার গ্রাফিক্সে এসেছিল ১৯৮৪ সালে। Franklin Crow SIGGRAPH সম্মেলনে “Summed-area tables for texture mapping” প্রবন্ধে দেখান, একটি ছবির প্রতিটি pixel-এ “বাঁ-ওপরের পুরো আয়তক্ষেত্রের যোগফল” রেখে দিলে যেকোনো আয়তাকার অংশের গড় রং চারটি সংখ্যা পড়েই বের করা যায়। দূরের বস্তুর ওপর texture বসাতে গেলে অনেকগুলো pixel-এর গড় লাগে — সেই গড় প্রতিবার আলাদা করে যোগ না করে O(1)-এ পাওয়া গেল। নামটিই বলে দেয় এটি কী: summed-area table.

প্রায় সতেরো বছর পর, ২০০১ সালে Paul Viola আর Michael Jones একই কৌশলকে “integral image” নামে কম্পিউটার ভিশনে নিয়ে আসেন। তাঁদের মুখ শনাক্তের পদ্ধতিতে ছবির ওপর হাজার হাজার ছোট আয়তাকার “feature” পরীক্ষা করা হতো — যেমন “চোখের অংশ গালের চেয়ে গাঢ় কি না”, যা দুটি পাশাপাশি আয়তক্ষেত্রের উজ্জ্বলতার পার্থক্য। প্রতিটি feature-এর জন্য সরলভাবে শত শত pixel যোগ করতে হলে কাজটি অসম্ভব ধীর হতো। integral image থাকায় প্রতিটি আয়তক্ষেত্র মাত্র চারটি পড়া, আকার যত বড়ই হোক। সাথে ছিল একটি “cascade” — সহজ পরীক্ষায় যেসব জায়গা স্পষ্টতই মুখ নয়, সেগুলো দ্রুত বাদ দেওয়া।

ফল: সেই সময়ের সাধারণ কম্পিউটারে চলমান ভিডিওতে বাস্তব-সময়ে মুখ শনাক্ত করা গেল। পরের বছরগুলোতে ডিজিটাল ক্যামেরা আর ফোনে ছবি তোলার সময় মুখের চারপাশে যে চৌকো বাক্স দেখা যেত, তার পেছনে বহু ক্ষেত্রে এই ধাঁচের পদ্ধতিই ছিল। OpenCV লাইব্রেরির Haar cascade detector এই পদ্ধতির একটি জনপ্রিয় বাস্তবায়ন, যা আজও শেখার কাজে ব্যবহৃত হয়। আজকের মুখ চেনা মূলত deep learning-এ চলে গেছে, কিন্তু সেখানেও box filter বা গড়-নেওয়ার কাজে summed-area table এখনো একটি পরিচিত হাতিয়ার।

লক্ষ করার বিষয় হলো, কেউ নতুন কোনো জটিল অ্যালগরিদম আবিষ্কার করেননি। একটি সরল preprocessing — একবার পুরো ছবি ঘুরে চলতি যোগফল — প্রতিটি প্রশ্নকে আকার-নিরপেক্ষ O(1) বানিয়ে দিয়েছে, আর তাতেই একটি ধীর ধারণা বাস্তবে ব্যবহারযোগ্য হয়েছে।

মূল উপলব্ধি: একটি অ্যালগরিদমের ভেতরের সবচেয়ে বেশিবার করা প্রশ্নটিকে খুঁজে বের করুন। সেই প্রশ্নকে preprocessing দিয়ে O(1) বানাতে পারলে পুরো ব্যবস্থার গতি কয়েক মাত্রা বদলে যেতে পারে।

প্র ০২P[i + 1] = P[i] + a[i] — প্রতিটি ধাপ আগেরটির ওপর নির্ভর করে। তবু prefix sum কীভাবে সমান্তরালে (parallel) চালানো যায়?

প্রথম দেখায় prefix sum সবচেয়ে “ধারাবাহিক” অ্যালগরিদম মনে হয়: P[5] জানতে P[4] লাগবে, P[4] জানতে P[3]… এক হাজার প্রসেসর থাকলেও যেন লাভ নেই। কিন্তু যোগ সহযোজী — (a + b) + c = a + (b + c) — আর এই একটি গুণই সমান্তরালতার দরজা খুলে দেয়।

সরল একটি কৌশল দেখুন। প্রথম ধাপে সবাই একসাথে পাশের জোড়া যোগ করে: (a0 + a1), (a2 + a3), … — n/2 টি যোগ, সবগুলো একসাথে। দ্বিতীয় ধাপে সেই জোড়াদের জোড়া, তারপর তাদের জোড়া — log₂ n ধাপে পুরো যোগফল একটি গাছের মতো তৈরি হয়। তারপর গাছ বেয়ে উল্টো দিকে নেমে প্রতিটি অবস্থানের prefix বের করা যায়, আরও log₂ n ধাপে। মোট কাজ O(n) — ধারাবাহিক পদ্ধতির সমান — কিন্তু “গভীরতা” (যথেষ্ট প্রসেসর থাকলে সময়) মাত্র O(log n).

ইতিহাসটি আকর্ষণীয়। ১৯৭৩ সালে Peter Kogge আর Harold Stone recurrence-এর সমান্তরাল সমাধান নিয়ে প্রবন্ধ লেখেন; তাঁদের নামের “Kogge–Stone adder” আজও দ্রুত হার্ডওয়্যার যোগযন্ত্রের একটি পরিচিত নকশা — কারণ দুটি বাইনারি সংখ্যা যোগে carry হিসাব করা আসলে একটি prefix সমস্যা! ১৯৮০ সালে Richard Ladner আর Michael Fischer “Parallel Prefix Computation” প্রবন্ধে বিষয়টিকে তাত্ত্বিকভাবে গুছিয়ে দেন। ১৯৮৬ সালে Hillis আর Steele Connection Machine-এর জন্য “Data Parallel Algorithms”-এ scan-কে মৌলিক হাতিয়ার হিসেবে দেখান, আর ১৯৯০ সালে Guy Blelloch-এর “Prefix Sums and Their Applications” দেখায় কীভাবে sorting, গ্রাফ অ্যালগরিদম, এমনকি সমান্তরাল quicksort-ও scan দিয়ে লেখা যায়।

আজ প্রতিটি GPU প্রোগ্রামিং লাইব্রেরিতে scan একটি মৌলিক অপারেশন। “একটি অ্যারে থেকে শুধু জোড় সংখ্যাগুলো ফাঁক না রেখে নতুন অ্যারেতে বসাও” (stream compaction) — প্রতিটি উপাদান নতুন অ্যারের কোন ঘরে যাবে, তা আগের উপাদানগুলোর মধ্যে কয়টি রাখা হয়েছে তার prefix count. C++17-এর std::inclusive_scan আর std::exclusive_scan execution policy নিতে পারে, অর্থাৎ লাইব্রেরি চাইলে সমান্তরালে চালাতে পারে — ঠিক এই কারণেই এগুলো যোগের ক্রম নিয়ে কোনো নিশ্চয়তা দেয় না, যা std::partial_sum দেয়।

মূল উপলব্ধি: কোনো হিসাব “ধারাবাহিক” কি না, তা কোড দেখে নয়, operation-এর গুণ দেখে বিচার করুন। সহযোজিতা থাকলে কাজকে গাছের মতো ভাগ করা যায় — prefix sum তার সবচেয়ে সুন্দর উদাহরণ।

প্র ০৩Hash map-এর “গড়ে O(1)” কি কনটেস্টে সবসময় নিরাপদ? কেউ ইচ্ছা করে ধীর বানাতে পারে কি?

sum = k গণনার পদ্ধতি গড়ে O(n), কারণ hash table-এ প্রতিটি খোঁজ গড়ে O(1). কিন্তু “গড়” কথাটির পেছনে একটি অনুমান আছে: key-গুলো bucket-এ মোটামুটি সমানভাবে ছড়িয়ে পড়ে। কেউ যদি জেনেশুনে এমন key বেছে নেয় যেগুলো সব একই bucket-এ পড়ে, তাহলে প্রতিটি খোঁজ O(n), আর পুরো অ্যালগরিদম Θ(n²). এটি পর্ব ০৫-এর adversarial input-এর বাস্তব রূপ।

C++-এর std::unordered_map-এ GCC-র লাইব্রেরি পূর্ণসংখ্যার hash হিসেবে সংখ্যাটিকেই ব্যবহার করে, আর bucket বাছাই হয় bucket সংখ্যার mod নিয়ে। bucket সংখ্যাগুলো একটি নির্দিষ্ট মৌলিক সংখ্যার তালিকা থেকে আসে, যা লাইব্রেরির কোড দেখেই জানা যায়। তাই Codeforces-এর মতো যেসব প্ল্যাটফর্মে অন্য প্রতিযোগী আপনার কোড দেখে তাকে ভাঙার test পাঠাতে পারেন (hacking), সেখানে এমন ইনপুট বানানো সম্ভব যাতে সব prefix sum একই bucket-এ পড়ে। প্রতিযোগিতামূলক প্রোগ্রামিংয়ের সমাজে এ নিয়ে বহুল আলোচিত লেখা আছে, আর প্রচলিত প্রতিকার হলো নিজের একটি hash function দেওয়া, যাতে একটি এলোমেলো সংখ্যা (যেমন চলার সময়ের ঘড়ি থেকে নেওয়া) মিশিয়ে splitmix64-এর মতো একটি মিশ্রণ ফাংশন চালানো হয় — আক্রমণকারী আগে থেকে জানতে পারেন না কোন key কোথায় যাবে।

আরও দুটি নিরাপদ বিকল্প আছে। এক, std::map (balanced BST, পর্ব ২৯) — প্রতিটি কাজ নিশ্চিত O(log n), মোট O(n log n); n = 2 × 105-এ এটি প্রায় সবসময় যথেষ্ট দ্রুত। দুই, prefix sum-গুলোকে আগে সাজিয়ে প্রতিটিকে একটি ছোট পূর্ণসংখ্যায় বদলে নেওয়া (coordinate compression), তারপর সাধারণ অ্যারেতে গণনা — নিশ্চিত O(n log n), আর hash-এর কোনো ঝুঁকি নেই। Python-এর dict-এ int-এর hash-ও সংখ্যাটিই, তাই তাত্ত্বিকভাবে সেখানেও আক্রমণ সম্ভব; তবে string-এর hash Python 3.3 থেকে প্রতিবার চালু হওয়ার সময় এলোমেলো করা হয় — ওয়েব সার্ভারে একই ধরনের “hash flooding” আক্রমণ ঠেকাতেই এই সিদ্ধান্ত।

বাস্তব সফটওয়্যারেও শিক্ষা একই: যে ইনপুট বাইরের কেউ নিয়ন্ত্রণ করে, তার ওপর “গড়-ক্ষেত্রের” নিশ্চয়তা নিরাপদ নয়।

মূল উপলব্ধি: “গড়ে O(1)” একটি সম্ভাবনার কথা, প্রতিশ্রুতি নয়। ইনপুট শত্রুভাবাপন্ন হতে পারলে এলোমেলো hash, নিশ্চিত-সীমার কাঠামো (map), বা coordinate compression — তিনটির একটি বেছে নিন।

প্র ০৪Prefix sum থাকতে Fenwick tree আর segment tree কেন আবিষ্কার করতে হলো? কখন কোনটি বেছে নেবেন?

prefix sum আর সাধারণ অ্যারে দুটি চরম অবস্থা। সাধারণ অ্যারেতে আপডেট O(1), প্রশ্ন O(n); prefix sum-এ প্রশ্ন O(1), আপডেট O(n). q টি মিশ্র কাজে দুটিতেই সবচেয়ে খারাপে O(n·q). প্রশ্ন জাগে: এর মাঝামাঝি কোনো ভারসাম্য আছে কি, যেখানে দুটিই দ্রুত?

প্রথম সহজ ভারসাম্য হলো অ্যারেকে √n আকারের খণ্ডে ভাগ করা, প্রতিটি খণ্ডের যোগফল রাখা — আপডেট O(1), প্রশ্ন O(√n). এই sqrt decomposition পর্ব ৩৭-এ আসবে। আরও ভালো ভারসাম্য দেয় একটি গাছ: প্রতিটি node একটি রেঞ্জের যোগফল রাখে, বাবা = দুই সন্তানের যোগফল। যেকোনো রেঞ্জ O(log n) টি node-এ ভাঙা যায়, আর একটি ঘর বদলালে শুধু মূল পর্যন্ত পথের O(log n) টি node বদলায়। এটিই segment tree, যার ধারণা ১৯৭০-এর দশকের শেষ দিকে Jon Bentley-র computational geometry-র কাজে প্রথম দেখা যায় বলে প্রচলিত।

১৯৯৪ সালে Peter Fenwick “A new data structure for cumulative frequency tables” প্রবন্ধে আরও হালকা একটি কাঠামো দেন — ডেটা সংকোচনের (arithmetic coding) জন্য, যেখানে প্রতিটি অক্ষর পড়ার পর তার গণনা বাড়াতে হয় আর ক্রমযোজিত গণনা জানতে হয়। এটিই Fenwick tree বা Binary Indexed Tree: শুধু n টি ঘরের একটি অ্যারে, প্রতিটি ঘর একটি নির্দিষ্ট দৈর্ঘ্যের prefix-এর টুকরো রাখে (দৈর্ঘ্য index-এর সবচেয়ে নিচের 1-bit দিয়ে ঠিক হয়), আর কোড মাত্র কয়েক লাইন। মজার ব্যাপার, স্কুলের পরিসংখ্যানে আপনি যে “ক্রমযোজিত গণসংখ্যা” পড়েছেন, এটি ঠিক তার পরিবর্তনশীল সংস্করণ। একই ধরনের ধারণা এর আগে Boris Ryabko-র কাজেও এসেছিল বলে উল্লেখ পাওয়া যায়।

বেছে নেওয়ার নিয়ম: (১) অ্যারে বদলায় না → prefix sum. (২) সব আপডেট আগে, সব প্রশ্ন পরে → difference array, তারপর prefix sum. (৩) point update + range sum মিশ্র → Fenwick tree, সবচেয়ে কম কোড আর দ্রুত। (৪) range update + range query, বা min/max/gcd-এর মতো উল্টোহীন operation → segment tree, দরকারে lazy propagation (পর্ব ৩৫)। (৫) শুধু min/max আর কোনো আপডেট নেই → sparse table, প্রতি প্রশ্ন O(1).

মূল উপলব্ধি: ডেটা স্ট্রাকচার বাছাই মানে কাজের মিশ্রণ বোঝা। কোন কাজ কতবার হবে আর কোনটি বদলায় — এই দুটি প্রশ্নের উত্তরই বলে দেয় prefix sum যথেষ্ট, নাকি log n-এর গাছ লাগবে।

প্র ০৫Overflow আর দশমিকের নির্ভুলতা — চলতি যোগফলে লুকানো বিপদগুলো বাস্তবে কেমন দেখা দিয়েছে?

চলতি যোগফলের মূল বৈশিষ্ট্য হলো এটি শুধু বাড়ে (বা অনেক দূর যায়), আর যে টাইপে রাখা হয় তার সীমা কেউ খেয়াল করে না — যতদিন না সীমা পেরোয়। বাস্তব জগতে এর বিখ্যাত উদাহরণ আছে। ২০১৪ সালের ডিসেম্বরে YouTube জানায়, একটি জনপ্রিয় গানের ভিডিওর (Gangnam Style) দেখার সংখ্যা 32-bit signed integer-এর সর্বোচ্চ মান 2,147,483,647 ছাড়িয়ে যাওয়ার কাছাকাছি পৌঁছেছিল, তাই তারা গণনাটি 64-bit-এ বদলে নেয়। একটি গণনা — যা আসলে একটি চলতি যোগফল — নকশার সময়ের ধারণাকে ছাড়িয়ে গিয়েছিল।

আরেকটি বিখ্যাত ঘটনা ২০০৬ সালের। Joshua Bloch তাঁর ব্লগে “Nearly All Binary Searches and Mergesorts are Broken” লেখায় দেখান যে Java লাইব্রেরির binary search-এ mid = (low + high) / 2 লাইনটি খুব বড় অ্যারেতে overflow করে ঋণাত্মক সূচক দেয় — দুটি বৈধ সূচকের যোগফল int ছাড়িয়ে যায়। কয়েক বছর ধরে বহু মানুষ সেই কোড ব্যবহার করেছেন, কেউ টের পাননি, কারণ তখন কারও এত বড় অ্যারে ছিল না। পর্ব ১৮-এ binary search লেখার সময় এই শিক্ষা কাজে লাগবে।

দশমিক সংখ্যায় (double) বিপদ ভিন্ন: overflow নয়, নির্ভুলতা হারানো। double-এ মোটামুটি ১৫–১৬টি অর্থপূর্ণ দশমিক অঙ্ক থাকে। P[r + 1] আর P[l] দুটিই যদি বিশাল হয় (ধরুন ১০১২-এর কাছাকাছি) আর তাদের পার্থক্য ছোট, তাহলে বিয়োগের পর বেশিরভাগ নির্ভুল অঙ্ক বাতিল হয়ে যায় — একে বলে catastrophic cancellation. অর্থাৎ double-এ prefix sum দিয়ে ছোট রেঞ্জের যোগফল সরাসরি যোগের চেয়ে কম নির্ভুল হতে পারে। দীর্ঘ যোগে ভুল কমাতে William Kahan-এর compensated summation (১৯৬০-এর দশক) ব্যবহৃত হয়, আর আর্থিক হিসাবে দশমিকের বদলে পয়সা বা টাকার পূর্ণসংখ্যা রাখা হয় — bKash বা ব্যাংকের সফটওয়্যার টাকার হিসাব double-এ রাখলে পয়সার গরমিল হতো।

কনটেস্টের ব্যবহারিক নিয়ম তিনটি: সবচেয়ে বড় সম্ভাব্য যোগফল হিসাব করুন (n × সর্বোচ্চ |a[i]|), সেটি 2 × 109-এর বেশি হলে long long; 9 × 1018-এর বেশি হলে mod বা __int128; আর দশমিক এড়াতে পারলে এড়ান — যেমন গড়ের তুলনার বদলে যোগফল আর দৈর্ঘ্যের গুণফল তুলনা।

মূল উপলব্ধি: prefix sum-এর সঠিকতা গণিতে নিখুঁত, কিন্তু কম্পিউটারে সংখ্যার সীমা আছে। অ্যালগরিদম ঠিক আর প্রোগ্রাম ঠিক — এই দুটি আলাদা দাবি; দ্বিতীয়টির জন্য টাইপের সীমা হিসাব করা আপনার দায়িত্ব।

অনুশীলন

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

  1. হাতে trace করুন: a = [2, −1, 3, 0, 4] (০-ভিত্তিক)। P[0] = 0 নিয়মে পুরো P লিখুন। তারপর সূত্র দিয়ে sum(1..3) আর sum(0..4) বের করুন, আর সরাসরি যোগ করে মিলিয়ে নিন।

    P = [0, 2, 1, 4, 4, 8] — ছয়টি ঘর (n + 1). প্রতিটি ধাপ: 0 + 2 = 2, 2 − 1 = 1, 1 + 3 = 4, 4 + 0 = 4, 4 + 4 = 8.

    sum(1..3) = P[4] − P[1] = 4 − 2 = 2; সরাসরি: −1 + 3 + 0 = 2 ✔. sum(0..4) = P[5] − P[0] = 8 = পুরো অ্যারে। লক্ষ করুন, P সবসময় বাড়ে না (2 থেকে 1-এ নেমেছে) — ঋণাত্মক সংখ্যায় এটি স্বাভাবিক, আর prefix sum-এর সূত্রে তাতে কিছু যায় আসে না।

  2. Difference array হাতে: n = 6 (ঘর 0..5), শুরুতে সব 0. তিনটি আপডেট: +5 → [0, 3], +2 → [2, 5], −3 → [1, 2]. D-এর চূড়ান্ত রূপ আর সব ঘরের শেষ মান লিখুন।

    D-এর আকার 7. আপডেট ১: D[0] += 5, D[4] −= 5. আপডেট ২: D[2] += 2, D[6] −= 2. আপডেট ৩: D[1] −= 3, D[3] += 3. চূড়ান্ত D = [5, −3, 2, 3, −5, 0, −2].

    prefix pass: 5, 5 − 3 = 2, 2 + 2 = 4, 4 + 3 = 7, 7 − 5 = 2, 2 + 0 = 2. ফল [5, 2, 4, 7, 2, 2]. সরাসরি মিলিয়ে দেখুন: ঘর 3-এ আপডেট ১ (+5) আর ২ (+2) — 7 ✔; ঘর 1-এ +5 আর −3 — 2 ✔. ঋণাত্মক v-ও একই নিয়মে চলে।

  3. Prefix XOR: a = [5, 3, 6, 1]. X[0] = 0 নিয়মে prefix XOR অ্যারে লিখুন। a[1] ⊕ a[2] সূত্র দিয়ে বের করুন। তারপর X দেখে বলুন — কোন subarray-এর XOR শূন্য?

    Binary-তে 5 = 101, 3 = 011, 6 = 110, 1 = 001. X[1] = 5, X[2] = 5 ⊕ 3 = 110 = 6, X[3] = 6 ⊕ 6 = 0, X[4] = 0 ⊕ 1 = 1. তাই X = [0, 5, 6, 0, 1].

    a[1] ⊕ a[2] = X[3] ⊕ X[1] = 0 ⊕ 5 = 5; সরাসরি 011 ⊕ 110 = 101 = 5 ✔. XOR শূন্য মানে দুটি সমান X: X[0] = X[3] = 0, তাই a[0..2] = 5 ⊕ 3 ⊕ 6 = 0. অন্য কোনো জোড়া সমান নয়, তাই এটিই একমাত্র।

  4. কোড লিখুন: শুধু 0 আর 1 দিয়ে তৈরি একটি অ্যারে দেওয়া। এমন দীর্ঘতম subarray-এর দৈর্ঘ্য বের করুন, যেখানে 0 আর 1-এর সংখ্যা সমান। (যেমন [0, 1, 1, 0, 1, 1, 1, 0] → 4.) সময় O(n) গড়ে। সময় ও জায়গার complexity বলুন।

    মূল চিন্তা: প্রতিটি 0-কে −1 ধরুন। তাহলে “0 আর 1 সমান” মানে যোগফল 0 — অংশ ৫-এর শূন্য-যোগফলের দীর্ঘতম subarray. প্রতিটি চলতি যোগফলের প্রথম উপস্থিতির সূচক রাখুন; শুরুতে যোগফল 0-এর সূচক −1 (খালি prefix).

    int longestBalanced(const vector<int>& a) {
        unordered_map<int, int> first{{0, -1}};   // sum 0 seen before index 0
        int s = 0, best = 0;
        for (int i = 0; i < (int)a.size(); ++i) {
            s += (a[i] == 1 ? 1 : -1);
            auto it = first.find(s);
            if (it != first.end()) best = max(best, i - it->second);
            else first[s] = i;                    // keep only the FIRST index
        }
        return best;
    }

    [0, 1, 1, 0, 1, 1, 1, 0]-এ চলতি যোগফল: −1, 0, 1, 0, 1, 2, 3, 2. i = 3-এ যোগফল 0 আগে ছিল সূচক −1-এ → দৈর্ঘ্য 4 (a[0..3] = 0, 1, 1, 0). পরে i = 4-এ 1 প্রথম ছিল i = 2-এ → দৈর্ঘ্য 2; i = 7-এ 2 প্রথম ছিল i = 5-এ → দৈর্ঘ্য 2. উত্তর 4. সময় গড়ে O(n), জায়গা O(n) — যোগফল −n থেকে n-এর মধ্যে থাকে বলে map-এর বদলে 2n + 1 আকারের অ্যারেও ব্যবহার করা যায়, তখন নিশ্চিত O(n).

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

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

ক্রমযোগফল ঠিক k এমন subarray গোনার অ্যালগরিদমে প্রতিটি কাজ সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. map-এ {0: 1} রাখা, চলতি যোগফল S = 0 আর উত্তর 0 দিয়ে শুরু
  2. পরের উপাদানটি S-এ যোগ করা
  3. map-এ S − k মানটি কতবার আছে, তা উত্তরে যোগ করা
  4. map-এ S মানের গণনা ১ বাড়ানো
  5. সব উপাদান শেষ হলে উত্তর ফেরত দেওয়া

০-ভিত্তিক অ্যারে a-এর জন্য P[0] = 0 এবং P[i + 1] = P[i] + a[i]. তাহলে a[l] + … + a[r]-এর সঠিক সূত্র কোনটি?

  • P[r] − P[l]
  • P[r + 1] − P[l]
  • P[r + 1] − P[l − 1]
  • P[r] − P[l − 1]

2D prefix sum-এ আয়তক্ষেত্রের যোগফল = P[r2+1][c2+1] − P[r1][c2+1] − P[r2+1][c1] + P[r1][c1]. শেষের P[r1][c1] কেন যোগ করা হয়?

  • আয়তক্ষেত্রের বাঁ-ওপরের ঘরটি অন্তর্ভুক্ত করতে
  • সীমানার শূন্য সারির ক্ষতিপূরণ দিতে
  • আয়তক্ষেত্রের সারির সংখ্যা বিজোড় হলে ভুল এড়াতে
  • বাঁ-ওপরের কোণার অংশ দুবার বিয়োগ হয়েছে, তাই একবার ফেরত দিতে

ঋণাত্মক সংখ্যাসহ একটি অ্যারেতে যোগফল ঠিক k এমন subarray গুনতে হবে। দুই-pointer-এর sliding window কেন নির্ভরযোগ্য নয়?

  • যোগফল আর window-এর আকারের সম্পর্ক একমুখী থাকে না, তাই কোন দিকে সরতে হবে বোঝা যায় না
  • sliding window O(n²) সময় নেয়
  • sliding window-এর hash map-এ ঋণাত্মক key রাখা যায় না
  • ঋণাত্মক সংখ্যা যোগ করলে overflow হয়

n ঘরের একটি শূন্য অ্যারেতে q টি “[l, r]-এর সব ঘরে v যোগ করো” আপডেট আসে, তারপর সব ঘরের চূড়ান্ত মান ছাপতে হবে। difference array দিয়ে মোট সময় কত?

  • O(n·q)
  • O(q log n)
  • O(n + q)
  • O(q)

একটি অ্যারেতে “a[i]-এর মান বদলাও” আর “a[l..r]-এর যোগফল বলো” — এই দুই ধরনের কাজ মিশে মিশে আসে, মোট q টি। কোন কাঠামো দুটি কাজই O(log n)-এ করে?

  • Prefix sum অ্যারে
  • Fenwick tree (Binary Indexed Tree)
  • Difference array
  • Sliding window

n = 2 × 105 আর প্রতিটি উপাদান 1 থেকে 109-এর মধ্যে। C++-এ prefix sum অ্যারে কোন টাইপে রাখা উচিত?

  • int, কারণ প্রতিটি উপাদান int-এ ধরে
  • জায়গা বাঁচাতে short
  • double, কারণ বড় সংখ্যা ধরে
  • long long, কারণ যোগফল 2 × 1014 পর্যন্ত যেতে পারে

শব্দকোষ

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

মনে রাখুন

  1. P[0] = 0, P[i + 1] = P[i] + a[i]; sum(l..r) = P[r + 1] − P[l]. প্রস্তুতি Θ(n), প্রতি প্রশ্ন Θ(1).
  2. 2D: চার কোণা, চিহ্ন + − − + — দুবার বাদ যাওয়া কোণা একবার ফেরত (inclusion–exclusion).
  3. sum = k গণনা: map-এ {0: 1}, প্রতি ধাপে আগে S − k গোনা, পরে S ঢোকানো; ঋণাত্মক সংখ্যাতেও চলে, sliding window চলে না।
  4. Difference array: D[l] += v, D[r + 1] −= v, শেষে একবার prefix pass — O(n + q); D-তে n + 1 ঘর।
  5. মাঝে আপডেট এলে prefix sum অচল → Fenwick / segment tree; যোগফল সবসময় long long.

পরের পর্বে: মডিউল ৩ শুরু — সার্চিং ও সর্টিং। প্রথমে linear আর binary search: সাজানো অ্যারেতে প্রতিবার অর্ধেক বাদ দিয়ে O(log n)-এ খোঁজা, loop invariant দিয়ে প্রমাণ, আর সেই বিখ্যাত overflow-এর ফাঁদ।

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

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