01 / The idea
Keep each airport’s routes with the airport.
An island airline flies 14 nonstop routes between seven airports. A route goes one way: Kelvik to Skarra is one flight, and Skarra to Kelvik is another. The booking page asks two questions all day: where can I fly from here, and who flies here?
An adjacency list keeps, for each airport, the list of airports it flies to. Kelvik’s list is Arnsay, Torvay, Mossa, Skarra. “Where can I fly from Kelvik?” reads that one list, four entries, however large the network grows. Airports are nodes, routes are directed edges, and each node’s list holds its neighbors.
A git commit object names “the parent commits if any”, and nothing in it names its children.
ActivityPub gives every account a following collection, “a list of everybody that the actor
has followed,” and a followers collection, “a list of everyone who has sent a Follow
activity for the actor.” A DOM node’s childNodes “returns a live NodeList of child
nodes”. The same idea each time: a node’s connections are kept next to the node.
02 / Name the rule
Store the routes that exist, not every pair that could.
The other way to store a graph is an adjacency matrix: a grid with a row and a column for every airport, and a 1 in a cell where the row’s airport flies to the column’s. Seven airports make 49 cells, whether they share 14 routes or 40.
Each answers a different question cheaply. Is Kelvik → Skarra a route? The matrix reads one cell; the list reads Kelvik’s departures until Skarra turns up. Where does Kelvik fly? The list reads Kelvik’s four entries; the matrix reads Kelvik’s whole row, seven cells here, and one cell for every airport in a bigger network.
A list answers in one direction only. Kelvik’s departures say nothing about who flies to Kelvik. So this map keeps a second list per airport, its arrivals, and writes every route twice: where it leaves and where it lands. That doubles the entries, and every change must touch both lists, in exchange for answering “who flies here?” from one list.
When is a matrix the better choice?Dense graphs and pair checks
When most pairs are connected, a matrix wastes little space, and it answers “is there an edge?” in one read, with no searching. Its cells can be single bits packed next to each other in memory.
Route networks are the opposite. In OpenFlights’ June 2014 route data, 3,425 airports had 37,594 one-way nonstop routes. A matrix would need 11,730,625 cells, and 0.32% of them would be set. The median airport flew to three others.
A list can check a pair faster too. If the neighbors are kept “as a sorted array, binary search may be used instead,” and a hash set per node makes the check one lookup. This lesson keeps lists in the order routes were added, so a check reads from the front.
03 / Follow one operation
Seven airports, a question, a new route, a closure.
Kelvik is the hub, with return routes to Arnsay, Torvay, Mossa, and Skarra. Torvay and Rennick fly both ways. Rennick flies to Arnsay with no return, and Mossa, Halvey, and Skarra are a loop flown one way round.
Before you watch, predict how many entries a nonstop check from Kelvik to Skarra reads, and how many lists closing Torvay must change. The animation replays what the TypeScript example recorded, with the same call counted on a matrix beside it. Try it lets you ask your own questions and close airports.
Read one list, not every route.
Adjacency lists
| Airport | Departures | Arrivals |
|---|---|---|
| Kelvik | ArnsayTorvayMossaSkarra | ArnsayTorvayMossaSkarra |
| Arnsay | Kelvik | KelvikRennick |
| Torvay | KelvikRennick | KelvikRennick |
| Mossa | KelvikHalvey | KelvikSkarra |
| Rennick | TorvayArnsay | Torvay |
| Skarra | KelvikMossa | KelvikHalvey |
| Halvey | Skarra | Mossa |
Matrix · row flies to column
List entries read 0 Matrix cells, same call 0 Stored 28 entries · 49 cells
Two short lists per airport.
Each airport lists where it flies, its departures, and who flies to it, its arrivals. A route is written twice: Kelvik → Skarra is in Kelvik’s departures and Skarra’s arrivals. That is 28 entries for 14 routes, where a matrix of every pair holds 49 cells.
Reduced motion: choose a scene to see its completed state.
Read this scene
Each airport lists where it flies, its departures, and who flies to it, its arrivals. A route is written twice: Kelvik → Skarra is in Kelvik’s departures and Skarra’s arrivals. That is 28 entries for 14 routes, where a matrix of every pair holds 49 cells.
Each airport lists where it flies, its departures, and who flies to it, its arrivals. A route is written twice: Kelvik → Skarra is in Kelvik’s departures and Skarra’s arrivals. That is 28 entries for 14 routes, where a matrix of every pair holds 49 cells.
List entries read so far: 0. Routes: 14.
Watch restarts when you return. Step through keeps your selected step. Try it starts from the island map each time you open it.
04 / Read the shape
Two representations, one route map.
Basic form is a directed graph stored two ways with the same methods: AdjacencyList keeps each node’s out and in lists, and AdjacencyMatrix keeps one cell per ordered pair. In the wild wraps the lists in RouteMap, which checks airport
names, refuses a route that lands where it starts, lists one-way routes, and closes an
airport.
Both can record every step, so the lesson counts work instead of guessing it. The matrix lists neighbors in airport order, because it reads a row from left to right. The lists keep routes in the order they were added.
A directed graph two ways. AdjacencyList keeps each node’s outgoing and incoming neighbors in the order edges were added; AdjacencyMatrix keeps one cell per ordered pair. They share methods, and both can record every entry or cell they read or change.
/**
* One unit of work. For a list entry, `kind` is read, append, or remove; `owner` owns the
* list, `list` says which one, and `value` is the node at `index`. For a matrix cell, `kind`
* is read-cell, set-cell, or clear-cell; `owner` is the row, `value` the column, and `index`
* the column's position.
*/
export type Step = {
kind: 'read' | 'append' | 'remove' | 'read-cell' | 'set-cell' | 'clear-cell';
list: 'out' | 'in' | 'cell';
owner: string;
index: number;
value: string;
};
type Lists = { out: string[]; in: string[] };
// A directed graph as adjacency lists. Each node keeps the nodes its edges go to (out) and
// the nodes whose edges come to it (in), both in the order the edges were added. Keeping
// both costs a second entry per edge and buys incoming edges without scanning every list.
export class AdjacencyList {
#lists = new Map<string, Lists>();
#edges = 0;
has(name: string): boolean {
return this.#lists.has(name);
}
addNode(name: string): void {
if (this.#lists.has(name)) throw new RangeError(`node ${name} already exists`);
this.#lists.set(name, { out: [], in: [] });
}
hasEdge(from: string, to: string, steps?: Step[]): boolean {
const [source] = this.#pair(from, to);
return find(source.out, 'out', from, to, steps) >= 0;
}
// Returns false when the edge already exists.
addEdge(from: string, to: string, steps?: Step[]): boolean {
const [source, target] = this.#pair(from, to);
if (find(source.out, 'out', from, to, steps) >= 0) return false;
steps?.push({ kind: 'append', list: 'out', owner: from, index: source.out.length, value: to });
source.out.push(to);
steps?.push({ kind: 'append', list: 'in', owner: to, index: target.in.length, value: from });
target.in.push(from);
this.#edges++;
return true;
}
// Returns false when there is no such edge.
removeEdge(from: string, to: string, steps?: Step[]): boolean {
const [source, target] = this.#pair(from, to);
const at = find(source.out, 'out', from, to, steps);
if (at < 0) return false;
remove(source.out, 'out', from, at, steps);
remove(target.in, 'in', to, find(target.in, 'in', to, from, steps), steps);
this.#edges--;
return true;
}
/** Where a node's edges go, in the order they were added. */
neighbors(name: string, steps?: Step[]): string[] {
return readAll(this.#get(name).out, 'out', name, steps);
}
/** Where a node's incoming edges come from, in the order they were added. */
sources(name: string, steps?: Step[]): string[] {
return readAll(this.#get(name).in, 'in', name, steps);
}
// Remove a node and every edge touching it. Its own two lists say exactly which other
// lists mention it, so no other list is read. Returns how many edges were removed.
removeNode(name: string, steps?: Step[]): number {
const { out, in: into } = this.#get(name);
let removed = 0;
out.forEach((to, i) => {
steps?.push({ kind: 'read', list: 'out', owner: name, index: i, value: to });
removed++;
if (to === name) return;
const other = this.#lists.get(to)!.in;
remove(other, 'in', to, find(other, 'in', to, name, steps), steps);
});
into.forEach((from, i) => {
steps?.push({ kind: 'read', list: 'in', owner: name, index: i, value: from });
if (from === name) return;
removed++;
const other = this.#lists.get(from)!.out;
remove(other, 'out', from, find(other, 'out', from, name, steps), steps);
});
this.#lists.delete(name);
this.#edges -= removed;
return removed;
}
nodes(): string[] {
return [...this.#lists.keys()];
}
get edgeCount(): number {
return this.#edges;
}
#get(name: string): Lists {
const lists = this.#lists.get(name);
if (!lists) throw new RangeError(`unknown node ${name}`);
return lists;
}
#pair(from: string, to: string): [Lists, Lists] {
const source = this.#get(from);
return [source, this.#get(to)];
}
}
// Read a list from the front until `target` turns up. Returns its position, or -1.
function find(
list: string[],
which: 'out' | 'in',
owner: string,
target: string,
steps?: Step[]
): number {
for (let i = 0; i < list.length; i++) {
steps?.push({ kind: 'read', list: which, owner, index: i, value: list[i] });
if (list[i] === target) return i;
}
return -1;
}
function remove(list: string[], which: 'out' | 'in', owner: string, index: number, steps?: Step[]) {
steps?.push({ kind: 'remove', list: which, owner, index, value: list[index] });
list.splice(index, 1);
}
function readAll(list: string[], which: 'out' | 'in', owner: string, steps?: Step[]): string[] {
list.forEach((value, index) => steps?.push({ kind: 'read', list: which, owner, index, value }));
return [...list];
}
// The same graph as a matrix: one cell per ordered pair of nodes, set when the edge exists.
// Checking one edge reads one cell, but listing a node's edges reads its whole row or
// column, and adding a node copies every cell into a larger matrix.
export class AdjacencyMatrix {
#index = new Map<string, number>();
#names: string[] = [];
#cells = new Uint8Array(0);
#edges = 0;
addNode(name: string): void {
if (this.#index.has(name)) throw new RangeError(`node ${name} already exists`);
const n = this.#names.length;
const next = new Uint8Array((n + 1) * (n + 1));
for (let row = 0; row < n; row++)
next.set(this.#cells.subarray(row * n, row * n + n), row * (n + 1));
this.#index.set(name, n);
this.#names.push(name);
this.#cells = next;
}
hasEdge(from: string, to: string, steps?: Step[]): boolean {
const [row, column] = this.#pair(from, to);
return this.#read(row, column, steps);
}
addEdge(from: string, to: string, steps?: Step[]): boolean {
const [row, column] = this.#pair(from, to);
if (this.#read(row, column, steps)) return false;
this.#write(row, column, true, steps);
this.#edges++;
return true;
}
removeEdge(from: string, to: string, steps?: Step[]): boolean {
const [row, column] = this.#pair(from, to);
if (!this.#read(row, column, steps)) return false;
this.#write(row, column, false, steps);
this.#edges--;
return true;
}
/** Where a node's edges go, in node order: every cell of its row is read. */
neighbors(name: string, steps?: Step[]): string[] {
const row = this.#at(name);
return this.#names.filter((_, column) => this.#read(row, column, steps));
}
/** Where a node's incoming edges come from, in node order: every cell of its column is read. */
sources(name: string, steps?: Step[]): string[] {
const column = this.#at(name);
return this.#names.filter((_, row) => this.#read(row, column, steps));
}
removeNode(name: string, steps?: Step[]): number {
const at = this.#at(name);
const n = this.#names.length;
let removed = 0;
for (let column = 0; column < n; column++)
if (this.#read(at, column, steps)) {
this.#write(at, column, false, steps);
removed++;
}
for (let row = 0; row < n; row++)
if (row !== at && this.#read(row, at, steps)) {
this.#write(row, at, false, steps);
removed++;
}
// Copy every other cell into a matrix one row and one column smaller.
const next = new Uint8Array((n - 1) * (n - 1));
let written = 0;
for (let row = 0; row < n; row++)
for (let column = 0; column < n; column++)
if (row !== at && column !== at) next[written++] = this.#cells[row * n + column];
this.#cells = next;
this.#names.splice(at, 1);
this.#index = new Map(this.#names.map((node, i) => [node, i]));
this.#edges -= removed;
return removed;
}
nodes(): string[] {
return [...this.#names];
}
get edgeCount(): number {
return this.#edges;
}
get cellCount(): number {
return this.#names.length ** 2;
}
#at(name: string): number {
const at = this.#index.get(name);
if (at === undefined) throw new RangeError(`unknown node ${name}`);
return at;
}
#pair(from: string, to: string): [number, number] {
const row = this.#at(from);
return [row, this.#at(to)];
}
#read(row: number, column: number, steps?: Step[]): boolean {
const [owner, value] = [this.#names[row], this.#names[column]];
steps?.push({ kind: 'read-cell', list: 'cell', owner, index: column, value });
return this.#cells[row * this.#names.length + column] === 1;
}
#write(row: number, column: number, set: boolean, steps?: Step[]): void {
const [owner, value] = [this.#names[row], this.#names[column]];
const kind = set ? 'set-cell' : 'clear-cell';
steps?.push({ kind, list: 'cell', owner, index: column, value });
this.#cells[row * this.#names.length + column] = set ? 1 : 0;
}
} // Step is one unit of work. For a list entry, Kind is "read", "append", or "remove";
// Owner owns the list, List says which one ("out" or "in"), and Value is the node at
// Index. For a matrix cell, Kind is "read-cell", "set-cell", or "clear-cell"; List is
// "cell", Owner is the row, Value the column, and Index the column's position.
type Step struct {
Kind string `json:"kind"`
List string `json:"list"`
Owner string `json:"owner"`
Index int `json:"index"`
Value string `json:"value"`
}
func record(steps *[]Step, kind, list, owner string, index int, value string) {
if steps != nil {
*steps = append(*steps, Step{kind, list, owner, index, value})
}
}
type lists struct{ out, in []string }
// AdjacencyList is a directed graph as adjacency lists. Each node keeps the nodes its
// edges go to (out) and the nodes whose edges come to it (in), both in the order the
// edges were added. Keeping both costs a second entry per edge and buys incoming edges
// without scanning every list.
type AdjacencyList struct {
lists map[string]*lists
order []string
edges int
}
func NewAdjacencyList() *AdjacencyList {
return &AdjacencyList{lists: map[string]*lists{}}
}
func (g *AdjacencyList) Has(name string) bool {
_, ok := g.lists[name]
return ok
}
func (g *AdjacencyList) AddNode(name string) error {
if g.Has(name) {
return fmt.Errorf("node %s already exists", name)
}
g.lists[name] = &lists{}
g.order = append(g.order, name)
return nil
}
func (g *AdjacencyList) HasEdge(from, to string, steps *[]Step) (bool, error) {
source, _, err := g.pair(from, to)
if err != nil {
return false, err
}
return find(source.out, "out", from, to, steps) >= 0, nil
}
// AddEdge returns false when the edge already exists.
func (g *AdjacencyList) AddEdge(from, to string, steps *[]Step) (bool, error) {
source, target, err := g.pair(from, to)
if err != nil {
return false, err
}
if find(source.out, "out", from, to, steps) >= 0 {
return false, nil
}
record(steps, "append", "out", from, len(source.out), to)
source.out = append(source.out, to)
record(steps, "append", "in", to, len(target.in), from)
target.in = append(target.in, from)
g.edges++
return true, nil
}
// RemoveEdge returns false when there is no such edge.
func (g *AdjacencyList) RemoveEdge(from, to string, steps *[]Step) (bool, error) {
source, target, err := g.pair(from, to)
if err != nil {
return false, err
}
at := find(source.out, "out", from, to, steps)
if at < 0 {
return false, nil
}
source.out = remove(source.out, "out", from, at, steps)
target.in = remove(target.in, "in", to, find(target.in, "in", to, from, steps), steps)
g.edges--
return true, nil
}
// Neighbors lists where a node's edges go, in the order they were added.
func (g *AdjacencyList) Neighbors(name string, steps *[]Step) ([]string, error) {
node, err := g.get(name)
if err != nil {
return nil, err
}
return readAll(node.out, "out", name, steps), nil
}
// Sources lists where a node's incoming edges come from, in the order they were added.
func (g *AdjacencyList) Sources(name string, steps *[]Step) ([]string, error) {
node, err := g.get(name)
if err != nil {
return nil, err
}
return readAll(node.in, "in", name, steps), nil
}
// RemoveNode removes a node and every edge touching it. Its own two lists say exactly
// which other lists mention it, so no other list is read. It returns how many edges
// were removed.
func (g *AdjacencyList) RemoveNode(name string, steps *[]Step) (int, error) {
node, err := g.get(name)
if err != nil {
return 0, err
}
removed := 0
for i, to := range node.out {
record(steps, "read", "out", name, i, to)
removed++
if to == name {
continue
}
other := g.lists[to]
other.in = remove(other.in, "in", to, find(other.in, "in", to, name, steps), steps)
}
for i, from := range node.in {
record(steps, "read", "in", name, i, from)
if from == name {
continue
}
removed++
other := g.lists[from]
other.out = remove(other.out, "out", from, find(other.out, "out", from, name, steps), steps)
}
delete(g.lists, name)
g.order = slices.DeleteFunc(g.order, func(n string) bool { return n == name })
g.edges -= removed
return removed, nil
}
func (g *AdjacencyList) Nodes() []string { return slices.Clone(g.order) }
func (g *AdjacencyList) EdgeCount() int { return g.edges }
func (g *AdjacencyList) get(name string) (*lists, error) {
node, ok := g.lists[name]
if !ok {
return nil, fmt.Errorf("unknown node %s", name)
}
return node, nil
}
func (g *AdjacencyList) pair(from, to string) (*lists, *lists, error) {
source, err := g.get(from)
if err != nil {
return nil, nil, err
}
target, err := g.get(to)
if err != nil {
return nil, nil, err
}
return source, target, nil
}
// find reads a list from the front until target turns up, and returns its position or -1.
func find(list []string, which, owner, target string, steps *[]Step) int {
for i, value := range list {
record(steps, "read", which, owner, i, value)
if value == target {
return i
}
}
return -1
}
func remove(list []string, which, owner string, index int, steps *[]Step) []string {
record(steps, "remove", which, owner, index, list[index])
return slices.Delete(list, index, index+1)
}
func readAll(list []string, which, owner string, steps *[]Step) []string {
for i, value := range list {
record(steps, "read", which, owner, i, value)
}
return slices.Clone(list)
}
// AdjacencyMatrix is the same graph as a matrix: one cell per ordered pair of nodes,
// set when the edge exists. Checking one edge reads one cell, but listing a node's edges
// reads its whole row or column, and adding a node copies every cell into a larger matrix.
type AdjacencyMatrix struct {
index map[string]int
names []string
cells []bool
edges int
}
func NewAdjacencyMatrix() *AdjacencyMatrix {
return &AdjacencyMatrix{index: map[string]int{}}
}
func (m *AdjacencyMatrix) AddNode(name string) error {
if _, ok := m.index[name]; ok {
return fmt.Errorf("node %s already exists", name)
}
n := len(m.names)
next := make([]bool, (n+1)*(n+1))
for row := range n {
copy(next[row*(n+1):row*(n+1)+n], m.cells[row*n:row*n+n])
}
m.index[name] = n
m.names = append(m.names, name)
m.cells = next
return nil
}
func (m *AdjacencyMatrix) HasEdge(from, to string, steps *[]Step) (bool, error) {
row, column, err := m.pair(from, to)
if err != nil {
return false, err
}
return m.read(row, column, steps), nil
}
func (m *AdjacencyMatrix) AddEdge(from, to string, steps *[]Step) (bool, error) {
row, column, err := m.pair(from, to)
if err != nil || m.read(row, column, steps) {
return false, err
}
m.write(row, column, true, steps)
m.edges++
return true, nil
}
func (m *AdjacencyMatrix) RemoveEdge(from, to string, steps *[]Step) (bool, error) {
row, column, err := m.pair(from, to)
if err != nil || !m.read(row, column, steps) {
return false, err
}
m.write(row, column, false, steps)
m.edges--
return true, nil
}
// Neighbors lists where a node's edges go, in node order: every cell of its row is read.
func (m *AdjacencyMatrix) Neighbors(name string, steps *[]Step) ([]string, error) {
row, err := m.at(name)
if err != nil {
return nil, err
}
found := []string{}
for column, node := range m.names {
if m.read(row, column, steps) {
found = append(found, node)
}
}
return found, nil
}
// Sources lists where a node's incoming edges come from, in node order: every cell of
// its column is read.
func (m *AdjacencyMatrix) Sources(name string, steps *[]Step) ([]string, error) {
column, err := m.at(name)
if err != nil {
return nil, err
}
found := []string{}
for row, node := range m.names {
if m.read(row, column, steps) {
found = append(found, node)
}
}
return found, nil
}
func (m *AdjacencyMatrix) RemoveNode(name string, steps *[]Step) (int, error) {
at, err := m.at(name)
if err != nil {
return 0, err
}
n := len(m.names)
removed := 0
for column := range n {
if m.read(at, column, steps) {
m.write(at, column, false, steps)
removed++
}
}
for row := range n {
if row != at && m.read(row, at, steps) {
m.write(row, at, false, steps)
removed++
}
}
// Copy every other cell into a matrix one row and one column smaller.
next := make([]bool, 0, (n-1)*(n-1))
for row := range n {
for column := range n {
if row != at && column != at {
next = append(next, m.cells[row*n+column])
}
}
}
m.cells = next
m.names = slices.Delete(m.names, at, at+1)
m.index = map[string]int{}
for i, node := range m.names {
m.index[node] = i
}
m.edges -= removed
return removed, nil
}
func (m *AdjacencyMatrix) Nodes() []string { return slices.Clone(m.names) }
func (m *AdjacencyMatrix) EdgeCount() int { return m.edges }
func (m *AdjacencyMatrix) CellCount() int { return len(m.names) * len(m.names) }
func (m *AdjacencyMatrix) at(name string) (int, error) {
at, ok := m.index[name]
if !ok {
return 0, fmt.Errorf("unknown node %s", name)
}
return at, nil
}
func (m *AdjacencyMatrix) pair(from, to string) (int, int, error) {
row, err := m.at(from)
if err != nil {
return 0, 0, err
}
column, err := m.at(to)
return row, column, err
}
func (m *AdjacencyMatrix) read(row, column int, steps *[]Step) bool {
record(steps, "read-cell", "cell", m.names[row], column, m.names[column])
return m.cells[row*len(m.names)+column]
}
func (m *AdjacencyMatrix) write(row, column int, set bool, steps *[]Step) {
kind := "clear-cell"
if set {
kind = "set-cell"
}
record(steps, kind, "cell", m.names[row], column, m.names[column])
m.cells[row*len(m.names)+column] = set
} Reading the TypeScriptA Map of two arrays
#lists is a Map from an airport to its { out, in } arrays. A Map keeps insertion order, so nodes() lists airports in the order they were added, and removing one is a
single delete.
find reads a list from the front and records each read. remove uses splice, so the entries after a removed one move up
and the order stays the order routes were added. The matrix is one flat Uint8Array: row r, column c lives at r * n + c.
Reading the GoPointers to lists, and an order slice
lists maps each name to a *lists pointer, so appending to source.out changes the stored list without writing it back into the map. A
separate order slice keeps airports in the order they were added, because the
iteration order over a Go map is not specified.
slices.Delete removes an entry and shifts the rest left. The matrix is a []bool in the same row-times-width layout, and AddNode copies each
old row into a larger slice.
What would I normally use in application code?Often a map of arrays, or a database index
Neither TypeScript nor Go has a graph type in its standard library. In application code an
adjacency list is usually a Map<string, string[]> or a map[string][]string. The class here exists to keep two lists in step and to
count what they read.
When routes live in a database, the lists are indexes. An index on the departure airport answers “where does this airport fly?”; answering “who flies here?” quickly needs a second index on the arrival airport, the same trade as the arrivals list.
05 / Try a decision
Who flies to Torvay?
A map that keeps only departures is simpler, and it is how git stores commits. Decide how it finds the routes into a closing airport before the feedback tells you.
06 / Follow the cost
239 entries, not 3,425 cells.
Here is every operation at a glance, with V airports, E routes, and d the length of one airport’s list. The rest of this section measures them on a real network.
| Operation | Time | Extra space | What it assumes |
|---|---|---|---|
| Where does an airport fly? | O(d) list · O(V) matrix | O(d) | The list reads the airport’s d departures. The matrix reads its whole row, one cell per airport. |
| Who flies to an airport? | O(d) with arrivals · O(E) without · O(V) matrix | O(d) | An arrivals list answers directly. With departures only, every departure entry is read. |
| Is there a nonstop? | O(d) list · O(1) matrix | O(1) | The list is read from the front until the destination turns up. The matrix reads one cell. |
| Add or cancel a route | O(d) list · O(1) matrix | O(1) | A list checks the departures, then writes both lists: two appends, or, to cancel, a search of the arrivals list too and two removals that shift later entries up. |
| Close an airport | O(sum of neighbors’ lists) · O(V) or O(V²) matrix | O(1) · O(V²) if shrunk | The list searches each neighbor’s list for the airport. The matrix clears a row and a column, 2V − 1 cells. This model then also copies the rest into a smaller matrix, which is its choice, not the matrix’s: leaving the cleared row in place keeps the closure O(V). |
| Store the map | — | O(V + E) list · O(V²) matrix | Each route is two list entries. A matrix holds a cell for every ordered pair, flown or not, and copies them all to add an airport. |
In the animation, Kelvik → Skarra read 4 list entries against 1 matrix cell, and Skarra’s arrivals took 2 entries where scanning every departure list reads 14 and the matrix reads 7.
On OpenFlights’ 2014 routes, loaded into this lesson’s TypeScript AdjacencyList, 3,425 airports and 37,594 routes make 75,188 list entries,
against 11,730,625 matrix cells. Frankfurt had the most departures, 239. Listing them reads
239 entries, where a matrix reads 3,425 cells. Finding its 238 arrivals without an arrivals
list means reading all 37,594 departure entries. Confirming a route reads 32.7 entries on
average, because busy airports have long lists and most routes touch them; the matrix reads
one cell.
Lists are not cheaper everywhere. Closing Frankfurt took 15,320 list steps, 14,843 of them reads, because each of its neighbors is busy too and has a long list to search. The matrix reads only 6,849 cells to clear it. This lesson’s matrix then copies 11,723,776 cells into a smaller one, and it grows the same way, so building it one airport at a time copied more than 13 billion cells. That is this model’s resize policy, not a cost every matrix pays: one sized once for all 3,425 airports, which keeps closed rows empty, copies nothing. These are counts from the lesson’s code, not timings, and OpenFlights says its route data is of historical value only.
07 / Give it a real job
Keep both lists in step.
Every change writes two lists. A new route appends to one airport’s departures and another’s
arrivals; a cancellation removes from both. If one write happens and the other does not, the
map disagrees with itself, and the booking page offers a flight the arrivals board has never
heard of. RouteMap makes each change one method call.
A closure is where one-way thinking hurts. Closing Torvay must cancel the routes into Torvay, and only the arrivals list says where they are. Skip that, and the neighbors go on listing flights from an airport that no longer takes any.
Build UIs?See the adjacency lists you already walk, and the day a diagram editor needs its own.
Where it already is in your components
The DOM is a tree stored this way. An element’s childNodes is its list of
children, and its parentNode points back up, the same two directions as departures
and arrivals. Rendering and querying a page walk those lists.
Your git history is another. Each commit names its parents, and git log follows them back. Nothing in a commit names its children, so asking what came after a commit
means searching the history from newer commits.
When you have to own it
Now you are building a diagram editor: flowchart boxes joined by arrows. The API sends a list of boxes and a list of connectors, and hovering a box should light up its arrows. Filtering the connectors for that box reads all 5,000 of them in a big diagram, every time the pointer crosses a box. So build two lists per box once, when the diagram loads: the connectors leaving it and the connectors arriving. A hover, or a keyboard focus, reads that box’s two lists and nothing else.
Then someone deletes a box. Its connectors must leave the connector set and the lists of every box at their other end, and the box’s own lists name exactly those. Undo has to put each connector back where it was, so remember the position you took it from and restore in reverse. Keep the lists in a plain class as the source of truth, and hand your framework a fresh snapshot after each edit. The focused box has just vanished, too: move focus to a neighbor its lists already name, or to the canvas, and announce what went.
A flowchart canvas: each box’s outgoing and incoming lists built once, and a hovered or focused box lights its connectors from those two lists. React memoizes the layer of every connector; Svelte’s each block over the connectors never reads the hovered box.
import { useMemo, useState } from 'react';
type Box = { id: string; label: string; x: number; y: number };
type Connector = { id: string; from: string; to: string };
// A box with its two lists: connectors leaving it, and connectors arriving.
type Entry = { box: Box; out: Connector[]; in: Connector[] };
const WIDTH = 140;
const HEIGHT = 48;
// One pass over the connectors files each one under both of its boxes.
function buildLists(boxes: Box[], connectors: Connector[]) {
const lists = new Map<string, Entry>();
for (const box of boxes) lists.set(box.id, { box, out: [], in: [] });
for (const connector of connectors) {
lists.get(connector.from)?.out.push(connector);
lists.get(connector.to)?.in.push(connector);
}
return lists;
}
function Wire({ connector, lists }: { connector: Connector; lists: Map<string, Entry> }) {
const from = lists.get(connector.from)!.box;
const to = lists.get(connector.to)!.box;
return (
<line
x1={from.x + WIDTH / 2}
y1={from.y + HEIGHT / 2}
x2={to.x + WIDTH / 2}
y2={to.y + HEIGHT / 2}
/>
);
}
export function FlowchartCanvas({ boxes, connectors }: { boxes: Box[]; connectors: Connector[] }) {
// Built once per diagram, not once per hover.
const lists = useMemo(() => buildLists(boxes, connectors), [boxes, connectors]);
// Every connector, drawn once. The elements are memoized, so a hover leaves them alone.
const wires = useMemo(
() => connectors.map((c) => <Wire key={c.id} connector={c} lists={lists} />),
[connectors, lists]
);
const [active, setActive] = useState<string | null>(null);
// The highlight reads one box's two lists, however many connectors the diagram holds.
const entry = active === null ? undefined : lists.get(active);
const lit = entry ? [...new Set([...entry.out, ...entry.in])] : [];
return (
<div className="canvas">
<svg aria-hidden="true">
<g>{wires}</g>
<g className="lit">
{lit.map((c) => (
<Wire key={c.id} connector={c} lists={lists} />
))}
</g>
</svg>
{boxes.map((box) => (
<button
key={box.id}
type="button"
style={{ left: box.x, top: box.y }}
onPointerEnter={() => setActive(box.id)}
onPointerLeave={() => setActive(null)}
onFocus={() => setActive(box.id)}
onBlur={() => setActive(null)}
>
{box.label}
</button>
))}
</div>
);
}
08 / Make the call
Ask which questions start from a node.
Reach for adjacency lists when the graph is sparse and questions start from a node: where can I go from here, who points at this, what does this depend on. Keep incoming lists too when “who points at this?” comes up, and accept that every change writes twice.
Look elsewhere when the question changes. If most pairs are connected, or the hot question is whether one pair is, a matrix answers in one read, and a hash set of neighbors per node does too while staying sparse. If you only need to know whether two airports are joined by any path, union-find tracks groups without listing routes. If the edges decide an order things must happen in, a topological sort walks these lists. And the cheapest route between two airports is Dijkstra’s shortest path, in Applied algorithms, which runs on them.
09 / Take the idea with you
Explain it without saying “adjacency list.”
“For every airport I keep two short lists: where it flies, and who flies to it. A question about one airport reads its lists. When a route changes, I fix both ends. When an airport closes, its own lists tell me which other lists mention it.” That describes the mechanism. The name is what you call it in a review.
Before moving on, explain three things without the name: why a pair check reads a list but only one matrix cell, why arrivals must be a second list, and why closing Frankfurt reads more entries than closing Torvay. Then find a place in your code that answers “who points at this?” by scanning everything, and decide whether it needs a reverse list.
Connections to follow nextRelated lessons
- Graph overview names nodes, edges, direction, and weight, and sends each question to the lesson that answers it.
- Topological sort walks each cell’s list of readers to order an invoice.
- Hash set turns a node’s neighbors into a one-lookup pair check.
- Dynamic array is what each list is: it grows at the end and shifts entries to remove one.
- Breadth-first and depth-first search follow these lists from one node to everything it reaches.