বাইনারি সার্চ ও এর ভ্যারিয়েন্ট
এই পাঠে যা শিখবেন
- বাইনারি সার্চের রিকারেন্স $T(n) = T(n/2) + O(1)$ থেকে $\Theta(\log n)$ ডেরাইভ করতে পারা
- কেন স্ট্যান্ডার্ড বাইনারি সার্চ ডুপ্লিকেটের ক্ষেত্রে "প্রথম অকারেন্স" নিশ্চিত করে না, এবং কীভাবে ঠিক করতে হয়
- leftmost occurrence ও bisect-স্টাইল insertion point ভ্যারিয়েন্ট ইমপ্লিমেন্ট করতে পারা
- একটি জেনুইন কোড দিয়ে এই ভ্যারিয়েন্টগুলো নেইভ বেসলাইন ও stdlib-এর বিরুদ্ধে বহু কেসে যাচাই করতে পারা
১ · বাইনারি সার্চ — সংক্ষিপ্ত রিক্যাপ
বাইনারি সার্চের মেকানিক্স (একটি সর্টেড অ্যারেতে মাঝের এলিমেন্টের সাথে তুলনা করে অর্ধেক অংশ বাতিল করা) ইতিমধ্যে DSA কোর্সে কভার করা হয়েছে। L14-এর D&C টেমপ্লেটে এটি একটি বিশেষ কেস — ডিভাইড ধাপেই সিদ্ধান্ত নেওয়া হয়ে যায় কোন অর্ধেকে উত্তর থাকতে পারে, কনকার ধাপে শুধু একটি সাব-প্রবলেম রিকার্সিভভাবে সমাধান করা হয় (অন্যটি সম্পূর্ণ বাতিল), এবং কমবাইন ধাপে কিছুই করার থাকে না।
২ · রিকারেন্স ডেরিভেশন — $\Theta(\log n)$
প্রতিটি ধাপে (মাঝের এলিমেন্টের সাথে একটি তুলনা, $O(1)$) অ্যারের আকার অর্ধেক হয়ে যায় এবং মাত্র একটি সাব-প্রবলেম রিকার্সিভভাবে সমাধান করতে হয় ($a = 1$)। তাই রিকারেন্স:
$$T(n) = T(n/2) + O(1), \qquad T(1) = O(1)$$মাস্টার থিওরেম প্রয়োগ করতে ($a=1$, $b=2$, $f(n) = \Theta(1)$): $n^{\log_b a} = n^{\log_2 1} = n^0 = 1$। যেহেতু $f(n) = \Theta(1) = \Theta(n^{\log_b a})$ ($k=0$ সহ), এটি কেস ২-এর সাথে মেলে (L13 দ্রষ্টব্য):
$$T(n) = \Theta\!\left(n^{\log_b a} \log n\right) = \Theta(1 \cdot \log n) = \Theta(\log n)$$একই ফলাফল সরাসরি আনরোল (unroll) করেও পাওয়া যায়: $k$ বার অর্ধেক করার পর আকার দাঁড়ায় $n/2^k$; এটি $1$-এ পৌঁছায় যখন $2^k = n$, অর্থাৎ $k = \log_2 n$। প্রতিটি ধাপে $O(1)$ কাজ হলে মোট কাজ $O(\log n)$।
৩ · ভ্যারিয়েন্ট — Leftmost Occurrence ও Insertion Point
স্ট্যান্ডার্ড বাইনারি সার্চ শুধু বলে "target আছে কি না, থাকলে কোনো একটি ইনডেক্সে"। কিন্তু অ্যারেতে
ডুপ্লিকেট থাকলে (যেমন [2, 4, 4, 4, 4, 7]-এ target $= 4$), স্ট্যান্ডার্ড সংস্করণ ইনডেক্স $2$,
$3$, বা $4$ — যেকোনো একটি ফেরত দিতে পারে (কোন ইনডেক্সটি "মাঝে" পড়ে তার উপর নির্ভর করে)। বাস্তব প্রয়োগে
প্রায়ই নির্দিষ্টভাবে প্রথম (leftmost) অকারেন্স দরকার হয়।
সমাধান সহজ: match পেলেও সাথে সাথে না থেমে, বাম দিকেও আরও ছোট কোনো match আছে কি না খুঁজতে
থাকা — অর্থাৎ যখন arr[mid] == target, তখনও ডান সীমানা mid-এ নামিয়ে বাম দিকে
সার্চ চালিয়ে যাওয়া, right অর্ধেক পুরোপুরি বাদ দেওয়া নয়। এই একই কৌশল দিয়ে insertion pointও
বের করা যায় — অর্থাৎ target-কে অ্যারেতে বসালে সর্টেড ক্রম বজায় রাখতে সবচেয়ে-বাম কোন ইনডেক্সে বসাতে হবে
(Python-এর bisect.bisect_left ঠিক এই কাজটিই করে)। আসলে leftmost occurrence আর insertion point
একই ফাংশনের দুটি ব্যবহার: idx = insertion_point(arr, target); যদি arr[idx] == target
হয় তবে সেটাই leftmost occurrence, নাহলে target অ্যারেতে নেই কিন্তু idx এখনও বৈধ insertion
point।
insertion-point সার্চে lo, hi দুটি সীমানা বজায় রাখা হয়, এবং প্রতিটি ধাপের পরও
এই ইনভেরিয়েন্ট সত্য থাকে: $\forall i < lo: arr[i] < target$ এবং
$\forall i \ge hi: arr[i] \ge target$। শুরুতে ($lo=0, hi=n$) উভয় শর্ত তুচ্ছভাবে সত্য
(খালি রেঞ্জ)। প্রতিটি ধাপে mid পরীক্ষা করে হয় lo নাহয় hi
আপডেট হয়, কিন্তু ইনভেরিয়েন্ট ভাঙে না (Maintenance)। লুপ শেষ হয় যখন lo == hi
(Termination) — তখন ইনভেরিয়েন্ট থেকেই সরাসরি প্রমাণ হয় lo ঠিক সেই সবচেয়ে-বাম ইনডেক্স যেখানে
$target$ ঢোকালে ক্রম ঠিক থাকবে।
৪ · কম্পিউটেশনাল যাচাই — নেইভ স্ক্যান ও stdlib bisect-এর বিরুদ্ধে
নিচের কোডে কাস্টম bisect_left_custom (insertion point) ও তার উপর ভিত্তি করে
leftmost_occurrence ইমপ্লিমেন্ট করা হয়েছে। এগুলো দুটি ভিন্ন গ্রাউন্ড-ট্রুথের বিরুদ্ধে
যাচাই করা হয়েছে — Python-এর নিজস্ব bisect.bisect_left (stdlib) এবং একটি নেইভ লিনিয়ার স্ক্যান —
বহু র্যান্ডম টেস্ট কেসে, যার মধ্যে ডুপ্লিকেট-সহ ও খালি অ্যারেও আছে।
import random
import bisect
def bisect_left_custom(arr, target):
"""target ঢোকানোর সবচেয়ে-বাম বৈধ ইনডেক্স (bisect.bisect_left-এর মতোই)।"""
lo, hi = 0, len(arr)
while lo < hi:
mid = (lo + hi) // 2
if arr[mid] < target:
lo = mid + 1
else:
hi = mid
return lo
def binary_search_standard(arr, target):
"""স্ট্যান্ডার্ড বাইনারি সার্চ: target-এর যেকোনো একটি ইনডেক্স, নাহলে -1।"""
lo, hi = 0, len(arr) - 1
while lo <= hi:
mid = (lo + hi) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return -1
def leftmost_occurrence(arr, target):
"""প্রথম অকারেন্সের ইনডেক্স, নাহলে -1।"""
idx = bisect_left_custom(arr, target)
if idx < len(arr) and arr[idx] == target:
return idx
return -1
def linear_search_leftmost(arr, target):
"""নেইভ বেসলাইন: বাম থেকে ডানে স্ক্যান করে প্রথম মিল খুঁজে বের করা।"""
for i, x in enumerate(arr):
if x == target:
return i
return -1
random.seed(42)
trials = 300
bisect_matches = 0
leftmost_matches = 0
standard_correct = 0
for _ in range(trials):
n = random.randint(0, 20)
arr = sorted(random.randint(0, 10) for _ in range(n)) # ডুপ্লিকেট-সহ সর্টেড অ্যারে
target = random.randint(-2, 12) # কিছু target অ্যারের বাইরে
if bisect_left_custom(arr, target) == bisect.bisect_left(arr, target):
bisect_matches += 1
if leftmost_occurrence(arr, target) == linear_search_leftmost(arr, target):
leftmost_matches += 1
idx = binary_search_standard(arr, target)
present_actual = target in arr
if idx == -1:
standard_correct += int(not present_actual)
else:
standard_correct += int(0 <= idx < len(arr) and arr[idx] == target)
print(f"মোট টেস্ট: {trials}")
print(f"bisect_left_custom বনাম stdlib bisect.bisect_left মিলেছে: {bisect_matches}/{trials}")
print(f"leftmost_occurrence বনাম নেইভ লিনিয়ার স্ক্যান মিলেছে: {leftmost_matches}/{trials}")
print(f"binary_search_standard সঠিক ফলাফল দিয়েছে: {standard_correct}/{trials}")
assert bisect_matches == trials
assert leftmost_matches == trials
assert standard_correct == trials
print("\nসব টেস্ট পাস করেছে")
assert-ই পাস করে, অর্থাৎ ৩০০টি র্যান্ডম টেস্ট কেসের প্রতিটিতে — যার মধ্যে খালি অ্যারে
($n=0$), ভারী ডুপ্লিকেট ($0$–$10$ রেঞ্জের মান $০$–$২০$টি এলিমেন্টে বসানো, তাই পুনরাবৃত্তি অনিবার্য), এবং
অ্যারের বাইরের target-ও আছে — কাস্টম ইমপ্লিমেন্টেশন দুটি গ্রাউন্ড-ট্রুথের (stdlib ও নেইভ স্ক্যান) সাথে
নিখুঁতভাবে মিলেছে। এই ব্যাপক, এলোমেলো টেস্টিং একটি একক হাতে-বাছাই করা উদাহরণের চেয়ে অনেক বেশি নির্ভরযোগ্য
প্রমাণ দেয় যে বাস্তবায়নটি সঠিক।
"বাইনারি সার্চ" একটি একক অ্যালগরিদম নয় — এটি একটি পরিবার, যার প্রতিটি সদস্য একই $\Theta(\log n)$ রানটাইম রাখে কিন্তু লুপ ইনভেরিয়েন্টের সামান্য ভিন্নতায় ভিন্ন প্রশ্নের (আছে কি না? প্রথম কোথায়? কোথায় ঢোকাতে হবে?) উত্তর দেয়। মূল পাঠ: একটি সঠিক ইনভেরিয়েন্ট লিখতে পারলে বিভিন্ন ভ্যারিয়েন্ট ডিজাইন করা যান্ত্রিক হয়ে যায় — অনুমান-নির্ভর নয়।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিজে কিছুক্ষণ ভাবুন — তারপর "→ উত্তর" চাপুন।
প্র ০১
[2, 4, 4, 4, 4, 7] অ্যারেতে target $=4$ দিয়ে স্ট্যান্ডার্ড বাইনারি সার্চ চালালে প্রথম
mid কোন ইনডেক্স হবে, এবং তা কেন leftmost occurrence নয়?
প্রথম mid = (0+5)//2 = 2, অর্থাৎ arr[2] = 4 — সাথে সাথে মিলে যায় এবং
স্ট্যান্ডার্ড সংস্করণ তখনই ইনডেক্স $2$ ফেরত দিয়ে থেমে যায়। কিন্তু $4$-এর প্রকৃত প্রথম অকারেন্স ইনডেক্স
$1$-এ — যা স্ট্যান্ডার্ড সংস্করণ কখনো দেখেই না, কারণ এটি প্রথম মিলেই থেমে যায়, বাম দিকে আরও খুঁজে দেখে
না।
প্র ০২
উপরের কোডে random.randint(0, 20) দিয়ে n বাছাই করা হয়েছে, যার মানে মাঝে
মাঝে $n=0$ (খালি অ্যারে) তৈরি হবে। এই এজ কেসটি ইচ্ছাকৃতভাবে রাখার কারণ কী?
খালি অ্যারে প্রায়ই এমন একটি এজ কেস যেখানে অসতর্কভাবে লেখা বাইনারি সার্চ কোড ভেঙে পড়ে (যেমন
hi = len(arr) - 1 = -1 দিয়ে শুরু হলে লুপ কন্ডিশন সামলাতে ভুল হতে পারে)। এলোমেলো টেস্টিং-এ
এই এজ কেসটি নিজে থেকেই মাঝে মাঝে আসতে দেওয়া (বিশেষভাবে বাদ না দিয়ে) কোডকে বাস্তবে যত রকম ইনপুট আসতে পারে
তার একটি বাস্তবসম্মত নমুনার বিরুদ্ধে পরীক্ষা করে — এটি এই কোর্সের "ব্যাপক, এলোমেলো টেস্টিং" নীতির একটি
উদাহরণ।
প্র ০৩
leftmost_occurrence ফাংশনটি bisect_left_custom-এর উপর ভিত্তি করে লেখা —
এভাবে "একটি বেস প্রিমিটিভ থেকে অন্যগুলো তৈরি করা" পদ্ধতির সুবিধা কী?
bisect_left_custom-এর সঠিকতা একবার প্রমাণ/যাচাই করলেই (লুপ ইনভেরিয়েন্ট দিয়ে, এবং কোডে
stdlib-এর বিরুদ্ধে টেস্ট করে), তার উপর ভিত্তি করে তৈরি leftmost_occurrence-এর সঠিকতাও
সহজে যুক্তিসঙ্গত করা যায় — নতুন করে সম্পূর্ণ আলাদা লুপ-লজিক লেখার (এবং নতুন করে বাগ তৈরি হওয়ার) ঝুঁকি
কমে যায়। এটি সফটওয়্যার ডিজাইনের একটি সাধারণ নীতি — একটি ছোট, ভালোভাবে-যাচাই-করা বিল্ডিং ব্লক থেকে জটিল
আচরণ রচনা (compose) করা।
অনুশীলন
-
চিন্তা করুন:
arr = [1, 3, 3, 3, 5, 5, 8]-এbisect_left_custom(arr, 5)কী ফেরত দেবে বলে আপনার ধারণা? এবংbisect_left_custom(arr, 4)?bisect_left_custom(arr, 5)ফেরত দেবে $4$ (ইনডেক্স $4$-এ প্রথম $5$)।bisect_left_custom(arr, 4)ফেরত দেবে $4$-ও (কারণ $4$ অ্যারেতে নেই, কিন্তু $4$-কে ইনডেক্স $4$-এ বসালে — অর্থাৎ দুটি $3$-এর পরে, দুটি $5$-এর আগে — ক্রম সর্টেড থাকবে)। -
পরীক্ষা করুন: উপরের কোড সেলের শেষে একটি নতুন লাইনে
print(bisect_left_custom([1, 3, 3, 3, 5, 5, 8], 5), bisect_left_custom([1, 3, 3, 3, 5, 5, 8], 4))যোগ করে Run চেপে আপনার অনুমান যাচাই করুন।আউটপুটে
4 4দেখা যাবে — দুটি মানই আপনার হিসাবের সাথে মিলে যাবে।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- কোর্সের সম্পূর্ণ সিলেবাস দেখুন ৫৭টি পাঠ অ্যাসিম্পটোটিক অ্যানালাইসিস, রিকারেন্স, ডিভাইড অ্যান্ড কনকার, গ্রিডি, DP, গ্রাফ অ্যালগরিদম, ব্যাকট্র্যাকিং, স্ট্রিং অ্যালগরিদম, অ্যামর্টাইজড অ্যানালাইসিস, NP-কমপ্লিটনেস ও অ্যাপ্রক্সিমেশন — বাকি পাঠগুলো শীঘ্রই যুক্ত হবে।
- কুইক সর্ট ও পিভট সিলেকশন আগের পাঠ আরেকটি D&C অ্যালগরিদম, যেখানে ইনপুটের উপর নির্ভর করে জটিলতা পাল্টে যায়।
- Data Structures & Algorithms কোর্স সহোদর কোর্স বাইনারি সার্চের ইমপ্লিমেন্টেশন ওয়াকথ্রু সেই কোর্সেই আছে।