Let's discuss the following task:
Given sorted single-linked lists, merge them into one sorted single-linked list.
Sample:
Merge(1 -> 3 -> 5 and 2 -> 4 -> 6)
=>
1 -> 2 -> 3 -> 4 -> 5 -> 6
Let's define several structures, that we will need to solve the task. It will be the single-linked list node class and some of its methods:
class Node {
constructor(value, next) {
this.value = value;
this.next = next;
}
/**
* Append value to list and return new node
* @param {any} value Value to append
* @return {Node} Appended node
*/
append(value) {
this.next = new Node(value);
return this.next;
}
/**
* Print list
*/
print() {
let me = this,
result = [];
while (me) {
result.push(me.value);
me = me.next;
}
console.log(result.join(", "));
}
/**
* Build list from array
* @param {...any} values Values to use
* @return {Node} Head of new list
*/
static build(...values) {
let root = new Node(),
appender = (node, value) => node.append(value),
list = values.reduce(appender, root);
return root.next;
}
}
let list1 = Node.build(1, 3, 5),
list2 = Node.build(2, 4, 6),
list3 = Node.build(0, 7, 8),
list4 = Node.build(-1, 9, 10);
console.log("Lists to merge:");
list1.print();
list2.print();
list3.print();
list4.print();
console.log("---");
Let's try solving the simplest variation of the problem: when there are just two lists.
The idea is simple:
Sample:
Result [] lists [1, 3, 5] and [2, 4, 6]
Result [1], lists [3, 5] and [2, 4, 6]
Result [1, 2] lists [3, 5] and [4, 6]
Result [1, 2, 3] lists [5] and [4, 6]
...
Result [1, 2, 3, 4, 5, 6] lists [] and []; DONE;
Implementation:
/**
* Merge two lists, sorted asc, so that result is also sorted asc.
* Time complexity is O(sum(len(lists))), which is linear to total length of two lists
* Space complexity is O(1), since additional memory is not used
* @param {Node} list1 First list to merge
* @param {Node} list2 Second list to merge
* @return {Node} Merged list
*/
function mergeTwoLists(list1, list2) {
// iter will be used to populate new list, result to keep its head
let iter = (result = new Node()),
lists = [list1, list2].filter(x => !!x); // keep only non-empty lists
// while have non-empty lists
while (lists.length > 0) {
// pick list, that has min value (remember, we have only two lists!)
let minIndex = lists[1] && lists[1].value < lists[0].value ? 1 : 0;
// add it to answer
iter = iter.append(lists[minIndex].value);
// iterate that list
lists[minIndex] = lists[minIndex].next;
// at this point, list[minIndex] may be null, so we try to remove it
if (!lists[minIndex]) lists.splice(minIndex, 1);
}
// return next from result, since result is just dummy
return result.next;
}
O(sum(len(lists)))
, which is linear to total length of two lists.L
, the complexity is O(2 * L)
.This is a good, linear solution. However, it does not scale to N-lists.
Another good thing, is that given this solution, it's possible to implement N-list merge:
/**
* Merge multiple lists one-by-one.
* Time complexity: `N * len(list1) + (N-1) * len(list2) ... + len(list_N)`
* if all lists have same size `L`: `L * N^2` **this is not linear**
* Space complexity: O(1)
* @param {...Node} lists Lists to merge
* @return {Node} Merged list
*/
function mergeListsNaive(...lists) {
// merge lists to answer one-by-one
return lists.reduce(mergeTwoLists);
}
O(L * N^2)
. This is since we merge two lists of length L
, then list of length 2 * L
and L
, etc. Eventually, first list is merged N - 1
times. This means the complexity is (N - 1) * L + (N - 2) * L + ... + L ~ L * (N - 1) * (N - 2) / 2 ~ L * N^2
. This is not linear anymore.You can still impress the interviewer, by showing this type of solution, but it may get hard to explain its time complexity.
Let's implement the N-List merge using the same approach, as we had for two lists. This way, we may achieve a faster implementation.
/**
* Merge N lists, sorted asc, so that result is also sorted asc.
* Time complexity is O(N*sum(len(lists))),
* If all lists have same len `L`, this is also `L * N^2`, which is not linear
* since each step we select the minimum index from N lists
* Space complexity is O(1), since additional mem is not used
* @param {...Node} lists Lists to merge
* @return {Node} Merged list
*/
function mergeListsOption1(...lists) {
// iter will be used to populater new list, result to keep its head
let iter = (result = new Node());
lists = lists.filter(x => !!x); // keep only non-empty lists
function getMinIndex() {
let index = 0;
for (let i = lists.length - 1; i >= 1; i--) {
if (lists[i].value < lists[index].value) index = i;
}
return index;
}
// while have non-empty lists
while (lists.length > 0) {
// pick list, that has min value
let minIndex = getMinIndex();
// add it to answer
iter = iter.append(lists[minIndex].value);
// iterate that list
lists[minIndex] = lists[minIndex].next;
// at this point, list[minIndex] may be null, so we try to remove it
lists = lists.filter(x => !!x);
}
// return next from result, since result is just dummy
return result.next;
}
O(L * N^2)
since we do L * N
steps and select minimum using N
steps each time.O(1)
Notes on implementation of mergeListsOption1
: getMinIndex
and lists.filter
both introduce a more than linear complexity, yet, we cannot avoid them.
Even in this implementation, the complexity is L * N^2
...
Theory: for N sorted lists, it is impossible to merge them in linear time.
Real proof (suggested here by @dmitry_vk):
If we have a linear way to merge N lists (based on comparison),
-> Then we have a way to merge N lists of size 1 in linear time,
--> Means we have a way to sort N number in linear time (based on comparison)
---> Which is impossible (see here for example)
Thoughts on real proof:
The real proof is heavily based on the fact, that we should merge lists in a "comparison-based" approach, means comparing pairs of numbers. If we find a way to sort them, which is not "comparison-based", we may find a linear solution to the task. Is is possible to start the search from looking at any of "linear-time sorting" algorithms, like radix, bucket or counting sorts. If we assume it's possible to use any of that sort, we can just put all our elements to a single array, sort it in linear time and then create a result-list from that array, making a full linear-time solution.
Based on above, it might be feasible, even for comparison-based sorts, to still put all data to an array and then try sorting it, since we know indexes of each sorted part... Or maybe not.
Thoughts on proof (before real proof was suggested):
To merge the lists in linear time, we need to be able to select the list, that has the next min-value in O(1). This would be possible, if the values are already pre-sorted, and we would just pick the [0] value from some structure. But we also need to support this structure to be sorted. This will also take some time.
Since the size of answer is sum(len(lists))
which is equal to N * L
for N lists of size L,
it is obvious, that this answer itself cannot be built faster than linear time of O(N * L)
. This means, that
there's only O(1)
time remaining for picking the "next best element"—which seems impossible. This means,
that there's no algorithm, based on picking "next best element", that works in linear time. Most likely, this also
means that there cannot such an algorithm at all.
Based on ideas above, it looks like:
O(N * L)
Based on thoughts above, we see, that we came to a problem of maintaining a sorted structure, that supports taking out one element, adding one element and being sorted, still.
There are multiple structures, that support it, the most simple being the Binary Heap. Let's use it here.
/**
* Merge N lists, sorted asc, so that result is also sorted asc.
* If all lists have same len `L`, time complexity is L*N*log(N),
* which is not linear, but good, very good
* Space complexity is O(N), since we use heap of size N
* @param {...Node} lists Lists to merge
* @return {Node} Merged list
*/
function mergeListsFast(...lists) {
// iter will be used to populater new list, result to keep its head
let iter = (result = new Node());
heap = lists.filter(x => !!x); // keep only non-empty lists
//make heap
function heapify(i) {
// indexes for children
const [l, r] = [2 * i + 1, 2 * i + 2];
// smallest element
let smallest = i;
// if l is smaller
if (l < heap.length && heap[l].value < heap[i].value) smallest = l;
// if r is even smaller
if (r < heap.length && heap[r].value < heap[smallest].value) smallest = r;
// swap smallest with i
if (smallest !== i) {
[heap[i], heap[smallest]] = [heap[smallest], heap[i]];
// heapify again
heapify(smallest);
}
}
// heapify starting from middle of array and up to top
for (var i = Math.floor(heap.length / 2); i >= 0; i--) heapify(i);
// while have non-empty lists
while (heap[0]) {
// add min node to answer
iter = iter.append(heap[0].value);
// iterate that list
heap[0] = heap[0].next;
// at this point, heap[0] may be null, so we try to remove it
if (!heap[0]) heap[0] = heap.pop();
// then we rebuild the heap (this is Log(N))
heapify(0);
}
// return next from result, since result is just dummy
return result.next;
}
This problem could be approached as "sort N lists of L elements". In that case,
the naive complexity would be a quicksort of N * L
elements, which is N * L * log(N * L)
time complexity, and would take N * L
additional space for intermediate array.
Our fast Heap-based approach is even better then this, in terms of time and space, so I consider it pretty optimal.
Let's revisit our two-list-merge-based implmenentation for N-lists.
We can improve it, by merging "pairs" of lists. E.g. if merging lists is "+" we could improve it from using N - 1
operations to merge N lists, to using following tree-like approach:
list1 + list2 + list3 + list4 + list5 + list6 + list7 + list8 =
list1+list2 = list12
+ ... = list1234
list3+list4 = list34
+... = list12345678
list5+list6 = list56
+ ... = list5678
list7+list8 = list78
/**
* Merge N lists, sorted asc, so that result is also sorted asc.
* If all lists have same len `L`, time complexity is L*N*log(N),
* which is not linear, but good, very good
* Space complexity is O(1), since we don't use additional space
* @param {...Node} lists Lists to merge
* @return {Node} Merged list
*/
function mergeListsFast2(...lists) {
let end = lists.length - 1;
while (end > 0) {
let [i, j] = [0, end];
while (i < j) {
lists[i] = mergeTwoLists(lists[i++], lists[j--]);
}
end = j;
}
return lists[0];
}
O(L * N * log(N))
, since on each stage we merge a total of L * N
items in lists. We do log(N)
steps of this (height of B-tree).O(1)
, unless we count intermediate answers as space, which I don't think we should do.Now we know multiple approaches to solving this generic task, ones that are optimal, and which are not. I think the best and simplest approach is to implement merge for two lists, and then construct tree-based merge from it (which is mergeListsFast2
here). However, if you care about case for non-even lists' lengths—think about the heap-based approach, which seems to be a more stable bet in "general case".
Notes:
mergeTwoLists
as a basis, and is short to code, and uses less memory, than any other solution;