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

Two pointer আর sliding window — জোড়ার লুপ O(n²) থেকে O(n)

Two Pointers & Sliding Window — Pair Sum, 3-Sum, Dutch National Flag, Merging & Longest Substring

Taught in Bangla, this lesson shows how two pointers turn quadratic pair loops into linear scans and proves no pair is missed. It covers opposite-end pointers for pair sum, 3-sum, container with most water and palindromes, same-direction pointers for duplicate removal, merging and Dutch national flag, fixed and variable sliding window templates, longest substring without repeating characters, and when negatives break the technique.

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

  • কেন দুটি একমুখী pointer O(n²) জোড়ার লুপকে O(n)-এ নামায়, আর কেন তাতে কোনো জোড়া হারায় না — প্রমাণসহ
  • বিপরীত প্রান্তের pointer দিয়ে pair sum, 3-sum, container with most water, palindrome আর উল্টানো
  • একই দিকের (fast/slow) pointer দিয়ে in-place duplicate সরানো, merge আর Dutch national flag partition
  • Fixed আর variable sliding window-এর টেমপ্লেট, longest substring without repeating characters, smallest subarray sum ≥ S আর at most K distinct
  • Amortized O(n)-এর যুক্তি, আর কখন কৌশলটি কাজ করে না (ঋণাত্মক সংখ্যা)

১ · জোড়া খোঁজার সমস্যা — দুই লুপ থেকে দুই আঙুল

ধরুন নীলক্ষেতের একটি বইয়ের দোকানে আপনার হাতে ঠিক ২৪০ টাকা। আপনি ঠিক দুটি বই কিনতে চান, যাদের দাম মিলিয়ে পুরো ২৪০ — এক টাকাও বেশি বা কম নয়। দোকানদার একটি তালিকা দিলেন, যেখানে বইয়ের দাম ছোট থেকে বড় সাজানো। কীভাবে খুঁজবেন?

সবচেয়ে সরল উপায়: প্রথম বইটি নিয়ে বাকি সব বইয়ের সাথে মিলিয়ে দেখুন, তারপর দ্বিতীয় বই নিয়ে আবার সবার সাথে… এটি brute force. n টি বই হলে জোড়া n(n − 1)/2 টি, অর্থাৎ quadratic time, Θ(n²). দশ হাজার বইয়ে প্রায় পাঁচ কোটি জোড়া; দশ লাখ বইয়ে প্রায় পঞ্চাশ হাজার কোটি — কনটেস্টের এক সেকেন্ডের সময়সীমায় অসম্ভব।

// brute force: every pair (i, j), i < j — Θ(n²) comparisons
for (int i = 0; i < n; ++i)
    for (int j = i + 1; j < n; ++j)
        if (a[i] + a[j] == target) return {i, j};

কিন্তু তালিকাটি যে সাজানো, এই তথ্য brute force একটুও কাজে লাগায়নি। এবার দুই বন্ধুর কথা ভাবুন। একজন তালিকার একেবারে শুরুতে (সবচেয়ে সস্তা বই), আরেকজন একেবারে শেষে (সবচেয়ে দামি বই)। দুজনের বইয়ের দাম যোগ করুন:

  • যোগফল ২৪০-এর কম হলে — যোগফল বাড়াতে হবে। দামি বইয়ের বন্ধু আর ডানে যেতে পারেন না (তার চেয়ে দামি বই নেই), তাই সস্তা বইয়ের বন্ধু এক ঘর ডানে যান।
  • যোগফল ২৪০-এর বেশি হলে — যোগফল কমাতে হবে, তাই দামি বইয়ের বন্ধু এক ঘর বাঁয়ে আসেন।
  • ঠিক ২৪০ হলে — পাওয়া গেছে!

প্রতিটি তুলনার পর একজন বন্ধু এক ঘর ভেতরে আসেন, আর কেউ কখনো পিছনে ফেরেন না। দুজন মুখোমুখি হলে খোঁজা শেষ। তাই মোট তুলনা সর্বোচ্চ n − 1 টি — Θ(n²) থেকে O(n)!

এটাই two pointer কৌশল। এখানে “pointer” মানে C-র মেমরি pointer নয় — শুধু একটি index ভ্যারিয়েবল, যা অ্যারের কোনো একটি ঘর দেখিয়ে রাখে। কৌশলটির প্রাণ একটি শর্ত:

মূল ধারণা

দুটি index রাখুন, আর প্রতিটি ধাপে অন্তত একটিকে শুধু এক দিকে (monotonically) সরান — কখনো পিছনে নয়। প্রতিটি index সর্বোচ্চ n বার সরতে পারে, তাই মোট ধাপ O(n). কঠিন অংশ হলো প্রমাণ করা যে কোনো একটি index সরানোর সময় আমরা কোনো সম্ভাব্য উত্তর ফেলে যাচ্ছি না।

এই একটি ধারণা থেকে দুটি পরিবার জন্মায়, যাদের এই পর্বে একে একে দেখব:

↔ বিপরীত প্রান্ত থেকে

একটি pointer শুরুতে, একটি শেষে; দুজন ভেতরের দিকে আসে। সাধারণত sorted array বা দুই প্রান্তের তুলনা লাগে। Pair sum, 3-sum, container with most water, palindrome, উল্টানো।

L → … ← R

→→ একই দিকে

দুটি pointer-ই বাঁ থেকে ডানে যায়, একটি দ্রুত, একটি ধীরে। Duplicate সরানো, partition, দুটি sorted array merge — আর এর মাঝের অংশটিকে “জানালা” ধরলে পাই sliding window.

slow → fast →

২ · বিপরীত প্রান্তের pointer — sorted array-এ pair sum

বইয়ের দোকানের সমস্যাটিকে অ্যালগরিদমের ভাষায় লিখি। ইনপুট: ছোট থেকে বড় সাজানো একটি অ্যারে a[0..n−1] আর একটি লক্ষ্য T. খুঁজতে হবে এমন দুটি index i < j, যাতে a[i] + a[j] = T. এই সমস্যাকে সাধারণত two-sum বা pair sum বলা হয়। Pseudocode (0-based index):

Pseudocode (pair sum on a sorted array, 0-based)

PAIR-SUM(a[0..n-1], T):            // a is sorted ascending
    L = 0, R = n - 1
    while L < R:
        s = a[L] + a[R]
        if s == T: return (L, R)
        if s < T:  L = L + 1          // a[L] is too small for every partner left
        else:      R = R - 1          // a[R] is too big for every partner left
    return NOT FOUND

নিচে a = [2, 5, 7, 9, 12, 15, 18, 21] আর T = 24 নিয়ে অ্যালগরিদমটি ধাপে ধাপে চালানো হলো। “out” সারিতে x মানে সেই উপাদানটি বাদ পড়েছে — তাকে আর কোনো জোড়ায় দেখা হবে না। “পরে” চাপুন:

অ্যারে আর pointerL, R = pointer; x = বাদ পড়া উপাদান

            
এই ধাপের হিসাব
L
R
a[L] + a[R]
সিদ্ধান্ত (T = 24)
  1. শুরু: দুই প্রান্ত। L = 0 (মান 2), R = 7 (মান 21). যোগফল 23, যা 24-এর চেয়ে ছোট। 2-এর সবচেয়ে বড় সম্ভাব্য সঙ্গী 21-ই, তাতেও হয় না — তাই 2 কোনো উত্তরের অংশ হতেই পারে না। 2 বাদ, L এক ঘর ডানে।
  2. যোগফল বড়। L = 1 (5), R = 7 (21): 5 + 21 = 26 > 24. বাকি সংখ্যাগুলোর মধ্যে 21-এর সবচেয়ে ছোট সঙ্গী 5, তাতেও যোগফল বেশি — তাই 21 বাদ, R এক ঘর বাঁয়ে।
  3. আবার ছোট। L = 1 (5), R = 6 (18): 23 < 24. বাকিদের মধ্যে 5-এর সবচেয়ে বড় সঙ্গী এখন 18 (21 আগেই বাদ), তবু কম — 5 বাদ, L ডানে।
  4. আবার বড়। L = 2 (7), R = 6 (18): 25 > 24 — 18 বাদ, R বাঁয়ে। খেয়াল করুন, প্রতিটি ধাপে একটি করে উপাদান চিরতরে বাদ পড়ছে।
  5. আবার ছোট। L = 2 (7), R = 5 (15): 22 < 24 — 7 বাদ, L ডানে। এ পর্যন্ত ৫টি উপাদান বাদ, বাকি মাত্র তিনটি: 9, 12, 15.
  6. মিলেছে! L = 3 (9), R = 5 (15): 9 + 15 = 24. মোট তুলনা মাত্র ৬টি। brute force-এ সবচেয়ে খারাপ ক্ষেত্রে দেখতে হতো 8 × 7 / 2 = ২৮টি জোড়া। n = 10⁶ হলে পার্থক্যটা হয় প্রায় দশ লাখ বনাম পঞ্চাশ হাজার কোটি।

বিশ্লেষণ। প্রতিটি ধাপে হয় উত্তর মেলে, নয়তো R − L ঠিক ১ কমে। শুরুতে R − L = n − 1, আর L < R থাকা পর্যন্ত লুপ চলে — তাই সর্বোচ্চ n − 1 টি ধাপ, প্রতিটিতে O(1) কাজ: সময় O(n), বাড়তি জায়গা O(1). অ্যারে আগে থেকে সাজানো না থাকলে প্রথমে sort করতে হয় — তখন মোট O(n log n). (না সাজিয়ে hash set দিয়েও গড়ে O(n)-এ করা যায়: প্রতিটি x-এর জন্য দেখুন T − x আগে দেখা হয়েছে কি না — hash table নিয়ে পর্ব ৩২-এ বিস্তারিত।)

৩ · কেন কোনো জোড়া হারায় না — শুদ্ধতার প্রমাণ

অনেকে প্রথমবার এই কৌশল দেখে অস্বস্তিতে পড়েন: “আমরা তো বেশিরভাগ জোড়া দেখলামই না! যদি (5, 19)-এর মতো কোনো জোড়া মাঝখানে লুকিয়ে থাকত?” প্রশ্নটি একদম ঠিক — আর এর উত্তর একটি loop invariant (পর্ব ০৩):

Invariant

লুপের প্রতিটি ঘূর্ণনের শুরুতে: যদি কোনো সমাধান (i*, j*) থাকে, তাহলে L ≤ i* এবং j* ≤ R — অর্থাৎ সমাধানটি এখনো খোঁজার অংশ a[L..R]-এর ভেতরে।

  • শুরুতে সত্য: L = 0, R = n − 1 — পুরো অ্যারেই খোঁজার অংশ।
  • যোগফল ছোট হলে (a[L] + a[R] < T): খোঁজার অংশে L-এর যেকোনো সঙ্গী j-এর জন্য j ≤ R, আর অ্যারে সাজানো বলে a[j] ≤ a[R]. তাহলে a[L] + a[j] ≤ a[L] + a[R] < T. অর্থাৎ বাকি অংশে a[L]-কে নিয়ে কোনো জোড়াই T হতে পারে না। তাই i* ≠ L, মানে i* ≥ L + 1 — L বাড়ালেও invariant টিকে থাকে।
  • যোগফল বড় হলে: একই যুক্তি উল্টো দিকে। যেকোনো i ≥ L-এর জন্য a[i] + a[R] ≥ a[L] + a[R] > T, তাই j* ≠ R — R কমানো নিরাপদ।
  • শেষে: লুপ থামে হয় উত্তর পেয়ে, নয়তো L ≥ R হলে। দ্বিতীয় ক্ষেত্রে খোঁজার অংশে দুটি আলাদা index-ই নেই; invariant বলছে সমাধান থাকলে সেটি এই অংশেই থাকত — তাই কোনো সমাধান নেই। “NOT FOUND” উত্তরটি ঠিক।

একটি ছবিতে ভাবলে আরও পরিষ্কার হয়। সব জোড়া (i, j)-কে একটি n × n ছকে সাজান — সারি i, কলাম j. Brute force ছকের প্রতিটি ঘর দেখে। Two pointer প্রতিটি তুলনায় একটি পুরো সারি (L বাদ) বা একটি পুরো কলাম (R বাদ) একসাথে বাতিল করে — কারণ সাজানো ক্রম বলে দেয় সেই সারি বা কলামের সব ঘরের যোগফল একই দিকে ভুল। তাই n − 1 টি তুলনায় পুরো ছক শেষ।

লাইব্রেরির তাকে বই উচ্চতা অনুযায়ী সাজানো, আর আপনি এমন দুটি বই খুঁজছেন যাদের একসাথে রাখলে ঠিক একটি খোপের উচ্চতা হয়। সবচেয়ে বেঁটে বইটিকে সবচেয়ে লম্বা বইয়ের সাথে মিলিয়েও যদি খোপ না ভরে, তাহলে বেঁটে বইটি অন্য কারো সাথে মিলিয়ে লাভ নেই — অন্যরা সবাই আরও বেঁটে। বইটি সরিয়ে রাখুন। প্রতিটি মাপে একটি বই চিরতরে সরে যায়, তাই খোঁজা দ্রুত ফুরায়।

এবার কোডে চালিয়ে দেখুন। তিনটি লক্ষ্যের জন্য একই অ্যারেতে কতগুলো ধাপ লাগে, তা-ও গোনা হচ্ছে:

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

// a is sorted ascending; returns (i, j), i < j, with a[i] + a[j] == target
pair<int, int> pairSum(const vector<int>& a, int target, int& steps) {
    int L = 0, R = (int)a.size() - 1;
    steps = 0;
    while (L < R) {
        ++steps;
        int s = a[L] + a[R];
        if (s == target) return {L, R};
        if (s < target) ++L;      // a[L] is too small for every remaining partner
        else --R;                 // a[R] is too big for every remaining partner
    }
    return {-1, -1};
}

int main() {
    vector<int> a{2, 5, 7, 9, 12, 15, 18, 21};
    int n = (int)a.size();
    for (int t : {24, 33, 10}) {
        int steps = 0;
        auto [i, j] = pairSum(a, t, steps);
        cout << "target " << t << ": ";
        if (i == -1) cout << "no pair";
        else cout << a[i] << " + " << a[j] << " (i=" << i << ", j=" << j << ")";
        cout << "  steps = " << steps << "  (brute force: up to " << n * (n - 1) / 2 << " pairs)\n";
    }
}

আউটপুট:

target 24: 9 + 15 (i=3, j=5)  steps = 6  (brute force: up to 28 pairs)
target 33: 12 + 21 (i=4, j=7)  steps = 5  (brute force: up to 28 pairs)
target 10: no pair  steps = 7  (brute force: up to 28 pairs)

প্রথম লাইনটি ঠিক ওপরের stepper-এর ৬ ধাপ। T = 33-এ R একবারও নড়েনি — 21 সবচেয়ে বড়, তাই প্রথম চারটি সংখ্যা একে একে “খুব ছোট” হিসেবে বাদ পড়ে, পঞ্চম ধাপে 12 + 21 মেলে। লক্ষ করুন, 15 + 18 = 33-ও একটি সমাধান, কিন্তু অ্যালগরিদম প্রথম যেটি পায় সেটিই ফেরত দেয়। T = 10-এ কোনো জোড়া নেই — 7 টি ধাপ = n − 1, সবচেয়ে খারাপ ক্ষেত্র। int& steps একটি reference parameter, যার মাধ্যমে ফাংশন বাইরের ভ্যারিয়েবলে গণনা লিখে দেয়; auto [i, j] হলো structured binding, যা pair-এর দুটি মান আলাদা নামে খুলে দেয়।

def pair_sum(a, target):
    """a is sorted ascending; returns (i, j, steps) with a[i] + a[j] == target."""
    L, R, steps = 0, len(a) - 1, 0
    while L < R:
        steps += 1
        s = a[L] + a[R]
        if s == target:
            return L, R, steps
        if s < target:
            L += 1          # a[L] is too small for every remaining partner
        else:
            R -= 1          # a[R] is too big for every remaining partner
    return -1, -1, steps

a = [2, 5, 7, 9, 12, 15, 18, 21]
n = len(a)
for t in (24, 33, 10):
    i, j, steps = pair_sum(a, t)
    found = "no pair" if i == -1 else f"{a[i]} + {a[j]} (i={i}, j={j})"
    print(f"target {t}: {found}  steps = {steps}  (brute force: up to {n * (n - 1) // 2} pairs)")

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

target 24: 9 + 15 (i=3, j=5)  steps = 6  (brute force: up to 28 pairs)
target 33: 12 + 21 (i=4, j=7)  steps = 5  (brute force: up to 28 pairs)
target 10: no pair  steps = 7  (brute force: up to 28 pairs)

Python-এ ফাংশন একসাথে তিনটি মান (tuple) ফেরত দিতে পারে, তাই reference parameter লাগেনি। // হলো integer ভাগ। একটি সতর্কতা: Python-এ a[-1] বৈধ (শেষ উপাদান), তাই index ভুল হলে C++-এর মতো crash না করে নিঃশব্দে ভুল উত্তর দিতে পারে — খালি অ্যারেতে R = -1 হয়, কিন্তু L < R মিথ্যা বলে লুপ ঢোকেই না, তাই এখানে নিরাপদ।

৪ · একই ছাঁচে আরও সমস্যা — 3-sum, পানির পাত্র, palindrome

3-sum — একটি লুপ + two pointer = O(n²)

এবার তিনটি সংখ্যা: অ্যারেতে এমন সব আলাদা ত্রয়ী খুঁজুন, যাদের যোগফল 0. এটি 3-sum সমস্যা। তিন লুপের brute force Θ(n³). কৌশল: অ্যারে sort করুন, তারপর প্রথম সংখ্যা a[i] স্থির রেখে বাকি অংশ a[i+1..n−1]-এ লক্ষ্য −a[i]-এর জন্য pair sum চালান। প্রতিটি i-তে two pointer O(n), i আছে n টি — মোট O(n²), sort-এর O(n log n) তার তুলনায় নগণ্য।

// all distinct triplets with sum 0: sort, then two pointers for each i — O(n²)
vector<array<int, 3>> threeSum(vector<int> a) {
    sort(a.begin(), a.end());
    vector<array<int, 3>> res;
    int n = (int)a.size();
    for (int i = 0; i + 2 < n; ++i) {
        if (i > 0 && a[i] == a[i - 1]) continue;       // same first value: skip
        int L = i + 1, R = n - 1;
        while (L < R) {
            int s = a[i] + a[L] + a[R];
            if (s < 0) ++L;
            else if (s > 0) --R;
            else {
                res.push_back({a[i], a[L], a[R]});
                ++L; --R;
                while (L < R && a[L] == a[L - 1]) ++L;  // skip a repeated second value
            }
        }
    }
    return res;
}
// threeSum({-1, 0, 1, 2, -1, -4}) -> {-1, -1, 2} {-1, 0, 1}

সবচেয়ে বেশি ভুল হয় duplicate সামলাতে। sort-এর পরে অ্যারে হয় [−4, −1, −1, 0, 1, 2]. i = 1 (−1) থেকে আমরা {−1, −1, 2} আর {−1, 0, 1} পাই; i = 2-তেও প্রথম সংখ্যা −1, তাই continue না থাকলে {−1, 0, 1} আবার আসত। একইভাবে উত্তর পাওয়ার পর L-কে সব সমান মান পার করিয়ে নিতে হয়। এই দুটি লাইন বাদ দিলে কোড “চলে”, কিন্তু একই ত্রয়ী একাধিকবার ছাপে — ইন্টারভিউতে এটিই সবচেয়ে প্রচলিত ভুল।

Container with most water — খাটো দেয়ালটি সরান

একটি অ্যারে h[0..n−1]-এ প্রতিটি সংখ্যা একটি খাড়া দেয়ালের উচ্চতা, পাশাপাশি এক ঘর দূরে দূরে। যেকোনো দুটি দেয়াল i < j বেছে নিলে মাঝে পানি ধরে (j − i) × min(h[i], h[j]) পরিমাণ — প্রস্থ × খাটো দেয়ালের উচ্চতা। সবচেয়ে বেশি পানি কোন জোড়ায়? এখানে অ্যারে সাজানো নয়, তবু two pointer কাজ করে:

int maxWater(const vector<int>& h) {
    int L = 0, R = (int)h.size() - 1, best = 0;
    while (L < R) {
        best = max(best, (R - L) * min(h[L], h[R]));
        if (h[L] < h[R]) ++L;      // the shorter wall limits every narrower pair
        else --R;
    }
    return best;
}
// maxWater({1, 8, 6, 2, 5, 4, 8, 3, 7}) -> 49   (walls at index 1 and 8: 7 * min(8, 7))

কেন খাটো দেয়ালটিই সরানো নিরাপদ? ধরুন h[L] ≤ h[R]. L-কে বাকি যেকোনো দেয়াল j (L < j < R)-এর সাথে জোড়া দিলে প্রস্থ (j − L) < (R − L), আর উচ্চতা min(h[L], h[j]) ≤ h[L]. তাই পানি ≤ (R − L) × h[L] = এখনকার জোড়ার পানি, যা আমরা ইতিমধ্যে best-এ নিয়েছি। অর্থাৎ L-এর আর কোনো ভালো সঙ্গী বাকি নেই — একে বাদ দেওয়া নিরাপদ। লম্বা দেয়ালটি সরালে এই যুক্তি খাটে না: সেটি অন্য কোনো লম্বা দেয়ালের সাথে মিলে বেশি পানি ধরতে পারে। এটি একটি ছোট greedy সিদ্ধান্ত, যার পেছনে আছে পরিষ্কার “বাদ দেওয়ার” প্রমাণ — পর্ব ৫০-এ greedy-র প্রমাণ নিয়ে আরও বিস্তারিত দেখবেন।

Palindrome যাচাই আর উল্টানো — সবচেয়ে সরল রূপ

“madam”, “racecar”, “নয়ন” (ন-য়-ন) — যে শব্দ সামনে-পেছনে একই, তা palindrome. যাচাই করতে দুই প্রান্ত থেকে একটি করে অক্ষর মিলিয়ে ভেতরে আসুন; অমিল পেলেই উত্তর “না”। অ্যারে উল্টাতেও একই চলন — শুধু তুলনার বদলে swap. দুটিতেই প্রায় n/2 ধাপ, O(n) সময়, O(1) বাড়তি জায়গা — অর্থাৎ in-place.

bool isPalindrome(const string& s) {
    int L = 0, R = (int)s.size() - 1;
    while (L < R)
        if (s[L++] != s[R--]) return false;   // compare the two ends, move both inward
    return true;
}

void reverseInPlace(vector<int>& a) {
    for (int L = 0, R = (int)a.size() - 1; L < R; ++L, --R)
        swap(a[L], a[R]);
}

বাংলা লেখায় সাবধান। C++-এর std::string বাইট ধরে কাজ করে, আর UTF-8-এ একটি বাংলা বর্ণ সাধারণত ৩ বাইট — তাই “নয়ন”-এর বাইট উল্টালে অর্থহীন বাইট-সারি পাবেন। Python-এর str code point ধরে চলে, তবু “কি” (ক + ি)-র মতো যুক্ত রূপ দুটি code point; উল্টালে কার-চিহ্ন ভুল অক্ষরে বসে। string নিয়ে এই সূক্ষ্মতা পর্ব ১১-তে আলোচিত হয়েছে।

৫ · একই দিকে দুই pointer — duplicate সরানো আর merge

দ্বিতীয় পরিবারে দুটি pointer-ই বাঁ থেকে ডানে যায়, কিন্তু আলাদা গতিতে। একটি সাধারণ নাম fast/slow pointer: fast প্রতিটি ধাপে এক ঘর এগিয়ে নতুন উপাদান “পড়ে”, আর slow শুধু তখনই এগোয় যখন কিছু “লেখার” মতো পাওয়া যায়। পরীক্ষার খাতা দেখার মতো ভাবুন: একজন শিক্ষক (fast) প্রতিটি খাতা পড়ছেন, আর একজন সহকারী (slow) শুধু পাস করা খাতাগুলো নতুন স্তূপে সাজাচ্ছেন।

Sorted array থেকে duplicate সরানো, in-place

// sorted a: keep one copy of each value at the front, return how many
int removeDuplicates(vector<int>& a) {
    if (a.empty()) return 0;
    int slow = 1;                              // a[0..slow-1] = distinct values so far
    for (int fast = 1; fast < (int)a.size(); ++fast)
        if (a[fast] != a[slow - 1])            // a new value: write it
            a[slow++] = a[fast];
    return slow;
}
// {1, 1, 2, 2, 2, 3, 5, 5} -> returns 4, front becomes 1 2 3 5

Invariant: প্রতিটি ঘূর্ণনের শুরুতে a[0..slow−1]-এ এ পর্যন্ত দেখা (a[0..fast−1]) সব আলাদা মান ক্রমানুসারে একবার করে আছে। অ্যারে সাজানো বলে একই মানের সব কপি পাশাপাশি থাকে, তাই নতুন মান চেনার জন্য শুধু শেষ লেখা মান a[slow − 1]-এর সাথে তুলনাই যথেষ্ট। fast ঠিক n − 1 বার এগোয়, slow তার চেয়ে কম — সময় Θ(n), বাড়তি জায়গা O(1). নতুন অ্যারে বানিয়ে করলে জায়গা লাগত O(n). লেখার পরে অ্যারের পেছনের অংশে পুরোনো মান থেকে যায় (উদাহরণে পুরো অ্যারে হয় [1, 2, 3, 5, 2, 3, 5, 5]); তাই ফাংশন নতুন দৈর্ঘ্য ফেরত দেয়। C++-এর std::unique ঠিক এই কাজটিই করে (STL algorithms দেখুন)।

দুটি sorted array merge করা

দুটি আলাদা ক্লাসের রোল-তালিকা, দুটিই নম্বর অনুযায়ী সাজানো — একটি সম্মিলিত সাজানো তালিকা চাই। প্রতিটি তালিকার শুরুতে একটি করে আঙুল রাখুন; দুই আঙুলের নিচের ছোট সংখ্যাটি নতুন তালিকায় লিখে সেই আঙুলটি এগিয়ে দিন। এটিই merge:

vector<int> mergeSorted(const vector<int>& a, const vector<int>& b) {
    vector<int> out;
    out.reserve(a.size() + b.size());
    size_t i = 0, j = 0;
    while (i < a.size() && j < b.size())
        out.push_back(a[i] <= b[j] ? a[i++] : b[j++]);   // <= keeps equal keys in order
    while (i < a.size()) out.push_back(a[i++]);         // copy what is left
    while (j < b.size()) out.push_back(b[j++]);
    return out;
}
// mergeSorted({1, 4, 9}, {2, 3, 10, 12}) -> 1 2 3 4 9 10 12

প্রতিটি তুলনায় একটি উপাদান চূড়ান্ত জায়গায় যায়, তাই তুলনা সর্বোচ্চ n + m − 1 টি, মোট সময় Θ(n + m). দুই অ্যারের প্রতিটি জোড়া তুলনা করার কোনো দরকারই হয়নি। <= চিহ্নটি গুরুত্বপূর্ণ: সমান হলে প্রথম অ্যারের উপাদান আগে যায়, ফলে সমান key-গুলোর মূল ক্রম বজায় থাকে (stable). এই merge-ই পর্ব ২১-এর merge sort-এর হৃদপিণ্ড।

আরও এক ধরনের fast/slow: linked list-এ fast প্রতি ধাপে দুই ঘর আর slow এক ঘর এগোলে, list-এ চক্র (cycle) থাকলে দুজন একসময় দেখা করবেই — না থাকলে fast আগে শেষে পৌঁছায়। একই চলনে list-এর মাঝের node-ও এক পাসে পাওয়া যায় (fast শেষে পৌঁছালে slow ঠিক মাঝে)। চক্র খোঁজার এই পদ্ধতি সাধারণত Robert Floyd-এর নামে “tortoise and hare” (কচ্ছপ ও খরগোশ) নামে পরিচিত; linked list নিয়ে পর্ব ১২ দেখুন।

৬ · Dutch national flag — এক পাসে তিন ভাগ

একটি অ্যারেতে শুধু তিন ধরনের মান: 0, 1 আর 2. এদের এক পাসে, বাড়তি অ্যারে ছাড়া, সাজাতে হবে — সব 0 আগে, তারপর 1, শেষে 2. গুনে নিয়ে আবার লিখে দেওয়া (counting) যায়, কিন্তু মানগুলো যদি আসলে বড় record হয় (যেমন “লাল/সাদা/নীল” লেবেলের গ্রাহক তালিকা), তাহলে সরাসরি ভাগ করে সাজানোই দরকার। একে বলে partition — একটি মাপকাঠি অনুযায়ী অ্যারেকে কয়েকটি ভাগে সাজানো।

এই সমস্যার নাম Dutch national flag problem. ডাচ কম্পিউটার বিজ্ঞানী Edsger W. Dijkstra এটি তুলে ধরেন তাঁর ১৯৭৬ সালের বই A Discipline of Programming-এ। নেদারল্যান্ডসের পতাকায় তিনটি অনুভূমিক রং — লাল, সাদা, নীল; এলোমেলো রঙের নুড়িগুলোকে পতাকার ক্রমে সাজানোর রূপকেই তিনি সমস্যাটি বলেছিলেন। শর্ত ছিল প্রতিটি নুড়ির রং মাত্র একবার দেখা যাবে, আর শুধু অদলবদল (swap) চলবে।

সমাধানে তিনটি pointer: lo, mid, hi. এরা অ্যারেকে চারটি অঞ্চলে ভাগ করে রাখে — আর পুরো অ্যালগরিদম হলো এই invariant বজায় রেখে মাঝের “অজানা” অঞ্চলটি শূন্য করা:

a[0..lo−1] = 0  |  a[lo..mid−1] = 1  |  a[mid..hi] = অজানা  |  a[hi+1..n−1] = 2
// sort an array of 0s, 1s and 2s in one pass (Dijkstra's Dutch national flag)
void dutchFlag(vector<int>& a) {
    int lo = 0, mid = 0, hi = (int)a.size() - 1;
    while (mid <= hi) {                         // the unknown zone a[mid..hi] is not empty
        if (a[mid] == 0) swap(a[lo++], a[mid++]);   // 0 joins the left zone
        else if (a[mid] == 1) ++mid;                // 1 is already in place
        else swap(a[mid], a[hi--]);                 // 2 goes right; new a[mid] is unseen
    }
}

নিচে a = [1, 2, 0, 2, 1, 0] নিয়ে পুরো চলন দেখুন। “zone” সারিতে প্রতিটি ঘরের অঞ্চল: 0, 1, 2 = নিশ্চিত অঞ্চল, ? = এখনো অজানা।

অ্যারে, তিন pointer আর অঞ্চল^ = pointer-এর অবস্থান

            
pointer আর কাজ
lo
mid
hi
শেষ কাজ
  1. শুরু। lo = mid = 0, hi = 5. 0-অঞ্চল, 1-অঞ্চল আর 2-অঞ্চল তিনটিই খালি; পুরো অ্যারে অজানা (a[0..5]). invariant শুরুতেই সত্য।
  2. a[0] = 1. 1 তো মাঝের অঞ্চলেরই সদস্য, আর সেই অঞ্চল ঠিক mid-এর বাঁয়ে শেষ হয়। তাই শুধু mid++ — কোনো swap লাগেনি। এখন a[0..0] = 1-অঞ্চল।
  3. a[1] = 2. 2-কে ডান প্রান্তে পাঠাই: a[1] আর a[hi] = a[5] অদলবদল, তারপর hi-- (hi = 4). অ্যারে [1, 0, 0, 2, 1, 2]. খেয়াল করুন, mid বাড়েনি — ডান থেকে যে 0 এসেছে, তাকে এখনো দেখা হয়নি।
  4. a[1] = 0. 0-কে বাঁয়ের অঞ্চলে নিতে a[lo] = a[0] (একটি 1)-এর সাথে swap: অ্যারে [0, 1, 0, 2, 1, 2]. তারপর lo++ আর mid++ (lo = 1, mid = 2). যে 1-টি সরে a[1]-এ গেল, সেটি 1-অঞ্চলেই রইল — তাই mid বাড়ানো নিরাপদ।
  5. a[2] = 0. আবার swap(a[1], a[2]): অ্যারে [0, 0, 1, 2, 1, 2], lo = 2, mid = 3. এখন 0-অঞ্চল a[0..1], 1-অঞ্চল a[2..2].
  6. a[3] = 2. swap(a[3], a[4]): অ্যারে [0, 0, 1, 1, 2, 2], hi = 3. আবার mid নড়েনি — নতুন a[3] = 1 এখনো যাচাই বাকি।
  7. a[3] = 1 → mid++. mid = 4 > hi = 3: অজানা অঞ্চল খালি, লুপ শেষ। ফল [0, 0, 1, 1, 2, 2]. প্রতিটি ধাপে অজানা অঞ্চল ঠিক এক ঘর ছোট হয়েছে (হয় mid++, নয়তো hi--) — তাই ঠিক n = ৬টি ধাপ, swap সর্বোচ্চ n টি।

শুদ্ধতা আর খরচ। তিনটি শাখার প্রতিটি invariant বজায় রাখে (stepper-এর ক্যাপশনে প্রতিটি দেখানো হয়েছে)। প্রতিটি ঘূর্ণনে অজানা অঞ্চলের দৈর্ঘ্য hi − mid + 1 ঠিক ১ কমে, তাই লুপ ঠিক n বার চলে: সময় Θ(n), বাড়তি জায়গা O(1). শেষে অজানা অঞ্চল খালি, তাই invariant নিজেই বলে দেয় অ্যারে সাজানো। এখানে সবচেয়ে সূক্ষ্ম বিষয় হলো 2-এর শাখায় mid না বাড়ানো — AI-এর লেখা কোডে এই ভুলটি মাঝে মাঝে দেখা যায়, আর তখন [1, 2, 0]-এর মতো ছোট ইনপুটেই ভুল ফল আসে ([0, 1, 2] হওয়ার কথা, কিন্তু ভুল কোড দেয় [1, 0, 2] — ডান থেকে আসা 0-টি আর যাচাই হয় না)।

এটি নিছক ধাঁধা নয়। Quick sort-এ অনেক সমান মান থাকলে pivot-এর চারপাশে “ছোট / সমান / বড়” তিন ভাগে ভাগ করাই (3-way partition) সবচেয়ে কার্যকর — ঠিক এই অ্যালগরিদম, যেখানে 0 = pivot-এর চেয়ে ছোট, 1 = সমান, 2 = বড়। পর্ব ২২-এ quick sort-এর সাথে এটি আবার দেখবেন।

৭ · Fixed sliding window — k ঘরের জানালা

একটি টি-টোয়েন্টি ম্যাচে প্রতিটি ওভারের রান একটি অ্যারেতে আছে। ধারাভাষ্যকার জানতে চান: “পরপর কোন ৩ ওভারে ব্যাটিং দল সবচেয়ে বেশি রান তুলেছে?” সরল উপায়ে প্রতিটি শুরুর ওভার থেকে ৩টি সংখ্যা যোগ করুন — n − k + 1 টি অবস্থান, প্রতিটিতে k টি যোগ: O(n·k). k বড় হলে (ধরুন n = 10⁵, k = 5 × 10⁴) এটি O(n²)-এর কাছাকাছি।

পর্যবেক্ষণ: পাশাপাশি দুটি window-এর মধ্যে k − 1 টি উপাদান একই! window এক ঘর ডানে সরালে শুধু একটি নতুন উপাদান ঢোকে (ডানে), আর একটি পুরোনো উপাদান বের হয় (বাঁয়ে)। তাই পুরো যোগফল আবার হিসাব না করে শুধু হালনাগাদ করুন:

নতুন sum = পুরোনো sum + a[r] − a[r − k]

এটাই fixed sliding window: k দৈর্ঘ্যের একটি জানালা, যা ট্রেনের জানালার মতো এক ঘর এক ঘর করে সামনে সরে — বাঁয়ের দৃশ্য বেরিয়ে যায়, ডানে নতুন দৃশ্য আসে। নিচে a = [4, 2, 7, 1, 8, 3, 6] আর k = 3-এ দেখুন। “chg” সারিতে − মানে যে উপাদান বের হলো, + মানে যে ঢুকল:

জানালা^ = window-এর ভেতরে

            
যোগফলের হালনাগাদ
window
হিসাব
sum
সেরা এ পর্যন্ত
  1. প্রথম window তৈরি। a[0..2] = 4, 2, 7 — শুধু এখানেই k টি যোগ: sum = 13. সেরা এ পর্যন্ত 13.
  2. এক ঘর সরানো। a[3] = 1 ঢুকল, a[0] = 4 বের হলো: 13 + 1 − 4 = 10. একটি যোগ আর একটি বিয়োগ — window যত বড়ই হোক, কাজ এটুকুই। সেরা এখনো 13.
  3. নতুন সেরা। a[4] = 8 ঢুকল, a[1] = 2 বের হলো: 10 + 8 − 2 = 16 > 13. সেরা হলো 16 (a[2..4] = 7, 1, 8).
  4. কমে গেল। a[5] = 3 ঢুকল, a[2] = 7 বের হলো: 16 + 3 − 7 = 12. সেরা 16-ই থাকল।
  5. শেষ window. a[6] = 6 ঢুকল, a[3] = 1 বের হলো: 12 + 6 − 1 = 17 — নতুন সেরা, a[4..6] = 8, 3, 6. মোট window n − k + 1 = ৫টি; প্রথমটিতে k টি যোগ, বাকি প্রতিটিতে ২টি কাজ — মোট O(n), k-এর ওপর নির্ভর করে না।
// max sum of k consecutive elements (1 <= k <= n): O(n) time, O(1) extra space
long long maxWindowSum(const vector<int>& a, int k) {
    long long sum = 0;
    for (int i = 0; i < k; ++i) sum += a[i];          // the first window
    long long best = sum;
    for (int r = k; r < (int)a.size(); ++r) {
        sum += a[r] - a[r - k];                        // a[r] enters, a[r-k] leaves
        best = max(best, sum);
    }
    return best;
}

গড় (average) চাইলে একই কোডে শুধু sum / k — যেমন সাত দিনের চলমান গড় তাপমাত্রা, বা শেয়ারবাজারের চার্টে “moving average”. sum-কে long long রাখা ইচ্ছাকৃত: 10⁵ টি সংখ্যা, প্রতিটি 10⁹ পর্যন্ত হলে যোগফল int-এর সীমা (প্রায় 2.1 × 10⁹) সহজেই ছাড়ায়।

যোগফল সহজ, সর্বোচ্চ মান কঠিন। যোগফলে বের হওয়া উপাদান “বিয়োগ” করা যায়। কিন্তু প্রতিটি window-এর সর্বোচ্চ উপাদান চাইলে? বের হয়ে যাওয়া উপাদানটিই যদি সর্বোচ্চ ছিল, তাহলে পরের সর্বোচ্চ কে — বিয়োগ করে জানা যায় না। সরলভাবে প্রতিটি window ঘুরে দেখলে আবার O(n·k). এর O(n) সমাধান হলো monotonic deque — deque নিয়ে পর্ব ১৪ দেখুন।

৮ · Variable sliding window — ডানে বাড়ান, বাঁয়ে ছোট করুন

অনেক সমস্যায় window-এর দৈর্ঘ্য আগে থেকে জানা থাকে না — সেটিই আসলে প্রশ্ন। যেমন: “একটি string-এর সবচেয়ে লম্বা কোন substring-এ কোনো অক্ষর দুবার নেই?” বা “যোগফল অন্তত S হয় এমন সবচেয়ে ছোট subarray কত লম্বা?” (subarray আর substring দুটোই মানে পাশাপাশি উপাদানের একটি টানা অংশ, যেমন a[L..R].) সব টানা অংশ আছে n(n + 1)/2 টি — brute force কমপক্ষে Θ(n²).

Variable window-এর কৌশল: দুটি pointer L আর R, window = a[L..R]. R প্রতি ধাপে ডানে এক ঘর বাড়ে (নতুন উপাদান ঢোকে)। window কোনো শর্ত ভাঙলে (“অবৈধ” হলে) L-কে ডানে সরিয়ে বাঁ দিক থেকে উপাদান বের করুন, যতক্ষণ না আবার বৈধ হয়। প্রতিবার বৈধ window পেলে উত্তর হালনাগাদ করুন। ঢাকার রাস্তায় বাসের মতো ভাবুন: সামনের দরজা দিয়ে যাত্রী ওঠে (R), বাস বেশি ভরে গেলে পেছনের দরজা দিয়ে নামাতে হয় (L) — কেউ কখনো উল্টো দিকে যায় না।

Pseudocode (variable sliding window template, 0-based)

L = 0
for R = 0 to n - 1:
    add a[R] to the window                 // expand right
    while the window a[L..R] is invalid:
        remove a[L] from the window        // shrink left
        L = L + 1
    update the answer using a[L..R]        // the window is valid here

এই টেমপ্লেট তখনই ঠিক, যখন শর্তটি একমুখী (monotone): একটি বৈধ window-এর যেকোনো ছোট টানা অংশও বৈধ। “কোনো অক্ষর দুবার নেই” — এই শর্তে একটি বৈধ অংশ থেকে কিছু বাদ দিলে সেটি বৈধই থাকে। তাই R স্থির রেখে L সবচেয়ে কম যতটুকু সরালে বৈধ হয়, সেটিই R-এ শেষ হওয়া সবচেয়ে লম্বা বৈধ window; আর পরের R-এর জন্য L-কে কখনো পিছিয়ে আনার দরকার নেই, কারণ বাঁয়ের অংশটি অবৈধ ছিল বলে আরও লম্বা window-ও অবৈধ হবে।

Longest substring without repeating characters

নিচে s = "pwwkew"-এ টেমপ্লেটটি চালানো হলো। window-এ কোন কোন অক্ষর আছে, “set” সারিতে দেখুন:

String আর windowL, R = pointer; ^ = window-এর ভেতরে

            
এই ধাপ
R (ঢুকল)
shrink
L
window
সেরা
  1. R = 0, 'p' ঢুকল। window "p", কোনো পুনরাবৃত্তি নেই। দৈর্ঘ্য R − L + 1 = 1 — সেরা 1.
  2. R = 1, 'w' ঢুকল। window "pw", এখনো বৈধ। সেরা 2.
  3. R = 2, আরেকটি 'w' — window অবৈধ! বৈধ করতে বাঁ দিক থেকে বের করি: প্রথমে 'p' (L = 1) — এখনো দুটি 'w'; তারপর প্রথম 'w' (L = 2). এখন window "w". একটি ধাপেই L দুই ঘর সরল — কিন্তু L কখনো পিছনে ফেরে না, এটিই amortized হিসাবের চাবি। সেরা 2-ই থাকল।
  4. R = 3, 'k' ঢুকল। window "wk", দৈর্ঘ্য 2 — সেরার সমান, বাড়ল না।
  5. R = 4, 'e' ঢুকল। window "wke", দৈর্ঘ্য 3 — নতুন সেরা 3 "wke".
  6. R = 5, আবার 'w'. window-এ আগে থেকেই 'w' আছে (index 2-এ)। বাঁ দিক থেকে 'w' বের করি (L = 3) — window "kew", বৈধ, দৈর্ঘ্য 3, সেরার সমান। শেষ উত্তর 3 ("wke"). লক্ষ করুন "pwke" উত্তর নয় — সেটি টানা অংশ নয় (subsequence), substring নয়।

Amortized O(n) — ভেতরের while লুপ থাকলেও কেন n² নয়? কোডে একটি for-এর ভেতরে একটি while দেখে অনেকে সরাসরি “O(n²)” বলে দেন। কিন্তু গুনে দেখুন: while-এর প্রতিটি ঘূর্ণনে L ঠিক ১ বাড়ে, আর L কখনো কমে না এবং n-এর বেশি হতে পারে না। তাই পুরো প্রোগ্রাম জুড়ে while সব মিলিয়ে সর্বোচ্চ n বার চলে — কোনো একটি R-এ বেশি চললে অন্যগুলোতে কম। প্রতিটি উপাদান window-এ একবার ঢোকে (R পেরোনোর সময়) আর সর্বোচ্চ একবার বের হয় (L পেরোনোর সময়)। মোট কাজ ≤ 2n টি ঢোকা-বের হওয়া, প্রতিটি O(1) — সময় O(n). একক কোনো ধাপ ব্যয়বহুল হলেও গড়ে প্রতি ধাপ O(1) — এই ধরনের হিসাবকে বলে amortized analysis, যার আনুষ্ঠানিক পদ্ধতিগুলো পর্ব ৬০-এ দেখবেন।

জায়গা: window-এ কোন অক্ষর কতবার আছে তা রাখতে একটি গণনা-টেবিল লাগে। ASCII-র জন্য ২৫৬ ঘরের অ্যারে — O(Σ), যেখানে Σ = বর্ণমালার আকার; hash map ব্যবহার করলে O(min(n, Σ)). এবার আসল কোড, ছয়টি ইনপুটে:

#include <iostream>
#include <string>
#include <array>
#include <utility>
#include <initializer_list>
using namespace std;

// {start, length} of the first longest substring with all distinct characters
pair<int, int> longestDistinct(const string& s) {
    array<int, 256> cnt{};                   // count of each character inside the window
    int L = 0, bestLen = 0, bestStart = 0;
    for (int R = 0; R < (int)s.size(); ++R) {
        unsigned char c = s[R];
        ++cnt[c];                             // expand: s[R] enters
        while (cnt[c] > 1) {                  // shrink until the window is valid again
            --cnt[(unsigned char)s[L]];
            ++L;
        }
        if (R - L + 1 > bestLen) { bestLen = R - L + 1; bestStart = L; }
    }
    return {bestStart, bestLen};
}

int main() {
    for (string s : {"abcabcbb", "bbbbb", "pwwkew", "", "dvdf", "abba"}) {
        auto [st, len] = longestDistinct(s);
        cout << '"' << s << "\" -> " << len << " \"" << s.substr(st, len) << "\"\n";
    }
}

আউটপুট:

"abcabcbb" -> 3 "abc"
"bbbbb" -> 1 "b"
"pwwkew" -> 3 "wke"
"" -> 0 ""
"dvdf" -> 3 "vdf"
"abba" -> 2 "ab"

"pwwkew"-এর লাইনটি ঠিক ওপরের stepper. খালি string-এ লুপ একবারও চলে না, উত্তর 0 — এই edge case আলাদা করে সামলাতে হয়নি। "dvdf" একটি সুন্দর পরীক্ষা: R = 2-এ দ্বিতীয় 'd' ঢুকলে শুধু প্রথম 'd' বের হয়, window "vd" থাকে, তারপর 'f' ঢুকে "vdf". কেউ যদি ভুল করে “পুনরাবৃত্তি পেলে window পুরো খালি করে নতুন করে শুরু” করেন, তাহলে এখানে উত্তর আসবে 2. unsigned char-এ রূপান্তর জরুরি: সাধারণ char অনেক compiler-এ signed, তাই 127-এর বেশি মানের বাইট (যেমন UTF-8 বাংলার বাইট) ঋণাত্মক index হয়ে যেত।

def longest_distinct(s):
    """(start, length) of the first longest substring with all distinct characters."""
    cnt = {}                         # character -> count inside the window
    L = best_len = best_start = 0
    for R, c in enumerate(s):
        cnt[c] = cnt.get(c, 0) + 1   # expand: s[R] enters
        while cnt[c] > 1:            # shrink until the window is valid again
            cnt[s[L]] -= 1
            L += 1
        if R - L + 1 > best_len:
            best_len, best_start = R - L + 1, L
    return best_start, best_len

for s in ["abcabcbb", "bbbbb", "pwwkew", "", "dvdf", "abba"]:
    st, n = longest_distinct(s)
    print(f'"{s}" -> {n} "{s[st:st + n]}"')

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

"abcabcbb" -> 3 "abc"
"bbbbb" -> 1 "b"
"pwwkew" -> 3 "wke"
"" -> 0 ""
"dvdf" -> 3 "vdf"
"abba" -> 2 "ab"

এখানে গণনা-টেবিল একটি dict — যেকোনো Unicode অক্ষরেও চলে, প্রতিটি কাজ গড়ে O(1). enumerate(s) একসাথে index আর অক্ষর দেয়। শেষ লাইনে s[st:st + n] একটি slice, যা শুধু উত্তর ছাপার সময় একবার কপি করে; লুপের ভেতরে slice বানালে প্রতিবার O(n) কপি হতো, আর পুরো অ্যালগরিদম O(n²)-এ নেমে যেত।

Smallest subarray with sum ≥ S — বৈধ হলে ছোট করার চেষ্টা

এবার উল্টো প্রশ্ন: সবচেয়ে ছোট window চাই। ধনাত্মক সংখ্যার অ্যারেতে যোগফল অন্তত S হয় এমন সবচেয়ে ছোট টানা অংশের দৈর্ঘ্য বের করুন। এখানে “বৈধ” মানে sum ≥ S, আর বৈধ থাকাকালীনই L সরিয়ে ছোট করার চেষ্টা করি:

// smallest length of a subarray with sum >= S (all a[i] > 0); 0 if none
int minLenAtLeast(const vector<int>& a, long long S) {
    int L = 0, best = INT_MAX;
    long long sum = 0;
    for (int R = 0; R < (int)a.size(); ++R) {
        sum += a[R];                       // expand
        while (sum >= S) {                 // valid: record it, then try a shorter one
            best = min(best, R - L + 1);
            sum -= a[L++];                 // shrink
        }
    }
    return best == INT_MAX ? 0 : best;
}
// minLenAtLeast({2, 3, 1, 2, 4, 3}, 7) -> 2   (the subarray 4, 3)

যুক্তি একই: সব সংখ্যা ধনাত্মক বলে window-এ উপাদান যোগ করলে sum বাড়ে, বাদ দিলে কমে — একমুখী। কোনো R-এ a[L..R] বৈধ হলে, পরের কোনো R′ > R-এর জন্য L থেকে শুরু হওয়া window আরও লম্বা, তাই সেটি কখনো ভালো উত্তর নয় — L সামনে এগোনো নিরাপদ। সময় amortized O(n), জায়গা O(1).

At most K distinct — শর্ত বদলালেই নতুন সমস্যা

“সবচেয়ে লম্বা কোন substring-এ সর্বোচ্চ K টি ভিন্ন অক্ষর আছে?” বাজারের সারির দোকানের মতো ভাবুন — একটি টানা অংশ থেকে কেনাকাটা করবেন, কিন্তু ব্যাগ আছে মাত্র K টি, প্রতিটি ব্যাগে এক রকম জিনিস। টেমপ্লেট হুবহু এক, শুধু “অবৈধ” মানে বদলায়: ভিন্ন অক্ষরের সংখ্যা K ছাড়ালে। গণনা 0 হয়ে গেলে অক্ষরটি dict থেকে মুছে দিতে হয়, যাতে len(cnt) ঠিক ভিন্ন অক্ষরের সংখ্যা দেখায়:

def longest_at_most_k(s, k):
    cnt, L, best = {}, 0, 0
    for R, c in enumerate(s):
        cnt[c] = cnt.get(c, 0) + 1          # expand
        while len(cnt) > k:                 # too many distinct characters: shrink
            cnt[s[L]] -= 1
            if cnt[s[L]] == 0:
                del cnt[s[L]]               # forget characters that left completely
            L += 1
        best = max(best, R - L + 1)
    return best

print(longest_at_most_k("eceba", 2))        # 3  ("ece")

একটি কাজের কৌশলও মনে রাখুন: “ঠিক K টি ভিন্ন অক্ষর আছে এমন substring কয়টি?” — এটি সরাসরি টেমপ্লেটে বসে না, কারণ “ঠিক K” শর্ত একমুখী নয়। কিন্তু (সর্বোচ্চ K টি থাকা substring-এর সংখ্যা) − (সর্বোচ্চ K − 1 টি থাকা substring-এর সংখ্যা) = ঠিক K. আর প্রতিটি R-এ “সর্বোচ্চ K” শর্তে বৈধ substring আছে R − L + 1 টি — যোগ করলেই গণনা। কনটেস্টে এটি বহুল ব্যবহৃত।

৯ · কখন কৌশলটি কাজ করে না — ঋণাত্মক সংখ্যা আর prefix sum

Two pointer আর sliding window জাদু নয়; এরা একটি গাণিতিক কাঠামোর ওপর দাঁড়িয়ে। Pair sum-এ সেই কাঠামো হলো সাজানো ক্রম, window-এ একমুখী শর্ত। কাঠামো না থাকলে কোড চলবে, কিন্তু নিঃশব্দে ভুল উত্তর দেবে। সবচেয়ে পরিচিত ফাঁদ: ঋণাত্মক সংখ্যা।

ওপরের minLenAtLeast-কে a = [4, −10, 3, 4] আর S = 7 দিন। সঠিক উত্তর 2 (শেষ দুটি: 3 + 4 = 7). কিন্তু কোড যা করে:

Ra[R]windowsumsum ≥ 7?
04[4]4না
1−10[4, −10]−6না
23[4, −10, 3]−3না
34[4, −10, 3, 4]1না

window কখনো বৈধ হয় না, তাই L একবারও নড়ে না, আর কোড বলে “কোনো উত্তর নেই” (0). সমস্যার মূল: −10 window-এর ভেতরে আটকে আছে। ঋণাত্মক উপাদান বাদ দিলে sum বাড়ে — তাই “বাঁ দিক থেকে বাদ দিলে sum কমে” — এই একমুখী যুক্তিটি আর সত্য নয়। কখন L সরানো লাভজনক, window আর জানে না।

একই কারণে “যোগফল ঠিক k এমন subarray কয়টি” প্রশ্নটি ঋণাত্মক সংখ্যা থাকলে window দিয়ে হয় না। এর সঠিক হাতিয়ার prefix sum: P[0] = 0, P[i + 1] = P[i] + a[i] ধরলে a[L..R]-এর যোগফল = P[R + 1] − P[L]. তাহলে “যোগফল k” মানে P[L] = P[R + 1] − k — অর্থাৎ প্রতিটি অবস্থানে শুধু জানতে হবে আগের কোন prefix-এর মান P[R + 1] − k ছিল, আর কতবার। একটি hash table-এ prefix-গুলোর গণনা রাখলে পুরো কাজ গড়ে O(n), ঋণাত্মক সংখ্যা থাকুক বা না থাকুক। এটি পরের পর্ব ১৭-এর বিষয়।

সমস্যার ধরনকাঠামো আছে?হাতিয়ার
Sorted array-এ জোড়া / ত্রয়ীসাজানো ক্রমবিপরীত প্রান্তের two pointer
Unsorted array-এ জোড়া (index ধরে রাখতে হবে)নেইHash table (গড়ে O(n)), অথবা index-সহ sort করে two pointer
ধনাত্মক সংখ্যায় sum ≥ S / sum ≤ S windowএকমুখীVariable sliding window
ঋণাত্মকসহ “যোগফল ঠিক k”নেইPrefix sum + hash table (পর্ব ১৭)
ঋণাত্মকসহ সবচেয়ে ছোট subarray, sum ≥ SনেইPrefix sum + monotonic deque
প্রতিটি k-window-এর সর্বোচ্চযোগ-বিয়োগ চলে নাMonotonic deque (পর্ব ১৪)

আরও গভীরে: বিপরীত প্রান্তের two pointer আসলে একটি ছকের ওপর খোঁজা: যদি একটি matrix-এর প্রতিটি সারি আর প্রতিটি কলাম সাজানো থাকে, তাহলে উপরের-ডান কোণ থেকে শুরু করে প্রতিটি তুলনায় একটি সারি বা কলাম বাদ দিয়ে n × m matrix-এ যেকোনো মান O(n + m)-এ খোঁজা যায় — একে বলে saddleback search. pair sum-এর ছক (সারি i, কলাম j, ঘরে a[i] + a[j]) ঠিক এমনই একটি সারি-কলাম সাজানো matrix. কনটেস্টে আরেকটি পরিচিত কৌশল: “ঠিক K” গণনা = f(K) − f(K − 1), যেখানে f হলো “সর্বোচ্চ K” — শর্তকে একমুখী বানিয়ে নেওয়ার ছোট গাণিতিক চাল।

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

  • Sorted array-এ a[i] + a[j] = T খোঁজার সবচেয়ে দক্ষ উপায় আর তার complexity? — দুই প্রান্ত থেকে two pointer; সময় O(n), বাড়তি জায়গা O(1). যোগফল ছোট হলে L++, বড় হলে R--.
  • Two pointer-এ কোনো জোড়া বাদ পড়ে না কেন? — যোগফল ছোট হলে a[L]-এর সবচেয়ে বড় সম্ভাব্য সঙ্গী a[R]-তেও হয়নি, তাই a[L] কোনো সমাধানে নেই (উল্টোটা R-এর জন্য); invariant: সমাধান থাকলে a[L..R]-এর ভেতরে।
  • 3-sum-এর সময় কত? — sort O(n log n) + প্রতিটি i-তে two pointer O(n) = O(n²).
  • for-এর ভেতরে while থাকা sliding window O(n²) নয় কেন? — L আর R দুটিই শুধু বাড়ে, প্রত্যেকে সর্বোচ্চ n বার; প্রতিটি উপাদান একবার ঢোকে, একবার বের হয় — amortized O(n).
  • ঋণাত্মক সংখ্যাসহ “যোগফল k” subarray গুনতে কী করবেন? — sliding window চলবে না; prefix sum আর hash map-এ prefix-এর গণনা রেখে O(n).

১০ · AI-এর যুগে two pointer আর sliding window — প্যাটার্ন চিনুন, প্রমাণ চান

“Longest substring without repeating characters-এর কোড দাও” লিখলে যেকোনো AI কয়েক সেকেন্ডে ঠিক কোড দেবে — সমস্যাটি এত পরিচিত যে উত্তরটি প্রায় মুখস্থ। বিপদ আসে যখন সমস্যা একটু বদলায়: ঋণাত্মক সংখ্যা ঢোকে, অ্যারে আর সাজানো থাকে না, বা “সর্বোচ্চ K”-এর বদলে “ঠিক K” চাওয়া হয়। তখন AI প্রায়ই চেনা টেমপ্লেটটিই বসিয়ে দেয় — দেখতে নিখুঁত, কিন্তু পেছনের একমুখী শর্তটি আর সত্য নয়। এই কৌশলে কোড ছোট, কিন্তু শুদ্ধতা পুরোটাই একটি যুক্তির ওপর দাঁড়িয়ে। সেই যুক্তি যাচাই করা আপনার কাজ।

এই কৌশলে AI সাধারণত যেসব ভুল করে

AI-এর ভুলকীভাবে ধরবেন
ঋণাত্মক সংখ্যাসহ অ্যারেতে sum-এর sliding window[4, −10, 3, 4], S = 7-এর মতো ছোট counterexample দিয়ে চালান; brute force-এর সাথে মেলান
Unsorted অ্যারেতে বিপরীত প্রান্তের pair sum (sort ছাড়া)প্রশ্ন করুন: “কোন ধর্মের কারণে L বা R বাদ দেওয়া নিরাপদ?” — উত্তর “সাজানো ক্রম” হলে sort কোথায়?
Window-এর দৈর্ঘ্য R - L লেখা (R - L + 1-এর বদলে)এক অক্ষরের string-এ চালান — উত্তর 1 আসার কথা
3-sum-এ duplicate বাদ না দেওয়া বা ভুলভাবে বাদ দেওয়া[0, 0, 0, 0] আর [−1, −1, 2, 2] দিয়ে চালান — একটি করে ত্রয়ী আসার কথা
Dutch flag-এ 2-এর swap-এর পর mid++[1, 2, 0] দিয়ে চালান — ফল [0, 1, 2] হওয়ার কথা
“last index”-এ লাফ দেওয়ার সংস্করণে L = max(L, last[c] + 1)-এর max বাদ"abba" দিয়ে চালান — উত্তর 2, ভুল কোড দেয় 3
Complexity ভুল: for-এর ভেতরে while দেখে O(n²) বলা, বা L-কে প্রতিবার 0-তে ফিরিয়ে এনে O(n) দাবিL কখনো কমে কি না দেখুন; ঢোকা-বের হওয়ার মোট সংখ্যা গুনতে counter বসান

AI-কে সমাধান লেখার মেশিন না বানিয়ে প্রমাণ-পরীক্ষক বানান। নিচের prompt-টি আপনাকেই ভাবতে বাধ্য করে:

আমি DSA শিখছি, এখন two pointer আর sliding window. নিচে একটি সমস্যা আর আমার লেখা সমাধান দিলাম: [সমস্যার বিবরণ] [এখানে আমার কোড] পুরো সমাধান লিখে দিও না। শুধু এগুলো করো: ১. L বা R সরানোর প্রতিটি সিদ্ধান্ত কেন কোনো সম্ভাব্য উত্তর হারায় না — ইনপুটের কোন ধর্ম (sorted, সব সংখ্যা ধনাত্মক, একমুখী শর্ত) এর ওপর নির্ভর করছে, এক লাইনে বলো। ২. এমন একটি ছোট ইনপুট খোঁজো (৫টির কম উপাদান) যেখানে আমার কোড ভুল উত্তর দেয়, যদি থাকে। ৩. সময় আর জায়গার complexity কত — L আর R মোট কতবার সরে, তা গুনে যুক্তি দাও। ৪. খালি ইনপুট, এক উপাদান, সব উপাদান সমান — এই তিনটিতে আমার কোড কী দেবে?

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

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

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

প্র ০১Dijkstra একটি “পতাকা সাজানোর” ধাঁধা নিয়ে কেন মাথা ঘামালেন — আর আজকের সর্টিং লাইব্রেরিতে এর ছাপ কোথায়?

Edsger W. Dijkstra (১৯৩০–২০০২) সবচেয়ে বেশি পরিচিত shortest path অ্যালগরিদমের জন্য (পর্ব ৪৩), কিন্তু তাঁর জীবনের বড় অংশ কেটেছে একটি প্রশ্ন নিয়ে: “প্রোগ্রাম লেখার সময়ই কীভাবে নিশ্চিত হওয়া যায় যে সেটি ঠিক?” ১৯৭৬ সালের বই A Discipline of Programming-এ তিনি দেখান যে প্রোগ্রাম আগে লিখে পরে পরীক্ষা করার বদলে আগে একটি invariant ঠিক করে নিয়ে সেটি বজায় রাখার মতো করে প্রোগ্রাম “তৈরি” করা যায়। Dutch national flag সমস্যাটি সেই বইয়ের একটি উদাহরণ — দেখতে খেলনা, কিন্তু invariant-চালিত নকশার নিখুঁত প্রদর্শনী। চারটি অঞ্চল (0, 1, অজানা, 2) আগে ঠিক করুন; তারপর প্রশ্ন একটিই: “অজানা অঞ্চলের একটি উপাদান দেখে কীভাবে invariant না ভেঙে অঞ্চলটি এক ঘর ছোট করব?” তিনটি শাখা প্রায় নিজে থেকেই বেরিয়ে আসে।

এর ব্যবহারিক গুরুত্ব বোঝা যায় quick sort-এ। সাধারণ quick sort অ্যারেকে pivot-এর চেয়ে “ছোট” আর “বড় বা সমান” দুই ভাগে ভাঙে। কিন্তু অনেক মান সমান হলে (যেমন লাখো গ্রাহকের তালিকা জেলা অনুযায়ী সাজানো — মাত্র ৬৪টি ভিন্ন মান), সমান মানগুলো বারবার একই দিকে পড়ে, ভাগ অসম হয়, আর সবচেয়ে খারাপ ক্ষেত্রে সময় Θ(n²)-এর দিকে যায়। তিন ভাগে ভাঙলে (ছোট | সমান | বড়) সমান অংশটি একবারেই চূড়ান্ত জায়গায় চলে যায়, আর তাতে আর রিকার্শন লাগে না। Jon Bentley ও M. Douglas McIlroy ১৯৯৩ সালের বিখ্যাত প্রবন্ধ “Engineering a Sort Function”-এ C লাইব্রেরির qsort-এর জন্য এমন একটি দক্ষ 3-way partition নকশা বর্ণনা করেন, যা পরে অনেক লাইব্রেরিকে প্রভাবিত করে।

আরেকটি শিক্ষা আছে এখানে: Dijkstra-র শর্ত ছিল প্রতিটি উপাদান “একবার দেখা”। আধুনিক ভাষায় এটিই one-pass, O(1) বাড়তি জায়গা — ডেটা যখন বিশাল, মেমরি সীমিত, তখন ঠিক এই গুণগুলোই মূল্যবান। আর উল্টো দিকে, counting দিয়ে (কয়টি 0, কয়টি 1, কয়টি 2 গুনে আবার লেখা) কাজটি সহজ হলেও দুই পাস লাগে আর মানগুলো যদি পুরো record হয় তবে কাজ করে না।

মূল উপলব্ধি: একটি ভালো invariant আগে লিখে নিলে কোড প্রায় নিজেই লেখা হয়ে যায় — আর ছোট একটি “ধাঁধা” থেকেই আসতে পারে সর্টিং লাইব্রেরির গুরুত্বপূর্ণ উন্নতি।

প্র ০২“লুপের ভেতরে লুপ মানেই O(n²)” — এই ধারণা কেন বিপজ্জনক, আর amortized হিসাব কীভাবে এর জবাব দেয়?

Big-O শেখার শুরুতে একটি সহজ নিয়ম সবাই শেখেন: একটি লুপ n বার, ভেতরে আরেকটি লুপ n বার, তাহলে n × n. নিয়মটি ঊর্ধ্বসীমা হিসেবে সত্য, কিন্তু প্রায়ই tight নয়। Sliding window-এ ভেতরের while কোনো এক R-এ n বার চলতে পারে — কিন্তু সব R মিলিয়ে মোট n-এর বেশি নয়, কারণ প্রতিটি ঘূর্ণন L-কে স্থায়ীভাবে এক ঘর এগিয়ে দেয়। এখানে প্রশ্নটি “একটি ঘূর্ণনে সবচেয়ে বেশি কত কাজ?” নয়, বরং “পুরো প্রোগ্রামে মোট কত কাজ?” এই দৃষ্টিভঙ্গিকে বলে aggregate analysis.

১৯৮৫ সালে Robert Tarjan তাঁর প্রবন্ধ “Amortized Computational Complexity”-তে এই ধরনের হিসাবকে আনুষ্ঠানিক রূপ দেন। একটি সুন্দর পদ্ধতি হলো potential function: ধরুন Φ = (n − L) + (n − R), অর্থাৎ দুই pointer-এর সামনে এখনো কত পথ বাকি। শুরুতে Φ = 2n, আর কখনো ঋণাত্মক হয় না। প্রতিটি মৌলিক কাজ (R++ বা L++) Φ-কে ঠিক ১ কমায়। তাই মোট কাজ ≤ 2n — কোনো অনুমান ছাড়াই, প্রতিটি ইনপুটে। একই ধরনের যুক্তি পাওয়া যায় dynamic array-এর push_back-এ (পর্ব ১০), KMP string matching-এর failure function-এ (পর্ব ৫৮), monotonic stack আর union-find-এ — প্রতিটির হৃদয়ে একটি “শুধু এক দিকে চলে এমন” সম্পদ।

বিপজ্জনক কেন? দুই দিক থেকে। এক, কেউ সঠিক O(n) সমাধানকে O(n²) ভেবে বাতিল করে দেন, আর কঠিন কোনো পথ খোঁজেন। দুই, তার চেয়েও খারাপ — কেউ এমন কোড লেখেন যেখানে ভেতরের লুপ L-কে প্রতিবার শুরুতে ফিরিয়ে আনে (যেমন “পুনরাবৃত্তি পেলে L = R করে আবার পেছন থেকে গোনা”), আর ভাবেন এটিও sliding window, তাই O(n). দেখতে প্রায় একই কোড, অথচ একটি O(n), অন্যটি O(n²). পার্থক্য ধরার একমাত্র উপায় হলো প্রশ্ন করা: “এই pointer কি কখনো পিছনে যায়?” কনটেস্টে n = 2 × 10⁵ হলে এই পার্থক্যই “Accepted” আর “Time Limit Exceeded”-এর পার্থক্য।

মূল উপলব্ধি: লুপের গঠন নয়, মোট কাজ গুনুন। যে সম্পদ শুধু এক দিকে খরচ হয় (pointer-এর অবস্থান, stack-এর উপাদান), তার মোট পরিমাণই অ্যালগরিদমের মোট খরচের সীমা টেনে দেয়।

প্র ০৩“Sliding window” নামটি কি শুধু অ্যালগরিদমের বইয়ে? ইন্টারনেট আর বাস্তব সিস্টেমে এই ধারণা কোথায় কাজ করছে?

নামটি আসলে নেটওয়ার্কিং থেকে বেশি পরিচিত। আপনি যখন মোবাইলে একটি ভিডিও দেখেন বা bKash অ্যাপে লেনদেন করেন, তথ্য সাধারণত যায় TCP (Transmission Control Protocol) দিয়ে, যার মূল নিয়ম ১৯৮১ সালের RFC 793-এ লেখা। TCP প্রতিটি প্যাকেট পাঠিয়ে উত্তরের জন্য বসে থাকে না — তাহলে দূরের সার্ভারের সাথে যোগাযোগ ভয়ানক ধীর হতো। বরং প্রেরক একসাথে একটি “window” পরিমাণ বাইট পাঠাতে পারে, যার প্রাপ্তিস্বীকার (acknowledgement) এখনো আসেনি। প্রাপক নিজের ফাঁকা buffer অনুযায়ী জানিয়ে দেয় window কত বড় হতে পারে (flow control). বাঁয়ের বাইটগুলোর প্রাপ্তিস্বীকার এলে window ডানে সরে, নতুন বাইট পাঠানো যায় — ঠিক আমাদের L আর R-এর মতো, দুজনেই শুধু সামনে যায়।

দ্বিতীয় উদাহরণ rate limiting. বড় ওয়েব সার্ভিস প্রায়ই নিয়ম রাখে, “একজন ব্যবহারকারী শেষ ৬০ সেকেন্ডে সর্বোচ্চ ১০০টি অনুরোধ করতে পারবেন”। “শেষ ৬০ সেকেন্ড” একটি সময়ের window: প্রতিটি নতুন অনুরোধ ডানে ঢোকে, ৬০ সেকেন্ডের পুরোনোগুলো বাঁ দিক থেকে বের হয়। অনুরোধের সময়গুলো একটি queue-তে রাখলে প্রতিটি অনুরোধ একবার ঢোকে, একবার বের হয় — amortized O(1). এই পদ্ধতিকে প্রায়ই “sliding window log” বলা হয়; জায়গা বাঁচাতে কেউ কেউ আনুমানিক “sliding window counter”-ও ব্যবহার করেন।

তৃতীয়: সংকেত আর সময়-ধারার ডেটা। শেয়ারবাজারের চার্টে ২০ দিনের moving average, ঘড়ি বা ফোনের পদক্ষেপ-গণনায় সেন্সরের শব্দ (noise) কমাতে শেষ কয়েকটি মাপের গড়, আবহাওয়া অফিসে সাত দিনের গড় বৃষ্টিপাত — সবই fixed window-এর যোগফল হালনাগাদ, প্রতিটি নতুন মাপে O(1) কাজ। ডেটা যখন অবিরাম আসতে থাকে (streaming), পুরোটা মেমরিতে রাখা সম্ভব নয়; তখন window-ই একমাত্র ব্যবহারিক উপায়।

মূল উপলব্ধি: “সাম্প্রতিক অংশটুকুর হিসাব রাখো, পুরোনোটা বাদ দাও, প্রতিটি ঘটনা একবারই সামলাও” — এই ধারণা কনটেস্টের সমস্যা থেকে শুরু করে ইন্টারনেটের মেরুদণ্ড পর্যন্ত বিস্তৃত।

প্র ০৪দুটি sorted তালিকা merge করার মতো সরল কাজ কীভাবে বিশাল ডেটাবেস আর সার্চ ইঞ্জিনের ভিত্তি হলো?

Merge-এর ইতিহাস কম্পিউটারের ইতিহাসের প্রায় সমান পুরোনো। ১৯৪৫ সালে John von Neumann merge sort-এর ধারণা লেখেন — EDVAC কম্পিউটারের জন্য, যখন RAM ছিল খুবই সামান্য। পরের কয়েক দশক বড় ডেটা থাকত চৌম্বক টেপে, যেখানে শুধু সামনে পড়া যায়, যেকোনো জায়গায় লাফ দেওয়া ব্যয়বহুল। merge-এর সৌন্দর্য হলো এটি দুটি ইনপুটই শুধু সামনে পড়ে আর আউটপুট শুধু সামনে লেখে — টেপের জন্য আদর্শ। এটিই external sorting-এর ভিত্তি: যখন ডেটা RAM-এর চেয়ে বড় (ধরুন ৫০০ GB লগ ফাইল, RAM ১৬ GB), তখন টুকরো টুকরো sort করে ডিস্কে লিখে রাখা হয়, তারপর সব টুকরো একসাথে merge — প্রতিটি টুকরোর জন্য একটি pointer, k-way merge (পর্ব ২৩-এর heap দিয়ে ছোটটি বাছাই).

ডেটাবেসে দুটি বড় টেবিল join করার একটি প্রধান পদ্ধতি হলো sort-merge join: দুই টেবিলকে join-এর key অনুযায়ী সাজিয়ে দুই pointer দিয়ে একসাথে হেঁটে মিলগুলো বের করা। দুটি টেবিল আগে থেকেই সাজানো থাকলে (যেমন index-এর কারণে) এটি প্রায় রৈখিক সময়ে হয়। সার্চ ইঞ্জিনে প্রতিটি শব্দের জন্য একটি “posting list” থাকে — যে ডকুমেন্টগুলোতে শব্দটি আছে তাদের সাজানো ID-র তালিকা। “ঢাকা মেট্রো” খুঁজলে দুটি তালিকার ছেদ (intersection) লাগে — আবার সেই দুই pointer: ছোটটিকে এগিয়ে দেওয়া, সমান হলে ফলাফলে লেখা।

আধুনিক অনেক ডেটাবেস (যেমন LevelDB, RocksDB, Cassandra) LSM-tree নামের কাঠামো ব্যবহার করে: নতুন লেখা প্রথমে মেমরিতে জমে, তারপর সাজানো ফাইল হিসেবে ডিস্কে যায়, আর পটভূমিতে নিয়মিত ছোট সাজানো ফাইলগুলোকে merge করে বড় ফাইল বানানো হয় (compaction). Unix-এর sort -m, comm আর join কমান্ডও সাজানো ইনপুট ধরে নিয়ে merge-এর মতো এক পাসে কাজ করে।

মূল উপলব্ধি: “দুটি সাজানো স্রোত, দুটি আঙুল, শুধু সামনে” — এই সরল কাঠামো ক্রমিক (sequential) পড়া-লেখার ওপর চলে বলে ডিস্ক, টেপ বা নেটওয়ার্কের মতো ধীর মাধ্যমেও দক্ষ। তাই এটি বড় ডেটার জগতে প্রায় সব জায়গায় আছে।

প্র ০৫3-sum O(n²)-এ হয় — এর চেয়ে দ্রুত কি সম্ভব? এই প্রশ্নটি কেন তাত্ত্বিক কম্পিউটার বিজ্ঞানে এত গুরুত্বপূর্ণ?

3-sum-এর O(n²) সমাধানটি এত সরল যে মনে হয় এটাই শেষ কথা। কিন্তু “এর চেয়ে ভালো সম্ভব নয়” — এটি প্রমাণ করা সম্পূর্ণ আলাদা ব্যাপার। ১৯৯৫ সালে Anka Gajentaan আর Mark Overmars একটি প্রবন্ধে দেখান যে computational geometry-র অনেক সমস্যা — যেমন “একটি সমতলে দেওয়া বিন্দুগুলোর মধ্যে কোনো তিনটি একই সরলরেখায় আছে কি না” — অন্তত 3-sum-এর সমান কঠিন: এদের কোনোটি n²-এর চেয়ে অনেক দ্রুত সমাধান করা গেলে 3-sum-ও যেত। তাঁরা এই শ্রেণির নাম দেন “3SUM-hard”. অর্থাৎ একটি সরল সমস্যার কাঠিন্য অনেকগুলো জ্যামিতিক সমস্যার কাঠিন্যের মাপকাঠি হয়ে দাঁড়াল।

বহু বছর ধরে অনেকে মনে করতেন n² হয়তো আসলেই নিম্নসীমা। ২০১৪ সালে Allan Grønlund আর Seth Pettie দেখান যে n²-কে একটি ছোট লগারিদমিক গুণকে হারানো যায় — অর্থাৎ সময় n²-এর চেয়ে সামান্য কম। কিন্তু O(n1.99)-এর মতো সত্যিকারের “উপ-বর্গ” (truly subquadratic) কোনো অ্যালগরিদম আজও অজানা। আধুনিক ধারণা (3SUM conjecture) হলো: কোনো ধ্রুবক ε > 0-এর জন্য O(n2−ε) অ্যালগরিদম নেই। এটি প্রমাণিত নয় — P বনাম NP-এর মতোই একটি খোলা প্রশ্ন, তবে ছোট মাপে।

এই অনুমানের ওপর গড়ে উঠেছে “fine-grained complexity” নামের একটি গবেষণা-ক্ষেত্র। সেখানে প্রশ্ন “বহুপদী সময়ে হয় কি না” নয়, বরং “n² নাকি n1.5?” — যেমন: যদি সমস্যা X-কে n1.9-এ সমাধান করা যায়, তাহলে 3-sum-ও যাবে; যেহেতু আমরা বিশ্বাস করি 3-sum যায় না, X-এর জন্য আর বৃথা চেষ্টা করার মানে নেই। এটি পর্ব ৬৪-এর NP-completeness-এর “reduction” ধারণার ছোট সংস্করণ।

ব্যবহারিক দিক থেকে ইন্টারভিউ বা কনটেস্টে 3-sum-এর প্রত্যাশিত উত্তর O(n²) — আর এর চেয়ে ভালো দাবি করলে (যেমন কোনো AI যদি “O(n log n)” বলে) সেটি প্রায় নিশ্চিতভাবেই ভুল।

মূল উপলব্ধি: সরল অ্যালগরিদমটি খুঁজে পাওয়া এক জিনিস, আর “এর চেয়ে ভালো হয় না” জানা আরেক জিনিস। কখনো কখনো সবচেয়ে সাধারণ প্রশ্নটিই বিজ্ঞানের সবচেয়ে কঠিন খোলা সমস্যার দরজা খুলে দেয়।

অনুশীলন

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

  1. হাতে trace করুন: a = [1, 4, 6, 8, 10, 13]-এ বিপরীত প্রান্তের pair sum চালান — প্রথমে T = 18, তারপর T = 20. প্রতিটি ধাপে L, R আর যোগফল লিখুন। কোনটিতে কয় ধাপ লাগল?

    T = 18: (L=0, R=5) 1 + 13 = 14 < 18 → L++; (1, 5) 4 + 13 = 17 < 18 → L++; (2, 5) 6 + 13 = 19 > 18 → R--; (2, 4) 6 + 10 = 16 < 18 → L++; (3, 4) 8 + 10 = 18 — মিলেছে, ৫ ধাপে, উত্তর (8, 10).

    T = 20: 14 → L++; 17 → L++; 19 → L++; (3, 5) 8 + 13 = 21 > 20 → R--; (3, 4) 8 + 10 = 18 < 20 → L++; এখন L = R = 4, লুপ শেষ — কোনো জোড়া নেই, ৫ ধাপ = n − 1. মিলিয়ে দেখুন: প্রতিটি x-এর দরকারি সঙ্গী 20 − x হলো 19, 16, 14, 12, 10, 7 — এদের মধ্যে অ্যারেতে আছে শুধু 10, আর সেটি 10-এর নিজেরই সঙ্গী (একই ঘর দুবার নেওয়া যায় না, i < j). তাই উত্তর সত্যিই “নেই”।

  2. ভুলটি খুঁজুন: কেউ longest substring without repeats-এর একটি “দ্রুত” সংস্করণ লিখলেন — প্রতিটি অক্ষরের শেষ দেখা index last[c] রেখে, পুনরাবৃত্তি পেলে সরাসরি L = last[c] + 1. "abba"-তে হাতে চালিয়ে দেখুন কী উত্তর আসে, আর কীভাবে ঠিক করবেন।

    R = 0 'a': last[a] = 0, window "a". R = 1 'b': "ab", সেরা 2. R = 2 'b': আগে দেখা, L = last[b] + 1 = 2, window "b". R = 3 'a': last[a] = 0, তাই L = 0 + 1 = 1 — L পিছনে চলে গেল! window হলো "bba" (দৈর্ঘ্য 3), যাতে 'b' দুবার। ভুল উত্তর 3.

    সমস্যা: last[a] = 0 অবস্থানটি ইতিমধ্যে window-এর বাইরে, তবু কোড তাকে বিশ্বাস করেছে। ঠিক রূপ: L = max(L, last[c] + 1) — L কখনো পিছনে যাবে না। তখন R = 3-এ L = max(2, 1) = 2, window "ba", উত্তর 2. শিক্ষা: two pointer-এর মূল শর্ত “pointer কখনো পিছনে ফেরে না” — এটি ভাঙলে শুদ্ধতা আর complexity দুটোই যায়।

  3. Window trace আর গণনা: a = [2, 3, 1, 2, 4, 3], S = 7-এ minLenAtLeast হাতে চালান। প্রতিটি R-এর পরে L আর sum লিখুন। পুরো প্রক্রিয়ায় ভেতরের while লুপ মোট কতবার চলল?

    R = 0: sum 2. R = 1: sum 5. R = 2: sum 6. R = 3: sum 8 ≥ 7 → best = 4 (a[0..3]), 2 বাদ, sum 6, L = 1. R = 4: sum 10 → best = min(4, 4) = 4 (a[1..4]), 3 বাদ → sum 7 ≥ 7 → best = 3 (a[2..4]), 1 বাদ → sum 6, L = 3. R = 5: sum 9 → best = 3 (a[3..5]), 2 বাদ → sum 7 → best = 2 (a[4..5] = 4, 3), 4 বাদ → sum 3, L = 5.

    while মোট চলল ১ + ২ + ২ = ৫ বার — n = 6-এর চেয়ে কম, ঠিক যেমন amortized যুক্তি বলে (L মোট ৫ ঘর এগিয়েছে)। উত্তর 2.

  4. কোড লিখুন: ছোট থেকে বড় সাজানো একটি অ্যারেতে কতগুলো জোড়া (i < j) আছে যাদের a[i] + a[j] ≤ T? O(n) সময়ে করুন, আর a = [1, 3, 4, 6, 9], T = 10-এ উত্তর বের করুন।

    মূল চিন্তা: a[L] + a[R] ≤ T হলে L-এর সাথে L + 1 থেকে R পর্যন্ত প্রতিটি সঙ্গী চলে (সবাই a[R]-এর চেয়ে ছোট বা সমান) — একসাথে R − L টি জোড়া গুনে L++. যোগফল বেশি হলে a[R] কোনো বাকি সঙ্গীর সাথেই চলবে না — R--.

    long long countPairsAtMost(const vector<int>& a, long long T) {
        long long count = 0;
        int L = 0, R = (int)a.size() - 1;
        while (L < R) {
            if (a[L] + a[R] <= T) { count += R - L; ++L; }   // all partners L+1..R work
            else --R;                                         // a[R] is too big for everyone
        }
        return count;
    }

    [1, 3, 4, 6, 9], T = 10: 1 + 9 = 10 ≤ 10 → +4 (L = 1); 3 + 9 = 12 → R--; 3 + 6 = 9 → +2 (L = 2); 4 + 6 = 10 → +1 (L = 3); L = R, শেষ। মোট ৭ — brute force-এ সব ১০টি জোড়া দেখে মিলিয়ে নিন। সময় O(n), জায়গা O(1); অ্যারে সাজানো না থাকলে আগে sort, মোট O(n log n).

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

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

ক্রমVariable sliding window অ্যালগরিদমের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. L = 0 নিয়ে খালি window দিয়ে শুরু করা
  2. R এক ঘর ডানে নিয়ে a[R]-কে window-এ যোগ করা
  3. window অবৈধ হলে a[L] বের করে L বাড়ানো, যতক্ষণ না আবার বৈধ হয়
  4. বৈধ window a[L..R] দিয়ে উত্তর হালনাগাদ করা
  5. R অ্যারের শেষে পৌঁছালে সংরক্ষিত সেরা উত্তর ফেরত দেওয়া

ছোট থেকে বড় সাজানো অ্যারেতে a[i] + a[j] = T খুঁজতে দুই প্রান্ত থেকে pointer L আর R চালানো হচ্ছে। কোনো ধাপে a[L] + a[R] > T হলে পরের কাজ কী?

  • L এক ঘর ডানে সরানো
  • L আর R দুটোকেই শুরুর অবস্থানে ফিরিয়ে নেওয়া
  • R এক ঘর বাঁয়ে সরানো
  • “কোনো জোড়া নেই” বলে থেমে যাওয়া

একটি sliding window অ্যালগরিদমে বাইরের for লুপ R-কে 0 থেকে n − 1 পর্যন্ত চালায়, আর ভেতরের while লুপ প্রতি ঘূর্ণনে L-কে ১ বাড়ায়; L কখনো কমে না। পুরো অ্যালগরিদমের time complexity (প্রতিটি window-কাজ O(1) ধরে) কত?

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

কোন সমস্যাটি সরাসরি “ডানে বাড়াও, অবৈধ হলে বাঁয়ে ছোট করো” sliding window দিয়ে সঠিকভাবে সমাধান করা যায় না?

  • String-এর সবচেয়ে লম্বা substring, যাতে কোনো অক্ষর দুবার নেই
  • ধনাত্মক সংখ্যার অ্যারেতে যোগফল ≥ S এমন সবচেয়ে ছোট subarray
  • সর্বোচ্চ K টি ভিন্ন অক্ষর আছে এমন সবচেয়ে লম্বা substring
  • ঋণাত্মক ও ধনাত্মক মেশানো অ্যারেতে যোগফল ঠিক k এমন subarray-এর সংখ্যা

0, 1 আর 2 দিয়ে গড়া অ্যারে এক পাসে সাজানোর Dutch national flag অ্যালগরিদমে (pointer lo, mid, hi) a[mid] == 2 হলে a[mid] আর a[hi] অদলবদল করে hi কমানো হয়। তখন mid বাড়ানো হয় না কেন?

  • ডান দিক থেকে আসা নতুন a[mid]-কে এখনো দেখা হয়নি
  • mid বাড়ালে অ্যালগরিদম দ্রুত হতো, কিন্তু বেশি জায়গা লাগত
  • swap-এর পরে a[mid]-এ সবসময় 2 থাকে
  • mid সবসময় lo-এর সমান থাকতে হয়

n টি পূর্ণসংখ্যার অ্যারেতে যোগফল 0 এমন সব ত্রয়ী (3-sum) খুঁজতে প্রথমে sort করে, তারপর প্রতিটি প্রথম উপাদানের জন্য বাকি অংশে two pointer চালানো হলো। মোট time complexity কত?

  • O(n³)
  • O(n log n)
  • O(n²)
  • O(n)

String "dvdf"-এর সবচেয়ে লম্বা কোন substring-এ কোনো অক্ষর দুবার নেই, আর তার দৈর্ঘ্য কত?

  • "dv", দৈর্ঘ্য 2
  • "vdf", দৈর্ঘ্য 3
  • "dvdf", দৈর্ঘ্য 4
  • "dvd", দৈর্ঘ্য 3

শব্দকোষ

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

মনে রাখুন

  1. Two pointer = দুটি index, প্রত্যেকে শুধু এক দিকে চলে; মোট চলাচল O(n), তাই জোড়ার Θ(n²) লুপ নেমে আসে O(n)-এ।
  2. শুদ্ধতার চাবি বাদ দেওয়ার যুক্তি: sorted array-এ যোগফল ছোট হলে a[L], বড় হলে a[R] কোনো সমাধানে থাকতে পারে না।
  3. বিপরীত প্রান্ত: pair sum O(n), 3-sum O(n²), container (খাটো দেয়াল সরানো), palindrome; একই দিক: duplicate সরানো, merge Θ(n + m), Dutch flag (2-এর পরে mid নয়)।
  4. Sliding window: fixed-এ sum += a[r] − a[r − k]; variable-এ ডানে বাড়ানো, অবৈধ হলে বাঁয়ে ছোট করা, দৈর্ঘ্য R − L + 1; প্রতিটি উপাদান একবার ঢোকে, একবার বের হয় — amortized O(n).
  5. শর্ত একমুখী না হলে (যেমন ঋণাত্মক সংখ্যায় যোগফল) window ভুল উত্তর দেয় — তখন prefix sum আর hashing.

পরের পর্বে: ঋণাত্মক সংখ্যায় যেখানে window ব্যর্থ হলো, সেখানে কাজ করে prefix sum — যেকোনো রেঞ্জের যোগফল O(1)-এ, আর difference array দিয়ে অনেকগুলো রেঞ্জ-আপডেট একসাথে।

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

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