Match an Address Against a List of IP Ranges

Updated · By

Insert each range as a prefix into a binary trie keyed on the address bits, with its action on the last node. To look up an address, walk its bits from the top and remember the last action you pass: that is the longest, most specific matching range. At most 32 steps for IPv4, however many ranges there are.

mediumPattern: TriesFrom Devana's Cloudflare question set

Problem

A request's source address must be checked against a large list of ranges, such as 10.0.0.0/8 or 192.168.5.0/24, on every request. Each range carries an action like block or allow. Ranges overlap, and when they do the most specific one wins: an address in both a /16 and a /24 gets the /24's action.

Write a PrefixTable with insert(prefix, action) and lookup(address), which returns the action of the longest matching prefix, or None. A linear scan over a hundred thousand ranges is far too slow.

class PrefixTable:
    def insert(self, prefix: str, action: str) -> None: ...
    def lookup(self, address: str) -> str | None: ...

This is one of Devana's own questions, written in the style of Cloudflare's interviews. It is not a question leaked from Cloudflare, and Devana is not affiliated with Cloudflare.

Constraints

  • IPv4 only in this version: dotted addresses, and prefixes with lengths from 0 to 32.
  • A prefix of length L matches an address whose first L bits equal the prefix's first L bits.
  • The longest matching prefix decides. 0.0.0.0/0 matches everything and acts as a default.
  • Inserting the same prefix again replaces its action.
  • Up to 10^5 prefixes; the cost of a lookup must not depend on how many there are.

Examples

Example 1

Input:  insert("192.168.0.0/16", "allow")
        insert("192.168.5.0/24", "block")
        lookup("192.168.5.9")
Output: "block"

Both ranges contain the address. The /24 is longer, so it is more specific and wins.

Example 2

Input:  the same table
        lookup("192.168.6.1")
Output: "allow"

Only the /16 contains this address; the /24 covers 192.168.5.0 to 192.168.5.255.

Hints

Open one at a time, and try the problem again after each.

Hint 1

An IPv4 address is a 32-bit number. What does a /24 prefix say about those bits?

Hint 2

Sorting ranges and binary searching works when ranges never overlap. Overlap is what makes this harder: you need the most specific match, not just any match.

Hint 3

Build a tree where each level consumes one bit of the address. A prefix of length L ends at depth L.

Hint 4

Walking down for an address, every node you pass that carries an action is a matching prefix. Which one do you return?

Approach: from brute force to optimal

Brute force: check every range

Convert the address to an integer, test it against every prefix with a mask, and keep the longest match.

Complexity: O(R) per lookup for R ranges

One hash map per prefix length

Keep 33 dicts, one per length, each mapping a masked network to its action. Probe from /32 down to /0 and return the first hit. Simple and fast, and worth mentioning as the alternative you considered.

Complexity: At most 33 hash lookups per address

A binary trie

Each node has a child for bit 0 and one for bit 1. Insert walks L bits and stores the action at the node it ends on. Lookup walks the address's bits until a child is missing, keeping the last action it saw. Depth is at most 32, so the cost is bounded whatever the number of ranges.

Complexity: O(32) per lookup and insert; O(R × L) nodes at worst, far fewer when prefixes share leading bits

How to say it in the interview

State the rule that decides everything, longest prefix wins, then propose the trie and give the cost of a lookup: at most 32 steps for IPv4, independent of the number of ranges. Mentioning the 33 hash maps alternative shows that you are choosing rather than reciting.

Solution

Python

class PrefixTable:
    """A binary trie over IPv4 address bits; a node may carry an action."""

    def __init__(self):
        self.root = {}

    @staticmethod
    def _to_int(address):
        a, b, c, d = (int(part) for part in address.split("."))
        return (a << 24) | (b << 16) | (c << 8) | d

    def insert(self, prefix, action):
        address, length = prefix.split("/")
        bits, node = self._to_int(address), self.root
        for i in range(int(length)):
            node = node.setdefault((bits >> (31 - i)) & 1, {})
        node["action"] = action

    def lookup(self, address):
        bits, node = self._to_int(address), self.root
        best = node.get("action")                  # a /0 matches everything
        for i in range(32):
            node = node.get((bits >> (31 - i)) & 1)
            if node is None:
                break
            best = node.get("action", best)        # a longer match replaces it
        return best

JavaScript

class PrefixTable {
  // A binary trie over IPv4 address bits; a node may carry an action.
  constructor() {
    this.root = { children: [null, null], action: null };
  }

  static toInt(address) {
    const [a, b, c, d] = address.split(".").map(Number);
    return ((a << 24) | (b << 16) | (c << 8) | d) >>> 0;
  }

  insert(prefix, action) {
    const [address, length] = prefix.split("/");
    const bits = PrefixTable.toInt(address);
    let node = this.root;
    for (let i = 0; i < Number(length); i++) {
      const bit = (bits >>> (31 - i)) & 1;
      if (!node.children[bit]) node.children[bit] = { children: [null, null], action: null };
      node = node.children[bit];
    }
    node.action = action;
  }

  lookup(address) {
    const bits = PrefixTable.toInt(address);
    let node = this.root;
    let best = node.action; // a /0 matches everything
    for (let i = 0; i < 32; i++) {
      node = node.children[(bits >>> (31 - i)) & 1];
      if (!node) break;
      if (node.action !== null) best = node.action; // a longer match replaces it
    }
    return best;
  }
}

Both versions are tested against the same cases, including every example on this page.

  • JavaScript's bitwise operators work on signed 32-bit integers, so >>> 0 and >>> keep addresses from 128.0.0.0 upward from turning negative.
  • The root can carry an action too, which is how 0.0.0.0/0 works as a default.
  • The Python version keeps children under the keys 0 and 1 and the action under "action" in the same dict. They cannot collide, because the keys have different types.

Follow-up questions

Why is longest-prefix matching the right semantics here?

Lists are written as broad rules with narrow exceptions: block a provider's /16 but allow one customer's /24 inside it. Longest prefix lets the exception win without anyone having to order the rules by hand.

What structure gets lookups under a microsecond?

A plain binary trie can take 32 dependent memory reads. A multibit trie that consumes 8 bits per level needs 4, and path compression removes chains of single-child nodes.

How do you update the table without blocking lookups?

Copy on write: build the changed path from new nodes and swap the root pointer atomically. Readers keep the version they started with, and old nodes are freed once no reader holds them.

How does IPv6 change the structure?

Addresses are 128 bits, so a plain binary trie can be four times deeper. Multibit strides and compression matter more, and the one-map-per-length approach needs 129 maps.

Common mistakes

  • Returning the first match found instead of the longest one.
  • Sorting ranges by start and binary searching, which breaks as soon as ranges nest.
  • Signed shifts in JavaScript turning high addresses negative.
  • Forgetting that a zero-length prefix lives at the root.

Practice it in a mock interview

Reading a solution and explaining one under questioning are different skills. The button below starts this problem as a voice mock interview with Josh, Devana's AI interviewer, with an editor that runs your code.

Practice this problem in a mock interview

Practice this problem out loud

Josh, Devana's AI interviewer, runs this exact problem as a voice mock interview, asks follow-ups like the ones above, and scores you out of 100. The Free plan has 3 interviews a month, no card needed.

Practice this problem in a mock interview