Linked List in Cairo: Complete Solution & Deep Dive Guide
Mastering Doubly Linked Lists in Cairo: A Deep Dive Guide
A Doubly Linked List in Cairo is a powerful, dynamic data structure composed of nodes, each holding data and two pointers: one to the next node and one to the previous. This implementation leverages a Felt252Dict for efficient, non-contiguous memory management, ideal for on-chain StarkNet applications.
You're building a groundbreaking application on StarkNet, perhaps a complex DeFi protocol or a dynamic NFT project. You need to manage a collection of items that grows and shrinks frequently. An array seems simple, but every time you add or remove an item from the beginning, you face a gas-intensive operation that shifts every single element. You feel the sting of inefficiency, knowing there has to be a better, more gas-friendly way. This is where the elegance of a Doubly Linked List shines, and mastering its implementation in Cairo is a game-changer for any serious StarkNet developer.
This comprehensive guide, part of the exclusive kodikra.com learning path, will take you from zero to hero. We'll deconstruct a robust Doubly Linked List implementation in Cairo, exploring not just the 'how' but the critical 'why' behind its design. You will learn to build a flexible and efficient data structure perfect for the demands of decentralized applications.
What Exactly is a Doubly Linked List?
Before diving into the Cairo code, let's solidify the core concept. Imagine a train. Each carriage is a node in our list. The content inside the carriage—passengers, cargo, or in our case, a station number—is the data.
In a simple "singly" linked list, each carriage only has a coupling to the one in front of it (a next pointer). You can easily walk from the engine to the caboose. But what if you're in the last carriage and want to go back to the engine? You can't. You're stuck moving in one direction.
A Doubly Linked List solves this. Each carriage has two couplings: one connecting to the carriage in front (the next pointer) and another connecting to the one behind (the previous pointer). This bidirectional link is the superpower of this data structure. It allows you to traverse the list forward and backward with equal ease.
This structure consists of:
- Nodes: The fundamental building blocks. Each node contains the actual data (e.g., a station ID) and two pointers.
- `next` Pointer: An address or index pointing to the subsequent node in the sequence.
- `previous` Pointer: An address or index pointing to the preceding node.
- Head: A special pointer that marks the very first node of the list.
- Tail: A special pointer that marks the very last node of the list.
This design makes adding or removing nodes at either the beginning (head) or the end (tail) incredibly efficient, a concept we will see is vital for gas optimization in Cairo.
Head Pointer
│
▼
┌────────────┐ next ┌────────────┐ next ┌────────────┐
│ Node 1 ├─────────────►│ Node 2 ├─────────────►│ Node 3 │
│ Data: 10 │ │ Data: 20 │ │ Data: 30 │
│ Prev: None │◄─────────────┤ Prev: Node 1 │◄─────────────┤ Prev: Node 2 │
└────────────┘ previous └────────────┘ previous └────────────┘
▲
│
Tail Pointer
Why Use a Doubly Linked List in Cairo?
In a general programming context, linked lists are chosen for their dynamic size and efficient insertions/deletions. In the world of Cairo and StarkNet, these benefits are amplified due to the nature of the blockchain environment, where computational cost (gas) is a primary concern.
The Gas Efficiency Advantage
The most significant advantage is the O(1) time complexity for adding or removing elements at the ends. Let's break this down:
- Push (Add to End): To add a new station to the end of the route, you simply create a new node, point the old tail's
nextto it, point the new node'spreviousto the old tail, and update the list'stailpointer to the new node. The number of existing stations doesn't matter. The cost is constant. - Pop (Remove from End): To remove the last station, you just update the
tailpointer to the second-to-last node and set itsnextpointer to null. Again, a constant-time operation. - Unshift (Add to Start): Similar to `push`, but at the head. Constant time.
- Shift (Remove from Start): Similar to `pop`, but at the head. Constant time.
Contrast this with a dynamic array (like a Span<T> in Cairo). Adding an element to the beginning requires shifting every single existing element one position over. This is an O(n) operation, where 'n' is the number of elements. On a blockchain, an O(n) operation can become prohibitively expensive as the list grows.
The `Felt252Dict` Backbone: A Cairo-Specific Design
Traditional languages like C++ or Rust implement linked lists with direct memory pointers. Cairo, running on the Cairo VM, has a different memory model. We can't just get a raw memory address. Instead, the canonical way to implement such structures is by using a dictionary or hash map, specifically Felt252Dict<V>.
In our implementation, we don't store nodes in contiguous memory. We store them in a dictionary where the key is a unique felt252 index and the value is the Node itself. The next and previous "pointers" are not memory addresses but simply the felt252 keys of the corresponding nodes in the dictionary. This provides the flexibility of non-contiguous storage, which is perfectly suited for the Cairo VM.
How to Implement a Doubly Linked List in Cairo: A Code Walkthrough
Let's dissect the complete implementation from the kodikra.com module. We'll analyze the data structures, the core logic, and each function's role in managing our train route.
The Core Data Structures
Everything starts with defining the `Node` and the `DoublyLinkedList` itself.
use core::dict::Felt252Dict;
// A type alias for clarity. `Option` makes our pointers nullable.
type Index = Option<felt252>;
#[derive(Drop, Copy)]
struct Node<T> {
station: T,
next: Index,
previous: Index,
}
struct DoublyLinkedList<T> {
dict: Felt252Dict<Nullable<Node<T>>>,
head: Index,
tail: Index,
len: usize,
next_index: felt252,
}
Dissecting the `Node<T>` Struct
#[derive(Drop, Copy)]: These traits are important.Dropensures memory is handled correctly when a node is no longer needed.Copyallows us to pass nodes by value easily, which is idiomatic for simple structs in Cairo.station: T: This is the generic data payload. By usingT, our linked list can hold any type of data, not just station numbers.next: Indexandprevious: Index: These are our "pointers." We use the type aliasIndex, which is anOption<felt252>. TheOptionenum is crucial here; it can either beSome(key), wherekeyis thefelt252index of the next/previous node in our dictionary, orNone, indicating the end of the list (e.g., thehead'spreviousisNone).
Dissecting the `DoublyLinkedList<T>` Struct
dict: Felt252Dict<Nullable<Node<T>>>: This is the heart of our storage. It's a dictionary mapping afelt252key to aNullable<Node<T>>. We useNullableto allow for efficient removal of nodes without immediately resizing the dictionary.head: Index: AnOption<felt252>that holds the key of the first node in the list. It'sNoneif the list is empty.tail: Index: AnOption<felt252>that holds the key of the last node in the list. It'sNoneif the list is empty.len: usize: A simple counter to keep track of the number of nodes. This provides an O(1) way to get the list's length without traversing it.next_index: felt252: An auto-incrementing counter used to generate unique keys for new nodes. This prevents key collisions in our dictionary.
The Implementation Trait and Constructor
We define the behavior of our list inside an impl block. The first step is creating a constructor, the new() function.
impl DoublyLinkedListImpl<T, +Drop<T>> of DoublyLinkedListTrait<T> {
fn new() -> DoublyLinkedList<T> {
DoublyLinkedList {
dict: Felt252Dict<Nullable<Node<T>>::new(),
head: Option::None,
tail: Option::None,
len: 0,
next_index: 0,
}
}
// ... other functions
}
The new() function is straightforward. It initializes an empty DoublyLinkedList. The dictionary is new and empty, head and tail are set to None, length is 0, and our index counter starts at 0.
Adding to the End: The `push` method
The `push` operation adds a new station to the end of the route. This is one of the most common operations.
fn push(ref self: DoublyLinkedList<T>, station: T) {
// 1. Create the new node
let new_node = Node { station, next: Option::None, previous: self.tail };
let new_index = self.next_index;
// 2. Insert the new node into the dictionary
self.dict.insert(new_index, nullable::new(new_node));
self.next_index += 1;
self.len += 1;
// 3. Update the old tail's `next` pointer
match self.tail {
Option::Some(tail_index) => {
let mut tail_node = self.dict.get(tail_index).deref();
tail_node.next = Option::Some(new_index);
self.dict.insert(tail_index, nullable::new(tail_node));
},
Option::None => {
// 4. If list was empty, head is also the new node
self.head = Option::Some(new_index);
}
};
// 5. Update the list's tail pointer
self.tail = Option::Some(new_index);
}
`push` Logic Explained:
- Create Node: We instantiate a new
Node. Itsstationis the data passed in. ItsnextisNonebecause it will be the new last node. Itspreviouspoints to the currenttailof the list. - Insert into Dictionary: We insert this new node into our
dictusing the currentnext_indexas the key. We then incrementnext_indexandlen. - Update Old Tail: We use a
matchstatement to check if the list was empty (self.tailisNone). If it wasn't, we get the old tail node from the dictionary, update itsnextpointer to point to our new node's index, and write it back to the dictionary. - Handle Empty List: If the list was empty (the
Option::Nonecase), this new node is both the head and the tail. So, we setself.headto the new index. - Update List Tail: Finally, we update the list's main
tailpointer to the index of the new node we just added.
This process is visually represented below.
● Start: Push(40)
│
▼
┌──────────────────────────┐
│ List: [10] <-> [20] <-> [30] │
│ Head: 10, Tail: 30 │
└───────────┬──────────────┘
│
▼
┌──────────────────────────┐
│ 1. Create New Node(40) │
│ - next: None │
│ - prev: 30 (old tail) │
└───────────┬──────────────┘
│
▼
┌──────────────────────────┐
│ 2. Update Old Tail(30) │
│ - node(30).next = 40 │
└───────────┬──────────────┘
│
▼
┌──────────────────────────┐
│ 3. Update List's Tail │
│ - list.tail = 40 │
└───────────┬──────────────┘
│
▼
● End: [10]<->[20]<->[30]<->[40]
Removing from the End: The `pop` method
The `pop` operation removes the last station and returns its value. It's the inverse of `push`.
fn pop(ref self: DoublyLinkedList<T>) -> Option<T> {
match self.tail {
Option::None => Option::None, // List is empty
Option::Some(tail_index) => {
// 1. Get the node to be removed
let tail_node_nullable = self.dict.get(tail_index);
let tail_node = tail_node_nullable.deref();
// 2. Update the list's tail to the previous node
self.tail = tail_node.previous;
// 3. Update the new tail's `next` pointer
match self.tail {
Option::Some(new_tail_index) => {
let mut new_tail_node = self.dict.get(new_tail_index).deref();
new_tail_node.next = Option::None;
self.dict.insert(new_tail_index, nullable::new(new_tail_node));
},
Option::None => {
// 4. If list is now empty, update head too
self.head = Option::None;
}
};
// 5. Decrement length and return station data
self.len -= 1;
// Note: The node is left in the dictionary as a null value,
// to be cleaned up later by `squash()`.
self.dict.insert(tail_index, nullable::null());
Option::Some(tail_node.station)
}
}
}
`pop` Logic Explained:
- Get Tail Node: We retrieve the current tail node from the dictionary using the
tail_index. - Update List Tail: We update the list's
tailpointer to point to the previous node (tail_node.previous). This node is about to become the new tail. - Update New Tail: We check if the list is now empty. If not, we fetch the new tail node, set its
nextpointer toNone(as it's now the last element), and save it back. - Handle Now-Empty List: If updating the tail resulted in
None, it means we just removed the last element. So, we must also set theheadtoNone. - Finalize: We decrement the length. Instead of truly removing the node from the dictionary (which can be expensive), we insert a
null()value at its key. This marks the slot for future cleanup. Finally, we return the station data wrapped inSome.
Operations at the Beginning: `unshift` and `shift`
The logic for unshift (add to start) and shift (remove from start) is perfectly symmetrical to push and pop. Instead of manipulating the tail and previous pointers, they manipulate the head and next pointers.
For instance, in unshift, a new node's previous is set to None, its next points to the old head, the old head's previous is updated to point to the new node, and the list's head pointer is updated.
When to Use (and Not Use) a Linked List in Cairo
Choosing the right data structure is critical for performance and gas cost. A Doubly Linked List is a specialized tool, not a universal solution. Here’s a comparison with a standard dynamic array (like Cairo's Array<T> or Span<T>) to help you decide.
Pros and Cons Analysis
| Operation | Doubly Linked List (Our Impl) | Dynamic Array (e.g., `Array<T>`) | Winner |
|---|---|---|---|
| Add/Remove at End (Push/Pop) | O(1) - Constant time |
O(1) - Amortized constant time |
Tie |
| Add/Remove at Start (Unshift/Shift) | O(1) - Constant time |
O(n) - Linear time (must shift all elements) |
Linked List |
| Insertion/Deletion in Middle | O(1) if you have the node's index, O(n) to find it |
O(n) - Linear time (must shift elements) |
Linked List (if node is known) |
| Element Access by Index (e.g., get 5th element) | O(n) - Linear time (must traverse from head/tail) |
O(1) - Constant time |
Array |
| Memory Usage | Higher per-element overhead (data + 2 pointers) | Lower per-element overhead (just data) | Array |
| Memory Layout | Fragmented (nodes can be anywhere in the dictionary) | Contiguous (elements are stored side-by-side) | Depends on use case |
The Verdict
- Choose a Doubly Linked List when: Your primary use case involves frequent additions or removals from both the beginning and the end of the collection. Think of implementing a queue (FIFO) or a deque (double-ended queue). This is where its O(1) performance shines and saves significant gas.
- Choose an Array when: You need fast, random access to elements by their index. If you are frequently doing things like `get_element_at(5)`, an array is vastly superior. It's also more memory-efficient if you don't need the flexibility of a linked list.
Frequently Asked Questions (FAQ)
What is the main advantage of a doubly linked list over a singly linked list?
The key advantage is bidirectional traversal. In a doubly linked list, you can move from any node to its previous node in constant time, O(1). This makes operations like removing the last element (pop) or inserting before a given node highly efficient. A singly linked list can only move forward, making such operations O(n) in complexity.
Why does this Cairo implementation use a Felt252Dict instead of direct pointers?
The Cairo VM has a unique memory model that doesn't expose raw memory addresses in the same way as systems languages like C or Rust. The Felt252Dict (a hash map) is the idiomatic and safe way to simulate pointer-like relationships. It allows for non-contiguous storage, where each node is a value in the dictionary, and its "pointers" (next/previous) are simply the keys to other nodes in the same dictionary.
Is a linked list efficient for random access (e.g., getting the 5th element)?
No, it is highly inefficient. To get the 5th element, you must start at the head and follow the next pointer four times. This is an O(n) operation, where 'n' is the index of the element you want. For frequent random access, an Array<T> is the correct data structure, as it provides O(1) access.
How is memory managed for removed nodes in this implementation?
When a node is "removed" via pop or shift, its entry in the Felt252Dict is replaced with a nullable::null() value rather than being deleted outright. This is a performance optimization. The memory isn't truly freed until the entire linked list goes out of scope and its destruct function is called, which in turn calls self.dict.squash() to clean up and reclaim the memory used by the dictionary.
Can a Cairo linked list store complex data types?
Yes. The implementation uses generics (<T>). This means T can be any type that conforms to the required traits (like Drop). You can store simple types like felt252 or complex custom structs, making the data structure very versatile.
What are some common pitfalls when working with linked lists?
The most common errors involve pointer management. Forgetting to update a next or previous pointer during an insertion or deletion can break the chain, leading to orphaned nodes or incorrect list state. For example, in a `pop` operation, forgetting to set the new tail's `next` pointer to `None` would be a critical bug. Careful, step-by-step logic and thorough testing are essential.
Conclusion: Your New Go-To Data Structure
You have now journeyed through one of the most fundamental and powerful data structures, reimagined for the Cairo ecosystem. The Doubly Linked List, backed by a Felt252Dict, is more than just an academic exercise; it's a practical, gas-efficient tool for managing dynamic, ordered data on-chain. Its O(1) performance for head/tail operations makes it an indispensable asset for building sophisticated queues, deques, and state management systems in your StarkNet contracts.
By understanding the trade-offs between linked lists and arrays, you are now better equipped to make informed architectural decisions that will define the performance and cost-effectiveness of your applications. This knowledge is a cornerstone of becoming a proficient Cairo developer.
To continue your journey, dive deeper into the Cairo programming language or explore the next module in our complete Cairo Learning Path to master even more advanced concepts and build the next generation of decentralized applications.
Disclaimer: All code is based on the current stable versions of Cairo. As the language evolves, syntax and best practices may change. Always refer to the official documentation for the latest updates.
Published by Kodikra — Your trusted Cairo learning resource.
Post a Comment