No history yet

Adjacency List Architectures

Transcript

Beau

Jo, I have to confess something. I got a TLE on a tree problem again. And I'm pretty sure my logic, my actual DFS, was correct. It feels like just the way I'm… storing the tree is slowing me down before I even start.

Jo

Oh, that's a classic. It's almost a rite of passage. You spend all this time on the algorithm, but the silent killer is the setup. Let me guess, you were doing something like... `struct Node { Node* left; Node* right; }` and using `new Node()` for everything?

Beau

I mean... yeah? Isn't that how you're supposed to make a tree? Create nodes and point them at each other?

Jo

In a textbook, yes. But in competitive programming, where N can be a million, calling `new` a million times is... it's a huge performance hit. Every `new` is a request to the operating system for memory, and it can give you that memory anywhere. Your nodes end up scattered all over the place in RAM.

Beau

So when my code tries to jump from a parent to a child, it's like a long-distance call for the CPU instead of just... leaning over to the next desk.

Jo

Exactly. We call that a cache miss. And a million of them add up to a TLE. So, we ditch the pointers and embrace the adjacency list.

Beau

Adjacency list. I've heard the term. It sounds... graph-y. I thought we were talking about trees.

Jo

Well, a tree is just a special kind of graph, right? An undirected, acyclic, connected graph. The representation works perfectly. Instead of objects pointing to each other, you just have an array of lists. The list at index `u` holds all the nodes that are neighbors of `u`.

Beau

Okay, mental movie time. So instead of a 'node 5' object that has a pointer to a 'node 8' object... I just have a big array, maybe called `adj`. I go to `adj[5]`, and inside that there's a list. And that list just contains the number 8.

Jo

Precisely. And if 5 is also connected to 2 and 9, the list at `adj[5]` would contain {8, 2, 9}. It's super simple. The most common way to do this in C++ is with a vector of vectors: `vector<vector<int>> adj;`

Beau

Ah, okay. So I'd size it to `N+1` nodes. And when I read an edge between `u` and `v`, I just do `adj[u].push_back(v)` and `adj[v].push_back(u)`. That feels... way easier.

Jo

It is! And for maybe eighty percent of problems, this is all you need. It's flexible, easy to write, and much better than pointers. But... it still has a hidden cost.

Beau

Of course it does. Let me guess, `push_back` is secretly doing `new` in the background?

Jo

You got it. `std::vector` has to dynamically allocate memory. When it runs out of space, it allocates a bigger chunk and copies everything over. For a million nodes and a million edges, that's a lot of reallocations and potential cache misses. It's better, but not perfect.

Beau

So for those truly monster problems, the ones with tight time or memory limits, what's the next level?

Jo

The next level is to get rid of dynamic allocation completely. We go full static. We simulate the adjacency list using plain old arrays. This is the 'head-edge' or sometimes called 'linked-list' style.

Beau

Okay. You're gonna have to walk me through that. How do you make a list with just arrays?

Jo

Imagine three arrays. We'll call them `to`, `next_edge`, and `head`. `head` is indexed by the node number. `head[u]` stores the index of the *first* edge coming out of node `u`. The `to` and `next_edge` arrays are indexed by the edge number.

Beau

Okay... so `head[5]` gives me an index, say, 7. What's at index 7?

Jo

At index 7, `to[7]` would tell you where that edge goes, say, to node 8. And `next_edge[7]` would tell you the index of the *next* edge that also starts at node 5. It's a linked list, but instead of memory pointers, we're using array indices as pointers.

Beau

Whoa. Okay. So to traverse all of node 5's neighbors, I start at `head[5]`. Let's say that's index `i`. I visit `to[i]`. Then I update `i` to be `next_edge[i]`, and repeat until `i` is some special value, like -1, that means I'm at the end of the list.

Jo

You've got it. That's the whole pattern. `for (int i = head[u]; i != -1; i = next_edge[i]) { int v = to[i]; /* do stuff with v */ }`. It's incredibly fast because all the edge data is packed together in contiguous memory. The CPU can just tear through it.

Beau

What about the size? For an undirected tree with N-1 edges, I need to store each edge twice, right? `u->v` and `v->u`.

Jo

Correct. So your edge arrays, `to` and `next_edge`, need to have a capacity of at least `2 * (N-1)`. We usually just size them to `2*M` where M is the max number of edges. And if you have weighted edges? Just add another array, `weight[2*M]`, and store the weight at the same edge index.

Beau

So the memory footprint is just... three or four arrays. For a million nodes, that's maybe `1M * 4 bytes` for `head`, and `2M * 4 bytes` for each of the edge arrays. It's completely predictable and allocated all at once.

Jo

Exactly. No hidden overhead from vector management, no scattered pointers. This is why it's the gold standard for problems where every nanosecond and every byte counts. You pay a small price in code complexity, but you gain maximum performance.

Beau

Okay, that makes sense. It's like choosing between a flexible, but sometimes slow, delivery service versus building your own hyper-efficient pneumatic tube system. For most packages, the delivery service is fine. For the critical, time-sensitive ones, you build the tubes.

Jo

That's a perfect analogy. Start with `vector<vector<int>>`. It's your default. But when you see that TLE and you know your algorithm is sound, it's time to bring out the static arrays. It's your ace in the hole.

Beau

Right. Don't prematurely optimize, but know how to optimize when you need to. I feel like I just leveled up.