এই পর্বে যা শিখবেন
- কখন linear search-ই সেরা, আর sentinel দিয়ে তাকে একটু দ্রুত করা
- Binary search-এর তিনটি প্রচলিত ছাঁচ চেনা, একটি টেমপ্লেট বেছে নিয়ে loop invariant দিয়ে প্রমাণ করা
- mid-এর overflow বাগ, আর lower bound, upper bound, গণনা, প্রথম ও শেষ অবস্থান — off-by-one ছাড়া
- Rotated sorted array, peak element আর দশমিক সংখ্যায় (বর্গমূল) binary search
- Θ(log n) বিশ্লেষণ, Ω(log n) নিম্নসীমার প্রমাণ, exponential আর interpolation search
১ · খোঁজার প্রথম পথ — linear search আর কখন সেটিই ঠিক
পরীক্ষার হলে খাতা জমা দেওয়ার পর পরিদর্শক স্যারের টেবিলে ৬০টি খাতা এলোমেলো স্তূপ হয়ে আছে। এখন রোল ৩৭-এর খাতাটি দরকার। স্যার কী করবেন? ওপর থেকে একটি একটি খাতা তুলে রোল দেখবেন — পেলে থামবেন, না পেলে শেষ পর্যন্ত যাবেন। খাতাগুলো সাজানো নেই, তাই এর চেয়ে বুদ্ধিমান কোনো পথ নেই। এটিই linear search: শুরু থেকে প্রতিটি উপাদান লক্ষ্যের (x) সাথে মিলিয়ে দেখা।
Pseudocode (0-based): linear search
LINEAR-SEARCH(a, n, x):
for i = 0 to n - 1:
if a[i] == x: return i // found: first position
return -1 // checked all n, not presentবিশ্লেষণ সোজা (পর্ব ০৫-এর ভাষায়): best case-এ x প্রথম ঘরেই, Θ(1). worst case-এ x শেষ ঘরে বা নেই — n টি তুলনা, Θ(n). x অ্যারেতে আছে আর প্রতিটি অবস্থান সমান সম্ভাব্য ধরলে গড়ে (n + 1)/2 টি তুলনা, তাও Θ(n). বাড়তি জায়গা O(1).
কখন linear search-ই সঠিক পছন্দ
- ডেটা সাজানো নয়, আর মাত্র একবার বা কয়েকবার খুঁজবেন। সাজাতে লাগে O(n log n) (পর্ব ২১–২২) — একটি খোঁজার জন্য সেটি linear search-এর চেয়ে বেশি খরচ। অনেকবার খুঁজতে হলে তখন সাজিয়ে নেওয়া (বা hash table, পর্ব ৩২) লাভজনক।
- অ্যারে খুব ছোট। দশ-বিশটি উপাদানে linear search-এর লুপ এত সরল যে আধুনিক CPU-তে তা প্রায়ই binary search-এর সমান বা দ্রুত — মেমরি পাশাপাশি পড়া হয় (পর্ব ১০-এর cache), আর শাখা (branch) অনুমান করা সহজ।
- Linked list. পর্ব ১২-এ দেখেছেন, linked list-এ k-তম node-এ যেতে k টি ধাপ লাগে। সাজানো linked list-এও “মাঝখানে যাওয়া” O(n), তাই binary search-এর কোনো লাভ থাকে না — linear search-ই স্বাভাবিক।
- শর্তটি তুলনা নয়। “প্রথম জোড় সংখ্যা”, “প্রথম যে গ্রাহকের বকেয়া আছে” — এমন খোঁজায় ক্রম কাজে লাগে না, তাই প্রতিটি দেখতে হয়।
Sentinel কৌশল — প্রতি ধাপে একটি তুলনা কম
ওপরের লুপে প্রতিটি ঘোরায় আসলে দুটি তুলনা হয়: i < n (সীমা পেরোলাম কি না) আর a[i] == x. Donald Knuth তাঁর “The Art of Computer Programming”-এর তৃতীয় খণ্ডে একটি পুরোনো কৌশল দেখান: অ্যারের শেষে একটি বাড়তি ঘরে x নিজেই বসিয়ে দিন। এই বসানো মানটিকে বলে sentinel (পাহারাদার)। এখন লুপ নিশ্চিতভাবে কোথাও না কোথাও x পাবে, তাই সীমার পরীক্ষা লাগে না:
Pseudocode (0-based): linear search with a sentinel; a has room for a[n]
SENTINEL-SEARCH(a, n, x):
a[n] = x // the sentinel guarantees a stop
i = 0
while a[i] != x: // ONE comparison per step
i = i + 1
return (i < n) ? i : -1 // stopped at the sentinel = not foundComplexity একই Θ(n) — শুধু ধ্রুবকটি ছোট। পর্ব ১২-এর sentinel node-এর মতোই ভাবনা: একটি নকল উপাদান বসিয়ে সীমানার বিশেষ পরীক্ষা মুছে ফেলা। তবে খেয়াল রাখুন, এতে অ্যারে বদলায় (বাড়তি ঘর লাগে, আর একসাথে কয়েকটি thread পড়লে বিপদ), আর আধুনিক compiler প্রায়ই সাধারণ লুপকেই ভালোভাবে অপ্টিমাইজ করে। তাই এটি পরীক্ষার আর ইন্টারভিউর প্রিয় প্রশ্ন, কিন্তু দৈনন্দিন কোডে খুব কম দরকার হয়।
২ · Binary search-এর ধারণা — অভিধান আর ফোনবুক
ইংরেজি-বাংলা অভিধানে “monsoon” শব্দটি খুঁজছেন। আপনি কি প্রথম পাতা থেকে একটি একটি করে উল্টাবেন? না। মাঝামাঝি একটি পাতা খুলবেন — দেখলেন “label”. “monsoon” বর্ণক্রমে পরে, তাই বাঁয়ের পুরো অর্ধেক মন থেকে মুছে ফেললেন। ডান অর্ধেকের মাঝে খুললেন — “river”, এবার আগে, তাই ডানের অংশ বাদ। প্রতিবার একটি পাতা দেখে বাকি অংশের অর্ধেক বাদ। পুরোনো দিনের ছাপা টেলিফোন ডিরেক্টরিতে নাম খোঁজাও ঠিক এমন। এটিই binary search.
এই কৌশলের একটিমাত্র শর্ত (precondition): অ্যারে সাজানো থাকতে হবে। সাজানো বলেই একটি তুলনা থেকে অনেক কিছু জানা যায়: a[mid] < x হলে a[0..mid]-এর সবাই ≤ a[mid] < x — একটি তুলনায় mid + 1 টি ঘর বাদ। এলোমেলো অ্যারেতে a[mid] দেখে বাকিদের সম্পর্কে কিছুই বলা যায় না।
| n (উপাদান) | Linear search, সবচেয়ে খারাপে | Binary search, সবচেয়ে খারাপে (⌊log₂ n⌋ + 1) |
|---|---|---|
| ১০০ | ১০০ | ৭ |
| ১০ লাখ (106) | ১০ লাখ | ২০ |
| ১০০ কোটি (109) | ১০০ কোটি | ৩০ |
পর্ব ০৬-এ দেখেছেন, log₂ n মানে “n-কে কতবার অর্ধেক করলে ১-এ পৌঁছায়”। বাংলাদেশের প্রায় ১৭ কোটি মানুষের একটি সাজানো তালিকাতেও একজনকে খুঁজতে ২৮টি ধাপই যথেষ্ট। n দ্বিগুণ হলে binary search-এর কাজ বাড়ে মাত্র একটি ধাপ — এটিই logarithmic time-এর শক্তি।
“২০ প্রশ্ন” খেলাটি ভাবুন: বন্ধু ১ থেকে ১০ লাখের মধ্যে একটি সংখ্যা মনে রেখেছে, আপনি শুধু “হ্যাঁ/না” প্রশ্ন করতে পারবেন। “সংখ্যাটি কি ৫ লাখের বেশি?” — প্রতিটি উত্তর সম্ভাবনা অর্ধেক করে, তাই ২০টি প্রশ্নেই নিশ্চিত উত্তর (220 = 10,48,576). অষ্টম অংশে দেখবেন, শুধু হ্যাঁ/না প্রশ্নে এর চেয়ে কম প্রশ্নে পারা অসম্ভব।
৩ · তিনটি ছাঁচ আর একটি প্রমাণিত টেমপ্লেট
ধারণা এক বাক্যের, কিন্তু কোড লেখা কুখ্যাতভাবে কঠিন — পর্ব ০৩-এ Jon Bentley আর Donald Knuth-এর পর্যবেক্ষণ দেখেছেন। গোলমালের মূলে চারটি ছোট সিদ্ধান্ত: hi শুরুতে n নাকি n − 1? শর্ত lo < hi নাকি lo <= hi? hi = mid নাকি mid − 1? শেষে কী ফেরত দেব? এই চারটি একে অপরের সাথে বাঁধা — একটি বদলালে বাকিগুলোও বদলাতে হয়। প্রচলিত তিনটি ছাঁচ পাশাপাশি দেখুন:
| ছাঁচ | পরিসরের মানে | শুরু | শর্ত | হালনাগাদ | কোথায় কাজে লাগে |
|---|---|---|---|---|---|
| ক. বন্ধ পরিসর [lo, hi] (পর্ব ০৩) | x থাকলে a[lo..hi]-এ আছে | 0, n − 1 | lo <= hi | lo = mid + 1 / hi = mid − 1, সমান পেলে ফেরত | “আছে কি না, থাকলে কোথাও একটি অবস্থান” |
| খ. half-open [lo, hi) — এই পর্বের টেমপ্লেট | উত্তর (একটি সীমানা) [lo, hi]-এর মধ্যে | 0, n | lo < hi | lo = mid + 1 / hi = mid | lower/upper bound, গণনা, ঢোকানোর জায়গা — C++ STL আর Python bisect-এর পদ্ধতি |
| গ. খোলা পরিসর (lo, hi) | a[lo] “না”, a[hi] “হ্যাঁ” — দুই প্রান্ত জানা | −1, n | hi − lo > 1 | lo = mid / hi = mid | কনটেস্টে জনপ্রিয়; উত্তরের ওপর binary search (পর্ব ১৯) |
তিনটিই ঠিক — যদি নিজের ভেতরে সংগত থাকে। বিপদ হয় মিশিয়ে ফেললে: ছাঁচ খ-এর hi = mid আর ছাঁচ ক-এর একসাথে লিখলে lo = hi = mid অবস্থায় লুপ চিরকাল ঘোরে। আমরা ছাঁচ খ-কে প্রধান টেমপ্লেট হিসেবে নেব, তিনটি কারণে: (১) এটি সবসময় একটি অর্থপূর্ণ উত্তর দেয়, x না থাকলেও; (২) সদৃশ মান থাকলেও ঠিক কোন অবস্থান আসবে, তা নির্দিষ্ট; (৩) C++-এর std::lower_bound আর Python-এর bisect_left হুবহু এটিই (পর্ব ১৫)। প্রশ্নটিকে লিখি একটি একমুখী (monotone) হ্যাঁ/না শর্ত হিসেবে:
সাজানো অ্যারেতে “a[i] ≥ x” শর্তটি বাঁ দিকে সব “না”, তারপর এক জায়গা থেকে সব “হ্যাঁ” — না, না, না, হ্যাঁ, হ্যাঁ। এমন শর্তকে বলে monotonic predicate. Binary search আসলে এই “না → হ্যাঁ” সীমানাটি খোঁজে; x-এর সমান মান থাকুক বা না থাকুক।
Pseudocode (0-based; a[0..n-1] sorted ascending; half-open range [lo, hi))
LOWER-BOUND(a, n, x): // first i with a[i] >= x, or n
lo = 0, hi = n
while lo < hi: // range [lo, hi) not empty
mid = lo + (hi - lo) / 2 // integer division; lo <= mid < hi
if a[mid] < x: lo = mid + 1 // a[0..mid] all < x
else: hi = mid // a[mid..n-1] all >= x
return lo // lo == hi == the boundary
FOUND? lo < n and a[lo] == xপ্রতিটি ঘোরার শুরুতে: a[0..lo)-এর সবাই < x, আর a[hi..n)-এর সবাই ≥ x. অর্থাৎ যা জানা হয়ে গেছে, তা দুই প্রান্তে; অজানা শুধু মাঝের [lo, hi).
প্রমাণ (পর্ব ০৩-এর তিন ধাপ + termination).
- Initialization: lo = 0, hi = n. a[0..0) আর a[n..n) দুটিই খালি — খালি সেট সম্পর্কে যেকোনো “সবাই” দাবি সত্য।
- Maintenance: lo < hi বলে mid = lo + ⌊(hi − lo)/2⌋ মানে lo ≤ mid < hi, তাই a[mid] বৈধ ঘর। যদি a[mid] < x, সাজানো বলে a[lo..mid]-এর সবাই ≤ a[mid] < x; তাই lo = mid + 1 করলেও “a[0..lo) < x” টেকে। নইলে a[mid] ≥ x, তাই a[mid..n)-এর সবাই ≥ x; hi = mid করলে “a[hi..n) ≥ x” টেকে।
- Termination: পরিমাপ hi − lo প্রতি ঘোরায় কঠোরভাবে কমে (mid + 1 > lo, আর mid < hi), কখনো ঋণাত্মক হয় না — তাই লুপ থামে (পর্ব ০৩-এর variant function). থামার সময় lo = hi. Invariant বলছে বাঁয়ের সবাই < x, ডান থেকে সবাই ≥ x — অর্থাৎ lo-ই প্রথম ঘর যেখানে a[i] ≥ x (বা n, যদি এমন ঘর নেই)। ∎
লক্ষ করুন, প্রমাণ থেকেই প্রতিটি লাইন বেরিয়েছে: hi = mid (mid − 1 নয়) কারণ a[mid] নিজেই উত্তর হতে পারে; lo = mid + 1 কারণ a[mid] নিশ্চিতভাবে উত্তর নয়; শর্ত lo < hi কারণ lo = hi মানে অজানা অংশ খালি। নিচে a = [3, 8, 15, 21, 27, 34, 42, 56] (n = 8)-এ x = 27 খুঁজে দেখুন। “mark” সারিতে < মানে “জানা গেছে a[i] < x”, ≥ মানে “জানা গেছে a[i] ≥ x”, ? মানে এখনো অজানা:
1int lo = 0, hi = n;2while (lo < hi) {3 int mid = lo + (hi - lo) / 2;4 if (a[mid] < x) lo = mid + 1;5 else hi = mid;6}7return lo; // found if a[lo] == x
| mid | |
| a[mid] | |
| lo, hi | |
| hi − lo |
- শুরু: lo = 0, hi = 8. hi অ্যারের শেষের ঠিক পরের ঘর — half-open পরিসর [0, 8) মানে পুরো অ্যারে অজানা। দুই প্রান্তের “জানা” অংশ খালি, তাই invariant সত্য।
- mid = 0 + 8/2 = 4, a[4] = 27 ≥ 27. শর্ত মিথ্যা, তাই hi = mid = 4. a[4..7] এখন “≥” — a[4] নিজেও, কারণ সেটি উত্তর হতে পারে। mid − 1 করলে ঠিক এই ঘরটিই হারাতাম।
- mid = 0 + 4/2 = 2, a[2] = 15 < 27. lo = mid + 1 = 3. সাজানো বলে a[0..2] সবাই < 27 — একটি তুলনায় তিনটি ঘর বাদ। অজানা শুধু ঘর 3.
- mid = 3 + 1/2 = 3, a[3] = 21 < 27. lo = 4. এখন lo = hi = 4 — অজানা অংশ খালি, hi − lo = 0.
- লুপ শেষ, ফেরত 4. বাঁয়ে সবাই < 27, ডানে সবাই ≥ 27 — সীমানা ঘর 4. a[4] == 27, তাই x পাওয়া গেল। মোট ৩টি ঘোরা = log₂ 8. লক্ষ করুন, a[mid] == x পেয়েও (ধাপ ২) লুপ থামেনি — এই টেমপ্লেট সমান পেলেও আরও বাঁয়ে প্রথমটি খোঁজে।
এবার একই অ্যারেতে x = 30 — যা অ্যারেতে নেই। বন্ধ ছাঁচে উত্তর হতো শুধু “−1”; half-open টেমপ্লেট বলে দেয় 30 কোথায় থাকা উচিত ছিল:
| mid | |
| a[mid] | |
| সিদ্ধান্ত | |
| hi − lo |
- শুরু। আবার lo = 0, hi = 8, সব অজানা।
- mid = 4, a[4] = 27 < 30. lo = 5 — বাঁয়ের পাঁচটি ঘর একসাথে বাদ।
- mid = 5 + 3/2 = 6, a[6] = 42 ≥ 30. hi = 6. অজানা শুধু ঘর 5.
- mid = 5, a[5] = 34 ≥ 30. hi = 5 = lo. অজানা অংশ খালি।
- ফেরত 5, কিন্তু a[5] = 34 ≠ 30. তাই 30 নেই। তবু 5 একটি দামি তথ্য: 30 ঢোকাতে হলে ঘর 5-এ ঢোকালে ক্রম ঠিক থাকে, আর “30-এর চেয়ে ছোট কয়টি?” — ঠিক 5টি। দুটি বিশেষ ক্ষেত্র: x = 1 দিলে ফল 0 (সবার আগে), x = 60 দিলে ফল 8 = n — তখন a[lo] পড়ার আগে অবশ্যই lo < n পরীক্ষা করুন, নইলে অ্যারের বাইরে পড়বেন।
“পাওয়া গেল কি না” দুই শর্তে। lo < n && a[lo] == x — প্রথম অংশটি বাদ দিলে x সবার চেয়ে বড় হলে a[n] পড়বেন: C++-এ undefined behaviour, Python-এ IndexError. এটি binary search-এর সবচেয়ে প্রচলিত crash.
৪ · mid-এর ফাঁদ — overflow আর সঠিক মাঝবিন্দু
বহু বইয়ে মাঝের ঘর লেখা হয় mid = (lo + hi) / 2. গণিতে এটি নিখুঁত। কিন্তু C++ বা Java-তে lo আর hi যদি 32-bit int হয়, তাহলে lo + hi যোগফলটি int-এর সীমা 2,147,483,647 (প্রায় 2.1 × 109) ছাড়াতে পারে — যদিও lo আর hi দুটিই বৈধ সূচক। ধরুন অ্যারেতে ১৫০ কোটি উপাদান, lo = 1,200,000,000 আর hi = 1,500,000,000. যোগফল 2.7 × 109 — integer overflow. C++-এ signed overflow undefined behaviour; বাস্তবে সাধারণত মান ঘুরে ঋণাত্মক হয়, আর a[mid] অ্যারের বাইরে পড়ে।
২০০৬ সালে Joshua Bloch তাঁর ব্লগে দেখান যে Java-র লাইব্রেরির Arrays.binarySearch-এ ঠিক এই লাইনটি বহু বছর ধরে ছিল — আর Jon Bentley-র “Programming Pearls”-এর বিখ্যাত সংস্করণেও একই ধরন। বাগটি ধরা পড়তে এত দেরি হয়েছিল কারণ ২³⁰-এর বেশি উপাদানের অ্যারে তখন বিরল ছিল। (গল্পের বিস্তারিত পর্ব ০৩-এর ভাবনার প্রশ্ন ২-এ।) সমাধান সহজ:
কেন এটি নিরাপদ? 0 ≤ lo ≤ hi বলে hi − lo ≥ 0 আর hi − lo ≤ hi, তাই কোনো মধ্যবর্তী মান hi-কে ছাড়ায় না। ফলও একই: lo + ⌊(hi − lo)/2⌋ = ⌊(lo + hi)/2⌋. অন্য বিকল্পগুলো:
- C++20:
std::midpoint(lo, hi)(হেডার<numeric>) — overflow ছাড়া মাঝবিন্দু, ঋণাত্মক মানেও। - Java:
(lo + hi) >>> 1— যোগফল overflow করলেও unsigned shift সঠিক ধনাত্মক ফল দেয়। Bloch-এর লেখায় এটিও প্রস্তাবিত। - Python: integer-এর আকার সীমাহীন, তাই
(lo + hi) // 2নিরাপদ। তবু অভ্যাসটি রাখুন — ভাষা বদলালে কাজে দেবে।
ঋণাত্মক পরিসরে আরেকটি সূক্ষ্ম ফাঁদ। পর্ব ১৯-এ উত্তরের ওপর binary search-এ lo ঋণাত্মক হতে পারে (যেমন −109). C++-এ পূর্ণসংখ্যার ভাগ শূন্যের দিকে কাটে: (−7) / 2 = −3, অথচ ⌊−3.5⌋ = −4. তখন (lo + hi) / 2 কখনো কখনো lo-এর বদলে hi-এর দিকে গোল হয়, আর “lo = mid” ধরনের ছাঁচে লুপ আটকে যায়। lo + (hi − lo) / 2-এ hi − lo ≥ 0, তাই ভাগ সবসময় নিচের দিকে — এই সমস্যাও থাকে না।
৫ · এক টেমপ্লেটে সব প্রশ্ন — lower bound, upper bound, গণনা আর অবস্থান
বাস্তবে প্রশ্ন শুধু “x আছে কি না” নয়। একটি অনলাইন দোকানের দাম-সাজানো তালিকায় প্রশ্ন আসে: “৫০০ টাকা বা তার বেশি দামের প্রথম জিনিস কোনটি?”, “ঠিক ৫০০ টাকার জিনিস কয়টি?”, “৫০০-এর কম দামের সবচেয়ে দামি কোনটি?” সব প্রশ্নের উত্তর দুটি সীমানা থেকে আসে। দ্বিতীয় সীমানাটি upper bound:
কোডে পার্থক্য একটিমাত্র চিহ্ন: a[mid] < x-এর বদলে a[mid] <= x. Invariant-ও একইভাবে বদলায়: “a[0..lo) ≤ x, a[hi..n) > x”. সাজানো অ্যারেতে x-এর সব কপি পাশাপাশি থাকে, ঠিক [lower_bound, upper_bound) পরিসরে। এখান থেকে সব উত্তর:
| প্রশ্ন | উত্তর (lb = lower_bound(x), ub = upper_bound(x)) | না থাকলে |
|---|---|---|
| x আছে কি না | lb < n আর a[lb] == x | — |
| x-এর প্রথম অবস্থান | lb | lb == ub হলে নেই |
| x-এর শেষ অবস্থান | ub − 1 | lb == ub হলে নেই |
| x কতবার আছে | ub − lb | 0 |
| ক্রম না ভেঙে x ঢোকানোর জায়গা | lb (সমানদের আগে) বা ub (সমানদের পরে) | সবসময় আছে |
| প্রথম উপাদান ≥ x / > x | a[lb] / a[ub] | সূচক n হলে নেই |
| শেষ উপাদান < x / ≤ x | a[lb − 1] / a[ub − 1] | সূচক −1 হলে নেই |
| [L, R] পরিসরে কতগুলো মান | upper_bound(R) − lower_bound(L) | 0 |
লাইব্রেরিতে এগুলো তৈরি আছে (পর্ব ১৫): C++-এ std::lower_bound আর std::upper_bound একটি iterator দেয়, সূচক পেতে - a.begin(); std::equal_range একবারে দুটিই দেয়। Python-এর bisect মডিউলে bisect_left = lower bound আর bisect_right = upper bound. নিচের কোডে নিজের লেখা দুটি ফাংশন একটি নির্দিষ্ট অ্যারেতে চালিয়ে প্রতিটি ফল লাইব্রেরির সাথে মিলিয়ে নেওয়া হয়েছে:
#include <iostream>
#include <iomanip>
#include <vector>
#include <algorithm>
#include <cassert>
#include <initializer_list>
using namespace std;
int lowerBound(const vector<int>& a, int x) { // first i with a[i] >= x, or n
int lo = 0, hi = (int)a.size(); // a[0..lo) < x <= a[hi..n)
while (lo < hi) {
int mid = lo + (hi - lo) / 2; // never overflows
if (a[mid] < x) lo = mid + 1; else hi = mid;
}
return lo;
}
int upperBound(const vector<int>& a, int x) { // first i with a[i] > x, or n
int lo = 0, hi = (int)a.size();
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (a[mid] <= x) lo = mid + 1; else hi = mid; // only change: <=
}
return lo;
}
int main() {
vector<int> a = {1, 3, 3, 3, 5, 8, 8, 13};
for (int x : {3, 8, 4, 0, 13, 20}) {
int lb = lowerBound(a, x), ub = upperBound(a, x);
assert(lb == lower_bound(a.begin(), a.end(), x) - a.begin() &&
ub == upper_bound(a.begin(), a.end(), x) - a.begin());
int cnt = ub - lb, first = cnt ? lb : -1, last = cnt ? ub - 1 : -1;
cout << "x=" << setw(2) << x << ": lower=" << lb << " upper=" << ub
<< " count=" << cnt << " first=" << first << " last=" << last << '\n';
}
cout << "all results match the library\n";
}আউটপুট:
x= 3: lower=1 upper=4 count=3 first=1 last=3
x= 8: lower=5 upper=7 count=2 first=5 last=6
x= 4: lower=4 upper=4 count=0 first=-1 last=-1
x= 0: lower=0 upper=0 count=0 first=-1 last=-1
x=13: lower=7 upper=8 count=1 first=7 last=7
x=20: lower=8 upper=8 count=0 first=-1 last=-1
all results match the library
x = 3-এর তিনটি কপি ঘর 1, 2, 3-এ — lower 1, upper 4, গণনা 4 − 1 = 3. x = 4 নেই: দুটি সীমানাই 4, অর্থাৎ 4 ঢোকালে ঘর 4-এ বসবে (5-এর আগে)। x = 0 সবার চেয়ে ছোট — দুটিই 0; x = 20 সবার চেয়ে বড় — দুটিই 8 = n, আর কোডে a[8] একবারও পড়া হয়নি। assert লাইন দুটি প্রতিটি ফল std::lower_bound/std::upper_bound-এর সাথে মেলায় — একটিও না মিললে প্রোগ্রাম থেমে যেত। প্রতিটি ফাংশন Θ(log n) সময় আর O(1) বাড়তি জায়গা নেয়; সমান মান অনেক থাকলেও (যেমন সব উপাদান 3) সময় বাড়ে না — linear-ভাবে বাঁয়ে হেঁটে প্রথম কপি খোঁজার ভুল পদ্ধতিতে যা Θ(n) হয়ে যেত।
from bisect import bisect_left, bisect_right
def lower_bound(a, x): # first i with a[i] >= x, or len(a)
lo, hi = 0, len(a) # invariant: a[:lo] < x <= a[hi:]
while lo < hi:
mid = (lo + hi) // 2 # Python ints never overflow
if a[mid] < x:
lo = mid + 1
else:
hi = mid
return lo
def upper_bound(a, x): # first i with a[i] > x, or len(a)
lo, hi = 0, len(a)
while lo < hi:
mid = (lo + hi) // 2
if a[mid] <= x: # the only change: <= instead of <
lo = mid + 1
else:
hi = mid
return lo
a = [1, 3, 3, 3, 5, 8, 8, 13]
for x in [3, 8, 4, 0, 13, 20]:
lb, ub = lower_bound(a, x), upper_bound(a, x)
assert (lb, ub) == (bisect_left(a, x), bisect_right(a, x))
cnt = ub - lb
first, last = (lb, ub - 1) if cnt else (-1, -1)
print(f"x={x:2}: lower={lb} upper={ub} count={cnt} first={first} last={last}")
print("all results match the library")আউটপুট C++-এর সাথে হুবহু এক:
x= 3: lower=1 upper=4 count=3 first=1 last=3
x= 8: lower=5 upper=7 count=2 first=5 last=6
x= 4: lower=4 upper=4 count=0 first=-1 last=-1
x= 0: lower=0 upper=0 count=0 first=-1 last=-1
x=13: lower=7 upper=8 count=1 first=7 last=7
x=20: lower=8 upper=8 count=0 first=-1 last=-1
all results match the library
f"{x:2}" সংখ্যাটিকে দুই ঘরের জায়গায় ডানে বসায় — C++-এর setw(2)-এর মতো, তাই দুই আউটপুট অক্ষরে অক্ষরে এক। বাস্তবে নিজের ফাংশন না লিখে সরাসরি bisect_left/bisect_right ব্যবহার করুন — এগুলো C-তে লেখা, অনেক দ্রুত। Python 3.10 থেকে এরা key= নিতে পারে, যেমন ছাত্রদের তালিকা নম্বর অনুযায়ী সাজানো থাকলে bisect_left(students, 80, key=lambda s: s.marks). তবে মনে রাখুন, a.index(x) বা x in a list-এ linear search — সাজানো list-এও Θ(n).
একটি সাধারণ ছাঁচ মনে রাখুন। যেকোনো monotonic predicate P(i)-এর জন্য (বাঁয়ে সব মিথ্যা, তারপর সব সত্য) “প্রথম সত্য” খোঁজার কোড একই: if (!P(mid)) lo = mid + 1; else hi = mid;. lower bound-এ P(i) = “a[i] ≥ x”, upper bound-এ “a[i] > x”. “শেষ সত্য” চাইলে উল্টো শর্তের “প্রথম সত্য” থেকে এক বাদ দিন। এই চিন্তাটিই পর্ব ১৯-এর ভিত্তি।
৬ · পুরোপুরি সাজানো নয়, তবু binary search — rotated array আর peak element
Binary search-এর আসল শর্ত “পুরো অ্যারে সাজানো” নয়; শর্ত হলো — একটি তুলনা থেকে নিশ্চিতভাবে বলা যাওয়া কোন অর্ধেক বাদ দেওয়া নিরাপদ। দুটি বিখ্যাত ইন্টারভিউ-সমস্যায় এটি দেখা যায়।
Rotated sorted array-তে খোঁজা
একটি সাজানো অ্যারেকে কোনো এক জায়গা থেকে কেটে সামনের অংশ পেছনে জুড়ে দিলে পাওয়া যায় rotated sorted array: [5, 10, 20, 30, 40, 50, 60] থেকে [30, 40, 50, 60, 5, 10, 20]. ভাবুন একটি গোল ঘড়ির মতো সাজানো সময়সূচি, যেটি মাঝরাতের বদলে অন্য কোনো সময় থেকে শুরু হয়েছে। এখানে উপাদানগুলো আলাদা (distinct) ধরে নিচ্ছি।
মূল পর্যবেক্ষণ: যেকোনো mid-এ দুই অর্ধেক a[lo..mid] আর a[mid..hi]-এর অন্তত একটি পুরোপুরি সাজানো, কারণ “ভাঙার জায়গা” (বড় থেকে হঠাৎ ছোট) একটিই, আর সেটি দুই অর্ধেকের একটিতেই পড়ে। কোনটি সাজানো তা এক তুলনায় জানা যায়: a[lo] ≤ a[mid] হলে বাঁ অর্ধেক। সাজানো অর্ধেকের দুই প্রান্ত দেখে বলা যায় x সেখানে থাকতে পারে কি না — পারলে সেদিকে যাই, না পারলে অন্য দিকে। বন্ধ ছাঁচ [lo, hi] এখানে স্বাভাবিক, কারণ আমরা একটি নির্দিষ্ট মান খুঁজছি:
Pseudocode (0-based; distinct values; closed range [lo, hi])
SEARCH-ROTATED(a, n, x):
lo = 0, hi = n - 1
while lo <= hi:
mid = lo + (hi - lo) / 2
if a[mid] == x: return mid
if a[lo] <= a[mid]: // left part a[lo..mid] sorted
if a[lo] <= x < a[mid]: hi = mid - 1
else: lo = mid + 1
else: // right part a[mid..hi] sorted
if a[mid] < x <= a[hi]: lo = mid + 1
else: hi = mid - 1
return -1| lo, hi, mid | |
| a[mid] | |
| সাজানো অংশ | |
| সিদ্ধান্ত |
- শুরু: lo = 0, hi = 6. অ্যারে ঘর 3 আর 4-এর মাঝে “ভাঙা” (60 থেকে 5). আমরা সেটি জানি না — জানার দরকারও নেই।
- mid = 3, a[3] = 60 ≠ 20. a[0] = 30 ≤ 60, তাই বাঁ অংশ 30..60 সাজানো। 20 কি [30, 60)-এর মধ্যে? না — তাই বাঁ অংশে 20 থাকা অসম্ভব। lo = 4.
- mid = 5, a[5] = 10 ≠ 20. a[4] = 5 ≤ 10, তাই বাঁ অংশ a[4..5] = 5..10 সাজানো। 20 সেখানে নেই (20 ≥ 10), তাই lo = 6.
- mid = 6, a[6] = 20 = x. ফেরত 6. তিনটি ঘোরা; প্রতিবার পরিসর অন্তত অর্ধেক হয়, তাই সাধারণ binary search-এর মতোই O(log n). যদি x = 35 খুঁজতাম: ধাপ ২-এ 35 ∈ [30, 60), তাই hi = 2; তারপর mid = 1, hi = 0; mid = 0, lo = 1 > hi — ফেরত −1.
একই ভাবনায় “ভাঙার জায়গা” অর্থাৎ সবচেয়ে ছোট উপাদানের সূচক (rotation point) বের করা যায় — a[mid]-কে a[hi]-এর সাথে তুলনা করে: a[mid] > a[hi] হলে ভাঙা mid-এর ডানে (lo = mid + 1), নইলে mid বা তার বাঁয়ে (hi = mid). এটি আসলে half-open টেমপ্লেট, predicate “a[i] ≤ a[n − 1]” — যা ভাঙার আগে মিথ্যা, পরে সত্য। rotation point জানলে দুই সাজানো খণ্ডের একটিতে সাধারণ binary search চালানোও আরেকটি সঠিক পদ্ধতি।
#include <iostream>
#include <iomanip>
#include <vector>
#include <algorithm>
#include <initializer_list>
using namespace std;
int searchRotated(const vector<int>& a, int x) { // distinct values; index or -1
int lo = 0, hi = (int)a.size() - 1; // closed interval [lo, hi]
while (lo <= hi) {
int mid = lo + (hi - lo) / 2;
if (a[mid] == x) return mid;
if (a[lo] <= a[mid]) { // left part a[lo..mid] is sorted
if (a[lo] <= x && x < a[mid]) hi = mid - 1; else lo = mid + 1;
} else { // right part a[mid..hi] is sorted
if (a[mid] < x && x <= a[hi]) lo = mid + 1; else hi = mid - 1;
}
}
return -1;
}
int minIndex(const vector<int>& a) { // rotation point
int lo = 0, hi = (int)a.size() - 1;
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (a[mid] > a[hi]) lo = mid + 1; else hi = mid;
}
return lo;
}
int main() {
vector<int> a = {30, 40, 50, 60, 5, 10, 20};
cout << "rotation point: " << minIndex(a) << '\n';
for (int x : {20, 35, 60, 5, 30, 99})
cout << "search " << setw(2) << x << " -> " << searchRotated(a, x) << '\n';
vector<int> base = {1, 3, 5, 7, 9, 11, 13}; bool ok = true; // all rotations
for (int k = 0; k < (int)base.size(); ++k) {
vector<int> r = base; rotate(r.begin(), r.begin() + k, r.end());
for (int x = 0; x < 15; ++x) {
auto it = find(r.begin(), r.end(), x); // brute force answer
int want = it == r.end() ? -1 : int(it - r.begin());
ok = ok && searchRotated(r, x) == want && r[minIndex(r)] == 1;
}
}
cout << "all 7 rotations checked: " << (ok ? "True" : "False") << '\n';
}আউটপুট:
rotation point: 4
search 20 -> 6
search 35 -> -1
search 60 -> 3
search 5 -> 4
search 30 -> 0
search 99 -> -1
all 7 rotations checked: True
rotation point 4 — সবচেয়ে ছোট 5 ঘর 4-এ। x = 20-এর ফল ৬, ওপরের ডায়াগ্রামের সাথে মিলে যায়; 35 আর 99 নেই। শেষ অংশটি একটি ছোট stress test: base-এর ৭টি rotation (k = 0 মানে একেবারে না ঘোরানো, সাধারণ সাজানো অ্যারে) আর 0 থেকে 14 পর্যন্ত প্রতিটি x — অর্থাৎ ভেতরের, নেই এমন, আর দুই প্রান্তের সব ক্ষেত্র — std::find-এর linear search-এর সাথে মেলানো। ১০৫টি পরীক্ষাই ঠিক। প্রতিটি খোঁজা O(log n) সময়, O(1) জায়গা।
def search_rotated(a, x): # distinct values; index of x or -1
lo, hi = 0, len(a) - 1 # closed interval [lo, hi]
while lo <= hi:
mid = (lo + hi) // 2
if a[mid] == x:
return mid
if a[lo] <= a[mid]: # left part a[lo..mid] is sorted
if a[lo] <= x < a[mid]: hi = mid - 1
else: lo = mid + 1
else: # right part a[mid..hi] is sorted
if a[mid] < x <= a[hi]: lo = mid + 1
else: hi = mid - 1
return -1
def min_index(a): # rotation point: index of the smallest
lo, hi = 0, len(a) - 1
while lo < hi:
mid = (lo + hi) // 2
if a[mid] > a[hi]: lo = mid + 1 # minimum is right of mid
else: hi = mid # minimum is mid or left of it
return lo
a = [30, 40, 50, 60, 5, 10, 20]
print("rotation point:", min_index(a))
for x in [20, 35, 60, 5, 30, 99]:
print(f"search {x:2} -> {search_rotated(a, x)}")
base, ok = [1, 3, 5, 7, 9, 11, 13], True # every rotation, every x
for k in range(len(base)):
r = base[k:] + base[:k]
for x in range(15):
want = r.index(x) if x in r else -1 # brute force answer
ok = ok and search_rotated(r, x) == want and r[min_index(r)] == 1
print("all 7 rotations checked:", ok)আউটপুট C++-এর সাথে হুবহু এক:
rotation point: 4
search 20 -> 6
search 35 -> -1
search 60 -> 3
search 5 -> 4
search 30 -> 0
search 99 -> -1
all 7 rotations checked: True
Python-এর a[lo] <= x < a[mid] লেখাটি গণিতের মতো শৃঙ্খলিত তুলনা; C++-এ একে দুই ভাগে && দিয়ে লিখতে হয় — C++-এ a <= x < b লিখলে প্রথম তুলনার true/false (1 বা 0)-কে b-এর সাথে তুলনা করে, কোনো error ছাড়াই ভুল ফল দেয়। base[k:] + base[:k] হলো k ঘর বাঁয়ে ঘোরানো, C++-এর std::rotate-এর সমান।
সদৃশ মান থাকলে নিশ্চয়তা ভেঙে যায়। [1, 1, 1, 1, 1, 0, 1]-এর মতো অ্যারেতে a[lo] = a[mid] = a[hi] হলে কোন অর্ধেক সাজানো, তা বলা যায় না। প্রচলিত সমাধান হলো তখন lo++ (বা hi−−) করে এক ঘর ছাঁটা — সঠিক, কিন্তু worst case Θ(n). আর এটি এড়ানো অসম্ভব: ভাবনার প্রশ্ন ৫-এ প্রমাণ দেখুন।
Peak element — সাজানো নয়, তবু log n
একটি অ্যারেতে peak element হলো এমন ঘর যার মান দুই প্রতিবেশীর চেয়ে বড় (প্রান্তের বাইরের ঘরকে −∞ ধরুন, আর পাশাপাশি দুটি মান সমান নয়)। পাহাড়ি পথে হাঁটছেন: সামনে চড়াই থাকলে সামনে এগোলে একটি চূড়া নিশ্চয়ই পাবেন — কারণ পথ কোথাও না কোথাও আবার নামবে, অথবা শেষ প্রান্তে গিয়ে থামবে, আর সেই প্রান্তও তখন চূড়া।
Pseudocode (0-based; a[-1] = a[n] = -infinity; neighbours differ)
FIND-PEAK(a, n):
lo = 0, hi = n - 1
while lo < hi:
mid = lo + (hi - lo) / 2 // mid + 1 <= hi, so a[mid+1] exists
if a[mid] < a[mid + 1]: lo = mid + 1 // uphill to the right
else: hi = mid // downhill: a peak is at mid or left
return loকেন ঠিক? Invariant: “a[lo − 1] < a[lo] (বা lo = 0), আর a[hi] > a[hi + 1] (বা hi = n − 1)”. এই অবস্থায় a[lo..hi]-এর সবচেয়ে বড় মানটি একটি peak: পরিসরের ভেতরের প্রতিবেশীরা তার চেয়ে ছোট, আর প্রান্তে থাকলে invariant বলছে বাইরের প্রতিবেশীও ছোট। a[mid] < a[mid + 1] হলে lo = mid + 1 করলে বাঁ শর্তটি নতুন lo-তে সত্য; নইলে hi = mid করলে ডান শর্তটি সত্য। lo = hi হলে সেই একমাত্র ঘরটিই peak. উদাহরণ [1, 3, 20, 4, 1, 0]: mid = 2, 20 > 4 → hi = 2; mid = 1, 3 < 20 → lo = 2; ফেরত 2 (মান 20). O(log n) — অথচ অ্যারে মোটেও সাজানো নয়! শিক্ষাটি হলো: binary search-এর জন্য দরকার এমন একটি প্রশ্ন, যার উত্তর একটি অর্ধেককে নিরাপদে বাদ দেয়।
৭ · দশমিক সংখ্যায় binary search — বর্গমূল আর নির্দিষ্ট সংখ্যক ঘোরা
এবার অ্যারেই নেই। প্রশ্ন: √2 কত? f(m) = m² ফাংশনটি m ≥ 0-এ বাড়তে থাকে, তাই “m² ≥ 2” একটি monotonic predicate — ছোট m-এ মিথ্যা, বড় m-এ সত্য। সীমানাটিই √2. পূর্ণসংখ্যার সূচকের বদলে এখন একটি বাস্তব সংখ্যার পরিসর [lo, hi] প্রতিবার অর্ধেক করা হয়। গণিতে এর নাম bisection method: f(lo) আর f(hi)-এর চিহ্ন আলাদা আর f অবিচ্ছিন্ন হলে মাঝে একটি মূল আছে (Bolzano-র intermediate value theorem), আর অর্ধেক করতে করতে সেটি ঘিরে ফেলা যায়।
Pseudocode: square root of c >= 0 by bisection on real numbers
SQRT(c):
lo = 0, hi = max(1, c) // invariant: lo*lo < c <= hi*hi (or c = 0)
repeat 100 times: // a fixed count, NOT "while hi - lo > eps"
mid = (lo + hi) / 2
if mid * mid < c: lo = mid
else: hi = mid
return hiএখানে lo = mid আর hi = mid — mid + 1 নেই, কারণ বাস্তব সংখ্যায় “পরের সংখ্যা” বলে কিছু নেই। নিচে c = 2, শুরুর পরিসর [0, 2]:
| ঘোরা | |
| mid | |
| mid² | |
| সিদ্ধান্ত | |
| নতুন [lo, hi] | |
| hi − lo |
- শুরু: [0, 2]. 0² = 0 < 2 আর 2² = 4 ≥ 2 — invariant অনুযায়ী √2 এই পরিসরেই।
- mid = 1, 1² = 1 < 2. √2 এর চেয়ে বড়, তাই lo = 1. পরিসরের প্রস্থ 2 থেকে 1.
- mid = 1.5, 2.25 ≥ 2. hi = 1.5. এখন জানি 1 ≤ √2 ≤ 1.5.
- mid = 1.25, 1.5625 < 2. lo = 1.25.
- mid = 1.375, 1.890625 < 2. lo = 1.375.
- mid = 1.4375, 2.06640625 ≥ 2. hi = 1.4375 — √2 = 1.41421356… সত্যিই 1.375 আর 1.4375-এর মাঝে।
- mid = 1.40625, lo = 1.40625. প্রস্থ 2/26 = 0.03125. প্রতিটি ঘোরা উত্তরের প্রায় এক bit যোগ করে: k ঘোরার পর প্রস্থ (hi − lo)/2k. ১০০ ঘোরার পর lo = 1.414213562373095 আর hi = 1.4142135623730951 — double-এ পাশাপাশি দুটি সংখ্যা, এর চেয়ে কাছে যাওয়া সম্ভব নয়।
কেন while (hi − lo > 1e-9) নয়? double-এ প্রায় ১৫–১৬টি অর্থপূর্ণ দশমিক অঙ্ক থাকে, তাই বড় সংখ্যার কাছে পাশাপাশি দুটি double-এর ফাঁক 1e-9-এর চেয়ে বড় হতে পারে (যেমন 1015-এর কাছে ফাঁক 0.125). তখন mid আর lo বা hi-এর থেকে আলাদা হয় না, hi − lo আর কমে না — লুপ চিরকাল চলে। নির্দিষ্ট সংখ্যক ঘোরা (৬০ থেকে ১০০) সবসময় থামে, আর সাধারণ পরিসরে double-এর সর্বোচ্চ নির্ভুলতায় পৌঁছায়। সময় O(k), যেখানে k ঘোরার সংখ্যা।
পূর্ণসংখ্যার বর্গমূল (⌊√c⌋) চাইলে দশমিকে না গিয়ে half-open টেমপ্লেট দিয়ে “প্রথম m যেখানে m·m > c” খুঁজুন, তারপর এক বাদ দিন — m·m গুণফল long long-এ রাখুন। আর এখানেই পরের পর্বের দরজা: “যে সবচেয়ে ছোট সময়ে কাজটি শেষ করা যায়”, “যে সবচেয়ে বড় দূরত্বে গরু রাখা যায়” — এমন অপ্টিমাইজেশন প্রশ্নকে একটি “এই মান দিয়ে কি সম্ভব?” প্রশ্নে বদলে, সেই হ্যাঁ/না-এর সীমানা binary search দিয়ে খোঁজা। এর নাম উত্তরের ওপর binary search, পুরোটা পর্ব ১৯-এ।
৮ · কত দ্রুত, আর এর চেয়ে দ্রুত সম্ভব কি — বিশ্লেষণ, নিম্নসীমা আর আরও দুই পদ্ধতি
Θ(log n) — হিসাবটি নিখুঁতভাবে
half-open টেমপ্লেটে পরিসরের আকার k = hi − lo. mid = lo + ⌊k/2⌋ হলে বাঁয়ে গেলে নতুন আকার ⌊k/2⌋, ডানে গেলে k − ⌊k/2⌋ − 1 ≤ ⌊k/2⌋. অর্থাৎ প্রতি ঘোরায় আকার সর্বোচ্চ ⌊k/2⌋. n থেকে শুরু করে বারবার অর্ধেক (নিচে গোল) করলে শূন্যে পৌঁছাতে ঠিক ⌊log₂ n⌋ + 1 টি ধাপ লাগে (n ≥ 1). recurrence-এর ভাষায় (পর্ব ০৮): T(n) = T(⌊n/2⌋) + Θ(1), তাই T(n) = Θ(log n).
- lower bound টেমপ্লেট: সমান পেলেও থামে না, তাই best case-ও Θ(log n) — ঘোরার সংখ্যা সবসময় ⌊log₂ n⌋ বা ⌊log₂ n⌋ + 1.
- বন্ধ ছাঁচ (সমান পেলে ফেরত): best case Θ(1) (প্রথম mid-এই x), worst case ⌊log₂ n⌋ + 1 = Θ(log n).
- জায়গা: iterative রূপে O(1); recursive রূপে প্রতিটি ডাক একটি stack frame, গভীরতা Θ(log n), তাই Θ(log n) (পর্ব ০৫)।
Ternary search কি দ্রুত, যেহেতু প্রতিবার তিন ভাগের দুই ভাগ বাদ দেয়? না। প্রতি ধাপে তার দুটি তুলনা লাগে, তাই মোট তুলনা প্রায় 2·log₃ n ≈ 1.26·log₂ n — binary search-এর চেয়ে বেশি। Ternary search-এর আসল ব্যবহার অন্য জায়গায়: একটি চূড়াওয়ালা (unimodal) ফাংশনের সর্বোচ্চ বিন্দু খোঁজা, যেখানে একমুখী predicate সরাসরি নেই।
নিম্নসীমা Ω(log n) — decision tree যুক্তি
কেউ কি আরও চালাক এমন অ্যালগরিদম বানাতে পারেন, যা শুধু উপাদানের তুলনা করে log n-এর চেয়ে কম ধাপে খোঁজে? না — আর এর প্রমাণ সুন্দর। শুধু তুলনা ব্যবহারকারী (comparison-based) যেকোনো অ্যালগরিদমকে একটি decision tree হিসেবে আঁকা যায়: প্রতিটি ভেতরের node একটি প্রশ্ন (“x ≤ a[i]?”), দুটি শাখা দুই উত্তর, আর প্রতিটি পাতা (leaf) একটি চূড়ান্ত উত্তর। একটি নির্দিষ্ট ইনপুটে অ্যালগরিদম মূল থেকে একটি পাতা পর্যন্ত একটি পথ বেয়ে চলে; worst case-এ তুলনার সংখ্যা = গাছের উচ্চতা h. n = 3-এর জন্য lower bound-এর গাছ:
x <= a[1] ?
/ \
yes no
x <= a[0] ? x <= a[2] ?
/ \ / \
ans 0 ans 1 ans 2 ans 3
lower bound-এর উত্তর 0, 1, …, n — মোট n + 1 টি সম্ভাব্য ফল, আর প্রতিটির জন্য অন্তত একটি পাতা চাই (নইলে কোনো ইনপুটে ভুল উত্তর আসবে)। h উচ্চতার একটি binary tree-তে সর্বোচ্চ 2h টি পাতা থাকে। তাই
আর binary search ঠিক ⌈log₂(n + 1)⌉ = ⌊log₂ n⌋ + 1 টি তুলনায় কাজ সারে — অর্থাৎ শুধু ক্রমে নয়, ধ্রুবকসহ এটি সর্বোত্তম। এই lower bound কোনো একটি অ্যালগরিদমের নয়, পুরো সমস্যার (পর্ব ০৪)। একই ধাঁচের যুক্তিতে পর্ব ২৪-এ দেখবেন, তুলনা দিয়ে সর্টিং Ω(n log n)-এর নিচে সম্ভব নয়। তবে সীমাটি শুধু তুলনার মডেলে খাটে: hash table (পর্ব ৩২) key-এর ওপর গণিত চালায়, তাই গড়ে O(1); interpolation search মানের পার্থক্য দিয়ে ভাগ করে — নিচে দেখুন।
Exponential (galloping) search — অ্যারের শেষ জানা না থাকলে
ধরুন একটি বিশাল বা অসীম সাজানো ক্রম — একটি log ফাইলের সময়-সাজানো লাইন, যার দৈর্ঘ্য আগে জানা নেই, অথবা খুব লম্বা অ্যারে যেখানে উত্তর সম্ভবত শুরুর কাছে। hi কোথায় শুরু করবেন? Exponential search: সূচক 1, 2, 4, 8, 16, … দেখতে থাকুন, যতক্ষণ না a[b] ≥ x. তখন জানি উত্তর (b/2, b]-এর মধ্যে — সেখানে সাধারণ lower bound.
Pseudocode (0-based): exponential search, answer = first i with a[i] >= x
EXP-SEARCH(a, n, x): // n may be huge or unknown (then treat a[i] = +inf past the end)
if a[0] >= x: return 0
b = 1
while b < n and a[b] < x: // gallop: 1, 2, 4, 8, ...
b = 2 * b
return LOWER-BOUND on [b/2 + 1, min(b, n)) // a[b/2] < x is knownউত্তর যদি অবস্থান p-তে থাকে, লাফানোর ধাপ ⌈log₂ p⌉-এর মতো, আর শেষের binary search-ও p/2 আকারের পরিসরে — মোট প্রায় 2·log₂ p টি তুলনা, অর্থাৎ O(log p), n-এর ওপর নির্ভর নয়। Jon Bentley আর Andrew Chi-Chih Yao ১৯৭৬ সালে “An almost optimal algorithm for unbounded searching” প্রবন্ধে অসীম খোঁজার এই প্রশ্ন বিশ্লেষণ করেন। বাস্তবে এর সবচেয়ে বিখ্যাত ব্যবহার Tim Peters-এর ২০০২ সালের Timsort-এ (Python-এর সর্টিং, পর্ব ২৫): দুটি সাজানো খণ্ড জোড়া দেওয়ার সময় একটি খণ্ড থেকে টানা অনেক উপাদান আসতে থাকলে “galloping mode”-এ লাফিয়ে লাফিয়ে সীমানা খোঁজা হয়।
Interpolation search — ফোনবুকে “Zaman” খুঁজতে শেষের দিকে খোলা
ফোনবুকে “Zaman” খুঁজতে কেউ মাঝখানে খোলেন না — সোজা শেষের দিকে যান, কারণ জানেন Z বর্ণমালার শেষে। Interpolation search এই অনুমানটিকে সূত্রে বাঁধে: মান যদি মোটামুটি সমানভাবে ছড়ানো থাকে, তাহলে x-এর সম্ভাব্য অবস্থান
mid-এর বদলে pos দেখে একই ভাবে এক দিক বাদ দেওয়া হয়। W. W. Peterson ১৯৫৭ সালে এর ধারণা প্রকাশ করেন। মান সমভাবে বণ্টিত (uniform) এলোমেলো হলে গড়ে মাত্র O(log log n) টি ধাপ লাগে — ১০০ কোটি উপাদানে মোটামুটি পাঁচ-ছয়টি — যা ১৯৭৬–১৯৭৮ সালে Yao ও Yao এবং Perl, Itai ও Avni-র বিশ্লেষণে প্রতিষ্ঠিত। কিন্তু worst case O(n): [1, 2, 3, …, 99, 109]-এর মতো বাঁকা বণ্টনে pos প্রতিবার মাত্র এক ঘর এগোয়। সাথে আছে গুণফলের overflow আর a[hi] = a[lo] হলে শূন্য দিয়ে ভাগের ঝুঁকি। তাই এটি বিশেষ ক্ষেত্রের (জানা, সমান বণ্টনের সংখ্যাসূচক key) হাতিয়ার; সাধারণ কাজে binary search-ই নিরাপদ।
| পদ্ধতি | শর্ত | সময় | বাড়তি জায়গা |
|---|---|---|---|
| Linear search | কোনো শর্ত নেই; linked list-এও চলে | Θ(n) worst, গড়ে (n + 1)/2 তুলনা | O(1) |
| Binary search | সাজানো, random access | Θ(log n) | O(1) iterative |
| Exponential search | সাজানো, দৈর্ঘ্য অজানা হলেও চলে | O(log p), p = উত্তরের অবস্থান | O(1) |
| Interpolation search | সাজানো সংখ্যা, প্রায় সমান বণ্টন | গড়ে O(log log n), worst O(n) | O(1) |
| Hash table (পর্ব ৩২) | শুধু “আছে কি না”; ক্রমের প্রশ্ন নয় | গড়ে O(1), worst O(n) | O(n) |
আরও গভীরে: তাত্ত্বিকভাবে সর্বোত্তম হলেও বড় অ্যারেতে binary search হার্ডওয়্যারের কাছে অপ্রিয়: প্রথম কয়েকটি mid একে অপরের থেকে অনেক দূরে, তাই প্রায় প্রতিটি ধাপে নতুন cache miss, আর “বাঁয়ে না ডানে” শাখাটি CPU আগে থেকে অনুমান করতে পারে না। সমাধান হিসেবে অ্যারেকে heap-এর মতো ক্রমে সাজানো (Eytzinger layout) আর শাখাহীন (branchless) binary search বাস্তবে কয়েক গুণ দ্রুত হতে পারে — Paul-Virak Khuong ও Pat Morin-এর ২০১৭ সালের পরীক্ষামূলক গবেষণা এর পরিচিত উৎস। ডিস্কে একই সমস্যার উত্তর B-tree (পর্ব ৩০): প্রতিটি node-এ শত শত key, তাই উচ্চতা logB n — ডিস্কের পড়া অনেক কম।
পরীক্ষা ও ইন্টারভিউতে:
- Binary search-এর worst-case তুলনার সংখ্যা n উপাদানে? — ⌊log₂ n⌋ + 1 = ⌈log₂(n + 1)⌉; সময় Θ(log n), iterative জায়গা O(1).
- সাজানো অ্যারেতে x কতবার আছে, O(log n)-এ? — upper_bound(x) − lower_bound(x).
- (lo + hi) / 2-এ সমস্যা কী? — lo + hi int-এর সীমা ছাড়াতে পারে; লিখুন lo + (hi − lo) / 2.
- তুলনা-ভিত্তিক খোঁজার নিম্নসীমা কেন Ω(log n)? — decision tree-তে অন্তত n + 1 টি পাতা লাগে, উচ্চতা h হলে 2h ≥ n + 1.
- Rotated sorted array-তে খোঁজা? — প্রতি mid-এ এক অর্ধেক সাজানো; সেটিতে x থাকতে পারে কি না দেখে সেদিকে যান — O(log n), সদৃশ মান থাকলে worst Θ(n).
৯ · AI-এর যুগে binary search — দশ লাইনের কোড, একশো রকম ভুল
“Binary search লিখে দাও” — AI এক সেকেন্ডে লিখবে, আর সাধারণ ক্ষেত্রে প্রায় সবসময় ঠিক। কিন্তু ইন্টারভিউ আর কনটেস্টের প্রশ্ন সাধারণ ক্ষেত্র নয়: “প্রথম অবস্থান”, “শেষ অবস্থান”, “ঠিক বড় এমন প্রথম”, rotated, সদৃশ মান, উত্তরের ওপর খোঁজা। এখানে AI প্রায়ই দুই ছাঁচের লাইন মিশিয়ে ফেলে — while (lo <= hi)-এর সাথে hi = mid — যা কিছু ইনপুটে ঠিক চলে, কিছুতে চিরকাল ঘোরে। ব্যাখ্যাও আত্মবিশ্বাসী থাকে, তাই ভুলটি চোখ এড়িয়ে যায়।
Binary search-এ AI সাধারণত যেসব ভুল করে
| AI-এর ভুল | কীভাবে ধরবেন |
|---|---|
দুই ছাঁচ মেশানো (lo <= hi + hi = mid, বা lo = mid + lo < hi) | দুই-ঘরের আর এক-ঘরের অ্যারেতে হাতে trace; লুপ থামে কি না দেখুন |
| “প্রথম অবস্থান” চাইলে সমান পেয়েই ফেরত দেওয়া | [2, 2, 2, 2] দিয়ে চালান — উত্তর 0 আসে কি না |
| সমান পেয়ে বাঁয়ে linear-ভাবে হাঁটা, আর তবু “O(log n)” দাবি | সব উপাদান সমান এমন বড় অ্যারে — সময় Θ(n) হয়ে যায় |
ফলাফল n হলে a[lo] পড়া | x সবার চেয়ে বড় — এই একটি test সবসময় দিন |
(lo + hi) / 2 int-এ | lo, hi 109-এর কাছাকাছি এমন উত্তর-পরিসরে চালান |
দশমিকে while (hi - lo > eps) খুব ছোট eps দিয়ে | বড় মানে (1015) চালান; নির্দিষ্ট ঘোরা চান |
| Rotated array-তে সদৃশ মানেও O(log n) দাবি | [1, 1, 1, 0, 1, 1]-এর মতো ইনপুট আর worst case-এর যুক্তি চান |
AI-কে কোড লেখার যন্ত্র নয়, পরীক্ষক হিসেবে ব্যবহার করুন — নিজে লিখুন, তারপর AI দিয়ে ভাঙার চেষ্টা করান:
AI-এর (বা নিজের) binary search যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১Binary search-এর ধারণা ১৯৪৬ সালেই জানা — তাহলে সব n-এর জন্য ঠিকঠাক কাজ করে এমন সংস্করণ ছাপা হতে এত বছর লাগল কেন?
অর্ধেক করে খোঁজার ধারণা মানুষ বহু আগে থেকেই ব্যবহার করত — অভিধান, লগারিদমের ছাপা টেবিল, গ্রন্থাগারের ক্যাটালগ। কম্পিউটারের প্রেক্ষাপটে প্রথম লিখিত উল্লেখ হিসেবে সাধারণত ধরা হয় ১৯৪৬ সালের Moore School Lectures, যেখানে John Mauchly (ENIAC-এর অন্যতম নির্মাতা) সাজানো তালিকায় খোঁজার এই পদ্ধতির কথা বলেন। Donald Knuth তাঁর “The Art of Computer Programming”-এর তৃতীয় খণ্ডে ইতিহাসটি খুঁটিয়ে লিখেছেন, আর একটি অবাক করা পর্যবেক্ষণ দিয়েছেন: পরের এক দশকের বেশি সময় প্রকাশিত সংস্করণগুলো মূলত এমন n-এর জন্য লেখা ছিল যা 2-এর ঘাত থেকে এক কম (যেমন ৭, ১৫, ৩১), যাতে প্রতিবার পরিসর ঠিক সমান দুই ভাগ হয়। সব n-এর জন্য কাজ করে এমন প্রথম সংস্করণের কৃতিত্ব সাধারণত দেওয়া হয় Derrick Henry Lehmer-এর ১৯৬০ সালের লেখাকে; পরে ১৯৬২ সালে Hermann Bottenbruch ALGOL 60-এ এমন একটি রূপ দেন যেখানে সমতার পরীক্ষা লুপের শেষে — আজকের lower bound ছাঁচের পূর্বসূরি।
এত দেরির কারণ ধারণার জটিলতা নয়, সীমানার জটিলতা। “মাঝখান” কোনটি যখন পরিসরের আকার জোড়? এক উপাদান বাকি থাকলে কী? x না থাকলে লুপ কোথায় থামবে? প্রতিটি প্রশ্নের দুই-তিনটি সম্ভাব্য উত্তর, আর সেগুলো একে অপরের সাথে মিলতে হয়। Jon Bentley “Programming Pearls”-এ লিখেছেন, তিনি পেশাদার প্রোগ্রামারদের কয়েক ঘণ্টা সময় দিয়ে binary search লিখতে বলেছিলেন, আর পরীক্ষার পর দেখা গেল মোটামুটি দশ শতাংশের কোড সঠিক। তারপর ২০০৬ সালে Joshua Bloch দেখালেন, Bentley-র নিজের প্রমাণিত সংস্করণ আর Java-র লাইব্রেরি — দুটিতেই overflow-এর বাগ ছিল, কারণ প্রমাণটি গাণিতিক পূর্ণসংখ্যা ধরে নিয়েছিল, কম্পিউটারের 32-bit int নয়।
এই ইতিহাস থেকে আধুনিক অভ্যাস তৈরি হয়েছে: একটি ছাঁচ ঠিক করুন, invariant লিখুন, আর সম্ভব হলে নিজে না লিখে পরীক্ষিত লাইব্রেরি (std::lower_bound, bisect) ব্যবহার করুন। প্রমাণের প্রতিটি অনুমান — “সূচক যোগ করলে overflow হয় না” এর মতো — কোডের বাস্তব টাইপের সাথে মেলান।
মূল উপলব্ধি: সহজ ধারণা আর সঠিক বাস্তবায়ন আলাদা জিনিস। Binary search-এর ইতিহাস দেখায় যে সীমানার সিদ্ধান্তগুলো ধারণার চেয়ে বেশি সময় আর মনোযোগ চায় — invariant সেই মনোযোগের সবচেয়ে কার্যকর রূপ।
প্র ০২তুলনা দিয়ে খোঁজার নিম্নসীমা Ω(log n) — তাহলে hash table কীভাবে গড়ে O(1)-এ খোঁজে? নিম্নসীমা কি ভুল?
নিম্নসীমা ভুল নয়; এটি একটি নির্দিষ্ট গণনার মডেলের ভেতরের সত্য। decision tree যুক্তি ধরে নেয় অ্যালগরিদম ইনপুট সম্পর্কে তথ্য পায় শুধু “x ≤ a[i]?” ধরনের হ্যাঁ/না প্রশ্নে। প্রতিটি প্রশ্নের উত্তর সম্ভাব্য ফলের সেটকে বড়জোর দুই ভাগ করে, তাই n + 1 টি ফলের মধ্যে একটিকে আলাদা করতে অন্তত log₂(n + 1) টি প্রশ্ন লাগে। এটি আসলে তথ্যতত্ত্বের (information theory) কথা: n + 1 টি সমান সম্ভাব্য বিকল্পের মধ্যে একটি চিহ্নিত করতে log₂(n + 1) bit তথ্য দরকার, আর একটি হ্যাঁ/না উত্তর সর্বোচ্চ এক bit দেয়। Claude Shannon ১৯৪৮ সালে তথ্যের এই পরিমাপ প্রতিষ্ঠা করেন।
Hash table এই মডেলের বাইরে কাজ করে। সে key-কে তুলনা করে না, key-এর ওপর একটি গাণিতিক ফাংশন চালিয়ে সরাসরি একটি সূচক বানায় — যেন লাইব্রেরিতে বইয়ের নাম দেখে সোজা কোন তাকে যাবেন তা হিসাব করে ফেলা। একটি সূচক-গণনা log n bit-এর চেয়ে অনেক বেশি তথ্য একসাথে দেয় (মেমরির যেকোনো ঘরে একবারে যাওয়া, পর্ব ০২-এর RAM model). তাই “আছে কি না” প্রশ্নে গড়ে O(1) সম্ভব। একইভাবে interpolation search a[i]-এর মানগুলোর পার্থক্য দিয়ে ভাগ করে — এটিও শুধু তুলনা নয়, তাই সমান বণ্টনে O(log log n).
কিন্তু মূল্য আছে। hash table ক্রম হারায়: “x-এর চেয়ে বড় প্রথম মান কোনটি?”, “৫০০ থেকে ৮০০ টাকার মধ্যে কয়টি জিনিস?” — এমন প্রশ্নের উত্তর সে দিতে পারে না; তখন সাজানো অ্যারে বা balanced BST (পর্ব ২৭–২৯) লাগে, আর সেখানে আবার log n. আর hash-এর O(1) গড়ের নিশ্চয়তা, শত্রুভাবাপন্ন ইনপুটে worst case O(n) (পর্ব ১৭-এর ভাবনার প্রশ্ন ৩)। অন্যদিকে binary search-এর log n একটি নিশ্চিত worst-case সীমা, কোনো সম্ভাবনার ওপর নির্ভর করে না।
পরীক্ষায় তাই নিম্নসীমা লেখার সময় মডেলটিও লিখুন: “comparison-based search-এর worst case Ω(log n).” মডেল না বললে বক্তব্যটি অসম্পূর্ণ — যেমন “তুলনা দিয়ে সর্টিং Ω(n log n)”, অথচ counting sort (পর্ব ২৪) ছোট পরিসরের পূর্ণসংখ্যায় O(n + k).
মূল উপলব্ধি: নিম্নসীমা সবসময় একটি মডেলের দাবি। মডেলের বাইরে গেলে (hash, interpolation, counting) সীমা ভাঙা যায় — কিন্তু তার বদলে সাধারণত অন্য কিছু ছাড়তে হয়: ক্রম, নিশ্চয়তা বা সাধারণতা।
প্র ০৩Binary search কি শুধু অ্যারের জন্য? সফটওয়্যারের বাগ খোঁজা থেকে সমীকরণ সমাধান — অ্যারে ছাড়া এর ব্যবহার কোথায়?
Binary search-এর আসল বস্তু অ্যারে নয়, একটি monotonic প্রশ্ন: এমন একটি ক্রম যেখানে উত্তর একবার “না” থেকে “হ্যাঁ”-তে বদলায়, আর ফিরে আসে না। এই ছাঁচ যেখানেই পাওয়া যায়, সেখানেই log n.
সবচেয়ে সুন্দর উদাহরণ সফটওয়্যার তৈরির জগতে। একটি প্রকল্পের ইতিহাসে ধরুন এক হাজার commit (কোডের সংস্করণ)। আজকের সংস্করণে একটি বাগ আছে, এক মাস আগের সংস্করণে ছিল না। কোন commit বাগটি আনল? “এই সংস্করণে বাগ আছে কি?” প্রশ্নটি monotonic — একবার বাগ ঢুকলে পরের সব সংস্করণে থাকে (সাধারণত)। Git-এর git bisect কমান্ড ঠিক এটি করে: মাঝের commit বের করে দেয়, আপনি পরীক্ষা করে বলেন “ভালো” বা “খারাপ”, আর সে অর্ধেক বাদ দেয়। এক হাজার commit-এর মধ্যে মোটামুটি ১০টি পরীক্ষায় দোষী commit পাওয়া যায়। এমনকি পরীক্ষাটি একটি script দিয়ে স্বয়ংক্রিয়ও করা যায়।
গণিতে এর নাম bisection method, আর ভিত্তি Bernard Bolzano-র উনিশ শতকের intermediate value theorem: অবিচ্ছিন্ন f-এর f(lo) < 0 < f(hi) হলে মাঝে একটি মূল আছে। এটি ধীর (প্রতি ধাপে এক bit), কিন্তু অত্যন্ত নির্ভরযোগ্য — Newton-এর পদ্ধতি অনেক দ্রুত (প্রতি ধাপে নির্ভুল অঙ্ক প্রায় দ্বিগুণ হয়) কিন্তু খারাপ শুরুতে বিপথে যেতে পারে। তাই বাস্তব সংখ্যাসূচক লাইব্রেরি প্রায়ই দুটি মেশায়: bisection-এর নিশ্চয়তা আর দ্রুত পদ্ধতির গতি।
দৈনন্দিন জীবনেও আছে: একটি দীর্ঘ বৈদ্যুতিক তারের কোথায় সংযোগ বিচ্ছিন্ন, তা মাঝখানে পরীক্ষা করে অর্ধেক বাদ দিয়ে খোঁজা; চোখের পরীক্ষায় লেন্সের শক্তি বাড়িয়ে-কমিয়ে সঠিক মাপে পৌঁছানো; একটি ওষুধের কার্যকর মাত্রা খোঁজা। কনটেস্টে এর সবচেয়ে শক্তিশালী রূপ পর্ব ১৯-এ: “সবচেয়ে কম কত সময়ে কাজ শেষ করা যায়?” — একটি মান t ধরে “t সময়ে কি সম্ভব?” প্রশ্ন করা, যার উত্তর t-এ monotonic.
মূল উপলব্ধি: যেকোনো সমস্যায় জিজ্ঞেস করুন — “এমন কোনো হ্যাঁ/না প্রশ্ন আছে কি, যার উত্তর একবার বদলায়?” থাকলে binary search প্রযোজ্য, অ্যারে থাকুক বা না থাকুক।
প্র ০৪ছোট অ্যারেতে linear search কেন কখনো কখনো binary search-এর চেয়ে দ্রুত? Big-O কি তাহলে মিথ্যা বলে?
Big-O মিথ্যা বলে না, কিন্তু সে শুধু n বড় হলে বৃদ্ধির হারের কথা বলে, ধ্রুবকের নয় (পর্ব ০৪)। n = 16-এ linear search সর্বোচ্চ ১৬টি তুলনা, binary search ৫টি। কাগজে binary search তিন গুণ ভালো। কিন্তু আধুনিক CPU-তে প্রতিটি ধাপের খরচ সমান নয়।
প্রথম কারণ cache. পর্ব ১০-এ দেখেছেন, মেমরি থেকে ডেটা আসে ৬৪ byte-এর cache line-এ — ১৬টি 4-byte int একটি লাইনেই ধরে যায়। linear search পাশাপাশি পড়ে, তাই একবার মেমরি থেকে আনার পর বাকি সব প্রায় বিনা খরচে। দ্বিতীয় কারণ branch prediction: CPU আগাম অনুমান করে কোন শাখায় যাবে আর সেই অনুযায়ী কাজ এগিয়ে রাখে। linear search-এ “এখনো মেলেনি, এগোও” প্রায় সবসময় সত্য, তাই অনুমান প্রায় নিখুঁত। binary search-এ “বাঁয়ে না ডানে” প্রতিবার মুদ্রা নিক্ষেপের মতো — প্রায় অর্ধেক অনুমান ভুল হয়, আর প্রতিটি ভুলে CPU-কে কয়েক ডজন চক্রের কাজ ফেলে দিতে হয়। তৃতীয় কারণ SIMD: আধুনিক CPU একসাথে ৪, ৮ বা ১৬টি সংখ্যা তুলনা করতে পারে, যা linear search-এ স্বাভাবিকভাবে খাটে।
ফলে কোন n পর্যন্ত linear search জেতে, তা যন্ত্র, টাইপ আর compiler-এর ওপর নির্ভর করে — একটি সর্বজনীন সংখ্যা নেই; মাপতে হয় (benchmark). বড় n-এ অবশ্যই log n জেতে, কিন্তু সেখানেও উন্নতির সুযোগ আছে। Paul-Virak Khuong আর Pat Morin ২০১৭ সালে বিভিন্ন array layout-এর ওপর বিস্তারিত পরীক্ষা প্রকাশ করেন: অ্যারেকে Eytzinger ক্রমে (heap-এর মতো, মূল ১, সন্তান ২i আর ২i + 1 — নামটি ষোড়শ শতকের বংশতালিকা-বিশারদ Michael Eytzinger-এর সংখ্যায়ন পদ্ধতি থেকে) সাজালে প্রথম কয়েক স্তর একসাথে cache-এ থাকে, আর আগাম পড়া (prefetch) সহজ হয়; শাখাহীন কোডে branch-এর ভুল অনুমানও থাকে না। অনেক মাপজোখে এগুলো সাধারণ binary search-কে স্পষ্টভাবে হারায়।
এ কারণেই বাস্তব লাইব্রেরি প্রায়ই মিশ্র কৌশল নেয়: পরিসর ছোট হয়ে এলে binary search থামিয়ে linear search — যেমন সর্টিং-এ ছোট খণ্ডে insertion sort (পর্ব ২০, ২৫)।
মূল উপলব্ধি: asymptotic বিশ্লেষণ বলে কোনটি শেষ পর্যন্ত জিতবে; হার্ডওয়্যার বলে কখন থেকে। দুটি প্রশ্নই আলাদা, আর দ্বিতীয়টির উত্তর মেপে বের করতে হয়।
প্র ০৫সদৃশ মানসহ rotated sorted array-তে খোঁজা কেন worst case-এ Θ(n)? কোনো চালাক অ্যালগরিদম কি একে log n-এ নামাতে পারে না?
পারে না — আর এর প্রমাণ একটি সুন্দর adversary যুক্তি (পর্ব ০৫-এর adversarial input-এর ধারণা)। ধরুন অ্যারের দৈর্ঘ্য n, আর খুঁজছি x = 0. একজন “প্রতিপক্ষ” ইনপুট বানাচ্ছে, কিন্তু আগে থেকে ঠিক করে না — অ্যালগরিদম যে ঘরই পড়ুক, প্রতিপক্ষ উত্তর দেয় “1”.
লক্ষ করুন, সব 1-এর অ্যারে [1, 1, …, 1] একটি বৈধ rotated sorted array (০ বার ঘোরানো)। আবার যেকোনো একটি ঘরে 0 বসিয়ে বাকি সব 1 রাখলে — যেমন [1, 1, 1, 0, 1, 1] — সেটিও বৈধ: সাজানো [0, 1, 1, 1, 1, 1]-কে ঠিক ততটা ঘোরালেই পাওয়া যায়। এখন অ্যালগরিদম যদি n − 1 টির কম ঘর পড়ে থামে, তাহলে অন্তত দুটি ঘর না-পড়া থেকে যায়, ধরুন i আর j. প্রতিপক্ষ তখন দুটি ইনপুটের যেকোনোটি দেখাতে পারে: “সব 1” (উত্তর −1), অথবা “ঘর i-এ 0” (উত্তর i). দুটিই পড়া সব ঘরে অ্যালগরিদমের দেখা মানের সাথে মেলে, তাই অ্যালগরিদম দুটিকে আলাদা করতে পারে না — একটিতে অবশ্যই ভুল। সুতরাং worst case-এ অন্তত n − 1 টি ঘর পড়তে হবে: Ω(n). আর linear search O(n), তাই সমস্যাটির জটিলতা ঠিক Θ(n).
সদৃশ না থাকলে এই যুক্তি খাটে না, কারণ তখন a[lo] < a[mid] বা a[lo] > a[mid] — একটি তুলনাই বলে দেয় কোন অর্ধেক সাজানো। সদৃশ থাকলে a[lo] = a[mid] = a[hi] অবস্থায় তথ্য শূন্য: ভাঙা বাঁয়েও হতে পারে, ডানেও। তাই প্রচলিত সমাধান হলো সেই অবস্থায় lo++ ও hi−− করে এক ঘর করে ছাঁটা — সঠিক, বেশিরভাগ ইনপুটে দ্রুত, কিন্তু worst case Θ(n). এটি কোনো দুর্বল কোডের দোষ নয়; সমস্যার নিজের সীমা।
একই ধরনের যুক্তি অন্য জায়গায়ও কাজে লাগে: সাজানো নয় এমন অ্যারেতে খোঁজায় n টি ঘর পড়তেই হবে, কারণ না-পড়া যেকোনো ঘরে x লুকিয়ে থাকতে পারে — linear search তাই সর্বোত্তম। ইন্টারভিউতে “O(log n)-এ করো” শুনলে আগে জিজ্ঞেস করুন সদৃশ মান থাকতে পারে কি না।
মূল উপলব্ধি: binary search-এর গতি আসে প্রতিটি প্রশ্নের তথ্য থেকে। যখন প্রশ্নের উত্তর কিছুই বাদ দিতে দেয় না, তখন কোনো চালাকিই log n দিতে পারে না — আর adversary যুক্তি দিয়ে সেটি প্রমাণ করা যায়।
অনুশীলন
খাতা-কলম নিন। প্রথম তিনটি হাতে করার, শেষটিতে কোড লিখতে হবে।
-
হাতে trace করুন: a = [2, 5, 5, 5, 9, 12, 17] (০-ভিত্তিক, n = 7). half-open টেমপ্লেটে lower_bound(5) আর upper_bound(5) চালান — প্রতিটি ঘোরায় lo, hi, mid আর a[mid] লিখুন। তারপর 5 কতবার আছে বলুন।
lower_bound(5): (0, 7) → mid 3, a[3] = 5 ≥ 5 → hi = 3. (0, 3) → mid 1, a[1] = 5 ≥ 5 → hi = 1. (0, 1) → mid 0, a[0] = 2 < 5 → lo = 1. lo = hi = 1.
upper_bound(5): (0, 7) → mid 3, 5 ≤ 5 → lo = 4. (4, 7) → mid 5, a[5] = 12 > 5 → hi = 5. (4, 5) → mid 4, a[4] = 9 > 5 → hi = 4. lo = hi = 4.
গণনা = 4 − 1 = 3 (ঘর 1, 2, 3). দুটিতেই ৩টি ঘোরা = ⌊log₂ 7⌋ + 1. লক্ষ করুন, lower_bound-এর প্রথম ধাপেই a[mid] = 5 পাওয়া গিয়েছিল, তবু থামেনি — সেজন্যই প্রথম কপি (ঘর 1) পাওয়া গেল, ঘর 3 নয়।
-
Rotated array হাতে: a = [15, 18, 22, 3, 6, 9, 12]. (ক) rotated array-র খোঁজার পদ্ধতিতে x = 6-এর সূচক বের করুন, প্রতিটি ধাপে কোন অর্ধেক সাজানো বলুন। (খ) a[mid]-কে a[hi]-এর সাথে তুলনা করে সবচেয়ে ছোট উপাদানের সূচক বের করুন।
(ক) [0, 6], mid 3, a[3] = 3 ≠ 6. a[0] = 15 ≤ 3? না → ডান অংশ a[3..6] = 3..12 সাজানো; 3 < 6 ≤ 12, তাই lo = 4. [4, 6], mid 5, a[5] = 9. a[4] = 6 ≤ 9 → বাঁ অংশ 6..9 সাজানো; 6 ≤ 6 < 9, তাই hi = 4. [4, 4], mid 4, a[4] = 6 = x → উত্তর 4.
(খ) (0, 6): mid 3, a[3] = 3 > a[6] = 12? না → hi = 3. (0, 3): mid 1, a[1] = 18 > a[3] = 3 → lo = 2. (2, 3): mid 2, 22 > 3 → lo = 3. lo = hi = 3, মান 3 — সবচেয়ে ছোট, ঠিক।
-
গুনে দেখুন: (ক) 109 উপাদানের একটি সাজানো অ্যারেতে উত্তর সূচক p = 1000-এর কাছে। সাধারণ lower bound আর exponential search মোটামুটি কয়টি তুলনা করে? (খ) কেউ দাবি করলেন তাঁর তুলনা-ভিত্তিক অ্যালগরিদম 1000 উপাদানের সাজানো অ্যারেতে যেকোনো x-এর lower bound সবসময় ৯টি তুলনায় বের করে। কেন এটি অসম্ভব?
(ক) সাধারণ lower bound: ⌊log₂ 109⌋ + 1 = 29 + 1 = ৩০টি ঘোরা। exponential search: b = 1, 2, 4, …, 1024 — মোট ১১টি লাফ (20 থেকে 210); 1024-এ প্রথমবার a[b] ≥ x. তারপর [513, 1024) পরিসরে (৫১১টি ঘর) lower bound — সর্বোচ্চ ⌊log₂ 511⌋ + 1 = ৯টি। মোট প্রায় ২০টি। p যত ছোট, লাভ তত বেশি।
(খ) lower bound-এর সম্ভাব্য ফল 0 থেকে 1000 — মোট ১০০১টি। ৯টি হ্যাঁ/না তুলনায় সর্বোচ্চ 29 = 512 টি আলাদা ফল আলাদা করা যায়, আর 512 < 1001. তাই কোনো না কোনো দুটি ইনপুট একই পাতায় পৌঁছাবে অথচ তাদের উত্তর আলাদা — একটিতে ভুল হবেই। কমপক্ষে ⌈log₂ 1001⌉ = ১০টি তুলনা লাগে।
-
কোড লিখুন: পরস্পর আলাদা পূর্ণসংখ্যার একটি ছোট থেকে বড় সাজানো অ্যারে a দেওয়া। এমন সবচেয়ে ছোট সূচক i বের করুন যেখানে a[i] == i (fixed point); না থাকলে −1. সময় O(log n) হতে হবে। কেন binary search এখানে বৈধ, তা প্রমাণ করুন। (যেমন [−10, −5, 0, 3, 7] → 3.)
মূল চিন্তা: b[i] = a[i] − i ধরুন। মানগুলো আলাদা পূর্ণসংখ্যা আর সাজানো, তাই a[i + 1] ≥ a[i] + 1, অর্থাৎ b[i + 1] = a[i + 1] − (i + 1) ≥ a[i] − i = b[i]. b অ-হ্রাসমান (non-decreasing), তাই “b[i] ≥ 0” একটি monotonic predicate. এর প্রথম সত্য সূচক (lower bound) বের করে দেখুন b সেখানে ঠিক 0 কি না।
int fixedPoint(const vector<int>& a) { int lo = 0, hi = (int)a.size(); // first i with a[i] - i >= 0 while (lo < hi) { int mid = lo + (hi - lo) / 2; if (a[mid] - mid < 0) lo = mid + 1; else hi = mid; } return (lo < (int)a.size() && a[lo] == lo) ? lo : -1; }[−10, −5, 0, 3, 7]-এ b = [−10, −6, −2, 0, 3]. প্রথম b ≥ 0 সূচক 3-এ, আর b[3] = 0, তাই উত্তর 3. প্রথম সত্যটিই সবচেয়ে ছোট fixed point, কারণ তার আগে সব b < 0. সময় Θ(log n), জায়গা O(1). মান আলাদা না হলে (যেমন [0, 0, 0, 3]) b আর একমুখী থাকে না (b = [0, −1, −2, 0]) — তখন এই পদ্ধতি অচল, এবং worst case-এ সব ঘর দেখা লাগতে পারে।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমআলাদা মানের rotated sorted array-তে x খোঁজার একটি ঘোরার কাজগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- lo = 0, hi = n − 1 দিয়ে বন্ধ পরিসর শুরু করা
- mid বের করে a[mid] == x কি না দেখা
- a[lo] ≤ a[mid] দেখে ঠিক করা কোন অর্ধেক সাজানো
- x সেই সাজানো অর্ধেকের দুই প্রান্তের মধ্যে থাকলে সেদিকে, নইলে অন্য অর্ধেকে যাওয়া
- পরিসর ফাঁকা হয়ে গেলে −1 ফেরত দেওয়া
সাজানো অ্যারেতে half-open পরিসর [lo, hi) দিয়ে lower bound (প্রথম i যেখানে a[i] ≥ x) খোঁজা হচ্ছে। a[mid] < x হলে কী করা ঠিক?
lo = midlo = mid + 1hi = midhi = mid − 1
a = [2, 4, 4, 4, 7, 9] (০-ভিত্তিক)। lower_bound(4) আর upper_bound(4) যথাক্রমে কত, আর 4 কতবার আছে?
- 0 আর 3; গণনা 3
- 1 আর 3; গণনা 2
- 1 আর 4; গণনা 3
- 1 আর 5; গণনা 4
C++ বা Java-তে int সূচক দিয়ে binary search-এ mid = (lo + hi) / 2-এর বদলে mid = lo + (hi - lo) / 2 লেখা হয় কেন?
- খুব বড় অ্যারেতে lo + hi int-এর সীমা ছাড়িয়ে overflow করতে পারে
- দ্বিতীয়টিতে লুপ কম বার ঘোরে
- ভাগের কাজ দ্রুত হয়
- অ্যারে সাজানো না থাকলেও যাতে কাজ করে
n উপাদানের সাজানো অ্যারেতে শুধু উপাদানের তুলনা ব্যবহারকারী যেকোনো খোঁজার অ্যালগরিদমের worst case কেন Ω(log n)?
- কারণ সব খোঁজার অ্যালগরিদম recursive
- কারণ প্রতিটি তুলনায় একটি cache miss হয়
- কারণ binary search প্রতিবার ঠিক অর্ধেক করে
- কারণ এর decision tree-তে অন্তত n + 1 টি পাতা লাগে, আর h উচ্চতায় সর্বোচ্চ 2h টি পাতা থাকে
n টি node-এর একটি সাজানো singly linked list-এ একটি মান খুঁজতে হবে। সবচেয়ে উপযুক্ত পদ্ধতি আর তার worst-case সময় কী?
- Binary search, O(log n)
- Linear search, O(n)
- Interpolation search, O(log log n)
- সরাসরি hash দিয়ে, O(1)
দৈর্ঘ্য জানা নেই এমন একটি সাজানো ক্রমে x-এর উত্তর অবস্থান p. সূচক 1, 2, 4, 8, … দেখে সীমা বের করে তারপর binary search করলে (exponential search) মোট সময় কত?
- O(p)
- O(log n)
- O(log p)
- O(log log p)
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- Linear search Θ(n) — অসাজানো, ছোট বা linked list-এ এটিই ঠিক; sentinel প্রতি ধাপে একটি তুলনা কমায়।
- টেমপ্লেট: lo = 0, hi = n;
while (lo < hi); a[mid] < x হলে lo = mid + 1, নইলে hi = mid. Invariant: a[0..lo) < x ≤ a[hi..n). - mid = lo + (hi − lo) / 2; ফল n হতে পারে — a[lo] পড়ার আগে lo < n দেখুন।
- গণনা = upper − lower; প্রথম অবস্থান lower, শেষ upper − 1; rotated আর peak-এও binary search, কারণ একটি তুলনা একটি অর্ধেক বাদ দেয়।
- Θ(log n) সর্বোত্তম তুলনার মডেলে (decision tree); exponential O(log p), interpolation গড়ে O(log log n), দশমিকে নির্দিষ্ট সংখ্যক ঘোরা।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ১৯ · উত্তরের ওপর binary searchপরবর্তী পর্বঅপ্টিমাইজেশন প্রশ্নকে সিদ্ধান্তের প্রশ্নে রূপান্তর।
- ডেটা স্ট্রাকচার ও অ্যালগরিদম · কোর্স হোম৭২টি পর্বপুরো কোর্সের রোডম্যাপ — নয়টি মডিউল।
- C++ প্রোগ্রামিং · STL algorithmভাষার দিক
lower_bound,upper_bound,equal_rangeআরbinary_search. - Python প্রোগ্রামিং · অ্যালগরিদমপাশের ট্র্যাক
bisectআর Python-এ খোঁজা ও সাজানো। - Java প্রোগ্রামিং · অ্যালগরিদমপাশের ট্র্যাক
Arrays.binarySearchআরCollections.binarySearch.