DSA Reordering a List
Reordering a list rewrites a chain as first, last, second, second last, and so on. Three known moves compose it, namely finding the middle, reversing the second half, and weaving the halves together.
The problem
Reorder List rewrites a linked list as first, last, second, second last, and so on. The chain keeps its own nodes. Only the links between them change.
The cases below are the ones this article tests every solution against. They cover odd and even lengths, and the shortest chains.
The cases used in this article
cases = [
([1, 2, 3, 4, 5], [1, 5, 2, 4, 3]),
([1, 2, 3, 4], [1, 4, 2, 3]),
([1, 2, 3], [1, 3, 2]),
([1], [1]),
]
for values, expected in cases:
print("before:", values, "after:", expected)Read the first row slowly. The order takes the head, then the tail, then the second node, then the second last. The middle value of an odd list, 3 in the first row, ends the chain.
Intuition: pick from both ends of a row
The first idea lays the values out in a row and picks from alternate ends. Left, right, left, right. The picking pattern is easy to see in a plain list.
A linked list has no right end to pick from. Reaching the tail means walking the whole chain, and the row of values exists only because a Python list stored it. That storage is the cost the real solution avoids.
The picking pattern on a plain list
values = [1, 2, 3, 4, 5]
out = []
lo = 0
hi = len(values) - 1
while lo <= hi:
out.append(values[lo])
lo += 1
if lo <= hi:
out.append(values[hi])
hi -= 1
print("picked order:", out)
print("values stored in a row first:", len(values))The pattern is right and the machinery is wrong for a chain. The optimal solution rebuilds the same pattern from three chain moves. The brute force first keeps the row, so its cost can be counted.
Brute force: collect the nodes, then relink
The brute force walks the chain once and collects every node into a list. It then relinks the nodes in picked order, taking from the front and the back of that list in turn. The chain's own nodes are reused, and the collected list is the extra memory.
The collect-and-relink version, counted
class Node:
def __init__(self, value):
self.value = value
self.next = None
def build(values):
nodes = [Node(v) for v in values]
for i in range(len(nodes) - 1):
nodes[i].next = nodes[i + 1]
return (nodes[0] if nodes else None), nodes
def to_list(head):
out = []
node = head
while node is not None:
out.append(node.value)
node = node.next
return out
def reorder_collect(head):
nodes = []
node = head
while node is not None:
nodes.append(node)
node = node.next
if not nodes:
return None, 0
order = []
lo = 0
hi = len(nodes) - 1
while lo <= hi:
order.append(nodes[lo])
lo += 1
if lo <= hi:
order.append(nodes[hi])
hi -= 1
for i in range(len(order) - 1):
order[i].next = order[i + 1]
order[-1].next = None
return order[0], len(nodes)
for values in [[1, 2, 3, 4, 5], [1, 2, 3, 4]]:
head, nodes = build(values)
new_head, stored = reorder_collect(head)
print("before:", values, "after:", to_list(new_head), "nodes stored:", stored)Five nodes meant five stored references. The count grows one for one with the chain. A chain too long for memory fails here even though the answer itself needs no new nodes at all.
The optimal solution: cut, reverse, weave
Weaving means taking one node from each of two chains in turn, and the whole reorder is three moves composed: find the middle and cut the chain there, reverse the second half in place, then weave the two halves together. That one sentence is the full method.
Reversing a chain in place points each link back at the node before it. The first half is never shorter than the second half, so the weave always runs out of second-half nodes first, which ends the chain cleanly.
The three moves on every case
class Node:
def __init__(self, value):
self.value = value
self.next = None
def build(values):
nodes = [Node(v) for v in values]
for i in range(len(nodes) - 1):
nodes[i].next = nodes[i + 1]
return (nodes[0] if nodes else None), nodes
def to_list(head):
out = []
node = head
while node is not None:
out.append(node.value)
node = node.next
return out
def reorder(head):
if head is None or head.next is None:
return head
slow = head
fast = head
while fast.next is not None and fast.next.next is not None:
slow = slow.next
fast = fast.next.next
second = slow.next
slow.next = None
prev = None
node = second
while node is not None:
nxt = node.next
node.next = prev
prev = node
node = nxt
second = prev
first = head
while second is not None:
next_first = first.next
next_second = second.next
first.next = second
second.next = next_first
first = next_first
second = next_second
return head
cases = [
([1, 2, 3, 4, 5], [1, 5, 2, 4, 3]),
([1, 2, 3, 4], [1, 4, 2, 3]),
([1, 2, 3], [1, 3, 2]),
([1], [1]),
]
for values, expected in cases:
head, nodes = build(values)
result = to_list(reorder(head))
print("before:", values, "after:", result, "expected:", expected, "match:", result == expected)All four cases from the start of the article pass. No list of nodes is built anywhere in the code. The only extra storage is a handful of node references.
Walkthrough: [1, 2, 3, 4, 5] through the three moves
The walk runs the three moves one at a time on the first case. The chain is printed after the cut, after the reversal, and after each weave step. The remaining second half is printed beside the woven chain while weaving lasts.
The chain after each move
class Node:
def __init__(self, value):
self.value = value
self.next = None
def build(values):
nodes = [Node(v) for v in values]
for i in range(len(nodes) - 1):
nodes[i].next = nodes[i + 1]
return (nodes[0] if nodes else None), nodes
def to_list(head):
out = []
node = head
while node is not None:
out.append(node.value)
node = node.next
return out
head, nodes = build([1, 2, 3, 4, 5])
slow = head
fast = head
while fast.next is not None and fast.next.next is not None:
slow = slow.next
fast = fast.next.next
second = slow.next
slow.next = None
print("after the cut - first half:", to_list(head), "second half:", to_list(second))
prev = None
node = second
while node is not None:
nxt = node.next
node.next = prev
prev = node
node = nxt
second = prev
print("after the reversal - second half:", to_list(second))
first = head
step = 0
while second is not None:
step += 1
next_first = first.next
next_second = second.next
first.next = second
second.next = next_first
first = next_first
second = next_second
print("weave step", step, "- chain so far:", to_list(head), "second half left:", to_list(second))
print("final chain:", to_list(head))The cut leaves the middle node, 3, at the end of the first half. The reversal turns 4, 5 into 5, 4. Two weave steps spend the second half, and the chain reads 1, 5, 2, 4, 3, exactly the target order.
Complexity
Both versions walk the chain a fixed number of times, so both take O(n) time for n nodes. Cutting, reversing, and weaving are one walk each, and a constant number of walks is still linear.
The collect version stores n node references, so its extra space is O(n). The three-move version stores a constant number of references, so its extra space is O(1).
Counting stored references at three sizes
for n in [5, 10, 20]:
print("nodes:", n, "collect version references stored:", n, "three-move references stored: 4")The three-move count never moves. Four references, the two runners and the two weave pointers, serve a chain of any length. That flat line is the whole gain over collecting.
Edge cases and pitfalls
Forgetting to cut the chain before reversing is the classic bug. The second half then still hangs off the first half, and the weave chases links back into nodes it already placed. The cut, one link set to None, is what makes the two halves separate chains.
An even-length chain splits into two equal halves. An odd-length chain leaves the extra node in the first half, where it correctly ends the woven chain.
Edge cases, run
class Node:
def __init__(self, value):
self.value = value
self.next = None
def build(values):
nodes = [Node(v) for v in values]
for i in range(len(nodes) - 1):
nodes[i].next = nodes[i + 1]
return (nodes[0] if nodes else None), nodes
def to_list(head):
out = []
node = head
while node is not None:
out.append(node.value)
node = node.next
return out
def reorder(head):
if head is None or head.next is None:
return head
slow = head
fast = head
while fast.next is not None and fast.next.next is not None:
slow = slow.next
fast = fast.next.next
second = slow.next
slow.next = None
prev = None
node = second
while node is not None:
nxt = node.next
node.next = prev
prev = node
node = nxt
second = prev
first = head
while second is not None:
next_first = first.next
next_second = second.next
first.next = second
second.next = next_first
first = next_first
second = next_second
return head
for values in [[], [1], [1, 2], [1, 2, 3], [1, 2, 3, 4, 5, 6]]:
head, nodes = build(values)
print("before:", values, "after:", to_list(reorder(head)))Two nodes survive unchanged, because first-then-last on a pair is the pair itself. The empty chain returns None through the same guard that handles a single node. No case needs its own branch.
Exercise
What are the first three values after reordering [1, 2, 3, 4, 5, 6]?