Skip to content

Commit f55e900

Browse files
Add: Merge Sort Visualizer with Python CLI, web visualizer, tests, and banner
1 parent d0947c4 commit f55e900

7 files changed

Lines changed: 637 additions & 0 deletions

File tree

math/Merge-Sort/Merge-Sort.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import sys
2+
import os
3+
4+
# Add project root to sys.path
5+
if "__file__" in globals():
6+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
7+
else:
8+
sys.path.append(os.path.abspath(os.getcwd()))
9+
10+
import importlib.util
11+
12+
# Load validation module directly to avoid broken package __init__.py
13+
_val_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "scripts", "validation.py"))
14+
_val_spec = importlib.util.spec_from_file_location("validation", _val_path)
15+
_val_mod = importlib.util.module_from_spec(_val_spec)
16+
_val_spec.loader.exec_module(_val_mod)
17+
get_choice = _val_mod.get_choice
18+
get_int_list = _val_mod.get_int_list
19+
20+
21+
def merge_sort(arr: list[int], reverse: bool = False) -> list[int]:
22+
"""Sorts a list of integers using the Merge Sort algorithm.
23+
24+
Args:
25+
arr: The list of integers to sort.
26+
reverse: If True, sorts in descending order. Otherwise, ascending.
27+
28+
Returns:
29+
A new sorted list.
30+
"""
31+
# Base case: a list of 0 or 1 elements is already sorted
32+
if len(arr) <= 1:
33+
return arr.copy()
34+
35+
# Divide: split the list into two halves
36+
mid = len(arr) // 2
37+
left = merge_sort(arr[:mid], reverse)
38+
right = merge_sort(arr[mid:], reverse)
39+
40+
# Conquer: merge the two sorted halves
41+
return merge(left, right, reverse)
42+
43+
44+
def merge(left: list[int], right: list[int], reverse: bool = False) -> list[int]:
45+
"""Merges two sorted lists into one sorted list.
46+
47+
Args:
48+
left: The left sorted list.
49+
right: The right sorted list.
50+
reverse: If True, merges in descending order. Otherwise, ascending.
51+
52+
Returns:
53+
A single merged and sorted list.
54+
"""
55+
result = []
56+
i = 0
57+
j = 0
58+
59+
while i < len(left) and j < len(right):
60+
if not reverse and left[i] <= right[j]:
61+
result.append(left[i])
62+
i += 1
63+
elif reverse and left[i] >= right[j]:
64+
result.append(left[i])
65+
i += 1
66+
else:
67+
result.append(right[j])
68+
j += 1
69+
70+
# Append any remaining elements
71+
result.extend(left[i:])
72+
result.extend(right[j:])
73+
return result
74+
75+
76+
def main() -> None:
77+
print("=" * 50)
78+
print("🔀 MERGE SORT INTERACTIVE TOOL 🔀")
79+
print("=" * 50)
80+
print("Sort a list of numbers in Ascending or Descending order.\n")
81+
82+
while True:
83+
print("=" * 50)
84+
arr = get_int_list(
85+
prompt="➡️ Enter numbers to sort separated by spaces (e.g., 64 34 25): ",
86+
error_empty="❌ Error: Input cannot be empty!",
87+
error_invalid="❌ Error: Please enter valid integers only."
88+
)
89+
90+
print("\nChoose sorting order:")
91+
print("1️⃣ Ascending")
92+
print("2️⃣ Descending")
93+
order_choice = get_choice(
94+
prompt="🎯 Enter your choice (1 or 2): ",
95+
choices=["1", "2"],
96+
error_invalid="❌ Invalid sorting choice! Please select 1 or 2."
97+
)
98+
99+
reverse = (order_choice == "2")
100+
sorted_arr = merge_sort(arr, reverse)
101+
102+
print(f"\n📊 Original list: {arr}")
103+
if reverse:
104+
print(f"✅ Sorted list (Descending): {sorted_arr}")
105+
else:
106+
print(f"✅ Sorted list (Ascending): {sorted_arr}")
107+
108+
again = input("\n🔄 Do you want to sort another list? (y/n): ").strip().lower()
109+
if again != 'y':
110+
print("\n👋 Thanks for using Merge Sort Tool! Goodbye!\n")
111+
break
112+
113+
114+
if __name__ == "__main__":
115+
main()

tests/test_merge_sort.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import unittest
2+
from unittest.mock import patch
3+
import io
4+
import os
5+
import importlib.util
6+
7+
# Absolute path to Merge-Sort.py
8+
file_path = os.path.join(
9+
os.path.dirname(__file__), "..",
10+
"math", "Merge-Sort", "Merge-Sort.py"
11+
)
12+
file_path = os.path.abspath(file_path)
13+
14+
# Load module dynamically
15+
spec = importlib.util.spec_from_file_location("merge_sort_module", file_path)
16+
merge_sort_module = importlib.util.module_from_spec(spec)
17+
spec.loader.exec_module(merge_sort_module)
18+
19+
merge_sort = merge_sort_module.merge_sort
20+
merge = merge_sort_module.merge
21+
main = merge_sort_module.main
22+
23+
24+
class TestMergeSort(unittest.TestCase):
25+
26+
def test_merge_sort_ascending(self):
27+
self.assertEqual(merge_sort([64, 34, 25, 12, 22, 11, 90]), [11, 12, 22, 25, 34, 64, 90])
28+
self.assertEqual(merge_sort([5, 1, 4, 2, 8]), [1, 2, 4, 5, 8])
29+
30+
def test_merge_sort_descending(self):
31+
self.assertEqual(merge_sort([64, 34, 25, 12, 22, 11, 90], reverse=True), [90, 64, 34, 25, 22, 12, 11])
32+
self.assertEqual(merge_sort([5, 1, 4, 2, 8], reverse=True), [8, 5, 4, 2, 1])
33+
34+
def test_merge_sort_empty_and_single_item(self):
35+
self.assertEqual(merge_sort([]), [])
36+
self.assertEqual(merge_sort([42]), [42])
37+
38+
def test_merge_sort_negative_integers(self):
39+
self.assertEqual(merge_sort([-5, -1, -10, 0, 5]), [-10, -5, -1, 0, 5])
40+
self.assertEqual(merge_sort([-5, -1, -10, 0, 5], reverse=True), [5, 0, -1, -5, -10])
41+
42+
def test_merge_sort_already_sorted(self):
43+
self.assertEqual(merge_sort([1, 2, 3, 4, 5]), [1, 2, 3, 4, 5])
44+
self.assertEqual(merge_sort([5, 4, 3, 2, 1], reverse=True), [5, 4, 3, 2, 1])
45+
46+
def test_merge_sort_with_duplicates(self):
47+
self.assertEqual(merge_sort([5, 3, 8, 3, 9, 1, 5]), [1, 3, 3, 5, 5, 8, 9])
48+
49+
def test_merge_sort_does_not_mutate_original(self):
50+
original = [5, 3, 1, 4, 2]
51+
original_copy = original.copy()
52+
merge_sort(original)
53+
self.assertEqual(original, original_copy)
54+
55+
def test_merge_ascending(self):
56+
self.assertEqual(merge([1, 3, 5], [2, 4, 6]), [1, 2, 3, 4, 5, 6])
57+
58+
def test_merge_descending(self):
59+
self.assertEqual(merge([5, 3, 1], [6, 4, 2], reverse=True), [6, 5, 4, 3, 2, 1])
60+
61+
def test_merge_empty_left(self):
62+
self.assertEqual(merge([], [1, 2, 3]), [1, 2, 3])
63+
64+
def test_merge_empty_right(self):
65+
self.assertEqual(merge([1, 2, 3], []), [1, 2, 3])
66+
67+
@patch('builtins.input')
68+
@patch('sys.stdout', new_callable=io.StringIO)
69+
def test_main_flow_ascending(self, mock_stdout, mock_input):
70+
mock_input.side_effect = ["64 34 25", "1", "n"]
71+
main()
72+
output = mock_stdout.getvalue()
73+
self.assertIn("Original list: [64, 34, 25]", output)
74+
self.assertIn("Sorted list (Ascending): [25, 34, 64]", output)
75+
76+
@patch('builtins.input')
77+
@patch('sys.stdout', new_callable=io.StringIO)
78+
def test_main_flow_descending(self, mock_stdout, mock_input):
79+
mock_input.side_effect = ["64 34 25", "2", "n"]
80+
main()
81+
output = mock_stdout.getvalue()
82+
self.assertIn("Original list: [64, 34, 25]", output)
83+
self.assertIn("Sorted list (Descending): [64, 34, 25]", output)
84+
85+
@patch('builtins.input')
86+
@patch('sys.stdout', new_callable=io.StringIO)
87+
def test_main_flow_invalid_inputs(self, mock_stdout, mock_input):
88+
mock_input.side_effect = ["", "64 abc 25", "64 34 25", "3", "64 34 25", "1", "y", "12 11", "2", "n"]
89+
main()
90+
output = mock_stdout.getvalue()
91+
self.assertIn("Error: Input cannot be empty!", output)
92+
self.assertIn("Error: Please enter valid integers only.", output)
93+
self.assertIn("Invalid sorting choice! Please select 1 or 2.", output)
94+
self.assertIn("Sorted list (Ascending): [25, 34, 64]", output)
95+
self.assertIn("Sorted list (Descending): [12, 11]", output)
96+
97+
98+
if __name__ == '__main__':
99+
unittest.main()
12.6 KB
Loading

web-app/generate_banners.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,26 @@ def draw_o(ox, oy):
563563
for i, h in enumerate(bar_heights):
564564
x = 230 + i * 50
565565
v_draw.rectangle([x, 320 - h, x + 30, 320], fill=color_accent)
566+
elif "merge sort" in n_lower:
567+
# Divide and conquer visualization - splitting bars
568+
cx, cy = 400, 225
569+
# Draw bars being split into halves
570+
bar_vals = [4, 2, 6, 1, 5, 3, 7]
571+
bar_w = 50
572+
total_w = len(bar_vals) * (bar_w + 8)
573+
start_x = cx - total_w // 2
574+
for i, val in enumerate(bar_vals):
575+
x = start_x + i * (bar_w + 8)
576+
h = val * 25
577+
y = cy + 80 - h
578+
color = color_accent if i < len(bar_vals) // 2 else (255, 255, 255, 180)
579+
v_draw.rounded_rectangle([x, y, x + bar_w, cy + 80], radius=6, fill=color)
580+
# Dividing line in the middle
581+
mid_x = start_x + (len(bar_vals) // 2) * (bar_w + 8) - 4
582+
v_draw.line([(mid_x, cy - 60), (mid_x, cy + 90)], fill=(239, 68, 68), width=3)
583+
# Arrow showing merge direction
584+
v_draw.line([(cx - 80, cy - 80), (cx + 80, cy - 80)], fill=color_accent, width=2)
585+
v_draw.polygon([(cx + 80, cy - 85), (cx + 95, cy - 80), (cx + 80, cy - 75)], fill=color_accent)
566586
elif "tsp" in n_lower or "salesperson" in n_lower:
567587
# Nodes connected by a path
568588
nodes = [(250, 150), (450, 100), (550, 250), (400, 350), (200, 300)]
@@ -668,6 +688,7 @@ def draw_o(ox, oy):
668688
("Binary Search", "math", "binary-search.webp"),
669689
("Bubble Sort", "math", "bubble-sort.webp"),
670690
("Quick Sort", "math", "quick-sort.webp"),
691+
("Merge Sort", "math", "merge-sort.webp"),
671692
("Tower of Hanoi", "math", "tower-of-hanoi.webp"),
672693
("Matrix Calculator", "math", "matrix-calculator.webp"),
673694
("Fourier Series", "math", "fourier-series.webp"),

web-app/index.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -866,6 +866,7 @@ <h3>Legal</h3>
866866
<script defer src="js/projects/binary-search.js"></script>
867867
<script defer src="js/projects/bubble-sort.js"></script>
868868
<script defer src="js/projects/quick-sort.js"></script>
869+
<script defer src="js/projects/merge-sort.js"></script>
869870
<script defer src="js/projects/hangman.js"></script>
870871
<script defer src="js/projects/flames.js"></script>
871872
<script defer src="js/projects/fibonacci.js"></script>
@@ -1082,6 +1083,13 @@ <h3>Legal</h3>
10821083
desc: "Visualize quick sort algorithm with pivot partitioning",
10831084
tags: "math,algorithm",
10841085
},
1086+
{
1087+
project: "merge-sort",
1088+
title: "Merge Sort",
1089+
category: "math",
1090+
desc: "Visualize merge sort algorithm with divide-and-conquer approach",
1091+
tags: "math,algorithm",
1092+
},
10851093
{
10861094
project: "fibonacci",
10871095
title: "Fibonacci",

web-app/js/projects.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ function getProjectHTML(projectName) {
3232
'bubble-sort': getBubbleSortHTML(),
3333
'quick-sort': getQuickSortHTML(),
3434
'fourier-series': getFourierSeriesHTML(),
35+
'merge-sort': getMergeSortHTML(),
3536
'pathfinding-visualizer': getPathfindingVisualizerHTML(),
3637
'tsp-visualizer': getTspVisualizerHTML()
3738
};
@@ -67,6 +68,7 @@ function initializeProject(projectName) {
6768
'reverse-hangman': initReverseHangman,
6869
'budget-tracker': initBudgetTracker,
6970
'fourier-series': initFourierSeries,
71+
'merge-sort': initMergeSort,
7072
'pathfinding-visualizer': initPathfindingVisualizer,
7173
'tsp-visualizer': initTspVisualizer
7274
};
@@ -1869,6 +1871,15 @@ const projectInstructions = {
18691871
"Smaller elements go left, larger go right",
18701872
"Process repeats until fully sorted"
18711873
]
1874+
},
1875+
"merge-sort": {
1876+
title: "🔀 How Merge Sort Works",
1877+
steps: [
1878+
"Enter an array of numbers",
1879+
"Array is divided into two halves",
1880+
"Each half is recursively sorted",
1881+
"Sorted halves are merged back together"
1882+
]
18721883
},
18731884
"tower-of-hanoi": {
18741885
title: "🗼 How to Solve Tower of Hanoi",
@@ -3202,6 +3213,7 @@ function initializeProject(projectName) {
32023213
"binary-search": "initBinarySearch",
32033214
"bubble-sort": "initBubbleSort",
32043215
"quick-sort": "initQuickSort",
3216+
"merge-sort": "initMergeSort",
32053217
"progression-recognizer": "initProgressionRecognizer",
32063218
"pascal-triangle": "initPascalTriangle",
32073219
armstrong: "initArmstrong",

0 commit comments

Comments
 (0)