Skip to content

Commit 65e8f92

Browse files
committed
✨ sorting: add radix sort implementation (#15)
1 parent 2fbdd0c commit 65e8f92

6 files changed

Lines changed: 225 additions & 1 deletion

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
using System;
2+
using System.Threading;
3+
using Cysharp.Threading.Tasks;
4+
using Soobak.Algo.Core;
5+
using UnityEngine;
6+
7+
namespace Soobak.Algo.Sorting {
8+
public sealed class RadixSortAlgorithm : ISortingAlgorithm {
9+
public string Id => "radix-sort";
10+
11+
public async UniTask ExecuteAsync(SortingState state, IAlgorithmStepSink<SortingState, SortOp> sink, CancellationToken cancellationToken) {
12+
if (state == null)
13+
throw new ArgumentNullException(nameof(state));
14+
15+
if (sink == null)
16+
throw new ArgumentNullException(nameof(sink));
17+
18+
try {
19+
var count = state.Items.Count;
20+
if (count <= 1) {
21+
await sink.PublishAsync(new AlgorithmStep<SortingState, SortOp>(state.Clone(), SortOp.Finalize("Radix sort complete")), cancellationToken);
22+
return;
23+
}
24+
25+
var min = int.MaxValue;
26+
var max = int.MinValue;
27+
foreach (var item in state.Items) {
28+
if (item.Value < 0)
29+
throw new NotSupportedException("RadixSortAlgorithm currently supports non-negative integers only.");
30+
if (item.Value < min)
31+
min = item.Value;
32+
if (item.Value > max)
33+
max = item.Value;
34+
}
35+
36+
var output = new SortingItem[count];
37+
var counts = new int[10];
38+
var exp = 1;
39+
40+
while (max / exp > 0) {
41+
cancellationToken.ThrowIfCancellationRequested();
42+
Array.Clear(counts, 0, counts.Length);
43+
44+
for (var i = 0; i < count; i++) {
45+
cancellationToken.ThrowIfCancellationRequested();
46+
var value = state.Items[i].Value;
47+
var digit = (value / exp) % 10;
48+
counts[digit]++;
49+
await sink.PublishAsync(new AlgorithmStep<SortingState, SortOp>(state.Clone(), SortOp.Highlight(i, $"Count digit {digit} (exp {exp})")), cancellationToken);
50+
}
51+
52+
for (var i = 1; i < counts.Length; i++) {
53+
cancellationToken.ThrowIfCancellationRequested();
54+
counts[i] += counts[i - 1];
55+
}
56+
57+
for (var i = count - 1; i >= 0; i--) {
58+
cancellationToken.ThrowIfCancellationRequested();
59+
var item = state.Items[i];
60+
var digit = (item.Value / exp) % 10;
61+
counts[digit]--;
62+
var targetIndex = counts[digit];
63+
output[targetIndex] = item.Clone();
64+
}
65+
66+
for (var i = 0; i < count; i++) {
67+
cancellationToken.ThrowIfCancellationRequested();
68+
state.Replace(i, output[i].Clone());
69+
await sink.PublishAsync(new AlgorithmStep<SortingState, SortOp>(state.Clone(), SortOp.Highlight(i, $"Write digit pass (exp {exp})")), cancellationToken);
70+
}
71+
72+
exp *= 10;
73+
}
74+
75+
await sink.PublishAsync(new AlgorithmStep<SortingState, SortOp>(state.Clone(), SortOp.Finalize("Radix sort complete")), cancellationToken);
76+
}
77+
catch (OperationCanceledException ex) {
78+
Debug.LogWarning($"RadixSortAlgorithm: Execution cancelled. {ex.Message}");
79+
throw;
80+
}
81+
catch (Exception ex) {
82+
Debug.LogError($"RadixSortAlgorithm: Execution failed. {ex}");
83+
throw;
84+
}
85+
}
86+
}
87+
}

Packages/com.soobak.algo.sorting/Runtime/Scripts/RadixSortAlgorithm.cs.meta

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Packages/com.soobak.algo.sorting/Runtime/Scripts/Registry/SortingAlgorithmCatalog.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,18 @@ static IEnumerable<IAlgorithmDescriptor<SortingState, SortOp>> CreateDefaultDesc
104104
{ "stability", "Stable" }
105105
});
106106

107+
yield return AlgorithmDescriptor.Create<SortingState, SortOp>(
108+
id: "radix-sort",
109+
displayName: "Radix Sort",
110+
description: "LSD radix sort using counting sort passes per digit.",
111+
factory: () => new RadixSortAlgorithm(),
112+
metadata: new Dictionary<string, string> {
113+
{ "complexity-average", "O(d (n + k))" },
114+
{ "complexity-best", "O(d (n + k))" },
115+
{ "complexity-worst", "O(d (n + k))" },
116+
{ "stability", "Stable" }
117+
});
118+
107119
yield return AlgorithmDescriptor.Create<SortingState, SortOp>(
108120
id: "quick-sort",
109121
displayName: "Quick Sort",
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Threading;
5+
using System.Text.RegularExpressions;
6+
using System.Threading.Tasks;
7+
using Cysharp.Threading.Tasks;
8+
using NUnit.Framework;
9+
using Soobak.Algo.Core;
10+
using UnityEngine;
11+
using UnityEngine.TestTools;
12+
13+
namespace Soobak.Algo.Sorting.Tests {
14+
public class RadixSortAlgorithmTests {
15+
[Test]
16+
public async Task ExecuteAsync_SortsAscendingValues() {
17+
var algorithm = new RadixSortAlgorithm();
18+
var state = SortingState.FromValues(new[] { 170, 45, 75, 90, 802, 24, 2, 66 });
19+
var sink = new RecordingSink();
20+
21+
await algorithm.ExecuteAsync(state, sink, CancellationToken.None);
22+
23+
Assert.That(sink.LastSnapshot.Items.Select(item => item.Value), Is.EqualTo(new[] { 2, 24, 45, 66, 75, 90, 170, 802 }));
24+
Assert.That(sink.Steps.Any(step => step.Event.Type == SortOpType.Highlight), Is.True);
25+
Assert.That(sink.Steps.Last().Event.Type, Is.EqualTo(SortOpType.Finalize));
26+
}
27+
28+
[Test]
29+
public async Task ExecuteAsync_MaintainsStability() {
30+
var algorithm = new RadixSortAlgorithm();
31+
var state = SortingState.FromLabeledValues(new[] {
32+
(21, "A"),
33+
(4, "B"),
34+
(21, "C"),
35+
(4, "D"),
36+
(3, "E")
37+
});
38+
var sink = new RecordingSink();
39+
40+
await algorithm.ExecuteAsync(state, sink, CancellationToken.None);
41+
42+
Assert.That(sink.LastSnapshot.Items.Select(item => item.Value), Is.EqualTo(new[] { 3, 4, 4, 21, 21 }));
43+
Assert.That(sink.LastSnapshot.Items.Where(item => item.Value == 4).Select(item => item.StableId), Is.EqualTo(new[] { "B", "D" }));
44+
Assert.That(sink.LastSnapshot.Items.Where(item => item.Value == 21).Select(item => item.StableId), Is.EqualTo(new[] { "A", "C" }));
45+
}
46+
47+
[Test]
48+
public async Task ExecuteAsync_ThrowsForNegativeValues() {
49+
var algorithm = new RadixSortAlgorithm();
50+
var state = SortingState.FromValues(new[] { 3, -1, 2 });
51+
52+
LogAssert.Expect(LogType.Error, new Regex("^RadixSortAlgorithm: Execution failed.*non-negative integers only\\.", RegexOptions.Singleline));
53+
Assert.ThrowsAsync<NotSupportedException>(async () => await algorithm.ExecuteAsync(state, new NoOpSink(), CancellationToken.None));
54+
}
55+
56+
[Test]
57+
public async Task ExecuteAsync_RespectsCancellation() {
58+
var algorithm = new RadixSortAlgorithm();
59+
var state = SortingState.FromValues(new[] { 3, 1, 2 });
60+
var sink = new RecordingSink();
61+
using var cts = new CancellationTokenSource();
62+
cts.Cancel();
63+
64+
Assert.ThrowsAsync<TaskCanceledException>(async () => await algorithm.ExecuteAsync(state, sink, cts.Token));
65+
}
66+
67+
sealed class RecordingSink : IAlgorithmStepSink<SortingState, SortOp> {
68+
public List<AlgorithmStep<SortingState, SortOp>> Steps { get; } = new();
69+
public SortingState LastSnapshot { get; private set; } = SortingState.FromValues(Array.Empty<int>());
70+
71+
public UniTask InitializeAsync(SortingState initialState, CancellationToken cancellationToken) {
72+
LastSnapshot = initialState.Clone();
73+
return UniTask.CompletedTask;
74+
}
75+
76+
public UniTask PublishAsync(AlgorithmStep<SortingState, SortOp> step, CancellationToken cancellationToken) {
77+
Steps.Add(step);
78+
LastSnapshot = step.Snapshot.Clone();
79+
return UniTask.CompletedTask;
80+
}
81+
82+
public UniTask CompleteAsync(SortingState finalState, CancellationToken cancellationToken) {
83+
LastSnapshot = finalState.Clone();
84+
return UniTask.CompletedTask;
85+
}
86+
}
87+
88+
sealed class NoOpSink : IAlgorithmStepSink<SortingState, SortOp> {
89+
public UniTask InitializeAsync(SortingState initialState, CancellationToken cancellationToken) => UniTask.CompletedTask;
90+
public UniTask PublishAsync(AlgorithmStep<SortingState, SortOp> step, CancellationToken cancellationToken) => UniTask.CompletedTask;
91+
public UniTask CompleteAsync(SortingState finalState, CancellationToken cancellationToken) => UniTask.CompletedTask;
92+
}
93+
}
94+
}

Packages/com.soobak.algo.sorting/Tests/EditMode/RadixSortAlgorithmTests.cs.meta

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Packages/com.soobak.algo.sorting/Tests/EditMode/SortingCatalogTests.cs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,22 @@ public void Catalog_ProvidesCountingSortDescriptor() {
9797
Assert.That(descriptor.Metadata["complexity-average"], Is.EqualTo("O(n + k)"));
9898
}
9999

100+
[Test]
101+
public void Catalog_ProvidesRadixSortDescriptor() {
102+
var catalog = new SortingAlgorithmCatalog();
103+
104+
Assert.That(catalog.Descriptors.Any(d => d.Id == "radix-sort"), Is.True);
105+
var descriptor = catalog.Descriptors.Single(d => d.Id == "radix-sort");
106+
Assert.That(descriptor.DisplayName, Is.EqualTo("Radix Sort"));
107+
Assert.That(descriptor.Metadata["stability"], Is.EqualTo("Stable"));
108+
Assert.That(descriptor.Metadata["complexity-average"], Is.EqualTo("O(d (n + k))"));
109+
}
110+
100111
[Test]
101112
public void Catalog_TryGetDescriptor_FailsForUnknownId() {
102113
var catalog = new SortingAlgorithmCatalog();
103114

104-
var result = catalog.TryGetDescriptor("radix-sort", out var descriptor);
115+
var result = catalog.TryGetDescriptor("bogus-sort", out var descriptor);
105116

106117
Assert.That(result, Is.False);
107118
Assert.That(descriptor, Is.Null);
@@ -219,6 +230,22 @@ public async Task ExecuteAsync_CountingSortDescriptor_RunsPipelineAndSorts() {
219230
Assert.That(visualizer.CompletedSnapshot.Items.Select(item => item.Value), Is.EqualTo(new[] { 1, 2, 3, 5, 5 }));
220231
}
221232

233+
[Test]
234+
public async Task ExecuteAsync_RadixSortDescriptor_RunsPipelineAndSorts() {
235+
var catalog = new SortingAlgorithmCatalog();
236+
var visualizer = new RecordingVisualizer();
237+
var runner = new SortingRunner(visualizer, catalog);
238+
var initial = SortingState.FromValues(new[] { 170, 45, 75, 90, 802, 24, 2, 66 });
239+
var original = initial.Clone();
240+
241+
var result = await runner.ExecuteAsync("radix-sort", initial, CancellationToken.None);
242+
243+
Assert.That(original.Items.Select(item => item.Value), Is.EqualTo(new[] { 170, 45, 75, 90, 802, 24, 2, 66 }));
244+
Assert.That(result.Items.Select(item => item.Value), Is.EqualTo(new[] { 2, 24, 45, 66, 75, 90, 170, 802 }));
245+
Assert.That(visualizer.Events.Count, Is.GreaterThan(0));
246+
Assert.That(visualizer.CompletedSnapshot.Items.Select(item => item.Value), Is.EqualTo(new[] { 2, 24, 45, 66, 75, 90, 170, 802 }));
247+
}
248+
222249
sealed class RecordingVisualizer : IBarVisualizer {
223250
public SortingState InitialSnapshot { get; private set; } = SortingState.FromValues(Array.Empty<int>());
224251
public SortingState CompletedSnapshot { get; private set; } = SortingState.FromValues(Array.Empty<int>());

0 commit comments

Comments
 (0)