Skip to content

Latest commit

 

History

History
625 lines (438 loc) · 25.7 KB

File metadata and controls

625 lines (438 loc) · 25.7 KB

Linked List

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.

dllx4 compact

The data doesn’t have to be a number. It can be anything that you need (e.g., images, songs, menu items).

Some features powered by linked lists:
  • 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.

Other Applications:
  • 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.

Types of Linked List

Linked Lists can be:
  • 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.

Linked List Node Implementation
link:../../../src/data-structures/linked-lists/node.js[role=include]

Let’s go one by one!

Singly Linked List

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.

sllx4
Figure 1. Singly Linked List Representation: each node has a reference (blue arrow) to the next one.

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

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.

Circular linked lists examples

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

Doubly Linked List has two references to the next and previous node.

dll
Figure 2. Doubly Linked List: each node has a reference to the next and previous element.

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.

Implementing a Linked 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.

Linked List’s constructor
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]);

Searching by value or index

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.

Linked List’s searching by values or index
link:../../../src/data-structures/linked-lists/linked-list.js[role=include]
  1. We initialize two variables current to the first node and position to 0 to keep track of the ordinal number.

  2. While the current node is not null, we keep going.

  3. On each loop, we move to the next node and increment the index.

  4. We check if the index is the one provided or if the node has the expected value.

  5. Returns the index and the current node if found.

Insertion

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.

Inserting elements at the beginning of the list

We will use the Node class to create a new element and stick it at the beginning of the list, as shown below.

dll add first
Figure 3. Insert at the beginning by linking the new node with the current first node.

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:

Add item to the beginning of a Linked List
link:../../../src/data-structures/linked-lists/linked-list.js[role=include]
  1. It might be confusing seen this.first.previous. It means that we are updating the previous pointer of the art node to point to new.

Inserting element at the end of the list

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.

dll add last
Figure 4. Add element to the end of the linked list
Linked List’s add to the end of the list implementation
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).

Inserting element at the middle of the list

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.

There are four references to update:
  1. New node’s next.

  2. New node’s previous.

  3. New node’s previous next.

  4. 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.

dll insert middle
Figure 5. Inserting node in the middle.

Take a look into the implementation of LinkedList.add:

Linked List’s add to the middle of the list
link:../../../src/data-structures/linked-lists/linked-list.js[role=include]
  1. If the new item goes to position 0, then we reuse the addFirst method, and we are done!

  2. However, if we add to the last position, we reuse the addLast method and done!

  3. Adding newNode to the middle: First, create the new node only if it exists. Take a look at Linked List’s searching by values or index to see findBy implementation again.

  4. Set newNode previous reference.

  5. Set newNode next link.

  6. So far, no other node in the list points to newNode, so we the art node’s next point to new (refer to the illustration).

  7. Make dog node’s previous point to new.

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.

Deletion

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 element from the head

Deleting the first element (or head) is a matter of removing all references to it.

dll remove first
Figure 6. Deleting an element from the head of the list

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.

Linked List’s remove from the beginning of the list
link:../../../src/data-structures/linked-lists/linked-list.js[role=include]
Check for edge cases:
  • 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.

Deleting element from the tail

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!

dll remove last
Figure 7. Removing the last element from the list.

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.

Linked List’s remove from the end of the list
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.

Deleting element from the middle

To remove a node from the middle, we make the surrounding nodes bypass the one we want to delete.

dll remove middle
Figure 8. Remove the middle node

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:

Linked List’s remove from the middle of the list
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).

Linked List vs. Array

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.

Table 1. Big O cheat sheet for Linked List and Array

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:

Use arrays when:
  • You want to access random elements by numeric key or index in constant time O(1).

  • You need two-dimensional and multi-dimensional arrays.

Use a doubly linked list when:
  • 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.

Linked List patterns for Interview Questions

Most linked list problems are solved using 1 to 3 pointers. Sometimes we move them in tandem or individually.

Examples of problems that can be solved using multiple pointers:
  • 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 to 3→2→1.

Let’s do some examples!

Fast/Slow Pointers

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:

Fast/Slow pointers
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.

fast/slow pointer in a circular linked list

You can detect the intersection point (node D on the example) by using this algorithm:
  • When fast and slow 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!

fast/slow pointer in a singly linked list

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).

Signature
/**
 * Find the node where the cycle begins or null.
 * @param {Node} head
 * @returns {Node|null}
 */
function findCycleStart(head) {

};
Examples
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.

Solution 1: Map/Set for detecting loop
link:../../interview-questions/linkedlist-find-cycle-start.js[role=include]
Complexity Analysis
  • 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.

Solution 2: Fast/Slow pointer
link:../../interview-questions/linkedlist-find-cycle-start.js[role=include]
Complexity Analysis
  • 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.

Multiple Pointers

LL-B) Determine if a singly linked list is a palindrome. A palindrome is a sequence that reads the same backward as forward.

Signature
/**
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!
}
Examples
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.

Solution 1: List to array
link:../../interview-questions/linkedlist-is-palindrome.js[role=include]
  1. Copy each one of the nodes' value into an array.

  2. Given two indices (lo and hi), 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?

Complexity Analysis
  • 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?

Here’s another algorithm to solve this problem in 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.

Solution 2: Reverse half of the list
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.

Complexity Analysis
  • 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.

Multi-level Linked Lists

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.

multi-level 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.

Signature
/**
 * Flatten a multi-level list
 * @param {Node} head
 * @return {Node}
 */
function flatten(head) {

}
Examples
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.

Algorithm summary:
  • Starting from the head, visit all nodes using the next pointer.

    • If any node has a child pointer, move it to the next.

    • Save next on the array (stack) for later use.

    • When we don’t have more nodes on next, pop from the array (stack).

Solution 1: Array/Stack approach
link:../../interview-questions/linkedlist-flatten-multilevel.js[role=include]
Complexity Analysis
  • 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.

Algorithm summary:
  • Starting from the head, visit all nodes using the next pointer.

    • If node curr has a child.

      • 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.

Solution 2: In-place approach
link:../../interview-questions/linkedlist-flatten-multilevel.js[role=include]
Complexity Analysis
  • Time Complexity: O(n). In the worst-case, we will visit most nodes twice 2nO(n).

  • Space complexity: O(1). No auxiliary structure was used to hold the lists.

Practice Questions

Merge Linked Lists into One

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
}
Check if two strings lists are the same

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
}