codexmerge-sort-split-and-merge.py
merge-sort-split-and-merge.py
"""Draw a computed stable merge-sort tree; save an opaque PNG to cwd.
Tested with Python 3.14.4, Matplotlib 3.10.7 and NumPy 2.3.5.
"""
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import FancyBboxPatch
VALUES = (7, 2, 5, 2, 9, 1, 4, 3)
def merge(left, right):
"""Compare numeric keys only, retaining original indices as identities."""
result, trace = [], []
i = j = 0
while i < len(left) and j < len(right):
trace.append((left[i], right[j]))
if left[i][0] <= right[j][0]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
return result + left[i:] + right[j:], trace
def sort_tree(items, start=0, depth=0):
"""Return the sorted items and every node of a floor-midpoint recursion."""
node = {"start": start, "size": len(items), "depth": depth,
"input": items, "comparisons": 0}
if len(items) < 2:
node["output"] = items
return items, [node]
mid = len(items) // 2
left, left_nodes = sort_tree(items[:mid], start, depth + 1)
right, right_nodes = sort_tree(items[mid:], start + mid, depth + 1)
output, trace = merge(left, right)
node.update(output=output, comparisons=len(trace), trace=trace)
return output, [node] + left_nodes + right_nodes
def label(items):
tags = {1: "2a", 3: "2b"}
return " ".join(tags[index] if value == 2 else str(value)
for value, index in items)
def center(node):
return 2.65 + 1.05 * (node["start"] + (node["size"] - 1) / 2)
def box(ax, node, y, output=False):
width = 0.47 * node["size"] + 0.23
x = center(node)
color = "#e3eef8" if not output else "#e0f1e5"
ax.add_patch(FancyBboxPatch((x - width / 2, y - 0.23), width, 0.46,
boxstyle="round,pad=0.04", facecolor=color,
edgecolor="#577187", linewidth=1.0, zorder=3))
ax.text(x, y, label(node["output"] if output else node["input"]),
ha="center", va="center", fontsize=13, color="#172c3c", zorder=4)
def main():
items = [(value, index) for index, value in enumerate(VALUES)]
output, nodes = sort_tree(items)
counts = [sum(n["comparisons"] for n in nodes if n["depth"] == depth)
for depth in (2, 1, 0)]
assert [v for v, _ in output] == [1, 2, 2, 3, 4, 5, 7, 9]
assert [i for v, i in output if v == 2] == [1, 3]
assert counts == [4, 6, 7]
assert sum(counts) == 17
plt.rcParams.update({"font.family": "DejaVu Sans", "font.size": 12})
fig, ax = plt.subplots(figsize=(12, 8.2), facecolor="white")
labels = ("Input", "Split halves", "Split pairs", "Base cases",
"Merge pairs", "Merge halves", "Merge all")
ys = [7.5 - 1.05 * i for i in range(7)]
for y, text in zip(ys, labels):
ax.text(0.05, y, text, va="center", fontsize=12, color="#172c3c")
for node in nodes:
depth = node["depth"]
box(ax, node, ys[depth])
if depth < 3:
box(ax, node, ys[6 - depth], output=True)
if node["size"] > 1:
children = [child for child in nodes
if child["depth"] == depth + 1
and node["start"] <= child["start"]
< node["start"] + node["size"]]
for child in children:
ax.annotate("", xy=(center(child), ys[depth + 1] + 0.28),
xytext=(center(node), ys[depth] - 0.28),
arrowprops={"arrowstyle": "->", "color": "#7291ab"})
ax.annotate("", xy=(center(node), ys[6 - depth] + 0.28),
xytext=(center(child), ys[5 - depth] - 0.28),
arrowprops={"arrowstyle": "->", "color": "#5a9270"})
ax.text(10.35, ys[3] + 0.45, "Head comparisons", fontsize=11)
for y, count in zip(ys[4:], counts):
ax.text(10.65, y, str(count), fontsize=15, va="center")
ax.text(10.35, 0.43, "Total: 17", weight="bold", fontsize=12)
ax.text(0.05, 0.43, "2a: original index 1 2b: original index 3 Equal keys: take left first",
fontsize=11, color="#172c3c")
ax.set(xlim=(-0.05, 12.15), ylim=(0.05, 8.05))
ax.axis("off")
fig.suptitle("Merge sort: split down, merge back in order", fontsize=18, y=0.98)
fig.subplots_adjust(left=0.025, right=0.99, top=0.93, bottom=0.025)
fig.savefig(Path.cwd() / "merge-sort-split-and-merge.png", dpi=100,
facecolor="white", transparent=False)
plt.close(fig)
print(f"Output: {label(output)}; comparisons by merge level: {counts}; total: 17")
if __name__ == "__main__":
main()