পাঠ ১৭ · ৫৭-এর মধ্যে · মডিউল ৪
Home / Courses / Design and Analysis of Algorithms / ডিভাইড অ্যান্ড কনকার

বাইনারি সার্চ ও এর ভ্যারিয়েন্ট

Binary Search and Its Variants
৮ মিনিট পড়া মধ্যম · Intermediate Python কোডসহ সম্পূর্ণ বাংলায়

এই পাঠে যা শিখবেন

  • বাইনারি সার্চের রিকারেন্স $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।

লুপ ইনভেরিয়েন্ট (সঠিকতার যুক্তি, L03 দ্রষ্টব্য)

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) এবং একটি নেইভ লিনিয়ার স্ক্যান — বহু র‍্যান্ডম টেস্ট কেসে, যার মধ্যে ডুপ্লিকেট-সহ ও খালি অ্যারেও আছে।

Python
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 ও নেইভ স্ক্যান) সাথে নিখুঁতভাবে মিলেছে। এই ব্যাপক, এলোমেলো টেস্টিং একটি একক হাতে-বাছাই করা উদাহরণের চেয়ে অনেক বেশি নির্ভরযোগ্য প্রমাণ দেয় যে বাস্তবায়নটি সঠিক।
মূল কথা · Key takeaway

"বাইনারি সার্চ" একটি একক অ্যালগরিদম নয় — এটি একটি পরিবার, যার প্রতিটি সদস্য একই $\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) করা।

অনুশীলন

  1. চিন্তা করুন: 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$-এর আগে — ক্রম সর্টেড থাকবে)।

  2. পরীক্ষা করুন: উপরের কোড সেলের শেষে একটি নতুন লাইনে 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-এ আপনার পরবর্তী পদক্ষেপ

আগের পাঠ
কুইক সর্ট ও পিভট সিলেকশন