ScreenshotNeo

BlogGuides

Implementing Search Algorithms in Python

Implement binary search, BFS, DFS, Dijkstra and A* in Python with correct data structures, edge-case handling and practical complexity guidance.

By the ScreenshotNeo team1 October 20267 min read

Choose the search algorithm from the data and the goal:

  • Sorted sequence, exact value or boundary: binary search with bisect.
  • Unweighted graph, fewest edges: breadth-first search (BFS).
  • Reachability or exhaustive traversal: depth-first search (DFS).
  • Weighted graph with non-negative edges: Dijkstra’s algorithm.
  • Weighted pathfinding with a useful heuristic: A*.

The implementations below are complete Python programs. Each makes its preconditions explicit, handles duplicates or cycles, and shows the data structure that controls the frontier.

1. Binary search with Python’s bisect

Binary search requires a sequence sorted by the same ordering rule used by the search. Python’s bisect_left returns the first valid insertion position; bisect_right returns the position after equal values. Neither function proves that a target exists, so validate the index and compare the element yourself. The module uses the < relation while locating positions rather than calling equality for every step. See the Python bisect documentation.

from bisect import bisect_left, bisect_right


def index_of(sorted_values, target):
    """Return the first index equal to target, or -1 when absent."""
    i = bisect_left(sorted_values, target)
    if i != len(sorted_values) and sorted_values[i] == target:
        return i
    return -1


def equal_range(sorted_values, target):
    """Return the half-open range [start, end) containing target."""
    start = bisect_left(sorted_values, target)
    end = bisect_right(sorted_values, target)
    return start, end


values = [1, 2, 2, 2, 5, 9]
print(index_of(values, 2))       # 1
print(index_of(values, 7))       # -1
print(equal_range(values, 2))    # (1, 4)

Insertion and maintenance costs

insort performs an O(log n) search for the insertion point, then shifts elements in a Python list. The list insertion is O(n), so repeated ordered-list insertion is not an O(log n) operation. A dictionary or set is usually a better choice for direct membership lookup; Python’s documentation specifically notes that dictionaries are more performant for locating specific values. Bisect operations are also not thread-safe when another thread concurrently mutates or bisects the same sequence.

2. Breadth-first search (BFS)

BFS explores a graph level by level. In an unweighted graph, the first time a node is discovered gives a shortest path measured in edges. Use collections.deque as a FIFO queue: remove with popleft() and append newly discovered nodes. Mark a node when enqueuing it, which prevents cycles and duplicate work.

from collections import deque


def bfs_shortest_path(graph, start, goal):
    """Return a shortest unweighted path, or None if goal is unreachable."""
    queue = deque([start])
    parent = {start: None}

    while queue:
        node = queue.popleft()
        if node == goal:
            path = []
            while node is not None:
                path.append(node)
                node = parent[node]
            return path[::-1]

        for neighbor in graph.get(node, ()):
            if neighbor not in parent:
                parent[neighbor] = node
                queue.append(neighbor)

    return None


graph = {
    "A": ["B", "C"],
    "B": ["D"],
    "C": ["D", "E"],
    "D": ["F"],
    "E": ["F"],
    "F": [],
}
print(bfs_shortest_path(graph, "A", "F"))  # ['A', 'B', 'D', 'F']

With adjacency lists, BFS runs in O(V + E) time and uses O(V) memory for the queue and discovered set, where V is the number of vertices and E the number of edges. The exact constants depend on your graph representation.

3. Depth-first search (DFS)

DFS follows one branch as far as possible before backtracking. It is useful for reachability, connected components, cycle checks and topological-style traversals. It does not guarantee a shortest path.

def dfs_reachable(graph, start, goal):
    stack = [start]
    visited = set()

    while stack:
        node = stack.pop()
        if node in visited:
            continue
        visited.add(node)
        if node == goal:
            return True
        stack.extend(reversed(graph.get(node, ())))
    return False


def dfs_order(graph, start):
    """Return preorder traversal without revisiting cyclic nodes."""
    stack = [start]
    visited = set()
    order = []
    while stack:
        node = stack.pop()
        if node in visited:
            continue
        visited.add(node)
        order.append(node)
        stack.extend(reversed(graph.get(node, ())))
    return order

An explicit stack avoids Python’s recursion-depth limit. Recursive DFS can be clearer for small trees, but increase neither the recursion limit nor trust it for attacker-controlled depth without considering stack exhaustion.

4. Dijkstra’s algorithm with heapq

Dijkstra computes minimum weighted distances when every edge weight is non-negative. Store tentative distances in a min-heap and discard stale entries when a shorter route has already been found. The heapq documentation defines a min-heap whose smallest item is at index zero; heapify transforms a list in linear time.

import heapq
from itertools import count


def dijkstra(graph, start):
    distances = {start: 0}
    previous = {start: None}
    serial = count()  # tie-breaker for equal priorities
    heap = [(0, next(serial), start)]

    while heap:
        distance, _, node = heapq.heappop(heap)
        if distance != distances.get(node):
            continue  # stale heap entry

        for neighbor, weight in graph.get(node, ()):
            if weight < 0:
                raise ValueError("Dijkstra requires non-negative edge weights")
            candidate = distance + weight
            if candidate < distances.get(neighbor, float("inf")):
                distances[neighbor] = candidate
                previous[neighbor] = node
                heapq.heappush(heap, (candidate, next(serial), neighbor))

    return distances, previous


def restore_path(previous, target):
    if target not in previous:
        return None
    path = []
    while target is not None:
        path.append(target)
        target = previous[target]
    return path[::-1]


weighted = {
    "A": [("B", 4), ("C", 1)],
    "B": [("D", 1)],
    "C": [("B", 2), ("D", 5)],
    "D": [],
}
distances, previous = dijkstra(weighted, "A")
print(distances["D"])                    # 4
print(restore_path(previous, "D"))      # ['A', 'C', 'B', 'D']

The counter in each heap entry makes equal priorities comparable even when node objects cannot be ordered. With a binary heap and adjacency lists, the usual bound is O((V + E) log V), assuming the stated representation and non-negative weights.

A* adds a heuristic h(node) to the cost already traveled g(node). For guaranteed optimal paths, the heuristic must not overestimate the remaining cost; consistency also lets implementations avoid reopening many nodes. If the heuristic is zero, A* behaves like Dijkstra.

import heapq
from itertools import count


def a_star(graph, start, goal, heuristic):
    serial = count()
    g_score = {start: 0}
    parent = {start: None}
    heap = [(heuristic(start), next(serial), start)]

    while heap:
        _, _, node = heapq.heappop(heap)
        if node == goal:
            return restore_path(parent, goal), g_score[node]

        for neighbor, weight in graph.get(node, ()):
            tentative = g_score[node] + weight
            if tentative < g_score.get(neighbor, float("inf")):
                g_score[neighbor] = tentative
                parent[neighbor] = node
                priority = tentative + heuristic(neighbor)
                heapq.heappush(heap, (priority, next(serial), neighbor))

    return None, float("inf")

For a grid, a Manhattan-distance heuristic is appropriate when movement is four-directional and each step has the same cost. Do not use that heuristic unchanged for diagonal movement or variable terrain costs.

6. Choosing the right implementation

Goal Precondition Frontier Typical choice
Exact value in ordered data Sorted sequence Indices bisect_left plus equality check
Range boundaries Sorted sequence Indices bisect_left/bisect_right
Fewest edges Unweighted graph FIFO deque BFS
Reachability or full traversal Any graph LIFO stack DFS
Lowest weighted cost Non-negative weights Min-heap Dijkstra
Lowest cost with domain knowledge Valid heuristic Min-heap A*

7. Testing and edge cases

  • Test an empty sequence, a one-element sequence, an absent target and duplicate targets.
  • For graphs, test a missing start node, an unreachable goal, self-loops and cycles.
  • For weighted searches, test zero-weight edges and reject negative weights for Dijkstra.
  • Use immutable, comparable heap keys or include a unique counter as a tie-breaker.
  • Define whether endpoints are inclusive or exclusive before implementing range searches.
  • Keep the visited/discovered set separate from path reconstruction data when multiple paths exist.

8. Troubleshooting

Symptom Likely cause Fix
Binary search returns an index for a missing value Insertion position was mistaken for a match Check i < len(values) and equality.
BFS or DFS never finishes Cycles are revisited Mark nodes when discovered or popped.
Shortest path is wrong BFS used on weighted edges, or negative weights passed to Dijkstra Use Dijkstra/A* for non-negative weights and validate weights.
TypeError from heapq Equal priorities force comparison of payload objects Add a monotonically increasing counter.
Recursive DFS raises RecursionError Graph depth exceeds Python’s recursion limit Use an explicit stack.
Results change between runs Neighbor iteration order is unstable Use a deterministic adjacency order when reproducibility matters.

9. Performance, reliability and cost

Measure preprocessing as well as each query. A sorted list supports fast boundary lookup but costly insertions; a dictionary or set trades ordering for fast average membership. For graph searches, memory often becomes the limiting resource before CPU: BFS can hold an entire frontier, while DFS can follow a deep branch. Heap entries may become stale after a better distance is found, so discard them as shown. For production workloads, define cancellation, maximum nodes, timeouts and input-size limits around the algorithm.

10. Or skip the browser setup: capture algorithm output with ScreenshotNeo

If you are publishing search visualizations or documentation pages, ScreenshotNeo can capture the rendered result through one request. Cookie banners, newsletter popups and chat widgets are removed before the shot. Bot checks, blank pages and failed loads are not billed, and the response reports the page verdict and billing status in headers. An MCP server provides take_screenshot, get_page_info and capture_pdf tools for Claude, Cursor and other MCP clients. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots.

See the ScreenshotNeo API documentation for all options, including full-page capture, element selectors, custom CSS, waits, blocking rules, headers, cookies, device presets, PDFs, caching, signed links and bulk capture.

cURL

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

Python

import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)

Node.js

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Create a free ScreenshotNeo account with 1,000 screenshots each month and no card.

11. FAQ

Is bisect faster than a dictionary?

For exact membership, dictionaries are generally the better structure. Bisect is valuable when you need ordered boundaries or ranges.

Should I mark BFS nodes visited on enqueue or dequeue?

Marking on enqueue prevents the same node entering the queue multiple times. It also preserves the first discovered shortest parent in an unweighted graph.

Can Dijkstra handle negative edges?

No. Use an algorithm designed for negative weights, such as Bellman–Ford, or change the model.

When is A* preferable to Dijkstra?

When you have a cheap heuristic that estimates remaining cost accurately enough to reduce exploration while preserving the required optimality conditions.