DSA sheet · Recursion · Pattern 3: divide and conquer

Divide & Conquer (Binary Search)

This video starts the third recursion pattern: divide and conquer. The teacher calls it a kind of non-linear recursion with one big difference: the smaller problems never overlap, so no call ever repeats work and no DP (dynamic programming) is needed. She explains it with the best-known example, binary search: first the slow linear search, then the usual binary search with a while loop, and finally the same algorithm rewritten as a recursive function.

Why it matters: binary search appears everywhere. And the "turn the loop into recursion" steps you learn here (loop condition → base case, pointer updates → new arguments) work for many other problems. Merge sort and Median of Two Sorted Arrays come next in the same pattern.

This is a concept video, so the order is:
① what the pattern is → ② how to recognise it → ③ the intuition → ④ the example problem, worked fully (question, constraints, logic, steps, code, line by line, recursion tree + call stack, complexity) → ⑤ a pattern template in Python → ⑥ remember

Part 0 · Recursion from scratch

1. A function that calls itself

Recursion means a function solves a problem by calling itself on a smaller piece of the same problem. In binary search, "find 5 in positions 0 to 5" becomes "find 5 in positions 3 to 5". It's the same question on a smaller range, so the same function can answer it.

2. The base case: where it must stop

The base case is a situation simple enough to answer directly, without another call. Without it, the function would call itself forever. Python stops this after about 1000 nested calls with a RecursionError (you can raise the limit with sys.setrecursionlimit, but a missing base case is a bug, not a limit problem). In binary search there are two ways to stop: the range has become empty (not found), or the middle element is the target (found).

3. The recursive case: make the problem smaller

The recursive case calls the function again on a smaller input. Each call must move closer to the base case. In binary search, the range at least halves every time, so it must eventually become empty or hit the target.

4. Trust the smaller call ("leap of faith")

When the code says "search the right half", just trust that the call returns the correct index (or −1) for that half. Don't trace it in your head. You only need to make sure that (a) the base cases are right and (b) you pick the correct half. That trust is called the leap of faith.

5. The call stack: push on call, pop on return

Python keeps a call stack: a pile of calls that have started but not finished. A new call is pushed on top and the caller waits. When a call returns, it is popped, and its answer goes to the call below it. The tallest the pile gets is the depth, and that is the extra memory the recursion uses.

6. Work on the way down vs on the way back up

Some recursions do their real work after the smaller call returns (on the way back up, e.g. adding up results). Binary search does all its work on the way down (computing mid, comparing, choosing a half). On the way back up, each call just passes the answer it received to its caller, unchanged.


Part A · The divide and conquer pattern

1What the pattern is

Divide and conquer solves a problem in three moves:

  1. Divide: split the input into smaller parts, usually two halves.
  2. Conquer: solve the part(s) you need by calling the same function (recursion).
  3. Combine: build the final answer from the parts' answers. (In binary search this step is empty: the half's answer is the answer.)

The teacher places it as a variation of non-linear recursion. "Non-linear" means a function may branch into more than one call. Merge sort really calls both halves. Binary search could go either way, but it picks one side at each step.

2How to recognise it

3The intuition: why there's no overlap

Take the sorted array [1, 2, 3, 4, 5, 6]. Splitting it gives [1, 2, 3] and [4, 5, 6]. Split those again and you get [1, 2], [3], [4, 5], [6], and so on. Draw all the ranges that could ever be asked:

                 [1 2 3 4 5 6]
                /             \
         [1 2 3]               [4 5 6]
         /     \               /     \
      [1 2]    [3]          [4 5]    [6]
      /   \                 /   \
    [1]   [2]             [4]   [5]

Every box is a different piece of the array. 4, 5 and 6 can never show up inside the left side, and 1, 2, 3 can never show up on the right. So no call ever solves the same sub-problem twice.

Compare it with a recursion that does overlap, like Fibonacci: fib(5) calls fib(4) and fib(3), and fib(4) calls fib(3) again. These repeated calls are why such recursions blow up to about 2ⁿ calls and need DP (storing answers so you don't recompute them). Divide and conquer doesn't have that problem.

Doubt: so is divide and conquer always fast?
→ It doesn't repeat work, so it's usually efficient, and the teacher finds it one of the easier recursions, with little risk of TLE. How fast exactly depends on how many parts you solve. Binary search solves one half → O(log n). Merge sort solves both halves and merges them → O(n log n).

Part B · Example: find a target in a sorted array

LeetCode 704 · Binary Search

1The question in simple words

You get an array nums sorted from small to large, and a number target. Return the index (position) of target in nums, or −1 if it isn't there. (If a question only asks "is it present?", return True / False instead. The logic is the same.)

numstargetanswer
[1, 2, 3, 4, 5, 6]54 (the teacher's example: nums[4] = 5)
[−1, 0, 3, 5, 9, 12]94
[−1, 0, 3, 5, 9, 12]2−1 (not present)

2What the constraints tell us

3Brute force: linear search

Check every index from left to right until you find the target. In the worst case the target is at the last index (or missing), so you look at all n elements → O(n). This works on any array, sorted or not. It just ignores the fact that ours is sorted.

Linear search (brute force, O(n))
class Solution:
    def search(self, nums, target):
        for i in range(len(nums)):
            if nums[i] == target:     # found it at index i
                return i
        return -1                     # looked everywhere, not there

4Intuition for binary search

Think of looking up a word in a dictionary. You don't read page by page. You open the middle, see whether your word comes before or after, and ignore the other half completely.

We keep two pointers, left and right, marking the part of the array where the target could still be. Look at the middle index mid:

5Building the conditions, the way the teacher derives them

Where does mid come from?

mid = left + (right - left) // 2. It's the middle of the current range. With left = 0, right = 5: mid = 0 + 5 // 2 = 2.

Doubt 1: why not just (left + right) // 2?
→ In Java/C++, left + right can go over the int limit when both are huge, and become negative. left + (right − left) // 2 gives the same middle without that risk. Python ints never overflow, so both work here, but it's a good habit (and interviewers like it).

nums[mid] < target → left = mid + 1, not mid

Say we want 5 and the middle is 3. The target is bigger, so it lies to the right. The right pointer stays where it is. The left pointer moves. But to mid or mid + 1?

To mid + 1. If nums[mid] were the target, the first check (==) would already have returned it. It didn't, so mid is definitely not the answer and doesn't need to stay in the range.

nums[mid] > target → right = mid - 1

Same reasoning, other side. Say the target is 1 and the middle is bigger. The answer lies to the left, so left stays and right moves to mid − 1 (mid is already ruled out).

Doubt 2: what actually goes wrong if I write left = mid?
→ Besides re-checking a useless index, it can loop forever. With left = 4, right = 5: mid = 4. If nums[4] is smaller than the target, left = mid = 4 again, and nothing changes. With mid + 1, the range shrinks every single step.

The loop condition: left <= right

Left only moves right and right only moves left, so at some point they meet and then cross. The teacher checks three situations:

situationvalid?why
left < rightyesthere are several unchecked elements
left == rightyesone element is still unchecked. We only ever compare nums[mid] with the target (never nums[left] or nums[right]), so we must run once more to let mid land on it.
left > right (crossed)noeverything between them has been checked. Going on would only revisit checked indexes.

So the loop runs while left <= right. If it ends without returning, the target was not there → return −1 (or False).

6Approach steps

  1. left = 0, right = n − 1.
  2. While left ≤ right: mid = left + (right − left) // 2.
  3. If nums[mid] == target → return mid.
  4. Else if nums[mid] < target → left = mid + 1.
  5. Else → right = mid − 1.
  6. After the loop → return −1.

7Code (Python)

Binary search, iterative
class Solution:
    def search(self, nums, target):
        left, right = 0, len(nums) - 1
        while left <= right:                    # one element left is still valid
            mid = left + (right - left) // 2
            if nums[mid] == target:             # found
                return mid
            elif nums[mid] < target:            # target is on the right
                left = mid + 1
            else:                               # target is on the left
                right = mid - 1
        return -1                               # pointers crossed: not present

8Code line by line

linewhat it means
left, right = 0, len(nums) - 1At first, the target could be anywhere: the whole array.
while left <= right:Keep going while at least one unchecked index remains.
mid = left + (right - left) // 2The middle of the current range (overflow-safe form).
if nums[mid] == target: return midLucky hit, done.
elif nums[mid] < target: left = mid + 1Throw away mid and everything to its left.
else: right = mid - 1Throw away mid and everything to its right.
return -1The range became empty without a match.

9Dry run (the teacher's example) and complexity

nums = [1, 2, 3, 4, 5, 6], target = 5.

roundleftrightmidnums[mid]decision
105233 < 5 → right side → left = 3 (we just ignored [1, 2, 3])
235455 == 5 → return 4

Time. Every round halves the range: n → n/2 → n/4 → n/8 → … → 1. The number of halvings until 1 is log₂ n (base 2 because we cut into 2 halves). So O(log n). Space O(1): just three variables.

Remember iterative binary searchwhile l <= r · mid = l + (r − l)//2 · equal → return · smaller → l = mid + 1 · bigger → r = mid − 1 · after the loop → −1.

Part C · Binary search as divide and conquer recursion

The time won't get better by writing it recursively. The teacher's reasons for doing it anyway: (1) to get used to writing recursive code, and (2) to see a non-linear recursion pattern where sub-problems don't overlap. That pattern is divide and conquer.

1The question

The same as Part B. Now the function receives the range it should search: bs(nums, left, right, target).

2Constraints

n ≤ 10⁴, so the recursion is at most about 15 calls deep, far below Python's limit of ~1000. No setrecursionlimit needed.

3Intuition: what changes when the loop becomes recursion

The teacher's conversion rules:

iterative (Part B)recursive (Part C)
left, right are local variablesthey become parameters of the function
loop runs while left <= rightbase case = the opposite: if left > right → stop
return -1 after the loopthat stop returns −1 (not a bare return)
left = mid + 1 (changed by hand)return bs(nums, mid + 1, right, target): the change goes in as an argument
right = mid - 1return bs(nums, left, mid - 1, target)
mid computation and == target → return midexactly the same

4Building the base case

In a loop, the while condition is what stops it. Recursion has no loop condition. It keeps calling until we tell it to stop. The loop was valid while left <= right, so we must stop when left > right.

Doubt: what should the base case return?
→ Ask what it means: left > right means the pointers crossed, so the whole range was searched and the target wasn't found. In the loop version we returned −1 after the loop. So here the base case returns −1. A bare return would give None, which is wrong.

Notice the "found" check is a second stopping point: when nums[mid] == target, we return mid without calling again.

5Approach steps

  1. search(nums, target) calls bs(nums, 0, n − 1, target).
  2. bs: if left > right → return −1.
  3. mid = left + (right − left) // 2.
  4. nums[mid] == target → return mid.
  5. nums[mid] < target → return bs(nums, mid + 1, right, target).
  6. else → return bs(nums, left, mid − 1, target).

6Code (Python)

Binary search, recursive (divide and conquer)
class Solution:
    def search(self, nums, target):
        return self.bs(nums, 0, len(nums) - 1, target)

    def bs(self, nums, left, right, target):
        if left > right:                        # base case: range empty, not found
            return -1
        mid = left + (right - left) // 2
        if nums[mid] == target:                 # base case: found
            return mid
        elif nums[mid] < target:                # conquer the right half only
            return self.bs(nums, mid + 1, right, target)
        else:                                   # conquer the left half only
            return self.bs(nums, left, mid - 1, target)

7Code line by line

linewhat it means
return self.bs(nums, 0, len(nums) - 1, target)Start with the full range.
if left > right: return -1The loop's condition flipped. Empty range → not found.
mid = left + (right - left) // 2Divide: pick the split point.
if nums[mid] == target: return midFound → return straight away.
return self.bs(nums, mid + 1, right, target)Conquer only the right half. The return passes its answer straight back up (there's nothing to combine).
return self.bs(nums, left, mid - 1, target)Conquer only the left half.
Common mistakeWriting self.bs(...) without return in front. The deep call finds the answer, but the outer call throws it away and returns None.

8Dry runs: recursion tree and call stack

Run 1: target found (the teacher's example)

nums = [1, 2, 3, 4, 5, 6], target = 5. Each node shows bs(left, right), its mid, and what it returns. The faded side is the half we never call.

                bs(0,5)  mid=2 (3)  → 4
               /                   \
   bs(0,1) never called         bs(3,5)  mid=4 (5)  → 4   ✓ found
  1. bs(0, 5): range not empty. mid = 2, nums[2] = 3 < 5 → call bs(3, 5) and wait. stack: bs(0,5)
  2. bs(3, 5): mid = 3 + 1 = 4, nums[4] = 5 == 5 → return 4. Popped.
  3. bs(0, 5) receives 4 and returns it unchanged. Popped. Answer 4 ✓.

Run 2: target missing (shows the left == right case and the base case)

nums = [1, 2, 3, 4, 5, 6], target = 7.

bs(0,5)  mid=2 (3 < 7)  → −1
   |  go right
bs(3,5)  mid=4 (5 < 7)  → −1
   |  go right
bs(5,5)  mid=5 (6 < 7)  → −1     ← left == right: still checked
   |  go right
bs(6,5)  left > right   → −1     ← base case
  1. bs(0,5): mid 2, 3 < 7 → right half bs(3,5).
  2. bs(3,5): mid 4, 5 < 7 → bs(5,5).
  3. bs(5,5): one element left. This is why left == right must still be searched: nums[5] = 6 is compared. 6 < 7 → bs(6,5).
  4. bs(6,5): left > right, the pointers crossed → return −1.
  5. −1 is passed back through bs(5,5), bs(3,5), bs(0,5) unchanged. Answer −1 ✓.
run 1, deepest
bs(0,5)bs(3,5) → 4
run 2, deepest (4 calls)
bs(0,5)bs(3,5)bs(5,5)bs(6,5) → −1
run 2, returning
bs(0,5)bs(3,5) → −1

Even though the pattern counts as "non-linear", each call makes at most one call, so what actually runs is a single chain.

9Complexity: counting the calls

Doubt (a small correction): for n = 10⁴, how many steps is log₂ n?
→ In the video she estimates "around 16 or 17". The exact count is a bit smaller: 2¹³ = 8192 and 2¹⁴ = 16384, so log₂(10⁴) ≈ 13.3. That means at most 14 comparisons (15 calls if we count the final empty-range call). Her point still holds: compared with 10⁴ steps for linear search, it's tiny, almost constant. That's why the submission was among the fastest.
Remember recursive binary searchLoop condition flipped → base case left > right → −1. Pointer updates → arguments of the next call. Always return the recursive call. Time O(log n), space O(log n).

Part D · Pattern template

Divide and conquer in general (merge sort, which comes next, uses every line):

pattern template (skeleton, fill in the blanks)
def solve(data, lo, hi):
    if lo > hi:                       # 1. base case: empty range
        return EMPTY_ANSWER
    if lo == hi:                      #    (or: one element, answer directly)
        return ANSWER_FOR_ONE(data[lo])
    mid = lo + (hi - lo) // 2         # 2. DIVIDE at the middle
    left = solve(data, lo, mid)       # 3. CONQUER each part you NEED
    right = solve(data, mid + 1, hi)  #    (binary search needs only one)
    return COMBINE(left, right)       # 4. COMBINE (binary search: nothing to do)

And the recipe the teacher uses to turn any loop into recursion:

  1. Every variable the loop changes (here left and right) → a parameter.
  2. The loop condition, flipped (left > right) → the base case, which returns what the code after the loop returned.
  3. Each update (left = mid + 1) → a recursive call with the new value as the argument, and return its result.

Her honest tip: you won't write recursive binary search very often. Divide and conquer questions can usually be done with a loop too, and the time is usually the same either way. Learn the recursive version so the pattern feels natural when a problem (like merge sort) really needs both halves.


Part E · Revision page

linear searchbinary search (loop)binary search (recursion)
needs sorted input?noyesyes
stops whenfound / end of arrayfound / left > right ends the loopfound / base case left > right
moving the rangei += 1left = mid + 1 / right = mid − 1new arguments in the next call
timeO(n)O(log n)O(log n)
spaceO(1)O(1)O(log n) (call stack)
divide and conqueroverlapping non-linear recursion
sub-problemsdisjoint pieces (no element in two pieces)the same sub-problem is solved many times
examplebinary search, merge sortFibonacci, counting paths
needs DP?noyes, to avoid ~2ⁿ calls
If you remember only 5 lines 1. Divide and conquer = divide → conquer (recursion) → combine, with no overlapping sub-problems, so no DP.
2. Binary search needs sorted data. Compare only nums[mid].
3. left <= right: equal is still a valid one-element range.
4. Move to mid + 1 / mid − 1 because mid has already been checked.
5. Recursive version: base case left > right → −1. Same O(log n) time, but O(log n) stack space.
Mistakes to avoid ✗ while left < right (misses the last single element)
✗ left = mid / right = mid (can loop forever)
✗ a bare return in the base case (gives None, not −1)
✗ forgetting return before the recursive call
✗ using binary search on an unsorted array
test it yourself (paste under any Solution above)
s = Solution()
print(s.search([1, 2, 3, 4, 5, 6], 5))        # 4
print(s.search([-1, 0, 3, 5, 9, 12], 9))      # 4
print(s.search([-1, 0, 3, 5, 9, 12], 2))      # -1
print(s.search([7], 7))                       # 0
print(s.search([7], 3))                       # -1
print(s.search([1, 2, 3, 4, 5, 6], 7))        # -1

Based on this video: Divide and Conquer Recursion | Binary Search