A list (or Linked List) is a linear data structure where each object has a pointer to the next one creating a chain. You can also have a back reference to the previous node.
The data doesn’t have to be a number. It can be anything that you need (e.g., images, songs, menu items).
-
Image viewer – The previous and next images are linked in an image viewer so that the user can navigate them.
-
Previous and next page in web browser – We can access the previous and next URL searched in a web browser by pressing the back and next button since they are linked.
-
Music Player - Queue of songs in a music player connects them so you can move to the next song or previous one.
-
Build [Stack] and [Queue] data structures, which are useful for Graph Traversal and other things.
-
Linked Lists are used on [hashmap-chap] to handle collisions.
-
Linked Lists can be used when representing a [graph] as an adjacency list.
-
Operate arbitrary big numbers (think hundreds of digits). Each digit is a node of a linked list.
-
Manipulation of polynomials by storing constants in the node of a linked list.
-
Representing sparse matrices (an array representation will waste a lot of memory when most of the cells are empty). The linked list will represent only the non-zero values saving significant space.
Hopefully, this will get you excited about learning Linked Lists since it’s the base of many interesting applications. Let’s learn more about the different types of linked lists.
-
Singly: every item has a pointer to the next.
-
Doubly: every item has a reference to the next and the previous.
-
Circular: the last element points to the first one, forming an infinite loop.
JavaScript doesn’t have a built-in List. However, it’s straightforward to create.
link:../../../src/data-structures/linked-lists/node.js[role=include]
Let’s go one by one!
In a singly linked list, each element or node is connected to the next one by a reference.
Usually, a Linked List is referenced by the first element called head (or root node). Let’s say that we have a list of strings with the following values: "art" → "dog" → "cat"
. It would look something like the following image.
If you want to get the cat
element from the example above, then the only way to get there is by using the next
field on the head node. You would get art
first, then use the next field recursively until you eventually get the cat
element.
Circular linked lists happen when the last node points to any node on the list, creating a loop. In the following illustration, you can see two circular linked lists.
One circular linked list happens when the last element points to the first element. Another kind of circular linked list is when the last node points to any node in the middle. There are some efficient algorithms to detect when the list has a loop or not. More on that later in this chapter.
Doubly Linked List has two references to the next
and previous
node.
With a doubly-linked list, you can move not only forward but also backward. If you keep a pointer to the last
element (cat
), you can step back recursively.
Finding an item on the linked list takes O(n) time. Because in the worst-case, you will have to iterate over the whole list.
We are going to implement a doubly linked list. First, let’s start with the constructor.
Tip
|
if you want to implement a singly linked list instead, it’s the same in most parts, but without the setting the previous pointers.
|
The only must-have field on the constructor is the first
or head reference. If you want to insert data to the back of the list in constant time, then the last
pointer is needed. Everything else is complimentary.
link:../../../src/data-structures/linked-lists/linked-list.js[role=include]
// ... methods go here ...
}
The iterable parameter is a nice to have. That will allow you to convert an array of items into a linked list. E.g. const list = new LinkedList([1, 2, 3]);
There’s no other way to find an element by value than iterating through the list. So, the runtime is O(n)
.
There are two prominent use cases for search: find an element by value, or find them by their index/position.
We can use a for-loop to keep track of the index and the current node simultaneously. Whichever fulfill first, we return that one.
link:../../../src/data-structures/linked-lists/linked-list.js[role=include]
-
We initialize two variables
current
to the first node andposition
to 0 to keep track of the ordinal number. -
While the
current
node is not null, we keep going. -
On each loop, we move to the next node and increment the index.
-
We check if the index is the one provided or if the node has the expected value.
-
Returns the index and the current node if found.
You can add elements at the beginning, end, or anywhere in the middle of the list in a linked list. So, let’s implement each case.
We will use the Node class to create a new element and stick it at the beginning of the list, as shown below.
To insert at the beginning, we create a new node with the next reference to the current first node. Then we update the pointer first
to the new node. In code, it would look something like this:
link:../../../src/data-structures/linked-lists/linked-list.js[role=include]
-
It might be confusing seen
this.first.previous
. It means that we are updating theprevious
pointer of theart
node to point tonew
.
Appending an element at the end of the list can be done very effectively if we have a pointer to the last
item. Otherwise, you would have to iterate through the whole list.
link:../../../src/data-structures/linked-lists/linked-list.js[role=include]
If there’s no element in the list yet, the first and last node would be the same. If there’s something, we go to the last
item and add the reference next
to the new node. That’s it! We got a constant time for inserting at the beginning and the end of the list: O(1).
For inserting an element in the middle of the list, you would need to specify the position (index) in the list. Then, you create the new node and update the references around it.
-
New node’s
next
. -
New node’s
previous
. -
New node’s previous
next
. -
New node’s next
previous
.
Let’s do an example with the following doubly linked list:
art <-> dog <-> cat
We want to insert the new
node in the 2nd position (index 1). For that, we first create the "new" node and update the references around it.
Take a look into the implementation of LinkedList.add
:
link:../../../src/data-structures/linked-lists/linked-list.js[role=include]
-
If the new item goes to position 0, then we reuse the
addFirst
method, and we are done! -
However, if we add to the last position, we reuse the
addLast
method and done! -
Adding
newNode
to the middle: First, create thenew
node only if it exists. Take a look at Linked List’s searching by values or index to seefindBy
implementation again. -
Set newNode
previous
reference. -
Set newNode
next
link. -
So far, no other node in the list points to
newNode
, so we theart
node’s next point tonew
(refer to the illustration). -
Make
dog
node’s previous point tonew
.
Take notice that we reused addFirst
and addLast
methods. For all the other cases, the insertion is in the middle. We use current.previous.next
and current.next
to update the surrounding elements and point to the new node. Inserting in the middle takes O(n) because we have to iterate through the list using the findBy
method.
Deleting is an interesting one. We don’t delete an element; we remove all references to that node. The garbage collector will remove it when no one points to it. Let’s go case by case to explore what happens.
Deleting the first element (or head) is a matter of removing all references to it.
For instance, to remove the head (“art”) node, we change the variable first
to point to the second node, “dog”. We also remove the variable previous
from the "dog" node, so it doesn’t reference the “art” node anymore. The garbage collector will get rid of the “art” node when it sees nothing is using it anymore.
link:../../../src/data-structures/linked-lists/linked-list.js[role=include]
-
List is already empty.
-
Removing the last node.
As you can see, when we want to remove the first node, we make the 2nd element (head.next
) the first one.
Removing the last element from the list would require iterate from the head until we find the last one: O(n)
. But, since we referenced the last element, we can do it in O(1) instead!
For instance, if we want to remove the last node “cat”. We use the last pointer to avoid iterating through the whole list. We check last.previous
to get the “dog” node and make it the new last
and remove its next reference to “cat.” Since nothing is pointing to “cat” it is out of the list and eventually is deleted from memory by the garbage collector.
link:../../../src/data-structures/linked-lists/linked-list.js[role=include]
The code is very similar to removeFirst
, but instead of first, we update last
reference, and instead of nullifying previous
, we null out the next
pointer.
To remove a node from the middle, we make the surrounding nodes bypass the one we want to delete.
In the illustration, we are removing the middle node “dog” by making art’s next
variable to point to cat and cat’s previous
to be “art,” totally bypassing “dog.”
Let’s implement it:
link:../../../src/data-structures/linked-lists/linked-list.js[role=include]
Notice that we are using the get
method to get the node at the current position. That method loops through the list until it found the node at the specified location. This iteration has a runtime of O(n).
Arrays give you instant access to data anywhere in the collection using an index. However, Linked List visits nodes in sequential order. In the worst-case scenario, it takes O(n) to get an element from a Linked List. You might be wondering: Isn’t an array always more efficient with O(1) access time? It depends.
We also have to understand the space complexity to see the trade-offs between arrays and linked lists. An array pre-allocates contiguous blocks of memory. If the array fillup, it has to create a larger array (usually 2x) and copy all the elements when it is getting full. That takes O(n) to copy all the items over. On the other hand, LinkedList’s nodes only reserve precisely the amount of memory they need. They don’t have to be next to each other in RAM, nor are large chunks of memory is booked beforehand like arrays. Linked List is more on a "grow as you go" basis. Linked list wins on memory usage over an array.
Another difference is that adding/deleting at the beginning of an array takes O(n)
; however, the linked list is a constant operation O(1)
as we will implement later. Linked List has better runtime than an array for inserting items at the beginning.
Data Structure |
Searching By |
Inserting at the |
Deleting from |
Space |
|||||
Index/Key |
Value |
beginning |
middle |
end |
beginning |
middle |
end |
||
Array |
O(1) |
O(n) |
O(n) |
O(n) |
O(1) |
O(n) |
O(n) |
O(1) |
O(n) |
Linked List (singly) |
O(n) |
O(n) |
O(1) |
O(n) |
O(n) |
O(1) |
O(n) |
O(n) |
O(n) |
Linked List (doubly) |
O(n) |
O(n) |
O(1) |
O(n) |
O(1) |
O(1) |
O(n) |
O(1) |
O(n) |
If you compare the singly linked list vs. doubly linked list, you will notice that the main difference is inserting elements to and deleting elements from the end. For a singly linked list, it’s O(n), while a doubly-linked list is O(1).
Comparing an array with a doubly-linked list, both have different use cases:
-
You want to access random elements by numeric key or index in constant time O(1).
-
You need two-dimensional and multi-dimensional arrays.
-
You want to access elements in a sequential manner only like part02-linear-data-structures.asc or part02-linear-data-structures.asc.
-
You want to insert elements at the start and end of the list. The linked list has O(1) while the array has O(n).
-
You want to save some memory when dealing with possibly large data sets. Arrays pre-allocate a large chunk of contiguous memory on initialization. Lists are more “grow as you go.”
For the next two linear data structures part02-linear-data-structures.asc and part02-linear-data-structures.asc, we are going to use a doubly-linked list to implement them. We could use an array as well, but since inserting/deleting from the start performs better with linked-lists, we will use that.
Most linked list problems are solved using 1 to 3 pointers. Sometimes we move them in tandem or individually.
-
Detecting if the linked list is circular (has a loop).
-
Finding the middle node of a linked list in 1-pass without any auxiliary data structure.
-
Reversing the linked list in 1-pass without any auxiliary data structure. e.g.
1→2→3
to3→2→1
.
Let’s do some examples!
One standard algorithm to detect loops in a linked list is fast/slow runner pointers (a.k.a The Tortoise 🐢 and the Hare🐇 or Floyd’s Algorithm). The slow pointer moves one node per iteration, while the fast pointer moves two nodes every time. You can see an example code below:
let fast = head, slow = head;
while (fast && fast.next) {
slow = slow.next; // slow moves 1 by 1.
fast = fast.next.next; // slow moves 2 by 2.
}
If the list has a loop, then at some point, both pointers will point to the same node. Take a look at the following image; take notice that both points to node I
on the 8th iteration.
node D
on the example) by using this algorithm:-
When
fast
andslow
are the same, then create a (3rd) new pointer from the start. -
Keep moving the 3rd pointer and the
slow
simultaneously one by one. -
Where slow and 3rd pointer meets, that’s the beginning of the loop or intersection (e.g.,
node D
).
Fast/slow pointer has essential properties, even if the list doesn’t have a loop!
If you don’t have a loop, then fast and slow will never meet. However, by the time the fast
pointer reaches the end, the slow
pointer would be precisely in the middle!
This technique is useful for getting the middle element of a singly list in one pass without using any auxiliary data structure (like array or map).
LL-A) Find out if a linked list has a cycle and, if so, return the intersection node (where the cycle begins).
/**
* Find the node where the cycle begins or null.
* @param {Node} head
* @returns {Node|null}
*/
function findCycleStart(head) {
};
findCycleStart(1 -> 2 -> 3); // null // no loops
findCycleStart(1 -> 2 -> 3 -> *1); // 1 // node 3 loops back to 1
findCycleStart(1 -> 2 -> 3 -> *2); // 2 // node 3 loops back to 2
Solution
One solution is to find a loop using a HashMap (Map
) or HashSet (Set
) to track the visited nodes. If we found a node that is already on Set
, then that’s where the loop starts.
link:../../interview-questions/linkedlist-find-cycle-start.js[role=include]
-
Time Complexity:
O(n)
. We might visit all nodes on the list (e.g., no loops). -
Space complexity:
O(n)
. In the worst-case (no loop), we store all the nodes on the Set.
Can we improve anything here? We can solve this problem without using any auxiliary data structure using the fast/slow pointer.
link:../../interview-questions/linkedlist-find-cycle-start.js[role=include]
-
Time Complexity:
O(n)
. In the worst case (no loop), we visit every node. -
Space complexity:
O(1)
. We didn’t use any auxiliary data structure.
LL-B) Determine if a singly linked list is a palindrome. A palindrome is a sequence that reads the same backward as forward.
/**
link:../../../src/data-structures/linked-lists/node.js[role=include]
*/
/**
* Determine if a list is a palindrome
* @param {Node} head
* @returns {boolean}
*/
function isPalindrome(head) {
// you code goes here!
}
const toList = (arr) => new LinkedList(arr).first;
isPalindrome(toList([1, 2, 3])); // false
isPalindrome(toList([1, 2, 3, 2, 1])); // true
isPalindrome(toList([1, 1, 2, 1])); // false
isPalindrome(toList([1, 2, 2, 1])); // true
Solution
To solve this problem, we have to check if the first and last node has the same value. Then we check if the second node and second last are the same, and so on. If we found any that’s not equal; then it’s not a palindrome. We can use two pointers, one at the start and the other at the end, and move them until they meet in the middle.
The issue is that with a singly linked list, we can’t move backward! We could either convert it into a doubly-linked list (with the last pointer) or copy the nodes into an array. Let’s do the latter as a first approach.
link:../../interview-questions/linkedlist-is-palindrome.js[role=include]
-
Copy each one of the nodes' value into an array.
-
Given two indices (
lo
andhi
), one with the lowest index (0) and the other with the highest index (length - 1). Move both of them towards the center. If any values are not the same, then it’s not a palindrome.
What’s the time complexity?
-
Time Complexity:
O(n)
. We do two passes, one on the for-loop and the other in the array. -
Space complexity:
O(n)
. We are using auxiliary storage with the array O(n).
That’s not bad, but can we do it without using any auxiliary data structure, O(1) space?
-
Find the middle node of the list (using fast/slow pointers).
-
Reverse the list from the middle to the end.
-
Have two new pointers, one at the beginning of the list and the other at the head of the reversed list.
-
If all nodes have the same value, then we have a palindrome. Otherwise, we don’t.
link:../../interview-questions/linkedlist-is-palindrome.js[role=include]
This solution is a little longer, but it’s more space-efficient since it doesn’t use any auxiliary data structure to hold the nodes.
-
Time Complexity:
O(n)
. We visit every node once. -
Space complexity:
O(1)
. We didn’t use any auxiliary data structure. We changed data in-place.
It’s good to know that linked lists might have other connections besides the next
and previous
pointers. They might also reference different lists forming a multi-leveled linked list.
Let’s explore the following example:
LL-C) Flatten a multi-level to a single level. You will be operating a doubly-linked list that, besides the pointers next
and previous
, also has a child
pointer. Return the head of the flattened list.
/**
* Flatten a multi-level list
* @param {Node} head
* @return {Node}
*/
function flatten(head) {
}
class Node {
value = null;
next = null;
prev = null;
child = null;
constructor(value) { this.value = value; }
}
const ll = (nums) => Array.from(new LinkedList(nums, Node));
const l1 = ll([1, 2, 3, 4, 5, 6]);
const l2 = ll([10, 12, 14, 16]);
const l3 = ll([21, 23]);
const l4 = ll([36, 37]);
l1[2].child = l2;
l2[1].child = l3;
l2[2].child = l4;
const head = l1[0];
// Head:
//
// 1--- 2--- 3--- 4--- 5--- 6
// |
// 10---12---14---16
// | |
// | 36---37
// |
// 21--23
flatten(head); // 1->2->10->12->21->23->14->36->37->16->3->4->5->6
Our job is to flatten a multi-level LinkedList. So far, we know how to navigate a list using the next
pointer. If we found another list on the child
pointer, we can flatten it out by moving the child’s chain to the next
pointer. However, if we are not careful, we will lose whatever nodes were on next
. One idea is to store that in an array and bring them back at a later time.
-
Starting from the
head
, visit all nodes using thenext
pointer.-
If any node has a
child
pointer, move it to thenext
. -
Save
next
on the array (stack) for later use. -
When we don’t have more nodes on
next
, pop from the array (stack).
-
link:../../interview-questions/linkedlist-flatten-multilevel.js[role=include]
-
Time Complexity:
O(n)
. We visit every node only once. -
Space complexity:
O(n)
. The stack array might hold almost all nodes.
This approach works well. However, we can do better in terms of space complexity. Instead of holding the data on an auxiliary array, we can append it to the end of the child’s list.
-
Starting from the
head
, visit all nodes using thenext
pointer.-
If node
curr
has achild
.-
Follow the child’s chain to the end.
-
Then connect the child’s tail to
curr.next
. By doing this, we merged the child’s chain with the main thread. -
Move the child’s chain to
curr.next
.
-
-
link:../../interview-questions/linkedlist-flatten-multilevel.js[role=include]
-
Time Complexity:
O(n)
. In the worst-case, we will visit most nodes twice2n
→O(n)
. -
Space complexity:
O(1)
. No auxiliary structure was used to hold the lists.
LL-1) Merge two sorted lists into one (and keep them sorted)
Examples:
mergeTwoLists(2->3->4, 1->2); // 1->2->2->3->4 mergeTwoLists(2->3->4,null); // 2->3->4
Common in interviews at: FAANG, Adobe, Microsoft
link:../../interview-questions/merge-lists.js[role=include]
// write you code here
}
Solution: [linkedlist-q-merge-lists]
LL-2) Given two linked lists with strings, check if the data is equivalent.
Examples:
hasSameData(he->ll->o, hel->lo); // true hasSameData(hello, hel->lo); // true hasSameData(he->ll->o, h->i); // false
Common in interviews at: Facebook
link:../../interview-questions/linkedlist-same-data.js[role=include]
// write you code here
}
Solution: [linkedlist-q-linkedlist-same-data]