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

উত্তরের ওপর binary search — সিদ্ধান্তের প্রশ্নে রূপান্তর

Binary Search on the Answer — Monotone Predicates, Minimise-the-Maximum, Maximise-the-Minimum, Real-Valued Answers, Ternary Search

Taught in Bangla, this lesson turns optimisation problems into yes-or-no checks with monotone predicates. It gives first-true and last-true templates with invariants, solves minimise-the-maximum problems like split array, book allocation and ship packages and maximise-the-minimum problems like aggressive cows, covers choosing bounds, counting to find the kth smallest, real-valued answers and ternary search on unimodal functions.

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

  • একটি অপটিমাইজেশনের প্রশ্নকে হ্যাঁ/না যাচাইয়ের প্রশ্নে বদলানো, আর predicate একমুখী কি না প্রমাণ করা
  • “প্রথম true” আর “শেষ true” খোঁজার template, তাদের invariant আর অসীম লুপের ফাঁদ
  • “সর্বোচ্চকে সর্বনিম্ন করা” (split array, book allocation, ship packages) আর “সর্বনিম্নকে সর্বোচ্চ করা” (aggressive cows) — greedy যাচাইসহ
  • lo ও hi বাছাই, overflow এড়ানো, k-তম ছোট মান গণনা দিয়ে খোঁজা, আর দশমিক উত্তরে নির্দিষ্ট ঘূর্ণন
  • predicate একমুখী না হলে কী হয়, কীভাবে ধরবেন, আর unimodal ফাংশনে ternary search

১ · উত্তর খোঁজা কঠিন, উত্তর যাচাই সহজ — মূল ধারণা

পর্ব ১৮-এ আপনি দেখেছেন, সাজানো অ্যারেতে একটি মান খুঁজতে binary search প্রতিবার বাকি অংশের অর্ধেক বাদ দেয়। এই পর্বে সেই একই কৌশল আমরা একটি অ্যারের ওপর নয়, চালাব সম্ভাব্য উত্তরগুলোর ওপর। কৌশলটির নাম binary search on the answer — উত্তরের ওপর binary search. কনটেস্ট আর ইন্টারভিউতে এটি সবচেয়ে বেশি ব্যবহৃত কৌশলগুলোর একটি, আর একবার চিনে ফেললে অনেক “কঠিন” দেখানো সমস্যা কয়েক লাইনে নেমে আসে।

একটি গল্প দিয়ে শুরু করি। বোর্ড পরীক্ষার পর করিম স্যারের কাছে চারটি স্কুলের খাতার বান্ডিল এসেছে: ৩, ৬, ৭ আর ১১টি খাতা। ফল প্রকাশের আগে তাঁর হাতে আছে মোট ৮ ঘণ্টা। তাঁর কাজের নিয়ম একটু অদ্ভুত: প্রতি ঘণ্টায় তিনি একটি বান্ডিল বেছে নেন আর সেটি থেকে সর্বোচ্চ k টি খাতা দেখেন। বান্ডিলে k-এর কম খাতা থাকলে সেগুলো শেষ করে ঘণ্টার বাকি সময় চা খান — অন্য বান্ডিলে হাত দেন না। প্রশ্ন: ৮ ঘণ্টায় সব শেষ করতে k-এর সর্বনিম্ন মান কত? (কম গতিতে দেখলে খাতা মনোযোগ দিয়ে দেখা হয়, তাই তিনি যত কম গতি সম্ভব, তত কম রাখতে চান।) কনটেস্টের জগতে এটি “Koko eating bananas” নামে পরিচিত — সেখানে একটি বানর কলার কাঁদি খায়।

সরাসরি “সবচেয়ে ছোট k কত” বের করার কোনো সূত্র চোখে পড়ে না। কিন্তু উল্টো প্রশ্নটি খুব সহজ: “k = 5 দিলে কি ৮ ঘণ্টায় হবে?” প্রতিটি বান্ডিলে ⌈খাতা / k⌉ ঘণ্টা লাগে (ceiling, অর্থাৎ ওপরের দিকে পূর্ণসংখ্যা), সব যোগ করে ৮-এর সাথে তুলনা করলেই উত্তর। k = 5-এ ⌈3/5⌉ + ⌈6/5⌉ + ⌈7/5⌉ + ⌈11/5⌉ = 1 + 2 + 2 + 3 = 8 ঘণ্টা — হবে। এই ধরনের হ্যাঁ/না প্রশ্নকে বলে decision problem (সিদ্ধান্তের প্রশ্ন), আর মূল “সবচেয়ে ছোট কত” প্রশ্নটি একটি optimization problem. সব k-এর জন্য হিসাবটি করে দেখুন:

k (খাতা/ঘণ্টা)1234567891011
মোট ঘণ্টা27151088655554
৮ ঘণ্টায় হবে?FFFTTTTTTTT

নিচের সারিটি লক্ষ করুন: F F F T T T T T T T T. প্রথমে কিছু “না”, তারপর একবার “হ্যাঁ” শুরু হলে বাকি সব “হ্যাঁ” — মাঝে কোনো ওলট-পালট নেই। এর কারণ সহজ: গতি বাড়ালে কোনো বান্ডিলে বেশি সময় লাগতে পারে না, তাই k দিয়ে হলে k + 1 দিয়েও হবে। এমন হ্যাঁ/না ফাংশনকে বলে monotone predicate (একমুখী শর্ত)। আর আমাদের উত্তর ঠিক সেই জায়গা, যেখানে F থেকে T শুরু — প্রথম T, এখানে k = 4.

মূল ধারণা

যদি (১) সম্ভাব্য উত্তরগুলো একটি সাজানো পরিসরে থাকে — একে বলে search space, (২) প্রতিটি সম্ভাব্য উত্তর x-এর জন্য “x কি যথেষ্ট?” প্রশ্নের উত্তর দ্রুত দেওয়া যায় — এই ফাংশনটির নাম feasibility check, আর (৩) সেই উত্তর একমুখী (F…F T…T), তাহলে সব x একে একে না দেখে binary search দিয়ে প্রথম T খুঁজুন। ১১টির বদলে ৪টি যাচাই; ১০৯টির বদলে মাত্র ৩০টি।

জুতার দোকানে মাপ খোঁজা। আপনি জুতার মাপ জানেন না। দোকানদার ৬ নম্বর দিলেন — টাইট। তাহলে ৫, ৪, ৩ নিশ্চয়ই আরও টাইট, সেগুলো পরার দরকারই নেই। ৯ নম্বর দিলেন — ঢুকে গেল। তাহলে ১০, ১১-ও ঢুকবে। আপনি আসলে “সবচেয়ে ছোট যে মাপে পা ঢোকে” খুঁজছেন, আর প্রতিটি পরীক্ষা একটি হ্যাঁ/না প্রশ্ন। “পা ঢোকে কি না” মাপ বাড়ার সাথে একমুখী — এটিই কৌশলটির প্রাণ।

২ · সাধারণ template — প্রথম true খোঁজা আর তার invariant

পর্ব ১৮-এর binary search-এ আপনি একটি নির্দিষ্ট মান খুঁজেছেন। এখানে আমরা খুঁজি একটি সীমানা — যেখানে predicate F থেকে T হয়। এর জন্য একটিমাত্র template মুখস্থ রাখুন, আর প্রতিটি সমস্যায় শুধু তিনটি জিনিস বদলান: lo, hi আর check(x).

Pseudocode: smallest x in [lo, hi] with check(x) = true
Precondition: check is monotone (F...F T...T) and check(hi) = true

FIRST-TRUE(lo, hi, check):
    while lo < hi:                    // at least two candidates left
        mid = lo + (hi - lo) / 2      // lower middle, never equals hi
        if check(mid):
            hi = mid                  // mid works: answer is mid or to its left
        else:
            lo = mid + 1              // mid fails: answer is strictly right
    return lo                         // lo == hi == first true

নিচে খাতার সমস্যায় এই template চলতে দেখুন। ওপরের সারিতে “known” মানে এই মুহূর্তে অ্যালগরিদম কী জানে: বড় হাতের T/F মানে সরাসরি যাচাই করা হয়েছে, ছোট হাতের t/f মানে যাচাই না করেও একমুখিতার জোরে জানা গেছে, আর ? মানে এখনো অজানা।

1lo = 1, hi = 11            // 11 = largest bundle2while (lo < hi) {3    mid = lo + (hi - lo) / 2;4    if (hours(mid) <= 8) hi = mid;5    else lo = mid + 1;6}7return lo;
এই ধাপের হিসাব
lo, hi (আগে)
mid
hours(mid)
সিদ্ধান্ত
অ্যালগরিদম যা জানেT/F যাচাই করা, t/f অনুমিত

                    
  1. শুরু: lo = 1, hi = 11. গতি অন্তত ১ হতেই হবে। আর k = 11 (সবচেয়ে বড় বান্ডিল) হলে প্রতিটি বান্ডিল ঠিক ১ ঘণ্টায় শেষ — মোট ৪ ঘণ্টা ≤ ৮, তাই check(11) নিশ্চিত T. উত্তর এই ১১টি প্রার্থীর মধ্যেই।
  2. mid = 6. ঘণ্টা = 1 + 1 + 2 + 2 = 6 ≤ 8 — T. তাই উত্তর 6 বা তার বাঁয়ে; hi = 6. লক্ষ করুন, 7 থেকে 11 যাচাই না করেই আমরা জানি সেগুলো T (ছোট হাতের t) — এটিই একমুখিতার লাভ।
  3. mid = 3. ঘণ্টা = 1 + 2 + 3 + 4 = 10 > 8 — F. তাহলে 1, 2-ও F; উত্তর অবশ্যই 3-এর ডানে, lo = 4. mid নিজে F, তাই তাকে পরিসরে রাখার কোনো কারণ নেই — এজন্যই mid + 1.
  4. mid = 5. ঘণ্টা = 1 + 2 + 2 + 3 = 8 ≤ 8 — T. hi = 5. mid নিজে T, সে-ই উত্তর হতে পারে, তাই তাকে বাদ দিই না — এজন্যই hi = mid, mid − 1 নয়।
  5. mid = 4 + (5 − 4) / 2 = 4. ঘণ্টা = ⌈3/4⌉ + ⌈6/4⌉ + ⌈7/4⌉ + ⌈11/4⌉ = 1 + 2 + 2 + 3 = 8 — T. hi = 4. এখন lo = hi = 4: পরিসরে একটিমাত্র প্রার্থী।
  6. থামা: উত্তর 4. মোট ৪টি যাচাই, ১১টি নয়। 3-এ F আর 4-এ T — দুটিই সরাসরি দেখা হয়েছে, তাই 4 নিশ্চিতভাবে প্রথম T. পরিসর প্রতিবার মোটামুটি অর্ধেক: 11 → 6 → 3 → 2 → 1.

শুদ্ধতার প্রমাণ। ধরুন ans হলো প্রকৃত প্রথম T. লুপের loop invariant: “lo ≤ ans ≤ hi” — অর্থাৎ lo-এর বাঁয়ের সব x-এ check F, আর hi-তে check T. শুরুতে এটি সত্য, কারণ শর্ত অনুযায়ী check(hi) = T আর lo হলো সবচেয়ে ছোট সম্ভাব্য মান। একটি ঘূর্ণনে: check(mid) = T হলে একমুখিতার কারণে ans ≤ mid, তাই hi = mid দিলেও ans ∈ [lo, hi]. check(mid) = F হলে mid আর তার বাঁয়ের সব F, তাই ans ≥ mid + 1 — lo = mid + 1 দিলেও invariant টেকে। থামা: lo < hi হলে mid = lo + ⌊(hi − lo)/2⌋ সবসময় lo ≤ mid < hi, তাই দুই শাখাতেই পরিসর (hi − lo) অন্তত ১ কমে, আর আসলে প্রায় অর্ধেক হয়। লুপ শেষে lo = hi, আর invariant বলে ans সেখানেই। মোট ঘূর্ণন ⌈log₂(hi − lo + 1)⌉-এর মতো।

শেষ true — উল্টো একমুখিতা আর অসীম লুপের ফাঁদ

কিছু সমস্যায় predicate উল্টো দিকে একমুখী: T T T T F F F, আর আমরা চাই শেষ T — যেমন “সবচেয়ে বড় কত দূরত্ব রাখা যায়” (অংশ ৪)। তখন template-এর আয়না ছবি লাগে, আর একটি ছোট কিন্তু মারাত্মক পরিবর্তন:

Pseudocode: largest x in [lo, hi] with check(x) = true
Precondition: check is T...T F...F and check(lo) = true

LAST-TRUE(lo, hi, check):
    while lo < hi:
        mid = lo + (hi - lo + 1) / 2  // UPPER middle, never equals lo
        if check(mid):
            lo = mid                  // mid works: answer is mid or to its right
        else:
            hi = mid - 1              // mid fails: answer is strictly left
    return lo

নিচের মধ্যক দিয়ে lo = mid লিখলে অসীম লুপ। ধরুন lo = 3, hi = 4, আর check(3) = T. নিচের মধ্যক mid = 3 + (4 − 3) / 2 = 3. check(3) = T, তাই lo = mid = 3 — কিছুই বদলাল না! পরের ঘূর্ণনে আবার একই, চিরকাল। নিয়মটি মনে রাখুন: যে শাখায় = mid আছে, mid যেন কখনো সেই সীমার সমান না হয়। hi = mid থাকলে নিচের মধ্যক (mid < hi নিশ্চিত), lo = mid থাকলে ওপরের মধ্যক (mid > lo নিশ্চিত)। বিকল্প: LAST-TRUE-কে FIRST-TRUE দিয়েই করুন — “প্রথম F” খুঁজে তার থেকে 1 বাদ দিন।

এবার নিজে খেলে দেখুন। নিচের প্রোগ্রামটি প্রতিটি k-এর ঘণ্টা আর T/F সারি ছাপে, তারপর binary search-এর প্রতিটি ধাপ। প্রথম লাইনের বান্ডিল আর ঘণ্টা বদলে Run চাপুন — যেমন H = 5 বা piles = [30, 11, 23, 4, 20], H = 6:

// Change these and press Run: bundles of exam scripts, and the hour limit
const piles = [3, 6, 7, 11], H = 8;

const hoursAt = (k) => piles.reduce((s, p) => s + Math.ceil(p / k), 0);
const maxPile = Math.max(...piles);
let rowK = "k     ", rowH = "hours ", rowP = "ok?   ";
for (let k = 1; k <= maxPile; k++) {
  rowK += String(k).padStart(4);
  rowH += String(hoursAt(k)).padStart(4);
  rowP += (hoursAt(k) <= H ? "T" : "F").padStart(4);
}
console.log(rowK + "\n" + rowH + "\n" + rowP);

let lo = 1, hi = maxPile;            // speed maxPile always works if H >= piles.length
while (lo < hi) {
  const mid = lo + Math.floor((hi - lo) / 2);
  const ok = hoursAt(mid) <= H;
  console.log(`lo=${lo} hi=${hi} mid=${mid} hours=${hoursAt(mid)} -> ${ok ? "T" : "F"}`);
  if (ok) hi = mid; else lo = mid + 1;
}
console.log(`minimum speed = ${lo} scripts per hour`);

আউটপুট:

k        1   2   3   4   5   6   7   8   9  10  11
hours   27  15  10   8   8   6   5   5   5   5   4
ok?      F   F   F   T   T   T   T   T   T   T   T
lo=1 hi=11 mid=6 hours=6 -> T
lo=1 hi=6 mid=3 hours=10 -> F
lo=4 hi=6 mid=5 hours=8 -> T
lo=4 hi=5 mid=4 hours=8 -> T
minimum speed = 4 scripts per hour

প্রথম তিন লাইন পুরো search space — বাস্তবে binary search এটি কখনো বানায় না, এটি শুধু আপনাকে F…F T…T দেখানোর জন্য। পরের চার লাইন ঠিক ওপরের ডায়াগ্রামের চারটি যাচাই। একটি সূক্ষ্ম শর্ত আছে: ঘণ্টা H যদি বান্ডিলের সংখ্যার চেয়ে কম হয় (যেমন H = 3), তাহলে কোনো গতিতেই হবে না — প্রতিটি বান্ডিলে অন্তত ১ ঘণ্টা লাগে। তখন check(hi) = F, template-এর পূর্বশর্ত ভাঙে, আর প্রোগ্রাম hi-কেই “উত্তর” বলে দেবে। বাস্তব কোডে লুপের আগে check(hi), বা লুপের পরে check(lo) একবার যাচাই করে “অসম্ভব” আলাদা করে জানান। প্রতিটি যাচাই O(n), যাচাই হয় O(log max) বার — মোট O(n log max).

৩ · সর্বোচ্চকে সর্বনিম্ন করা — split array, বই ভাগ আর জাহাজের মাল

এবার একটি ক্লাসিক সমস্যা, যা তিনটি ভিন্ন নামে আসে। বই ভাগ (book allocation): লাইব্রেরির তাকে পরপর সাজানো n টি বই, প্রতিটির পৃষ্ঠাসংখ্যা জানা; k জন ছাত্রের মধ্যে ভাগ করতে হবে, প্রত্যেকে পাশাপাশি থাকা এক টুকরো বই পাবে। সবচেয়ে বেশি যে পড়বে, তার পৃষ্ঠা যত কম সম্ভব রাখুন। রং করা (painter’s partition): পাশাপাশি দেয়াল, k জন রংমিস্ত্রি, প্রত্যেকে টানা কয়েকটি দেয়াল — সবচেয়ে ধীরে শেষ করা মিস্ত্রির সময় কমান। Split array largest sum: অ্যারে a-কে k টি অবিচ্ছিন্ন টুকরোয় ভাঙুন, যাতে টুকরোগুলোর সবচেয়ে বড় যোগফলটি সর্বনিম্ন হয়। তিনটিই একই অঙ্ক।

এই ধরনকে বলে “minimise the maximum” — সবচেয়ে খারাপ টুকরোটিকে যত ভালো সম্ভব করা। সরাসরি সব ভাগ পরীক্ষা করলে C(n − 1, k − 1) টি উপায়, যা দ্রুত অসম্ভব হয়ে যায়। কিন্তু সিদ্ধান্তের প্রশ্নটি দেখুন: “প্রতিটি টুকরোর যোগফল ≤ X রেখে কি k বা তার কম টুকরোয় ভাঙা যায়?” এর উত্তর দেয় একটি সরল greedy: বাঁ থেকে যোগ করতে থাকুন; পরের সংখ্যাটি যোগ করলে X ছাড়িয়ে গেলে সেখানে টুকরো কাটুন, নতুন টুকরো শুরু করুন। শেষে টুকরোর সংখ্যা ≤ k কি না দেখুন।

X বাড়ালে টুকরো কম লাগে (বা সমান), তাই predicate “টুকরো ≤ k” একমুখী: F…F T…T. আর সীমা: কোনো টুকরো একটি সংখ্যার চেয়ে ছোট হতে পারে না, তাই lo = max(a); পুরো অ্যারে এক টুকরো হলে যোগফল sum(a), যা সবসময় সম্ভব — hi = sum(a). a = [7, 2, 5, 10, 8], k = 2-এ পুরো খোঁজা দেখুন:

এই ধাপের হিসাব
lo, hi (আগে)
mid = X
greedy টুকরো
সিদ্ধান্ত
greedy কাটা (সীমা X)[ ] = একটি টুকরো

                
  1. সীমা। lo = 10, কারণ 10-কে কোনোভাবেই ভাঙা যায় না — কোনো টুকরোর যোগফল 10-এর কম হলে 10 কোথাও ধরবে না। hi = 32, পুরো অ্যারে এক টুকরো। উত্তর [10, 32]-এর ভেতরে, ২৩টি প্রার্থী।
  2. X = 21. 7 + 2 + 5 = 14; পরের 10 যোগ করলে 24 > 21, তাই কাটা। 10 + 8 = 18 ≤ 21. মোট ২ টুকরো ≤ k — T, hi = 21.
  3. X = 15. 14-এর পর কাটা; 10, তারপর 10 + 8 = 18 > 15 — আবার কাটা। ৩ টুকরো > 2 — F. সীমা 15 খুব ছোট, lo = 16.
  4. X = 18. [7 2 5] = 14, [10 8] = 18 ≤ 18. ২ টুকরো — T, hi = 18.
  5. X = 17. 10 + 8 = 18 > 17, তাই আবার ৩ টুকরো — F, lo = 18.
  6. উত্তর 18. lo = hi = 18: সেরা ভাগ [7, 2, 5] আর [10, 8], বড় টুকরো 18. 17-এ F আর 18-এ T সরাসরি দেখা — তাই 18-এর চেয়ে ভালো কোনো ভাগ নেই। ২৩টি প্রার্থীর মধ্যে মাত্র ৪টি যাচাই।

greedy যাচাই কেন সঠিক? দাবি: সীমা X-এর জন্য greedy সবচেয়ে কম সংখ্যক টুকরো দেয়। প্রমাণটি একটি “এগিয়ে থাকা” (greedy stays ahead) যুক্তি, যা exchange argument-এর আত্মীয়। যেকোনো বৈধ ভাগ OPT নিন। দাবি: greedy-র প্রথম j টি টুকরো OPT-এর প্রথম j টি টুকরোর সমান বা বেশি দূর পর্যন্ত যায়। j = 1-এ সত্য, কারণ greedy প্রথম টুকরোকে যতটা সম্ভব লম্বা করে। j থেকে j + 1-এ: greedy-র j-তম টুকরো OPT-এর চেয়ে আগে শেষ নয়, তাই greedy-র (j + 1)-তম টুকরো শুরু হয় OPT-এর চেয়ে আগে নয়; OPT-এর (j + 1)-তম টুকরোর শেষবিন্দু পর্যন্ত বাকি অংশ OPT-এর টুকরোর একটি অংশ, তাই যোগফল ≤ X — greedy অন্তত ততদূর যাবে। অতএব OPT m টুকরোয় শেষ করলে greedy-ও m বা কম টুকরোয় শেষ করে। আর “k বা কম টুকরো” পেলে “ঠিক k টুকরো” বানানো যায়, যদি n ≥ k — যেকোনো টুকরো আরও ভাঙলে বড় যোগফল বাড়ে না।

কোডে — প্রতিটি ঘূর্ণনে যাচাইয়ের ফল ছাপা হয়, তারপর k = 1 থেকে 5 পর্যন্ত উত্তর:

#include <iostream>
#include <vector>
#include <algorithm>
#include <numeric>
using namespace std;
int piecesNeeded(const vector<long long>& a, long long cap) {   // greedy
    int pieces = 1; long long cur = 0;
    for (long long x : a) {
        if (cur + x > cap) { ++pieces; cur = 0; }    // x does not fit: new piece
        cur += x;
    }
    return pieces;
}
long long splitArray(const vector<long long>& a, int k, bool trace) {
    long long lo = *max_element(a.begin(), a.end());       // answer in [lo, hi]
    long long hi = accumulate(a.begin(), a.end(), 0LL);
    while (lo < hi) {
        long long mid = lo + (hi - lo) / 2;
        int p = piecesNeeded(a, mid);
        if (trace) cout << "lo=" << lo << " hi=" << hi << " mid=" << mid << " pieces=" << p
                        << " -> " << (p <= k ? "feasible" : "too small") << '\n';
        if (p <= k) hi = mid;          // mid works, so answer <= mid
        else lo = mid + 1;             // mid fails, so answer > mid
    }
    return lo;
}

int main() {
    vector<long long> a = {7, 2, 5, 10, 8};
    long long ans = splitArray(a, 2, true);          // trace lines print first
    cout << "answer = " << ans << '\n';
    cout << "k = 1..5:";
    for (int k = 1; k <= 5; ++k) cout << ' ' << splitArray(a, k, false);
    cout << '\n';
}

আউটপুট:

lo=10 hi=32 mid=21 pieces=2 -> feasible
lo=10 hi=21 mid=15 pieces=3 -> too small
lo=16 hi=21 mid=18 pieces=2 -> feasible
lo=16 hi=18 mid=17 pieces=3 -> too small
answer = 18
k = 1..5: 32 18 14 10 10

চারটি লাইন ঠিক ওপরের ডায়াগ্রামের চারটি যাচাই। উত্তরটি আগে ans-এ রাখা হয়েছে ইচ্ছা করে: cout << "answer = " << splitArray(...) লিখলে C++17 থেকে বাঁ থেকে ডানে চলার নিয়মে “answer = ” আগে ছাপা হতো, তারপর trace-এর লাইনগুলো — আউটপুট এলোমেলো দেখাত। শেষ লাইনটিও যাচাই করে নিন: k = 1-এ পুরো অ্যারে (32); k = 3-এ [7, 2, 5], [10], [8] — বড়টি 14; k = 4 আর 5-এ উত্তর 10, কারণ 10-এর চেয়ে নিচে কখনো যাওয়া যায় না (lo = max). উত্তর k বাড়ার সাথে কখনো বাড়ে না — এটিও একমুখিতা। সময়: প্রতিটি যাচাই Θ(n), যাচাই হয় ⌈log₂(sum − max + 1)⌉ বারের মতো, তাই মোট O(n log S), S = sum(a); বাড়তি জায়গা O(1). যোগফল long long-এ, কারণ n = 105 আর প্রতিটি মান 109 হলে sum 1014.

def pieces_needed(a, cap):            # greedy: fewest pieces, each sum <= cap
    pieces, cur = 1, 0
    for x in a:
        if cur + x > cap:                 # x does not fit: start a new piece
            pieces, cur = pieces + 1, 0
        cur += x
    return pieces

def split_array(a, k, trace=False):
    lo, hi = max(a), sum(a)               # the answer is somewhere in [lo, hi]
    while lo < hi:
        mid = lo + (hi - lo) // 2
        p = pieces_needed(a, mid)
        if trace:
            print(f"lo={lo} hi={hi} mid={mid} pieces={p} ->",
                  "feasible" if p <= k else "too small")
        if p <= k:
            hi = mid                      # mid works, so answer <= mid
        else:
            lo = mid + 1                  # mid fails, so answer > mid
    return lo

a = [7, 2, 5, 10, 8]
print("answer =", split_array(a, 2, trace=True))
print("k = 1..5:", *(split_array(a, k) for k in range(1, 6)))

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

lo=10 hi=32 mid=21 pieces=2 -> feasible
lo=10 hi=21 mid=15 pieces=3 -> too small
lo=16 hi=21 mid=18 pieces=2 -> feasible
lo=16 hi=18 mid=17 pieces=3 -> too small
answer = 18
k = 1..5: 32 18 14 10 10

Python-এ (hi - lo) // 2 লেখার দরকার ছিল না — integer-এ overflow নেই, (lo + hi) // 2-ও চলত। তবু একই রূপ রাখা ভালো অভ্যাস, যাতে C++, Java বা Go-তে লেখার সময় হাত ভুল না করে। // হলো পূর্ণসংখ্যার ভাগ; ভুলে / লিখলে mid দশমিক হয়ে যায়। Python 3.10 থেকে এমনকি lo + bisect.bisect_left(range(lo, hi), True, key=lambda x: pieces_needed(a, x) <= k) দিয়েও প্রথম T খোঁজা যায় (bisect সূচক দেয়, তাই lo যোগ), কারণ range পুরো তালিকা বানায় না।

একই ছাঁচ: D দিনে জাহাজের মাল

চট্টগ্রাম বন্দর থেকে একটি জাহাজ প্রতিদিন একবার যায়। কন্টেইনারগুলো লাইনে দাঁড়ানো, ক্রম বদলানো যায় না: ওজন [3, 2, 2, 4, 1, 4]. D = 3 দিনে সব পাঠাতে জাহাজের সর্বনিম্ন ধারণক্ষমতা কত? এটি হুবহু split array: ধারণক্ষমতা = টুকরোর সীমা, দিন = টুকরো। lo = 4 (সবচেয়ে ভারী কন্টেইনার), hi = 16 (সব একদিনে)। যাচাই হয় 10 (T), 7 (T), 5 (F, লাগে ৪ দিন), 6 (T: [3, 2], [2, 4], [1, 4]) — উত্তর 6. সমস্যার গল্প যতই বদলাক, গঠনটি চিনুন: পাশাপাশি টুকরো, প্রতিটির যোগফলে সীমা, টুকরোর সংখ্যা সীমিত।

৪ · সর্বনিম্নকে সর্বোচ্চ করা — aggressive cows আর হাইওয়ের টাওয়ার

এবার উল্টো ধরনের প্রশ্ন: “maximise the minimum”. একটি মোবাইল অপারেটর (ধরুন Grameenphone) ঢাকা–চট্টগ্রাম হাইওয়ের পাশে কয়েকটি নির্দিষ্ট জায়গায় টাওয়ার বসাতে পারে — জমি পাওয়া গেছে কিলোমিটার চিহ্ন 1, 2, 4, 8 আর 9-এ। বসাতে হবে c = 3টি টাওয়ার। পাশাপাশি দুটি টাওয়ার খুব কাছে হলে সংকেত একে অপরকে বিরক্ত করে, তাই দুটি টাওয়ারের সবচেয়ে ছোট দূরত্বটি যত বড় সম্ভব রাখতে হবে। কনটেস্টে এই সমস্যা “aggressive cows” নামে বিখ্যাত — সেখানে রাগী গরুদের গোয়ালের খোপে যতদূর সম্ভব আলাদা রাখতে হয়।

সিদ্ধান্তের প্রশ্ন: “প্রতিটি জোড়ার দূরত্ব অন্তত d রেখে কি c টি টাওয়ার বসানো যায়?” greedy যাচাই: জায়গাগুলো সাজিয়ে নিন; প্রথম টাওয়ার প্রথম জায়গায়; তারপর বাঁ থেকে ডানে যে প্রথম জায়গাটি শেষ টাওয়ার থেকে অন্তত d দূরে, সেখানে পরেরটি। এখানে predicate উল্টো একমুখী: d দিয়ে হলে d − 1 দিয়েও হবে (একই বিন্যাস চলবে), তাই T…T F…F, আর আমরা চাই শেষ T — অংশ ২-এর LAST-TRUE, ওপরের মধ্যকসহ।

সীমা: d = 0 সবসময় সম্ভব (lo = 0). আর c টি টাওয়ার পুরো দৈর্ঘ্য L = সর্বোচ্চ − সর্বনিম্ন-এর ওপর বসলে c − 1 টি ফাঁক তৈরি হয়, যাদের যোগফল ≤ L; তাই সবচেয়ে ছোট ফাঁক ≤ L / (c − 1) — গড়ের চেয়ে সবচেয়ে ছোটটি বড় হতে পারে না। অতএব hi = ⌊L / (c − 1)⌋ = ⌊8 / 2⌋ = 4. (c = 1 হলে প্রশ্নটি অর্থহীন — কোনো জোড়াই নেই — তাই কোডে c ≥ 2 ধরে নেওয়া।)

এই ধাপের হিসাব
lo, hi (আগে)
mid = d
বসানো গেল
সিদ্ধান্ত
হাইওয়েo = খালি জায়গা, T = টাওয়ার, - = জমি নেই

                
  1. সীমা: lo = 0, hi = 4. জায়গা সাজানো: 1, 2, 4, 8, 9; দৈর্ঘ্য L = 8, ফাঁক c − 1 = 2টি, তাই ছোট ফাঁক 4-এর বেশি হতে পারে না। চাই শেষ T, তাই ওপরের মধ্যক।
  2. d = 2. প্রথম টাওয়ার km 1-এ। km 2 মাত্র ১ দূরে — বাদ। km 4 (৩ দূরে) — বসানো হলো। km 8 (৪ দূরে) — বসানো হলো। ৩টি হয়ে গেল — T, lo = 2. এখানে নিচের মধ্যকও 2 হতো, কিন্তু শেষ দিকে (lo = 3, hi = 4) পার্থক্যটি জরুরি: নিচের মধ্যক 3 দিত, আর lo = mid-এ লুপ আটকে যেত।
  3. d = 3. km 1, তারপর অন্তত km 4 লাগবে — km 4-এ বসানো হলো; তারপর অন্তত km 7 — km 8-এ বসানো হলো। ৩টি — T, lo = 3.
  4. d = 4. km 1, তারপর অন্তত km 5 — প্রথম যোগ্য জায়গা km 8; তারপর অন্তত km 12 — নেই। মাত্র ২টি — F, hi = 3.
  5. উত্তর 3. lo = hi = 3: টাওয়ার km 1, 4, 8-এ, ফাঁক 3 আর 4, সবচেয়ে ছোট 3. 4-এ F সরাসরি দেখা, তাই এর চেয়ে ভালো বিন্যাস নেই। সাজানো O(n log n), প্রতিটি যাচাই O(n).

greedy যাচাই কেন সঠিক? ধরুন দূরত্ব d রেখে কোনো বৈধ বিন্যাস OPT আছে, c টি টাওয়ারসহ। greedy-র টাওয়ারগুলো g₁ < g₂ < … আর OPT-এর o₁ < o₂ < …. আরোহ (induction) দিয়ে দেখাই gᵢ ≤ oᵢ. i = 1: g₁ সবচেয়ে বাঁয়ের জায়গা, তাই g₁ ≤ o₁. i − 1 থেকে i: oᵢ ≥ oᵢ₋₁ + d ≥ gᵢ₋₁ + d, তাই oᵢ নিজেই greedy-র জন্য একটি যোগ্য জায়গা; greedy সবচেয়ে বাঁয়ের যোগ্য জায়গা নেয়, তাই gᵢ ≤ oᵢ, আর greedy-র কাছে জায়গা ফুরায় না। অর্থাৎ OPT c টি বসাতে পারলে greedy-ও পারে — একটি exchange-ধাঁচের “এগিয়ে থাকা” যুক্তি। আর একমুখিতা: d-তে বৈধ বিন্যাস d − 1-এও বৈধ।

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

bool canPlace(const vector<long long>& pos, int c, long long d) {
    int placed = 1;
    long long last = pos[0];                         // first tower at the first site
    for (size_t i = 1; i < pos.size(); ++i)
        if (pos[i] - last >= d) { ++placed; last = pos[i]; }   // earliest site that fits
    return placed >= c;
}

long long bestGap(vector<long long> pos, int c, bool trace) {   // needs c >= 2
    sort(pos.begin(), pos.end());
    long long lo = 0, hi = (pos.back() - pos.front()) / (c - 1);
    while (lo < hi) {
        long long mid = lo + (hi - lo + 1) / 2;      // upper middle: lo = mid must move
        bool ok = canPlace(pos, c, mid);
        if (trace) cout << "lo=" << lo << " hi=" << hi << " mid=" << mid
                        << (ok ? " -> fits" : " -> does not fit") << '\n';
        if (ok) lo = mid; else hi = mid - 1;
    }
    return lo;
}

int main() {
    vector<long long> km = {1, 2, 8, 4, 9};          // possible tower sites, unsorted
    long long g = bestGap(km, 3, true);              // trace lines print first
    cout << "best gap for 3 towers = " << g << '\n';
    for (int c = 2; c <= 5; ++c)
        cout << "towers=" << c << " best gap=" << bestGap(km, c, false) << '\n';
}

আউটপুট:

lo=0 hi=4 mid=2 -> fits
lo=2 hi=4 mid=3 -> fits
lo=3 hi=4 mid=4 -> does not fit
best gap for 3 towers = 3
towers=2 best gap=8
towers=3 best gap=3
towers=4 best gap=1
towers=5 best gap=1

প্রথম তিন লাইন তৃতীয় ডায়াগ্রামের তিনটি যাচাই। ইনপুট ইচ্ছা করে এলোমেলো দেওয়া — bestGap নিজেই সাজায়, কারণ greedy-র প্রমাণ সাজানো ক্রমের ওপর দাঁড়িয়ে। ২টি টাওয়ারে দুই প্রান্ত, km 1 আর 9 — দূরত্ব 8. ৪টিতে উত্তর 1: পাঁচটি জায়গা থেকে চারটি নিলে মাত্র একটি বাদ যায়, অথচ km 1–2 আর km 8–9 — দুটি জোড়াতেই ফাঁক 1; একটি জায়গা বাদ দিয়ে দুটি জোড়া একসাথে ভাঙা যায় না, তাই ছোট ফাঁক 2 কোনো বিন্যাসেই হয় না। ৫টি টাওয়ার মানে সব জায়গা নেওয়া, ছোট ফাঁক 2 − 1 = 1. মোট সময় O(n log n + n log L): একবার সাজানো, তারপর O(log L) টি যাচাই, প্রতিটি O(n); বাড়তি জায়গা O(n) — শুধু সাজানো কপির জন্য।

৫ · lo আর hi বাছাই, complexity আর overflow

template-টি নিজে প্রায় কখনো ভুল হয় না; ভুল হয় সীমায়। দুটি নিয়ম মানুন। lo: উত্তর এর নিচে হতেই পারে না, এমন একটি মান — প্রয়োজনে একটু ঢিলেঢালা নিন, শুধু বেশি নয়। hi: এমন একটি মান যেখানে check নিশ্চিতভাবে T (FIRST-TRUE-এ), আর সেটি প্রমাণ করতে পারতে হবে। ঢিলেঢালা সীমায় খরচ সামান্য — পরিসর দ্বিগুণ হলে মাত্র একটি বাড়তি ঘূর্ণন। কিন্তু ভুল দিকে টাইট সীমা দিলে উত্তর পরিসরের বাইরে পড়ে, আর template নিঃশব্দে একটি ভুল প্রান্ত ফেরত দেয়।

সমস্যাcheck(x)lohiমোট সময়
খাতা দেখা / KokoΣ⌈pᵢ / x⌉ ≤ h1max(p)O(n log max p)
Split array / বই ভাগ / জাহাজgreedy টুকরো ≤ kmax(a)sum(a)O(n log S)
Aggressive cows / টাওয়ারgreedy বসানো ≥ c0L / (c − 1)O(n log n + n log L)
k জন কর্মীর সর্বনিম্ন সময়Σ⌊T / tᵢ⌋ ≥ m1min(t) · mO(n log(min t · m))
গুণের নামতায় k-তম ছোটcount(≤ x) ≥ k1m · nO(m log(mn))
মোট সময় = O(log(hi − lo) × cost(check))    বাড়তি জায়গা = check-এর জায়গা

লক্ষ করুন, log-এর ভেতরে n নয়, আছে উত্তরের পরিসর। hi = 1018 হলেও log₂ 1018 ≈ 60 — মাত্র ৬০টি যাচাই। তাই এই কৌশল অনেক সময় বিশাল সংখ্যার পরিসরেও নির্ভয়ে চলে; আসল খরচ check-এর ভেতরে।

k জন কর্মী আর overflow-এর ফাঁদ

একটি চায়ের দোকানে তিনজন কারিগর: একজন ২ মিনিটে এক কাপ চা বানান, আরেকজন ৩ মিনিটে, তৃতীয়জন ৭ মিনিটে। সবাই একসাথে কাজ করেন। ১০ কাপ চা হতে সর্বনিম্ন কত মিনিট? T মিনিটে কারিগর i বানান ⌊T / tᵢ⌋ কাপ, তাই check(T): Σ⌊T / tᵢ⌋ ≥ 10. T বাড়লে কাপ কমে না — একমুখী। hi = সবচেয়ে দ্রুত কারিগর একা সব বানালে = 2 × 10 = 20. T = 11-এ 5 + 3 + 1 = 9 কাপ (F), T = 12-এ 6 + 4 + 1 = 11 কাপ (T) — উত্তর 12 মিনিট। এই একই গঠন আসে কারখানার মেশিন, ATM বুথ, বা অনলাইন জাজের একাধিক সার্ভারে কাজ ভাগে।

check-এর ভেতরে overflow. কনটেস্টে প্রায়ই n = 105 কর্মী, tᵢ ≤ 109, m ≤ 109 — তখন hi = 1018-এর কাছাকাছি। mid ≈ 1018 আর tᵢ = 1 হলে প্রতিটি ⌊mid / tᵢ⌋ ≈ 1018, আর n টি যোগ করলে 64-bit-এর সীমা (প্রায় 9.2 × 1018) ছাড়িয়ে যায় — integer overflow, ভুল “F”, ভুল উত্তর। প্রতিকার: যোগফল m ছুঁলেই লুপ থামিয়ে T ফেরত দিন (if (sum >= m) return true;). আর mid সবসময় lo + (hi - lo) / 2 — (lo + hi) / 2-এ দুটি বড় সংখ্যার যোগ নিজেই overflow করতে পারে (পর্ব ১৮).

৬ · গণনা দিয়ে k-তম ছোট — গুণের নামতা আর সাজানো ম্যাট্রিক্স

উত্তরের ওপর binary search শুধু অপটিমাইজেশনে নয়, “k-তম ছোট মান কত?” প্রশ্নেও কাজ করে — যখন সব মান লিখে সাজানো খুব ব্যয়বহুল। উদাহরণ: m × n গুণের নামতা, ঘর (i, j)-এ i × j. m = n = 3 × 104 হলে ৯০ কোটি ঘর — সাজানো অসম্ভব। কিন্তু প্রশ্ন “x-এর সমান বা ছোট কয়টি ঘর?” খুব সহজ: সারি i-তে i, 2i, 3i, … — এদের মধ্যে x-এর সমান বা ছোট ⌊x / i⌋ টি, তবে n-এর বেশি নয়। তাই

count(x) = Σi = 1..m min(n, ⌊x / i⌋)    উত্তর = count(x) ≥ k এমন প্রথম x

count(x) x বাড়লে কমে না, তাই predicate “count(x) ≥ k” একমুখী, আর প্রথম T-ই k-তম ছোট মান। 3 × 3 নামতায় k = 5 দেখুন ([ ] মানে ঘরটি x-এর সমান বা ছোট):

এই ধাপের হিসাব (k = 5)
lo, hi (আগে)
mid = x
count(x)
সিদ্ধান্ত
নামতা আর প্রতি সারির গণনা[ ] = x-এর সমান বা ছোট

                
  1. সীমা। সবচেয়ে ছোট ঘর 1, সবচেয়ে বড় m × n = 9. count(9) = 9 ≥ 5, তাই hi = 9-এ check নিশ্চিত T. নামতাটি কখনো পুরো বানাতে হবে না — এখানে শুধু দেখানোর জন্য।
  2. x = 5. সারি 1: min(3, 5) = 3; সারি 2: min(3, ⌊5/2⌋) = 2; সারি 3: min(3, ⌊5/3⌋) = 1. মোট 6 ≥ 5 — T, hi = 5.
  3. x = 3. 3 + ⌊3/2⌋ + ⌊3/3⌋ = 3 + 1 + 1 = 5 ≥ 5 — T, hi = 3. লক্ষ করুন, 3 আর 4-এর মাঝে ঠিক এখানেই গণনা ৫ ছোঁয়।
  4. x = 2. min(3, 2) + ⌊2/2⌋ + ⌊2/3⌋ = 2 + 1 + 0 = 3 < 5 — F, lo = 3.
  5. উত্তর 3. সাজানো তালিকা 1, 2, 2, 3, 3, 4, 6, 6, 9 — ৫ম মান 3, মিলে গেল। প্রতিটি count O(m), যাচাই O(log(mn)) বার — মোট O(m log(mn)), বাড়তি জায়গা O(1).

উত্তরটি নামতায় আছেই তো? binary search পূর্ণসংখ্যার ওপর চলছে, নামতার মানের ওপর নয় — তাহলে 5-এর মতো একটি মান, যা নামতায় নেই, উত্তর হতে পারে কি? না। ধরুন প্রথম T হলো x*. তাহলে count(x*) ≥ k কিন্তু count(x* − 1) < k, অর্থাৎ count(x*) − count(x* − 1) ≥ 1 — ঠিক x* মানের অন্তত একটি ঘর আছে। প্রথম T সবসময় এমন একটি জায়গা, যেখানে গণনা লাফ দেয়, আর গণনা লাফ দেয় শুধু নামতার মানে। এই যুক্তিটি “k-তম ছোট” ধাঁচের সব সমস্যায় খাটে।

সাজানো ম্যাট্রিক্সে k-তম ছোট। একটি n × n ম্যাট্রিক্সের প্রতিটি সারি আর কলাম বাড়তি ক্রমে সাজানো। count(x) এখানে একটি “সিঁড়ি” হাঁটায় O(n)-এ: নিচের-বাঁ কোণা থেকে শুরু করুন; ঘরটি ≤ x হলে সেই কলামের ওপরের সব ঘরও ≤ x — গণনায় (সারি + 1) যোগ করে ডানে যান; না হলে ওপরে যান। মোট O(n) ধাপ, তাই পুরো সমাধান O(n log(max − min)). কোনো heap বা পুরো সাজানো লাগে না। একই ভাবনা “দুটি সাজানো অ্যারের জোড়া-যোগফলের k-তম ছোট” বা “k-তম ছোট জোড়া-দূরত্ব” জাতীয় সমস্যায়ও চলে — গণনাটি two pointer দিয়ে (পর্ব ১৬).

৭ · দশমিক উত্তর — নির্দিষ্টসংখ্যক ঘূর্ণন আর সর্বোচ্চ গড়

কিছু সমস্যার উত্তর পূর্ণসংখ্যা নয়: একটি বর্গমূল, একটি গতি, একটি গড়। তখন lo আর hi double, আর “প্রথম T” মানে একটি বাস্তব সংখ্যা, যেটি আমরা যতটা দরকার ততটা কাছ থেকে ঘিরে ফেলি। template-এ দুটি বদল: mid = (lo + hi) / 2 (দশমিকে +1 নেই), আর lo = mid / hi = mid — কারণ দশমিকে “পরের মান” বলে কিছু নেই।

Pseudocode: real-valued answer, fixed number of iterations

REAL-FIRST-TRUE(lo, hi, check):        // check(hi) = true, monotone
    repeat 100 times:                  // NOT "while hi - lo > eps"
        mid = (lo + hi) / 2
        if check(mid): hi = mid
        else:          lo = mid
    return hi                          // hi - lo = (initial range) / 2^100

কেন while (hi − lo > eps) নয়? double-এ পাশাপাশি দুটি প্রতিনিধিত্বযোগ্য সংখ্যার মধ্যে একটি ন্যূনতম ফাঁক আছে, আর সেটি সংখ্যার আকারের সাথে বাড়ে: 109-এর কাছে সেই ফাঁক প্রায় 1.2 × 10−7. eps = 10−9 দিলে lo আর hi পাশাপাশি দুটি double-এ পৌঁছে যায়, mid হিসাব করলে lo বা hi-ই আসে, পরিসর আর কমে না — অসীম লুপ। নির্দিষ্ট ১০০ ঘূর্ণনে এই বিপদ নেই: প্রতি ঘূর্ণনে পরিসর অর্ধেক, ১০০ ঘূর্ণনে 2100 ভাগের এক ভাগ — double যতটা নির্ভুল হতে পারে তার চেয়ে অনেক বেশি। ঘূর্ণনসংখ্যা জানা থাকায় সময়ও নিশ্চিত: O(100 × cost(check)).

দৈর্ঘ্য অন্তত L, সর্বোচ্চ গড়ের subarray

একটি ক্লাসিক কঠিন সমস্যা: অ্যারে a আর একটি সংখ্যা L দেওয়া। দৈর্ঘ্য অন্তত L এমন কোন subarray-এর গড় সবচেয়ে বেশি? (ধরুন একটি দোকানের দৈনিক লাভ-ক্ষতির হিসাব থেকে “অন্তত ৪ দিনের টানা সময়ে সর্বোচ্চ গড় লাভ”।) সব subarray দেখলে Θ(n²). সিদ্ধান্তের প্রশ্নে বদলাই: “গড় অন্তত x এমন কোনো subarray (দৈর্ঘ্য ≥ L) আছে কি?”

এখানে একটি সুন্দর কৌশল: গড়(a[l..r]) ≥ x ⇔ Σ(a[i] − x) ≥ 0. অর্থাৎ প্রতিটি সংখ্যা থেকে x বাদ দিন, তারপর প্রশ্ন দাঁড়ায় “দৈর্ঘ্য ≥ L এমন কোনো subarray-এর যোগফল ≥ 0?” — পর্ব ১৭-এর prefix sum দিয়ে O(n)-এ: প্রতিটি শেষবিন্দু r-এর জন্য P[r + 1] − min(P[0..r + 1 − L]) ≥ 0 কি না দেখুন, আর চলতি সর্বনিম্নটি এক লুপেই রাখা যায়। x বাড়ালে সব বিয়োগফল কমে, তাই check(x) T…T F…F — চাই শেষ T. a = [1, 12, −5, −6, 50, 3], L = 4-এ উত্তর 12.75 (subarray [12, −5, −6, 50] = 51 / 4). মোট সময় O(n × ঘূর্ণন), যেমন ১০০ ঘূর্ণনে O(100 n).

Pseudocode (0-based): is there a subarray of length >= L with average >= x ?

CHECK-AVG(a, L, x):
    P[0] = 0
    for i = 0 to n - 1: P[i + 1] = P[i] + (a[i] - x)
    best_min = +infinity
    for r = L - 1 to n - 1:
        best_min = min(best_min, P[r + 1 - L])   // earliest allowed start prefix
        if P[r + 1] - best_min >= 0: return true
    return false
// lo = min(a), hi = max(a); LAST-TRUE on reals with 100 iterations

দশমিক এড়ানো গেলে এড়ান। উত্তরটি যদি দুটি পূর্ণসংখ্যার ভাগফল হয় (যেমন যোগফল / দৈর্ঘ্য), অনেক সময় “a / b ≥ x” তুলনাকে “a ≥ x · b” লিখে পূর্ণসংখ্যায় রাখা যায়। আর উত্তর যদি 10−6 নির্ভুলতায় ছাপতে হয়, পুরো সমস্যাকে 106 দিয়ে গুণ করে পূর্ণসংখ্যার binary search-ও একটি পথ — তবে check-এর ভেতরের হিসাবেও তখন একই স্কেল মানতে হবে।

৮ · predicate একমুখী না হলে — বিপদ চেনা, আর ternary search

পুরো কৌশলটি একটি শর্তের ওপর দাঁড়িয়ে: predicate একমুখী। শর্তটি না থাকলে binary search কোনো error দেয় না, ক্র্যাশও করে না — শুধু আত্মবিশ্বাসের সাথে একটি ভুল উত্তর দেয়। একটি উদাহরণ দেখুন। a = [5, −10, 5, 5], S = 5. প্রশ্ন: “সবচেয়ে বড় কোন দৈর্ঘ্য L-এর এমন একটি window আছে, যার যোগফল ≥ 5?” check(L) = “দৈর্ঘ্য ঠিক L এমন কোনো window-এর যোগফল ≥ 5”. কেউ ভাবল “ছোট window পারলে বড়টিও পারবে না” — অর্থাৎ T…T F…F — আর LAST-TRUE চালাল:

এই ধাপের হিসাব (S = 5)
lo, hi (আগে)
mid = L
সিদ্ধান্ত
প্রতিটি দৈর্ঘ্যের সেরা windowa = [5, −10, 5, 5]

                
  1. পুরো ছবি। L = 1: [5] = 5 — T. L = 2: [5, 5] = 10 — T. L = 3: −10 থাকেই, সেরা 0 — F. L = 4: পুরো অ্যারে 5 — T. সারি T T F T — একমুখী নয়, ঋণাত্মক −10-এর কারণে।
  2. mid = 3: F. LAST-TRUE ধরে নেয় 3-এর ডানের সবও F, তাই hi = 2. এক আঘাতে সঠিক উত্তর L = 4 বাদ পড়ে গেল — অ্যালগরিদম কখনো 4 যাচাই করবে না।
  3. mid = 2: T. lo = 2. এখন lo = hi = 2.
  4. ফল: 2, অথচ সঠিক 4. কোনো সতর্কবার্তা নেই। এই ছোট উদাহরণটিই একটি counterexample — একমুখিতার দাবি মিথ্যা প্রমাণ করতে একটি ইনপুটই যথেষ্ট। (সব সংখ্যা অঋণাত্মক হলে উল্টোটি সত্য: দৈর্ঘ্য বাড়ালে সেরা যোগফল কমে না, predicate F…F T…T — তখন “সবচেয়ে ছোট L” প্রশ্নে binary search বৈধ।)

একমুখিতা কীভাবে যাচাই করবেন

  • প্রমাণের চেষ্টা: “x-এ একটি বৈধ সমাধান থাকলে x + 1-এও আছে” — এক বাক্যে দেখান কীভাবে x-এর সমাধানটিকে x + 1-এর সমাধানে বদলানো যায় (খাতা দেখায় একই সময়সূচি দ্রুত গতিতে আরও আগে শেষ হয়; টাওয়ারে d-এর বিন্যাস d − 1-এও বৈধ)। এই রূপান্তরটি লিখতে না পারলে সন্দেহ করুন।
  • brute force দিয়ে সারি ছাপান: ছোট random ইনপুটে সব x-এর জন্য check(x) ছাপুন আর দেখুন F থেকে T-তে একবারের বেশি বদল হয় কি না — কয়েকশো ইনপুটে একটিও T F T বা F T F ধরা পড়লে সেটিই counterexample. এটি stress testing-এর সবচেয়ে সস্তা রূপ।
  • সতর্ক সংকেত: ঋণাত্মক সংখ্যা, “ঠিক সমান” (= k, ≥ নয়) ধরনের শর্ত, আর দুই দিক থেকে টানা শর্ত (“যথেষ্ট বড় কিন্তু খুব বড় নয়”). শেষেরটি প্রায়ই একমুখী নয়, বরং “এক চূড়ার” — তখন ternary search ভাবুন।

Ternary search — এক চূড়ার ফাংশনে

কিছু ফাংশন প্রথমে বাড়ে, তারপর কমে — একটিমাত্র চূড়া। যেমন একটি দোকানের দাম p ধরলে লাভ: দাম খুব কম হলে লাভ নেই, খুব বেশি হলে ক্রেতা নেই, মাঝে কোথাও সর্বোচ্চ। এমন ফাংশনকে বলে unimodal function. এর সর্বোচ্চ বিন্দু খুঁজতে ternary search: পরিসরের এক-তৃতীয়াংশ আর দুই-তৃতীয়াংশে দুটি বিন্দু m₁ < m₂ নিন। f(m₁) < f(m₂) হলে চূড়া m₁-এর বাঁয়ে হতে পারে না — lo = m₁; না হলে চূড়া m₂-এর ডানে হতে পারে না — hi = m₂. প্রতি ধাপে পরিসরের এক-তৃতীয়াংশ বাদ, তাই O(log n) ধাপ (ভিত্তি 3/2).

Pseudocode: maximum of a strictly unimodal f on integers [lo, hi]

TERNARY-MAX(lo, hi, f):
    while hi - lo > 2:
        m1 = lo + (hi - lo) / 3
        m2 = hi - (hi - lo) / 3
        if f(m1) < f(m2): lo = m1 + 1     // peak is right of m1
        else:             hi = m2         // peak is at or left of m2
    return the best of f(lo), ..., f(hi)  // at most 3 values

// Often simpler on integers: FIRST-TRUE on check(x) = f(x) >= f(x + 1)

পূর্ণসংখ্যায় আরও পরিষ্কার একটি পথ: “f(x) ≥ f(x + 1)?” predicate-টি এক চূড়ার ফাংশনে F…F T…T — চূড়ার আগে ফাংশন বাড়ছে (F), চূড়া থেকে কমছে (T). তাই সাধারণ FIRST-TRUE-ই চূড়া দেয়, আর আমাদের পরিচিত invariant কাজে লাগে। সাবধান: ফাংশনে সমতল অংশ থাকলে (একাধিক x-এ একই মান, চূড়ার বাইরে) দুটি পদ্ধতিই ভুল দিকে যেতে পারে — ternary search চায় কঠোরভাবে বাড়া আর কঠোরভাবে কমা।

আরও গভীরে: উত্তরের ওপর binary search-এর তাত্ত্বিক রূপের নাম parametric search. Nimrod Megiddo ১৯৮৩ সালে দেখান, যদি সিদ্ধান্তের অ্যালগরিদমটি (check) নিজেই সমান্তরালে চালানো যায়, তাহলে উত্তরের সংখ্যা-পরিসরের ওপর অন্ধভাবে binary search না করে check-এর ভেতরের তুলনাগুলোকে একসাথে সমাধান করে সঠিক বাস্তব-সংখ্যার উত্তর বের করা যায় — log(range) গুণকটি না দিয়ে। কনটেস্টে এর একটি আত্মীয় হলো “Aliens trick” বা Lagrangian relaxation: “ঠিক k টুকরো” শর্ত সরিয়ে প্রতি টুকরোয় একটি জরিমানা λ বসান, আর λ-এর ওপর binary search করুন — শর্ত হলো খরচটি k-এর উত্তল (convex) ফাংশন হওয়া।

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

  • উত্তরের ওপর binary search কখন চলে? — যখন সম্ভাব্য উত্তর একটি সাজানো পরিসরে, যাচাই দ্রুত, আর যাচাইয়ের ফল একমুখী (F…F T…T বা উল্টো).
  • Split array largest sum-এর complexity? — O(n log S), S = অ্যারের যোগফল; প্রতিটি greedy যাচাই O(n), যাচাই O(log S) বার।
  • lo = mid শাখায় mid কেন ওপরের মধ্যক? — নিচের মধ্যকে hi = lo + 1 হলে mid = lo, পরিসর বদলায় না, অসীম লুপ।
  • Aggressive cows-এ greedy কেন সঠিক? — “এগিয়ে থাকা” যুক্তি: greedy-র i-তম টাওয়ার যেকোনো বৈধ বিন্যাসের i-তম টাওয়ারের বাঁয়ে বা একই জায়গায়, তাই জায়গা ফুরায় না।
  • দশমিক উত্তরে loop কীভাবে থামাবেন? — নির্দিষ্ট ঘূর্ণন (যেমন ১০০); eps-ভিত্তিক শর্ত double-এর ফাঁকের কারণে অসীম লুপ হতে পারে।

৯ · AI-এর যুগে উত্তরের ওপর binary search — template সহজ, প্রমাণ কঠিন

“Split array largest sum সমাধান করো” লিখলে যেকোনো AI কয়েক সেকেন্ডে সঠিক কোড দেয় — সমস্যাটি অত্যন্ত পরিচিত। কিন্তু যখন সমস্যাটি নতুন গল্পে আসে, AI-এর দুটি দুর্বলতা দেখা দেয়। প্রথমত, এটি প্রায়ই predicate একমুখী কি না প্রমাণ না করেই binary search বসিয়ে দেয় — দেখতে মসৃণ, চলেও, শুধু কিছু ইনপুটে ভুল। দ্বিতীয়ত, সীমা আর মধ্যকের খুঁটিনাটিতে off-by-one ভুল: lo = mid-এর সাথে নিচের মধ্যক, hi এমন মান যেখানে check F, বা check-এর ভেতরে overflow. আর complexity-তে প্রায়ই “O(n log n)” লিখে দেয়, যেখানে আসল উত্তর O(n log S).

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

AI-এর ভুলকীভাবে ধরবেন
একমুখিতা প্রমাণ ছাড়াই binary searchছোট ইনপুটে সব x-এর check ছাপুন — T F T আছে কি না
lo = mid-এর সাথে (lo + hi) / 2hi = lo + 1 আর check(lo) = T — দুই উপাদানের পরিসরে হাতে চালান
hi-তে check নিশ্চিত T নয় (যেমন Koko-তে H < বান্ডিলের সংখ্যা)“অসম্ভব” এমন একটি ইনপুট দিন — প্রোগ্রাম কী বলে
hi খুব ছোট (split array-তে hi = max(a) বা sum / k)k = 1 দিন — উত্তর পুরো যোগফল আসে কি না
check-এর যোগফল int-এ বা আগে-থামা ছাড়াhi ≈ 1018, tᵢ = 1 এমন বড় ইনপুট
greedy check-এর শুদ্ধতা না দেখানো, বা অসাজানো ইনপুটে চালানোএলোমেলো ক্রমের ইনপুট; brute force-এর সাথে মেলান
দশমিকে while (hi - lo > 1e-9)উত্তর 109-এর কাছে এমন ইনপুট — প্রোগ্রাম শেষ হয় কি না

AI-কে সমাধান লিখতে না বলে বরং আপনার নিজের ভাবনা পরীক্ষা করাতে ব্যবহার করুন:

আমি DSA শিখছি, এখন উত্তরের ওপর binary search. নিচে একটি সমস্যা আর আমার পরিকল্পনা দিলাম: [সমস্যার বিবরণ] [আমার check(x), lo, hi আর কোড] পুরো সমাধান নতুন করে লিখে দিও না। শুধু এগুলো করো: ১. আমার check(x) কি সত্যিই একমুখী? "x-এ হলে x + 1-এও হবে" ধাঁচের একটি প্রমাণ দাও, অথবা একটি ছোট counterexample দাও। ২. lo-তে উত্তর এর নিচে হতে পারে না আর hi-তে check নিশ্চিত true — দুটিই যুক্তিসহ যাচাই করো। ৩. আমার মধ্যক আর lo/hi আপডেটের জোড়া কি অসীম লুপ ঘটাতে পারে? hi = lo + 1 দিয়ে হাতে চালিয়ে দেখাও। ৪. ৫টি edge case দাও (অসম্ভব ইনপুট, এক উপাদান, সব সমান, সর্বোচ্চ মান/overflow, এলোমেলো ক্রম) আর Python-এ একটি brute force ও random test generator লিখে দাও। ৫. মোট complexity কত — log-এর ভেতরে কী আর check-এর খরচ কত, আলাদা করে।

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

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

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

প্র ০১Binary search এত সরল, তবু দক্ষ প্রোগ্রামাররাও কেন এটি বারবার ভুল লেখেন? একটি নির্দিষ্ট template আর invariant কীভাবে সাহায্য করে?

Binary search-এর ধারণা কম্পিউটারের প্রায় শুরু থেকেই আছে। Donald Knuth তাঁর The Art of Computer Programming-এর তৃতীয় খণ্ডে উল্লেখ করেছেন, ১৯৪৬ সালে Moore School-এর বিখ্যাত বক্তৃতামালায় John Mauchly সাজানো তালিকায় এভাবে খোঁজার কথা বলেছিলেন। অথচ Knuth-এর হিসাবে সব n-এর জন্য নির্ভুলভাবে কাজ করে এমন একটি binary search প্রথম প্রকাশিত হয় ১৯৬০ সালে, D. H. Lehmer-এর কাজে — অর্থাৎ ধারণা থেকে নির্ভুল কোডে পৌঁছাতে প্রায় চৌদ্দ বছর।

Jon Bentley তাঁর Programming Pearls বইয়ে একটি পরীক্ষার কথা লিখেছেন: পেশাদার প্রোগ্রামারদের কয়েক ঘণ্টা সময় দিয়ে binary search লিখতে বলা হয়েছিল, আর পরীক্ষা করে দেখা গেল তাঁদের মাত্র প্রায় দশ শতাংশের কোড সঠিক। তারপর ২০০৬ সালে Joshua Bloch দেখান, Java-র নিজস্ব লাইব্রেরির binary search-এ (low + high) / 2 বড় অ্যারেতে overflow করে — বইয়ে ছাপা প্রমাণিত কোডেও বছরের পর বছর ধরে লুকানো ভুল।

ভুলগুলোর উৎস প্রায় সবসময় একই: সীমার অর্থ অস্পষ্ট। hi কি শেষ প্রার্থী, নাকি শেষের পরের ঘর? লুপ < না <=? mid-কে রাখব, না বাদ দেব? প্রতিটি সিদ্ধান্ত আলাদাভাবে ঠিক মনে হয়, কিন্তু একসাথে মেলে না — ফল off-by-one বা অসীম লুপ। এর প্রতিকার হলো সিদ্ধান্তগুলো একটি বাক্য থেকে বের করা: “উত্তর সবসময় [lo, hi]-এ, আর hi-তে check true.” এই invariant ঠিক করে দিলে বাকি সব যান্ত্রিক — check true হলে mid উত্তর হতে পারে, তাই hi = mid; false হলে পারে না, তাই lo = mid + 1; lo = hi হলে একটিই প্রার্থী, তাই থামা; আর hi = mid শাখার জন্য mid যেন hi না হয়, তাই নিচের মধ্যক।

উত্তরের ওপর binary search এই শৃঙ্খলাকে আরও জরুরি করে, কারণ এখানে কোনো অ্যারে নেই যেটি দেখে উত্তর মেলানো যায় — শুধু একটি predicate. যিনি “প্রথম true” নামের একটি template রাখেন আর প্রতিটি সমস্যাকে সেই আকারে আনেন, তাঁর কাছে Koko, split array, k-তম ছোট মান সব একই কোড, শুধু check আলাদা। C++-এর std::partition_point আর Python-এর bisect-ও আসলে এই একই ধারণা: একটি বিভক্ত পরিসরে সীমানা খোঁজা।

মূল উপলব্ধি: binary search-এ ভুল আসে সীমার অস্পষ্ট অর্থ থেকে। একটি invariant লিখে নিন, প্রতিটি লাইন তা থেকে বের করুন, আর একটি template-ই সব সমস্যায় ব্যবহার করুন।

প্র ০২git bisect কীভাবে কোড ইতিহাসের ওপর একটি monotone predicate-এ binary search চালায়, আর predicate একমুখী না হলে সেখানে কী ঘটে?

সফটওয়্যার প্রকল্পে প্রায়ই এমন হয়: আজকের সংস্করণে একটি bug আছে, অথচ তিন মাস আগের সংস্করণে ছিল না। মাঝখানে হয়তো দুই হাজার commit. কোনটি bug ঢুকিয়েছে? Git-এর git bisect কমান্ড ঠিক এই প্রশ্নের উত্তর দেয়, আর এটি উত্তরের ওপর binary search-এর একটি নিখুঁত বাস্তব রূপ। search space হলো commit-গুলোর ক্রম, আর predicate: “এই commit-এ bug আছে কি?” আপনি একটি ভালো (good) আর একটি খারাপ (bad) commit বলে দেন; Git মাঝের commit-টি checkout করে, আপনি পরীক্ষা করে good বা bad বলেন, আর Git অর্ধেক বাদ দেয়। দুই হাজার commit-এ প্রায় এগারোটি পরীক্ষাতেই দোষী commit পাওয়া যায়।

এখানে লুকানো অনুমানটি হলো একমুখিতা: একবার bug ঢুকলে পরের সব commit-এ সেটি থাকে — ইতিহাসের ওপর F F F T T T. বেশিরভাগ সময় এটি সত্য, আর তাই কৌশলটি এত কার্যকর। git bisect run দিয়ে এমনকি পুরো প্রক্রিয়া স্বয়ংক্রিয় করা যায়: একটি test script দিন, যার exit code 0 মানে good আর অন্য বেশিরভাগ মান মানে bad; বিশেষ মান 125 মানে “এই commit পরীক্ষা করা যাচ্ছে না, বাদ দাও” (যেমন সেটি compile-ই হয় না)।

কিন্তু predicate একমুখী না হলে? ধরুন bug-টি একবার ঢুকে পরে একটি commit-এ কাকতালীয়ভাবে লুকিয়ে গিয়েছিল, তারপর আবার দেখা দিয়েছে — ইতিহাসে T F T. অথবা test-টি “flaky”: একই commit-এ কখনো পাস করে, কখনো ফেল (সময়, নেটওয়ার্ক বা এলোমেলো ক্রমের ওপর নির্ভর করে)। তখন bisect ঠিক এই পর্বের অংশ ৮-এর মতো আচরণ করে: কোনো error দেয় না, আত্মবিশ্বাসের সাথে একটি commit দেখিয়ে বলে “এটিই প্রথম খারাপ” — যেটি হয়তো সম্পূর্ণ নির্দোষ। অভিজ্ঞ ডেভেলপাররা তাই flaky test-কে কয়েকবার চালিয়ে ফল স্থির করেন, আর bisect-এর উত্তর পেলে সেই commit আর তার ঠিক আগেরটি আবার পরীক্ষা করে নিশ্চিত হন।

একই নীতি অন্যত্রও: কোন কম্পাইলার অপটিমাইজেশন একটি ভুল ঘটাচ্ছে, কোন ইনপুট-আকার থেকে প্রোগ্রাম ধীর হয়ে যায়, কোন সংস্করণে একটি লাইব্রেরি আচরণ বদলেছে — সবখানে “অর্ধেক করে খোঁজো”, যদি predicate একমুখী হয়।

মূল উপলব্ধি: binary search শুধু অ্যারের জিনিস নয় — যেকোনো সাজানো পরিসরে একমুখী হ্যাঁ/না প্রশ্নে চলে। আর একমুখিতা ভাঙলে এটি ভুল উত্তর দেয় নিঃশব্দে, তাই শেষে উত্তরের দুই পাশ সরাসরি যাচাই করুন।

প্র ০৩দশমিক উত্তরে binary search-এর পেছনে কোন পুরনো গণিত আছে, আর কম্পিউটারের double কেন একে বিপজ্জনক করে তোলে?

দশমিক উত্তরের binary search গণিতে “bisection method” নামে বহু পুরনো। একটি অবিচ্ছিন্ন ফাংশন f যদি a-তে ঋণাত্মক আর b-তে ধনাত্মক হয়, তাহলে মাঝে কোথাও f = 0 হবেই — এটি intermediate value theorem, যার একটি কঠোর প্রমাণ Bernard Bolzano ১৮১৭ সালে দেন। bisection সেই নিশ্চয়তাকে অ্যালগরিদমে বদলায়: মাঝের বিন্দুতে চিহ্ন দেখা হয়, আর যে অর্ধেকে চিহ্ন বদলায় সেটি রাখা হয়। predicate “f(x) ≥ 0” এখানে একমুখী না-ও হতে পারে, কিন্তু চিহ্ন-বদলের একটি জায়গা রাখার invariant-টিই যথেষ্ট — আমাদের “উত্তর [lo, hi]-এ” invariant-এর একটি আত্মীয়। প্রতিটি ধাপে ভুলের সীমা অর্ধেক, তাই এটি ধীর হলেও কখনো পথ হারায় না — Newton-এর পদ্ধতির মতো দ্রুত কিন্তু কখনো কখনো বিপথগামী পদ্ধতির বিপরীতে।

কম্পিউটারে সমস্যা আসে বাস্তব সংখ্যার প্রতিনিধিত্ব থেকে। ১৯৮৫ সালের IEEE 754 মান অনুযায়ী double-এর significand-এ ৫৩ bit, অর্থাৎ মোটামুটি ১৫–১৬টি দশমিক অঙ্কের নির্ভুলতা। সংখ্যা যত বড়, পাশাপাশি দুটি double-এর ফাঁকও তত বড়: 1-এর কাছে প্রায় 2.2 × 10−16, কিন্তু 109-এর কাছে 2−23 ≈ 1.2 × 10−7. তাই উত্তর যদি 109-এর কাছে হয় আর আপনি লেখেন while (hi - lo > 1e-9), শর্তটি কখনো মিথ্যা হবে না — lo আর hi পাশাপাশি দুটি double-এ আটকে যায়, mid তাদেরই একটি, পরিসর আর ছোট হয় না। প্রোগ্রাম সময়সীমা পেরিয়ে যায়, আর কোডে চোখে পড়ার মতো কোনো ভুল নেই।

কনটেস্টের প্রচলিত সমাধান তাই নির্দিষ্ট ঘূর্ণন: ৬০ থেকে ১০০ বার। double-এর ৫৩ bit নির্ভুলতা থাকায় ৬০–৭০ ঘূর্ণনের পর আর কোনো উন্নতি হয় না, কিন্তু বাড়তি ঘূর্ণন ক্ষতিও করে না। আরেকটি উপায় হলো আপেক্ষিক ভুলের শর্ত (hi − lo ≤ eps × max(1, |lo|)), যা সংখ্যার আকারের সাথে মানিয়ে নেয়। আর check-এর ভেতরেও দশমিকের ভুল জমে — যেমন সর্বোচ্চ গড়ের সমস্যায় প্রতিটি a[i] − x যোগ করতে গিয়ে — তাই প্রশ্নে যে নির্ভুলতা চাওয়া হয় (যেমন 10−6), তার চেয়ে অনেক ভালো নির্ভুলতায় হিসাব রাখুন।

মূল উপলব্ধি: bisection গণিতে নিখুঁত, কিন্তু double-এ “যত খুশি ছোট” পরিসর বলে কিছু নেই। ঘূর্ণনের সংখ্যা দিয়ে লুপ থামান, শর্ত দিয়ে নয়।

প্র ০৪check(x) যখন নিজেই একটি বড় অ্যালগরিদম — flow, matching বা DP — তখন উত্তরের ওপর binary search কতটা শক্তিশালী হয়? আর এর সীমা কোথায়?

এই পর্বের সব check ছিল একটি সরল greedy pass. কিন্তু কৌশলটির আসল শক্তি হলো, check যেকোনো অ্যালগরিদম হতে পারে — শর্ত শুধু একমুখিতা। একটি পরিচিত ধরন হলো “bottleneck” সমস্যা: n জন কর্মী আর n টি কাজ, প্রতিটি জোড়ার একটি সময় দেওয়া; প্রত্যেককে একটি কাজ দিন, যাতে সবচেয়ে দীর্ঘ সময়টি সর্বনিম্ন হয়। সিদ্ধান্তের প্রশ্ন: “শুধু সময় ≤ T এমন জোড়াগুলো ব্যবহার করে কি সবাইকে কাজ দেওয়া যায়?” — এটি একটি bipartite matching প্রশ্ন (পর্ব ৪৭)। T বাড়ালে জোড়া বাড়ে, matching ছোট হয় না — একমুখী। তাই সম্ভাব্য T-গুলো (মাত্র n² টি আলাদা সময়) সাজিয়ে তাদের ওপর binary search, প্রতি ধাপে একটি matching.

একইভাবে “প্রতিটি পাইপে প্রবাহের সীমা x হলে কি চাহিদা মেটে” — max flow; “দূরত্ব অন্তত d রেখে কি সব শর্ত মানা যায়” — অনেক সময় 2-SAT; “জরিমানা λ দিলে সর্বোত্তম সমাধানে কয়টি টুকরো” — DP. শেষেরটি কনটেস্টে “Aliens trick” নামে পরিচিত, IOI 2016-এর “Aliens” সমস্যা থেকে নামটি জনপ্রিয় হয়; চীনা প্রতিযোগীদের মধ্যে এটি WQS binary search নামেও চেনা। গণিতের ভাষায় এটি Lagrangian relaxation: কঠিন শর্তটিকে (ঠিক k টুকরো) খরচের ভেতরে জরিমানা হিসেবে ঢুকিয়ে দিন, আর জরিমানার ওপর binary search করুন। এটি কাজ করে শুধু যদি সর্বোত্তম খরচ k-এর একটি উত্তল ফাংশন হয় — আবারও একটি গঠনগত শর্ত, যা প্রমাণ করতে হয়।

সীমা দুটি। প্রথমত, log(range) গুণকটি সবসময় থাকে। check যদি নিজেই O(n³) হয়, ৬০ গুণ সেটি অনেক। তাত্ত্বিকভাবে Nimrod Megiddo ১৯৮৩ সালে parametric search দিয়ে দেখান, অনেক ক্ষেত্রে এই গুণক এড়িয়ে সঠিক উত্তর সরাসরি পাওয়া যায়, আর Richard Cole পরে কৌশলটিকে আরও দ্রুত করেন; তবে বাস্তবায়ন জটিল, তাই কনটেস্টে সাধারণ binary search-ই চলে। দ্বিতীয়ত, কিছু সমস্যার সরাসরি সমাধান আরও ভালো: split array-কে DP দিয়েও করা যায়, কিন্তু সরল DP O(k·n²) — binary search-এর O(n log S)-এর চেয়ে অনেক ধীর; উল্টোদিকে কিছু সমস্যায় একটি সূত্রই উত্তর দেয়, binary search অপ্রয়োজনীয়।

মূল উপলব্ধি: “সর্বোত্তম কত” প্রশ্নকে “এটি কি সম্ভব” প্রশ্নে নামাতে পারলে আপনি যেকোনো সিদ্ধান্ত-অ্যালগরিদমকে অপটিমাইজারে বদলাতে পারেন — দাম মাত্র একটি log গুণক, আর শর্ত একটি একমুখিতার প্রমাণ।

প্র ০৫Ternary search প্রতি ধাপে দুটি মান দেখে এক-তৃতীয়াংশ বাদ দেয়। এর চেয়ে কম মূল্যায়নে চূড়া খোঁজা যায় কি? Golden-section search কীভাবে আসে?

Ternary search-এর হিসাবটি একটু খুঁটিয়ে দেখুন। প্রতি ধাপে দুটি নতুন বিন্দুতে f মূল্যায়ন করা হয়, আর পরিসর ২/৩ গুণ হয়। তাই প্রতি একটি মূল্যায়নে পরিসর কমে √(2/3) ≈ 0.816 গুণ। তুলনায় binary search প্রতি একটি যাচাইয়ে 0.5 গুণ — অনেক দ্রুত। যখন f মূল্যায়ন ব্যয়বহুল (যেমন একটি বড় simulation বা প্রতিবার একটি পুরো অ্যালগরিদম চালানো), এই পার্থক্য গুরুত্বপূর্ণ।

প্রথম উন্নতি সহজ: বিন্দু দুটি এমনভাবে বসান যে পরের ধাপে একটি বিন্দু আবার ব্যবহার করা যায়। ধরুন পরিসর [0, 1], বিন্দু দুটি r আর 1 − r-এ। যদি বাঁয়ের অংশ বাদ যায়, নতুন পরিসর [1 − r, 1], আর পুরনো বিন্দু r যেন নতুন পরিসরের “বাঁয়ের বিন্দু” হয়। হিসাব করলে শর্তটি দাঁড়ায় r² = 1 − r, অর্থাৎ r = (√5 − 1)/2 ≈ 0.618 — সোনালি অনুপাতের উল্টো। এটিই golden-section search: প্রতি ধাপে মাত্র একটি নতুন মূল্যায়ন, আর পরিসর 0.618 গুণ। Jack Kiefer ১৯৫৩ সালে “Sequential minimax search for a maximum” প্রবন্ধে দেখান, মূল্যায়নের সংখ্যা আগে থেকে নির্দিষ্ট থাকলে সবচেয়ে ভালো কৌশল হলো Fibonacci সংখ্যার অনুপাতে বিন্দু বসানো (Fibonacci search), আর অনেক ধাপের সীমায় সেই অনুপাত সোনালি অনুপাতের দিকে যায়। অর্থাৎ শুধু f-এর মান দেখে (ঢাল না দেখে) এক চূড়ার ফাংশনে এর চেয়ে ভালো করা যায় না।

তবে পূর্ণসংখ্যার জগতে আরও একটি কৌশল আছে, যা প্রায়ই সবচেয়ে সরল: binary search on the slope. predicate “f(x) ≥ f(x + 1)” কঠোরভাবে এক চূড়ার ফাংশনে F…F T…T, তাই FIRST-TRUE সরাসরি চূড়া দেয় — প্রতি ধাপে দুটি মূল্যায়ন, কিন্তু পরিসর অর্ধেক, ফলে মূল্যায়নপ্রতি 0.707 গুণ, ternary-র চেয়ে ভালো। অবিচ্ছিন্ন ক্ষেত্রেও যদি ঢাল (derivative) হিসাব করা যায়, তার চিহ্নের ওপর bisection একই কাজ করে।

সব পদ্ধতির সাধারণ দুর্বলতা হলো সমতল অংশ। f(m₁) = f(m₂) হলে চূড়া কোন দিকে, বলা যায় না — দুই পাশেই থাকতে পারে। তাই উত্তল (convex) বা কঠোরভাবে এক চূড়ার ফাংশনেই এই কৌশলগুলো নিরাপদ; কনটেস্টে প্রমাণ না থাকলে ছোট ইনপুটে brute force দিয়ে ফাংশনের আকার আঁকিয়ে দেখা ভালো অভ্যাস।

মূল উপলব্ধি: এক চূড়ার ফাংশনে ternary search কাজ চালায়, কিন্তু সর্বোত্তম নয়: golden-section মূল্যায়ন বাঁচায়, আর পূর্ণসংখ্যায় ঢালের ওপর binary search প্রায়ই সবচেয়ে পরিষ্কার।

অনুশীলন

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

  1. হাতে trace করুন: কন্টেইনারের ওজন [3, 2, 2, 4, 1, 4], D = 3 দিন (ক্রম বদলানো যাবে না)। জাহাজের সর্বনিম্ন ধারণক্ষমতা FIRST-TRUE দিয়ে বের করুন — lo, hi, প্রতিটি mid আর greedy-তে কত দিন লাগে, লিখুন।

    lo = max = 4, hi = sum = 16. mid = 10: [3 2 2] [4 1 4] — ২ দিন ≤ 3, T, hi = 10. mid = 7: [3 2 2] [4 1] [4] — ৩ দিন, T, hi = 7. mid = 5: [3 2] [2] [4 1] [4] — ৪ দিন > 3, F, lo = 6. mid = 6: [3 2] [2 4] [1 4] — ৩ দিন, T, hi = 6. lo = hi = 6.

    চারটি যাচাই, ১৩টি প্রার্থী। 5-এ F আর 6-এ T সরাসরি দেখা, তাই 6-ই সর্বনিম্ন। লক্ষ করুন, mid = 5-এ [2]-কে একা পাঠাতে হলো, কারণ 3 + 2 + 2 = 7 > 5 আর 2 + 4 = 6 > 5.

  2. predicate আর সীমা ডিজাইন: তিনজন কারিগর এক কাপ চা বানাতে যথাক্রমে ২, ৩ আর ৭ মিনিট নেন, সবাই একসাথে কাজ করেন। ১০ কাপ চা হতে সর্বনিম্ন কত মিনিট? check(T), lo, hi লিখুন, একমুখিতা এক বাক্যে প্রমাণ করুন, তারপর binary search হাতে চালান।

    check(T): ⌊T/2⌋ + ⌊T/3⌋ + ⌊T/7⌋ ≥ 10. একমুখিতা: T বাড়লে প্রতিটি ⌊T / tᵢ⌋ কমে না, তাই যোগফলও কমে না — T-তে হলে T + 1-এও হবে। lo = 1, hi = 2 × 10 = 20 (সবচেয়ে দ্রুত কারিগর একাই ২০ মিনিটে ১০ কাপ বানান, তাই check(20) নিশ্চিত T).

    mid = 10: 5 + 3 + 1 = 9 — F, lo = 11. mid = 15: 7 + 5 + 2 = 14 — T, hi = 15. mid = 13: 6 + 4 + 1 = 11 — T, hi = 13. mid = 12: 6 + 4 + 1 = 11 — T, hi = 12. mid = 11: 5 + 3 + 1 = 9 — F, lo = 12. উত্তর ১২ মিনিট। বড় ইনপুটে যোগফল m ছুঁলেই থামুন, নইলে overflow.

  3. একমুখী কি না: নিচের তিনটি predicate-এর প্রতিটি সম্ভাব্য উত্তর x-এর ওপর একমুখী কি না বলুন — একমুখী হলে এক বাক্যে কারণ, না হলে একটি counterexample. (ক) ধনাত্মক সংখ্যার অ্যারেতে “যোগফল ≥ S এমন দৈর্ঘ্য x-এর কোনো subarray আছে”. (খ) “x-এর বর্গ ≤ N” (x ≥ 0, পূর্ণসংখ্যা)। (গ) “দৈর্ঘ্য x-এর কোনো subarray-এর যোগফল ঠিক S”, অ্যারে [1, 2, 1], S = 3.

    (ক) একমুখী, F…F T…T: দৈর্ঘ্য x-এর একটি window-এর যোগফল ≥ S হলে তার পাশে একটি ঘর যোগ করে (x < n হলে) দৈর্ঘ্য x + 1-এর window পাওয়া যায়, যার যোগফল আরও বড় — ধনাত্মক সংখ্যা বলেই। “সবচেয়ে ছোট x” প্রশ্নে FIRST-TRUE চলে।

    (খ) একমুখী, T…T F…F: x² বাড়তে থাকে, একবার N ছাড়ালে আর ফেরে না। শেষ T হলো ⌊√N⌋ — পূর্ণসংখ্যার বর্গমূলের ক্লাসিক binary search (LAST-TRUE, ওপরের মধ্যক; x · x-এ overflow সাবধান)।

    (গ) একমুখী নয়: x = 1: কোনো ঘর 3 নয় — F; x = 2: [1, 2] = 3 — T; x = 3: 1 + 2 + 1 = 4 — F. সারি F T F — counterexample. “ঠিক সমান” শর্ত প্রায়ই একমুখিতা ভাঙে।

  4. কোড লিখুন: একটি বাগানে n টি গাছ সারিতে, গাছ i-তে ফুল ফোটে bloomDay[i] দিনে। একটি তোড়া বানাতে পাশাপাশি k টি ফোটা ফুল লাগে। m টি তোড়া বানাতে সর্বনিম্ন কত দিন অপেক্ষা করতে হবে? অসম্ভব হলে −1. (যেমন [1, 10, 3, 10, 2], m = 3, k = 1 → 3.) সময় ও জায়গার complexity বলুন।

    মূল চিন্তা: check(d) = “d দিনে যেসব ফুল ফুটেছে, তা দিয়ে বাঁ থেকে greedy-ভাবে পাশাপাশি k টি করে নিলে অন্তত m টি তোড়া হয়”. d বাড়লে ফোটা ফুল শুধু বাড়ে, তাই একমুখী। m · k > n হলে কখনো সম্ভব নয় — আগেই −1. lo = min(bloomDay), hi = max(bloomDay) (তখন সব ফুটেছে, check নিশ্চিত T).

    int minDays(const vector<int>& bloom, int m, int k) {
        if ((long long)m * k > (long long)bloom.size()) return -1;
        auto ok = [&](int d) {
            long long made = 0; int run = 0;
            for (int b : bloom) {
                run = (b <= d) ? run + 1 : 0;       // consecutive bloomed flowers
                if (run == k) { ++made; run = 0; }  // use them for one bouquet
            }
            return made >= m;
        };
        int lo = *min_element(bloom.begin(), bloom.end());
        int hi = *max_element(bloom.begin(), bloom.end());
        while (lo < hi) {
            int mid = lo + (hi - lo) / 2;
            if (ok(mid)) hi = mid; else lo = mid + 1;
        }
        return lo;
    }

    উদাহরণে: mid = 5 — ফুটেছে 1, 3, 2 → ৩টি তোড়া, T; mid = 3 — একই ৩টি, T; mid = 2 — শুধু 1 আর 2 → ২টি, F. উত্তর 3. greedy সঠিক, কারণ পাশাপাশি ফোটা ফুলের একটি টানা অংশ থেকে সর্বোচ্চ ⌊দৈর্ঘ্য / k⌋ তোড়া হয়, আর বাঁ থেকে নেওয়া ঠিক সেটিই দেয়। সময় O(n log(max − min)), বাড়তি জায়গা O(1). m * k গুণে long long, কারণ দুটিই 106 হলে int ছাড়িয়ে যায়।

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

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

ক্রমএকটি নতুন সমস্যায় উত্তরের ওপর binary search প্রয়োগের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. উত্তর কোন রাশি আর সেটি কোন সাজানো পরিসরে থাকতে পারে, তা ঠিক করা
  2. “x দিয়ে কি কাজটি হয়?” — হ্যাঁ/না যাচাই check(x) লেখা
  3. check(x) যে একমুখী, তা প্রমাণ করা
  4. lo আর hi বাছাই, যাতে উত্তর পরিসরের ভেতরে আর hi-তে check নিশ্চিত true
  5. template চালিয়ে প্রথম true খুঁজে বের করা
  6. ছোট ইনপুটে brute force-এর সাথে উত্তর মিলিয়ে দেখা

একটি predicate সম্ভাব্য মানগুলোর ওপর F…F T…T, আর check(hi) = true. লুপ: while (lo < hi) { mid = lo + (hi − lo) / 2; if (check(mid)) hi = mid; else lo = mid + 1; }. লুপ শেষে lo কী নির্দেশ করে?

  • শেষ যে মানে check false
  • শেষ যে মানে check true
  • প্রথম যে মানে check true
  • কিছুই নয় — এই লুপ অসীমভাবে চলতে পারে

n টি ধনাত্মক পূর্ণসংখ্যার অ্যারেকে k টি অবিচ্ছিন্ন টুকরোয় ভাঙতে হবে, যাতে সবচেয়ে বড় টুকরোর যোগফল সর্বনিম্ন হয়। সব সংখ্যার যোগফল S. উত্তরের ওপর binary search আর greedy যাচাই দিয়ে মোট সময় কত?

  • O(n log n)
  • O(n log S)
  • O(k · n²)
  • O(n · S)

শেষ true খোঁজার একটি লুপ: while (lo < hi) { mid = (lo + hi) / 2; if (check(mid)) lo = mid; else hi = mid − 1; }. lo = 3, hi = 4 আর check(3) = true, check(4) = false হলে কী ঘটে?

  • লুপ 4 ফেরত দেয়
  • লুপ সঠিকভাবে 3 ফেরত দেয়
  • hi = 2 হয়ে যায় আর ভুল উত্তর আসে
  • অসীম লুপ — lo আর hi আর বদলায় না

সাজানো জায়গাগুলোতে c টি বস্তু বসাতে হবে, যাতে পাশাপাশি দুটির সবচেয়ে ছোট দূরত্ব সর্বোচ্চ হয়। check(d) = “দূরত্ব অন্তত d রেখে c টি বসানো যায়”. এই predicate-এর আকার কেমন?

  • T…T F…F — ছোট d-তে true, তাই শেষ true খুঁজতে হয়
  • F…F T…T — বড় d-তে true, তাই প্রথম true খুঁজতে হয়
  • একমুখী নয়, তাই binary search চলে না
  • এক চূড়ার, তাই ternary search লাগে

ধনাত্মক সংখ্যার অ্যারে a-কে k টি অবিচ্ছিন্ন টুকরোয় ভাঙার সমস্যায় (সবচেয়ে বড় টুকরোর যোগফল সর্বনিম্ন) কোন lo আর hi নিরাপদ ও যথেষ্ট?

  • lo = 0, hi = max(a)
  • lo = sum(a) / k, hi = max(a)
  • lo = max(a), hi = sum(a)
  • lo = min(a), hi = sum(a) / k

দশমিক উত্তরে binary search-এ while (hi − lo > 1e-9)-এর বদলে নির্দিষ্টসংখ্যক (যেমন ১০০) ঘূর্ণন চালানোর প্রধান কারণ কী?

  • ১০০ ঘূর্ণন সবসময় eps-শর্তের চেয়ে দ্রুত শেষ হয়
  • double-এর সীমিত নির্ভুলতায় eps-শর্ত কখনো মিথ্যা না-ও হতে পারে, ফলে অসীম লুপ
  • নির্দিষ্ট ঘূর্ণনে predicate একমুখী হওয়ার দরকার পড়ে না
  • eps-শর্তে mid হিসাবে overflow হয়

শব্দকোষ

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

মনে রাখুন

  1. “সবচেয়ে ছোট/বড় কত” → “x দিয়ে কি হয়?” — check(x) একমুখী হলে প্রথম (বা শেষ) true-ই উত্তর।
  2. FIRST-TRUE: নিচের মধ্যক, true → hi = mid, false → lo = mid + 1; invariant “উত্তর [lo, hi]-এ”. LAST-TRUE-এ ওপরের মধ্যক।
  3. সীমা: lo-এর নিচে উত্তর অসম্ভব, hi-তে check নিশ্চিত true; split array-তে [max, sum], টাওয়ারে [0, L / (c − 1)].
  4. খরচ O(log(range) × cost(check)) — যাচাই সাধারণত একটি greedy pass, যার শুদ্ধতা “এগিয়ে থাকা” যুক্তিতে প্রমাণ হয়; check-এর ভেতরে overflow সাবধান।
  5. দশমিকে নির্দিষ্ট ঘূর্ণন; একমুখিতা ভাঙলে binary search নিঃশব্দে ভুল — counterexample খুঁজুন; এক চূড়ার ফাংশনে ternary search বা ঢালের ওপর binary search.

পরের পর্বে: সর্টিং শুরু — bubble, selection আর insertion sort. কেন এরা O(n²), কোনটি প্রায়-সাজানো ডেটায় দ্রুত, loop invariant দিয়ে শুদ্ধতা, আর stable হওয়ার মানে কী।

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

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