Sorted data: binary search and intervals
“Where would target 3 first fit in sorted [1,3,3,8]? Now decide whether bookings ending and starting at time 3 overlap.”
The search returns index 1. Booking policy determines endpoint overlap; write that contract before using either binary search or interval merging.
The coding-practice chapter will apply this tool to complete problems. Here, focus on the mechanism and trace how its state changes.
Working example: Find the first index ≥ target; then merge overlapping closed intervals.
The idea: Search keeps [lo, hi) shrinking. Merging sorts by start, so only the last emitted interval can overlap the next.
Closed: [1,3] + [3,4] → [1,4]. Half-open meetings: [1,3) and [3,4) do not conflict.
First, what does sorted input buy us?
If numbers are already sorted, binary search can discard half the remaining candidates after each comparison. lower_bound asks for the first position whose value is at least the target. For [1, 3, 3, 8], target 3, the answer is index 1, not just any position holding 3. Returning len(nums) means every value is smaller.
nums, target = [1, 3, 3, 8], 3
lo, hi = 0, len(nums) # possible answer lives in [lo, hi)
while lo < hi:
mid = (lo + hi) // 2
if nums[mid] < target:
lo = mid + 1 # mid is too small to be the answer
else:
hi = mid # mid might be the FIRST valid position
print(lo) # 1
Python's slice notation [lo, hi) includes lo but excludes hi. The same endpoint convention matters for bookings. A meeting [09:00, 10:00) frees its room at 10:00, so [10:00, 11:00) can use that room. Closed intervals [1, 3] and [3, 4] both contain 3 and therefore overlap.
Read both visuals: in the first, watch the candidate search range shrink; in the second, watch a merged interval extend only when the next interval touches under the chosen closed-endpoint policy. Do not carry that policy unchanged into room scheduling.
Check the mechanism
Predict each expected result, then trace the state that produces it. Explain the boundary case before opening the reference.
Cost: Search: O(log n) time, O(1) space on sorted data. Merge: O(n log n) time, O(n) space including the sorted copy.
01 · Try this input
- Input / starting state
lower_bound([1, 2, 2, 5], 2)- Expected result
1
Why: First 2, not the later 2.
02 · Try this input
- Input / starting state
lower_bound([], 2)- Expected result
0
Why: Empty list: insertion goes at index 0.
03 · Try this input
- Input / starting state
lower_bound([1, 5], 9)- Expected result
2
Why: Insert after the last item.
04 · Try this input
- Input / starting state
- Merge closed
[[1, 5], [2, 3]] - Expected result
[[1, 5]]
Why: The inner interval changes nothing.
05 · Try this input
- Input / starting state
- Merge closed
[[1, 3], [3, 4]] - Expected result
[[1, 4]]
Why: Shared endpoint counts as overlap.
06 · Try this input
- Input / starting state
- Rooms half-open
[9, 10),[10, 11) - Expected result
- One room suffices
Why: End at 10 is no longer occupied.
For search, halving n candidates about log₂ n times gives O(log n) comparisons and two index variables give O(1) extra space. For merging, sorting dominates at O(n log n); the sorted copy and output can each store up to n intervals, so O(n) extra space. Define whether sorting may mutate the input before choosing an implementation.
Pass before moving on: Explain both midpoint updates and draw the difference between closed and half-open intervals.
Calendar meetings are half-open intervals. Should 09:00–10:00 merge with 10:00–11:00?
After attempting: reference and explanation
Compare lower_bound, merge_intervals in algorithms.py (download file, source below). Use pattern notes for the invariant and contracts for complexity edge cases. Reimplement tomorrow without copying.
"""Reference solutions. Try the exercises in README.md before opening this file."""
from collections import Counter, OrderedDict, deque
from heapq import heappop, heappush, nlargest
def two_sum(nums, target):
visited = {}
for j, value in enumerate(nums):
complement = target - value
if complement in visited:
return visited[complement], j
visited.setdefault(value, j)
return None
def longest_unique(text):
left = best = 0
last_seen = {}
for right, char in enumerate(text):
left = max(left, last_seen.get(char, -1) + 1)
best = max(best, right - left + 1)
last_seen[char] = right
return best
def subarray_sum(nums, target):
prefix_counts = {0: 1}
prefix = result = 0
for value in nums:
prefix += value
result += prefix_counts.get(prefix - target, 0)
prefix_counts[prefix] = prefix_counts.get(prefix, 0) + 1
return result
def lower_bound(nums, target):
lo, hi = 0, len(nums)
while lo < hi:
mid = lo + (hi - lo) // 2
if nums[mid] < target:
lo = mid + 1
else:
hi = mid
return lo
def merge_intervals(intervals):
"""Closed intervals; touching endpoints merge. Does not mutate input."""
out = []
for start, end in sorted(intervals):
if start > end:
raise ValueError('reversed interval')
if out and start <= out[-1][1]:
out[-1][1] = max(out[-1][1], end)
else:
out.append([start, end])
return out
def top_k_frequent(nums, k):
if k < 0:
raise ValueError('k must be nonnegative')
counts = Counter(nums)
# Higher count wins; smaller value breaks ties deterministically.
return nlargest(k, counts, key=lambda value: (counts[value], -value))
def islands(grid):
"""Rectangular list of lists of '0'/'1'; input remains unchanged."""
if not grid:
return 0
cols = len(grid[0])
if any(len(row) != cols or any(x not in ('0', '1') for x in row) for row in grid):
raise ValueError('expected rectangular binary grid')
seen = set()
count = 0
for r, row in enumerate(grid):
for c, value in enumerate(row):
if value != '1' or (r, c) in seen:
continue
count += 1
seen.add((r, c))
queue = deque([(r, c)])
while queue:
x, y = queue.popleft()
for a, b in ((x-1, y), (x+1, y), (x, y-1), (x, y+1)):
if 0 <= a < len(grid) and 0 <= b < cols and grid[a][b] == '1' and (a, b) not in seen:
seen.add((a, b)) # Mark on enqueue, not dequeue.
queue.append((a, b))
return count
def course_order(n, prerequisites):
"""Each pair is (course, prerequisite); [] means a cycle or no courses."""
if n < 0:
raise ValueError('negative course count')
adjacency = [set() for _ in range(n)]
degree = [0] * n
for course, prerequisite in prerequisites:
if not 0 <= course < n or not 0 <= prerequisite < n:
raise ValueError('course outside graph')
if course not in adjacency[prerequisite]:
adjacency[prerequisite].add(course)
degree[course] += 1
queue = deque(i for i, d in enumerate(degree) if d == 0)
order = []
while queue:
node = queue.popleft()
order.append(node)
for neighbor in adjacency[node]:
degree[neighbor] -= 1
if degree[neighbor] == 0:
queue.append(neighbor)
return order if len(order) == n else []
def shortest_paths(n, edges, source):
"""Directed nonnegative weighted graph; unreachable distances are infinity."""
if not 0 <= source < n:
raise ValueError('invalid source')
graph = [[] for _ in range(n)]
for u, v, weight in edges:
if not 0 <= u < n or not 0 <= v < n or weight < 0:
raise ValueError('invalid edge')
graph[u].append((v, weight))
distance = [float('inf')] * n
distance[source] = 0
heap = [(0, source)]
while heap:
cost, node = heappop(heap)
if cost != distance[node]:
continue
for neighbor, weight in graph[node]:
candidate = cost + weight
if candidate < distance[neighbor]:
distance[neighbor] = candidate
heappush(heap, (candidate, neighbor))
return distance
class LRU:
"""Capacity in entries; None denotes a miss. Not thread-safe."""
def __init__(self, capacity):
if capacity < 0:
raise ValueError('negative capacity')
self.capacity = capacity
self.data = OrderedDict()
def get(self, key):
if key not in self.data:
return None
self.data.move_to_end(key)
return self.data[key]
def put(self, key, value):
if self.capacity == 0:
return
self.data[key] = value
self.data.move_to_end(key)
if len(self.data) > self.capacity:
self.data.popitem(last=False)
def min_coins(coins, amount):
if amount < 0 or any(c <= 0 for c in coins):
raise ValueError('nonnegative amount and positive coins required')
dp = [0] + [amount + 1] * amount
for total in range(1, amount + 1):
for coin in coins:
if coin <= total:
dp[total] = min(dp[total], dp[total-coin] + 1)
return -1 if dp[amount] > amount else dp[amount]
def daily_temperatures(temperatures):
answer = [0] * len(temperatures)
stack = []
for i, temp in enumerate(temperatures):
while stack and temperatures[stack[-1]] < temp:
previous = stack.pop()
answer[previous] = i - previous
stack.append(i)
return answer
def word_exists(board, word):
"""4-neighbor word search; do not reuse a cell or mutate the board."""
if not word:
return True
if not board:
return False
cols = len(board[0])
if any(len(row) != cols for row in board):
raise ValueError('ragged board')
def visit(r, c, index, used):
if not (0 <= r < len(board) and 0 <= c < cols) or (r, c) in used or board[r][c] != word[index]:
return False
if index == len(word) - 1:
return True
used.add((r, c))
found = any(visit(a, b, index+1, used) for a, b in ((r+1,c),(r-1,c),(r,c+1),(r,c-1)))
used.remove((r, c))
return found
return any(visit(r, c, 0, set()) for r in range(len(board)) for c in range(cols))
class Trie:
END = object()
def __init__(self):
self.root = {}
def insert(self, word):
node = self.root
for char in word:
node = node.setdefault(char, {})
node[self.END] = True
def contains(self, word):
node = self.root
for char in word:
if char not in node:
return False
node = node[char]
return self.END in node