Some nodes in a network matter not because they have many connections but because they sit between groups that would otherwise be far apart. A router that every inter-region flow crosses, an account that relays money between two otherwise unrelated clusters, a junction that most commutes pass through: remove one and traffic reroutes or stops. Betweenness centrality turns that intuition into a number. It counts, over every pair of other nodes, what fraction of their shortest paths runs through a given node.

The definition, due to Linton Freeman in 1977, is easy to state and expensive to compute naively. Ulrik Brandes' 2001 algorithm made it practical by reorganising the sum so that one shortest-path search per source is enough. This article builds the definition, derives Brandes' recurrence, gives a tested implementation for unweighted and weighted graphs, works a small example by hand, and covers normalisation, sampling for large graphs, and the mistakes that most often produce wrong numbers.

What betweenness measures

Let sigma(s, t) be the number of shortest paths from s to t, and sigma(s, t | v) the number of those that pass through v. The betweenness of v sums, over all ordered pairs of distinct nodes s and t that are both different from v, the fraction sigma(s, t | v) / sigma(s, t). Pairs with no path contribute nothing. If three shortest paths join s and t and two of them cross v, then v earns two thirds of a unit from that pair.

Several conventions matter in practice. Endpoints are excluded by default, so a node gains nothing from paths that start or end at it. On an undirected graph each pair is counted twice, once in each direction, so the usual practice is to halve the sum. To compare graphs of different sizes, divide by the number of pairs that could pass through v: (n - 1)(n - 2) for directed graphs and (n - 1)(n - 2) / 2 for undirected graphs after halving. A normalised score of 1 then means every pair routes through v, which is the centre of a star.

Edge betweenness uses the same sum with an edge in place of v. It is the quantity the Girvan-Newman community detection algorithm removes edges by, and the same accumulation computes it. This article stays with nodes.

From the definition to Brandes' recurrence

The direct approach computes all-pairs distances, counts paths for every pair, and then tests every v against every pair: v lies on a shortest s-t path exactly when dist(s, v) + dist(v, t) = dist(s, t), and the number of such paths is sigma(s, v) times sigma(v, t). That costs O(n^3) time and O(n^2) memory just for the distance and count tables. At 100,000 nodes the tables alone need 10^10 entries.

Brandes' observation was that the inner sum over targets can be collapsed. Define the dependency of a source s on v as delta_s(v), the sum of sigma(s, t | v) / sigma(s, t) over all targets t. Then betweenness is just the sum of delta_s(v) over all sources, and the dependencies obey a recurrence over the shortest-path DAG rooted at s:

delta_s(v) = sum over children w of v in the shortest-path DAG of s:
                 sigma(s, v) / sigma(s, w) * (1 + delta_s(w))

The intuition: every shortest path from s to w that goes through its parent v accounts for a fraction sigma(s, v) / sigma(s, w) of the paths to w. The 1 is the pair (s, w) itself, and delta_s(w) carries everything beyond w that is routed back through it. Processing nodes from farthest to nearest makes every delta_s(w) final before it is used. The result is O(nm) time for unweighted graphs and O(nm + n^2 log n) with Dijkstra on weighted graphs, all in O(n + m) memory.

Brandes: one pass per source s, forward to count paths, backward to accumulate1. BFS or Dijkstra from sdist, sigma, predecessor lists2. Stack of settled nodesnon-decreasing distance order3. Pop in reverse orderdelta[v] += sigma ratio x (1 + delta[w])4. C_B[w] += delta[w]for every w other than sper sourceRepeat for all n sourcesor for k sampled pivotsnext sMemory per pass is O(n + m); passes are independent, so they parallelise by source.
Each source runs one forward search and one backward sweep. Only the score array survives between sources.

A tested implementation

The implementation below handles both cases. The forward phase records, for every node, its distance, its path count sigma and its predecessors on shortest paths, and it pushes nodes onto a stack in the order they are settled. The backward phase pops the stack and applies the recurrence. I checked it against the brute-force definition on 300 random graphs of up to nine nodes, half of them with integer weights, and every score matched.

import heapq
from collections import deque

def brandes(adj, weighted=False):
    """adj: {v: [(w, cost), ...]}. Raw scores over ordered pairs; halve if undirected."""
    bc = {v: 0.0 for v in adj}
    for s in adj:
        stack, preds = [], {v: [] for v in adj}
        sigma = dict.fromkeys(adj, 0); sigma[s] = 1
        dist = dict.fromkeys(adj, None); dist[s] = 0
        if weighted:                       # Dijkstra; ties extend sigma and preds
            pq = [(0, s)]
            while pq:
                d, v = heapq.heappop(pq)
                if d > dist[v]:
                    continue
                stack.append(v)
                for w, c in adj[v]:
                    nd = d + c
                    if dist[w] is None or nd < dist[w]:
                        dist[w] = nd; sigma[w] = sigma[v]; preds[w] = [v]
                        heapq.heappush(pq, (nd, w))
                    elif nd == dist[w]:
                        sigma[w] += sigma[v]; preds[w].append(v)
        else:                              # BFS
            q = deque([s])
            while q:
                v = q.popleft(); stack.append(v)
                for w, _ in adj[v]:
                    if dist[w] is None:
                        dist[w] = dist[v] + 1; q.append(w)
                    if dist[w] == dist[v] + 1:
                        sigma[w] += sigma[v]; preds[w].append(v)
        delta = dict.fromkeys(adj, 0.0)
        while stack:                       # farthest first
            w = stack.pop()
            for v in preds[w]:
                delta[v] += sigma[v] / sigma[w] * (1 + delta[w])
            if w != s:
                bc[w] += delta[w]
    return bc

Two details carry the correctness. In the weighted branch a node is pushed onto the stack only when it is popped from the heap with its final distance, so stack order is non-decreasing distance. A strictly shorter path must also reset both sigma and the predecessor list, not just the distance. Forgetting the reset is the most common bug, and it inflates counts silently.

Worked example: two triangles and a bridge

Take two triangles, {0, 1, 2} and {4, 5, 6}, joined by a path 2 - 3 - 4. Before running anything, reason it out. Every shortest path between the left group and the right group must cross node 3, and there are 3 x 3 = 9 such unordered pairs, so 3 should score 9. Node 2 lies on the paths from 0 and 1 to each of 3, 4, 5 and 6, which is 8 pairs. Node 4 mirrors node 2. Nodes 0, 1, 5 and 6 sit on no shortest path between other nodes, because each triangle's sides are direct edges.

Two triangles joined through node 3: raw undirected betweenness of each node0C_B = 01C_B = 02C_B = 83C_B = 94C_B = 85C_B = 06C_B = 0Every one of the 9 pairs that cross between the triangles passes through 3.Node 2 carries the 8 pairs from {0, 1} to {3, 4, 5, 6}; node 4 is its mirror.
Raw undirected scores after halving. The code above returns exactly these values.
NodeRaw (ordered pairs)Undirected (halved)Normalised, divided by 15
0, 1, 5, 6000
2 and 41680.5333
31890.6

With n = 7 the undirected normaliser is 6 x 5 / 2 = 15. Notice that node 3 has degree 2, the lowest of the three central nodes, yet it scores highest. That is the point of betweenness: degree measures local reach, betweenness measures how much the rest of the graph depends on you. Compare eigenvector centrality and PageRank, which reward being connected to important nodes. On this graph both rank 2 and 4 above 3, and PageRank with damping 0.85 even puts 3 slightly below the leaves.

Large graphs: sampling pivots

O(nm) is still too slow for graphs with tens of millions of edges. The standard fix, studied by Brandes and Pich in 2007, is to run the per-source pass from k randomly chosen pivots and scale the sum by n / k. Each pass is an unbiased sample of the per-source dependency, so the estimate is unbiased, and its standard error falls roughly as one over the square root of k. Riondato and Kornaropoulos later gave a sampling scheme with a sample size bound tied to the graph's vertex diameter, which tells you how many samples you need for a stated additive error.

I measured pivot sampling on a 2,000-node preferential-attachment graph with three edges per new node, using the pure Python code above. The exact run took 8.8 seconds.

Pivots kTimeTop-20 overlap with exactMedian relative error, exact top 20
500.25 s15 of 2024.1%
2000.97 s18 of 206.1%
2,000 (exact)8.8 s20 of 200

The pattern generalises. Sampling recovers the identity of the top nodes faster than their exact scores, because high-betweenness nodes collect dependency from almost every source. Low scores are noisy at any practical k. If you need a ranking, sample. If you need to compare small scores, or to report a number someone will audit, compute exactly or report a confidence interval.

Operational guidance

  • Parallelise by source. Each source pass is independent and only adds into the score array. Give each worker a block of sources and a private score array, then sum. Memory per worker stays O(n + m).
  • Use a library for production graphs. NetworkX's betweenness_centrality(G, k=None, normalized=True, weight=None, endpoints=False, seed=None) implements Brandes with optional pivot sampling and handles a few thousand nodes comfortably. Compiled libraries such as igraph and NetworKit go much further, NetworKit runs in parallel, and it ships several approximation algorithms. Check which normalisation each one applies before comparing their outputs.
  • Decide what a weight means. Shortest-path algorithms treat weight as a cost. If your edge weights are strengths, such as message counts, invert or transform them first, or the most heavily used links become the longest.
  • Pin the conventions in your output. Store whether the graph was directed, whether endpoints counted, and which normaliser was used next to the scores.
  • Split disconnected graphs. Pairs in different components contribute zero. A normaliser based on the whole graph's n understates nodes in small components, so consider normalising per component.

Failure modes

  • Float ties. With real-valued weights, two equal-length paths can differ in the last bit, and the weighted branch then keeps only one predecessor. Round weights to integers or a fixed grid when ties are meaningful.
  • Missing the sigma reset. Updating the distance without resetting sigma and the predecessors when a shorter path appears gives inflated, plausible-looking scores. Test against brute force on small graphs.
  • Double counting. Undirected scores that were not halved are exactly twice the textbook value, which looks like a normalisation bug downstream.
  • Treating betweenness as flow. It assumes traffic follows shortest paths and that all pairs matter equally. Real traffic follows demand and routing policy. Use demand-weighted variants, or random-walk betweenness, when that assumption is wrong.
  • Recomputing from scratch after every change. On a changing graph, recomputing nightly is often fine. Incremental algorithms exist but are complex. Measure how stale the ranking may be before building one.

Trade-offs

ApproachCostUse when
Exact Brandes, BFSO(nm)a few thousand nodes in pure Python, far more compiled
Exact Brandes, DijkstraO(nm + n^2 log n)costs on edges matter
Pivot samplingO(km) unweightedyou need the top nodes of a large graph
Path sampling with error boundsdepends on vertex diameteryou need a stated additive error
Degree or PageRank insteadO(m) per iterationyou need importance, not brokerage

What to do next

  1. Implement the code above and confirm that the two-triangle example returns 0, 8, 9 and 8 for nodes 0, 2, 3 and 4.
  2. Write a brute-force checker using dist(s, v) + dist(v, t) = dist(s, t) and run both on a few hundred random small graphs, weighted and unweighted.
  3. Decide and document your conventions: directed or not, endpoints, normaliser, and how weights map to costs.
  4. On your real graph, compare k = 100 and k = 500 pivot runs. If the top 20 barely changes, sampling is enough for your use case.
  5. Revisit breadth-first search and Dijkstra's algorithm. Brandes is exactly those two searches plus a backward sweep.
Key takeaway: Betweenness counts the share of all-pairs shortest paths that pass through a node, so it finds brokers and bottlenecks that degree misses. Brandes computes it exactly with one search per source plus a backward sweep that accumulates dependencies, in O(nm) time and linear memory. Sample pivots when the graph is large and you need a ranking. Always state your directed, endpoint and normalisation conventions.