Problem overview
The method mergeKLists receives an array lists of k sorted linked lists. Each list is given by its head node, and an empty list is null. It returns one sorted linked list containing every value from all the lists.
For three lists 1→4→5, 1→3→4 and 2→6, the result is 1→1→2→3→4→4→5→6. An empty array of lists gives an empty result, and so does an array holding one empty list; in both cases the method returns null.
This lesson asks for O(n × k) time and no extra space beyond the merged list, where n is the total number of nodes and k the number of lists. A min-heap version that runs in O(n log k) has its own lesson.
Approach: always take the smallest head
Every input list is already sorted, so the smallest value not yet used is always the first node of one of the lists. Merging is therefore a repeated choice: look at the current head of every list, take the smallest, add its value to the result, and advance that list by one node. Repeat until every list is empty.
Finding the smallest head is a plain scan over the k heads, which costs O(k). It runs once for each of the n output nodes, so the total is O(n × k). That is the brute-force way to pick the minimum, and its weakness is the repeated scan: a min-heap finds the smallest head in O(log k) instead.
For the result, a dummy node placed before the real head avoids a special case. Without it, the code that appends the first node would differ from the code that appends the others. With it, every append is tail.next = ... and the answer is dummy.next.
On the example, the heads are 1, 1 and 2. The scan uses a strict <, so the first of the two equal 1s wins: its value is appended and that list advances to 4. The heads are now 4, 1 and 2, then 4, 3 and 2, and so on, until the lists run out in the order 1, 1, 2, 3, 4, 4, 5, 6.
Step by step
- Create the dummy node
dummywithnew ListNode(). Its own value is never used. The merged list will hang offdummy.next. - Declare
tailas a variable that always points at the last node appended. It starts at the dummy. - Loop while some list still has nodes.
lists.some(l => l !== null)is true as long as at least one entry is notnull. - Declare
indas -1. It remembers which list has the smallest head, and -1 means "no choice yet". - Scan all
klists with an indexed loop overlists. - Update
indwhen a list wins. A list competes if its entry is notnull. It wins if nobody has been picked yet or its head value is strictly smaller than the current best. - Append a new node with the winning value:
tail.next = new ListNode(lists[ind].val). - Advance the winning list past the node just used:
lists[ind] = lists[ind].next. When a list runs out, its entry becomesnulland it stops competing. - Move
tailonto the node just appended. - Return
dummy.nextafter the loop. It isnullwhen no values were appended.
The solution in TypeScript
class Solution {
mergeKLists(lists: Array<ListNode | null>): ListNode | null {
const dummy = new ListNode();
let tail = dummy;
while (lists.some(l => l !== null)) {
let ind = -1;
for (let i = 0; i < lists.length; i++) {
if (lists[i] && (ind === -1 || lists[i].val < lists[ind].val)) {
ind = i;
}
}
tail.next = new ListNode(lists[ind].val);
lists[ind] = lists[ind].next;
tail = tail.next;
}
return dummy.next;
}
}
The TypeScript details worth a closer look:
- The union type
ListNode | null. An empty list isnull, so each entry oflistsis either a node ornull. With the lesson's compiler settings, reading.valfrom such an entry isn't reported as an error. Thelists[i] && ...guard is what keeps the code from reading a property ofnullat run time. - Truthiness as a null check.
lists[i] && (...)is false fornull, so exhausted lists are skipped. The&&also short-circuits: the right side is not evaluated when the left side is falsy. Array.prototype.some. It calls the arrow function on each entry and stops at the firsttrue, so the check is cheap while the first list is still non-empty, and costs up tokonce the leading lists are empty.- The input array is consumed.
lists[ind] = lists[ind].nextoverwrites entries of the caller's array. When the method returns, every entry isnull. The nodes themselves are not changed, because the result is built from new nodes, so the original lists stay intact for anyone who kept a reference to their heads. constand mutation.const dummyonly fixes the variable. Itsnextfield is still assigned later.tailisletbecause it is reassigned.
Complexity
Time: O(n × k). The outer loop runs once per output node, so n times. Each run does one some check and one scan, both up to k steps. With the constraints' limit of 10^4 nodes in total and up to 10^4 lists, the worst case is on the order of 10^8 simple steps, which is why the heap version is worth learning.
Space: O(1) extra. Besides the merged list, which has one new node per value and is the required output, the method holds the dummy node, tail, ind and i.
Edge cases and common mistakes
- No lists.
lists = []:someon an empty array isfalse, the loop never runs and the method returnsdummy.next, which isnull. - Only empty lists.
[null]or[null, null]behave the same way. - Some lists empty.
[null, 1→2]works: thenullentry never competes, and the result is1→2. - Equal values. The strict
<keeps the first of equal heads, and the result is the same sorted sequence either way. - Using
indwithout the -1 check. Starting with a value such as 0 would treat the list at index 0 as already chosen, even when it is empty. - Returning
dummy. That would put a bogus extra node at the front. The answer starts atdummy.next. - Forgetting to move
tail. Every new node would overwrite the previous one's place, and the result would hold only the last value.
Testing
[1→4→5, 1→3→4, 2→6]gives1→1→2→3→4→4→5→6.[]gives an empty list (null).[null]gives an empty list (null).[null, -3→2]gives-3→2.[1→1, 1]gives1→1→1.
