Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
package com.phasmidsoftware.dsaipg.misc.lab_1;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;

import java.util.Collection;
import java.util.Objects;

/**
Expand Down Expand Up @@ -65,13 +62,16 @@ public Node<X> addChild(X xx) {

/**
* Method to replace child y by z in this Node.
* <p>
* Children are compared by value, so y need not be the very Node held here.
* Where several children are equal to y, only the first is replaced, and z
* takes that child's position rather than being appended. A Node which has
* no child equal to y is returned unchanged.
*
* @param y the Node which is to replace y.
* @return the new Node which is a copy of this Node but with y replaced by z.
*/
public Node<X> replace(Node<X> y, Node<X> z) {
Collection<Node<X>> ns = Lists.newArrayList(children.iterator());
boolean ok = Iterables.removeIf(ns, xNode -> Objects.equals(xNode, y));
ImmutableList.Builder<Node<X>> builder = ImmutableList.builder();
// TO BE IMPLEMENTED
throw new com.phasmidsoftware.dsaipg.util.general.ImplementationMissing();
Expand All @@ -87,6 +87,37 @@ public Node<X> replace(Node<X> y, X z) {
return replace(y, new Node<>(z));
}

/**
* Two Nodes are equal when their values are equal and their children are
* equal, in order. This class is immutable, so a Node is a value and is
* compared as one -- that is also what lets replace(Node, Node) find a child
* the caller has described rather than one the caller already holds.
* <p>
* NOTE this is O(n) in the size of the subtree, as is hashCode, since both
* descend through every child. That is the price of value semantics on a
* recursive structure: fine for comparing trees, but do not put either in a
* loop over a large one, and do not use a Node as a key in a hash table that
* is written to often.
*
* @param o the object to compare with.
* @return true if o is a Node with the same value and the same children.
*/
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Node)) return false;
Node<?> node = (Node<?>) o;
return Objects.equals(x, node.x) && Objects.equals(children, node.children);
}

/**
* @return a hash code consistent with equals, and O(n) for the same reason.
*/
@Override
public int hashCode() {
return Objects.hash(x, children);
}

final X x;
final ImmutableList<Node<X>> children;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ private void doSort(String[] xs, int from, int to, int d) {
if (n <= 1)
return;
// NOTE that we never cut over to Quicksort at the top-level.
// NOTE the cutoff used to be honoured only on the instrumented Helper, so
// every timed run silently took the default of 20 whatever was configured.
// Table 8.1 in the book is captioned 256 and was measured at 20; on the
// corpus here 20 is the better choice above about 16,000 words anyway. See
// docs/Errata.md. Assert MSDCutoff() is what you asked for before trusting
// any measurement of this sort.
if (d > 0 && n <= helper.MSDCutoff()) cutToQuicksort(xs, from, to, d, n);
else doMSDrecursive(xs, from, to, d);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -747,7 +747,8 @@ default int cutoff() {
}

/**
* Retrieves the default MSD (Mean Squared Deviation) cutoff value.
* Retrieves the default cutoff below which MSD (Most Significant Digit) radix
* sort hands the remaining sub-array to quicksort.
*
* @return the default cutoff value as an integer
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,8 @@ public int compare(X[] xs, int i, int j) {
}

/**
* Retrieves the cutoff value for MSD (Mean Square Displacement) calculation.
* Retrieves the cutoff below which MSD (Most Significant Digit) radix sort hands
* the remaining sub-array to quicksort.
*
* @return the current MSD cutoff value as an integer.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.junit.Test;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertTrue;
import org.junit.Rule;
import org.junit.rules.TestRule;
Expand All @@ -21,12 +22,83 @@ public void Node0() {
assertEquals("x", Integer.valueOf(1), x);
}

@Ignore
// NOTE this was annotated @Ignore with no @Test at all, so JUnit never saw it:
// not a disabled test, but not a test. It is the one that catches addChild
// returning null.
@Test
public void Node1() {
MyTree<Integer> tree = new MyTree<>(1).addChild(new MyTree.Node<>(2));
ImmutableList<MyTree.Node<Integer>> x = tree.getRoot().children;
assertTrue(x.iterator().hasNext());
assertEquals(Integer.valueOf(2), x.iterator().next().x);
}

@Test
public void addChildKeepsTheEarlierChildren() {
MyTree.Node<Integer> node = new MyTree.Node<>(1).addChild(2).addChild(3);
assertEquals(2, node.children.size());
assertEquals(Integer.valueOf(2), node.children.get(0).x);
assertEquals(Integer.valueOf(3), node.children.get(1).x);
}

@Test
public void addChildLeavesTheOriginalAlone() {
MyTree.Node<Integer> node = new MyTree.Node<>(1);
MyTree.Node<Integer> bigger = node.addChild(2);
assertEquals("the tree is immutable, so the original keeps no children", 0, node.children.size());
assertEquals(1, bigger.children.size());
}

@Test
public void replacePutsTheNewChildWhereTheOldOneWas() {
MyTree.Node<Integer> node = new MyTree.Node<>(1).addChild(2).addChild(3).addChild(4);
MyTree.Node<Integer> replaced = node.replace(node.children.get(0), new MyTree.Node<>(9));
assertEquals(3, replaced.children.size());
assertEquals("9 takes the place of 2 rather than going to the end",
Integer.valueOf(9), replaced.children.get(0).x);
assertEquals(Integer.valueOf(3), replaced.children.get(1).x);
assertEquals(Integer.valueOf(4), replaced.children.get(2).x);
}

@Test
public void replaceMatchesByValueNotByReference() {
MyTree.Node<Integer> node = new MyTree.Node<>(1).addChild(2);
// a Node equal to the child, but not the same object
MyTree.Node<Integer> replaced = node.replace(new MyTree.Node<>(2), new MyTree.Node<>(9));
assertEquals(1, replaced.children.size());
assertEquals(Integer.valueOf(9), replaced.children.get(0).x);
}

@Test
public void replaceLeavesANodeWithNoSuchChildAlone() {
MyTree.Node<Integer> node = new MyTree.Node<>(1).addChild(2);
assertEquals(node, node.replace(new MyTree.Node<>(7), new MyTree.Node<>(9)));
}

@Test
public void replaceChangesOnlyTheFirstOfSeveralEqualChildren() {
MyTree.Node<Integer> node = new MyTree.Node<>(1).addChild(2).addChild(2);
MyTree.Node<Integer> replaced = node.replace(new MyTree.Node<>(2), new MyTree.Node<>(9));
assertEquals(Integer.valueOf(9), replaced.children.get(0).x);
assertEquals("the second child equal to y is left alone",
Integer.valueOf(2), replaced.children.get(1).x);
}

@Test
public void equalNodesAreEqualAndAgreeOnHashCode() {
MyTree.Node<Integer> one = new MyTree.Node<>(1).addChild(2).addChild(3);
MyTree.Node<Integer> two = new MyTree.Node<>(1).addChild(2).addChild(3);
assertEquals(one, two);
assertEquals(one.hashCode(), two.hashCode());
}

@Test
public void equalityLooksAtChildrenAndTheirOrder() {
MyTree.Node<Integer> base = new MyTree.Node<>(1).addChild(2).addChild(3);
assertNotEquals("a different value", base, new MyTree.Node<>(9).addChild(2).addChild(3));
assertNotEquals("fewer children", base, new MyTree.Node<>(1).addChild(2));
assertNotEquals("the same children in a different order",
base, new MyTree.Node<>(1).addChild(3).addChild(2));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,6 @@ public void noNewVacuousTests() throws IOException {
"util/general/UTMTest.java:testToPositionEdgeCasePoleHemisphereChange",
// genuinely disabled: never annotated at all
"adt/symbolTable/hashtable/HashTableLPTest.java:testHashTable5",
"misc/lab_1/MyTreeTest.java:Node1",
"sort/helper/InstrumentedComparableHelperTest.java:testMergeSortMany",
"sort/linearithmic/QuickSort_ClassicTest.java:testSortDetailedRandom",
"util/benchmark/SortBenchmarkTest.java:testMinComparisons",
Expand Down
16 changes: 10 additions & 6 deletions Python/src/misc/lab_1/my_tree.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
"""
Ported from misc/lab_1/MyTree.java.

NOTE the Java's reference answers to addChild and replace are missing: both read
``return null; // TODO what should go here?`` inside their SOLUTION blocks, so the
solutions repository does not have a solution here. The stubs below are faithful
to that, and MyTree is therefore an exercise with no published answer in either
tree. Recorded in INFO6205/docs/Deferred work.md; whether to write it is Robin's
call, not something to slip in.
NOTE Node is a frozen dataclass, so it already compares by value, and replace
finds a child the caller has described rather than one the caller already holds --
which is what the Java now does too, having gained equals and hashCode. Comparing
or hashing a Node descends through its children, so both are O(n) in the size of
the subtree: fine for comparing trees, but not for a loop over a large one.
"""

from __future__ import annotations
Expand Down Expand Up @@ -49,6 +48,11 @@ def add_child_value(self, xx: X) -> Node[X]:

def replace(self, y: Node[X], z: Node[X]) -> Node[X]:
"""
Children are compared by value, so y need not be the very Node held here.
Where several children are equal to y, only the first is replaced, and z
takes that child's position rather than being appended. A Node with no
child equal to y is returned unchanged.

:param y: the child to remove.
:param z: the child to put in its place.
:return: a new Node with the replacement made.
Expand Down
29 changes: 24 additions & 5 deletions Python/test/misc/lab_1/test_lab_1.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@ def test_event_equality(self):

class TestMyTree:
"""
NOTE most of this is skipped in both trees, and not because anyone has yet to
do the exercise: the Java's own reference answers to addChild and replace are
missing -- both read "return null; // TODO what should go here?" -- so there is
no published solution to port. The Java test that would catch it is annotated
@Ignore and carries no @Test at all.
NOTE these skip until add_child and replace are written, like any other
exercise. That was not always the reason: the Java had no reference answer
either, both SOLUTION blocks reading "return null", so there was nothing to
port. Written on 2026-08-31, along with equals and hashCode, so the two trees
now agree that a Node is a value.
"""

def test_a_root_on_its_own(self):
Expand Down Expand Up @@ -91,6 +91,25 @@ def test_replace(self):
root = Node(1).add_child(two)
assert root.replace(two, three).children == (three,)

def test_replace_matches_by_value_not_by_identity(self):
root = Node(1).add_child(Node(2))
assert root.replace(Node(2), Node(9)).children[0].x == 9

def test_replace_leaves_a_node_with_no_such_child_alone(self):
root = Node(1).add_child(Node(2))
assert root.replace(Node(7), Node(9)) == root

def test_replace_changes_only_the_first_of_several_equal_children(self):
root = Node(1).add_child(Node(2)).add_child(Node(2))
replaced = root.replace(Node(2), Node(9))
assert replaced.children[0].x == 9
assert replaced.children[1].x == 2

def test_replace_keeps_the_position(self):
root = Node(1).add_child(Node(2)).add_child(Node(3)).add_child(Node(4))
replaced = root.replace(Node(2), Node(9))
assert [c.x for c in replaced.children] == [9, 3, 4]

def test_replace_by_value(self):
two = Node(2)
root = Node(1).add_child(two)
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,15 @@ get lost:
reading the corpus files, which failed for anyone whose home directory contained
a space.

## Errata

The book is printed, so corrections to it live in `docs/Errata.md`.
The first entry concerns Table 8.1: its MSD row is labelled as using a cutoff of 256
and was in fact measured at 20, because the cutoff was honoured only on the
instrumented code path and a timing run is uninstrumented.
The numbers are sound; the caption is not, and honouring it would reverse the
chapter's conclusion.

## Comparing the two

Having the same algorithms twice makes it possible to ask what survives a change
Expand Down
94 changes: 94 additions & 0 deletions docs/Errata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Errata

The book is printed and a second edition is unlikely, so corrections live here.
Each entry says what the book claims, what is actually the case, and how that was
established, so you can check it rather than take it on trust.

## Table 8.1, p. 146 — the MSD row was measured at a cutoff of 20, not 256

The row is labelled **"MSD string sort (ext. ASCII with 256 cutoff)"**. The
measurements are sound; the caption is not. They describe a cutoff of **20**.

### Why it happened

MSD radix sort stops recursing when a sub-array gets small and hands the rest to
quicksort. The size at which it does that is `Helper.MSDCutoff()`. That method was
overridden only on `InstrumentedComparatorHelper` — and a timing run is
uninstrumented, because instrumentation counts comparisons and swaps and would be
measured along with the sort. So every timed run took `Helper`'s default of 20,
whatever the configuration said.

This is a fact about the code rather than an inference from the numbers: it is
recorded in commit `22f9f0e4`, "MSDCutoff() was overridden only on
InstrumentedComparatorHelper, so every other Helper took Helper's default of
CUTOFF_DEFAULT (20)". The cutoff is honoured on both paths now.

### What the measurements show

Re-measured 2026-08-31 on an Apple M1, JDK 21, uninstrumented, sorting distinct
words from `eng-uk_web_2002_100K-sentences.txt`. Best of 12 after 20 warm-up runs;
every result checked against `Arrays.sort` with the same comparator. All four
columns come from the same machine, corpus and run, so they can be compared with
each other.

| n | MSD, cutoff 20 | MSD, cutoff 256 | quicksort | merge sort |
| ---: | ---: | ---: | ---: | ---: |
| 1,000 | 0.469 ms | 0.364 ms | 0.434 ms | 0.456 ms |
| 4,000 | 1.939 ms | 1.402 ms | 0.713 ms | 2.104 ms |
| 16,000 | **3.047 ms** | 5.285 ms | 3.254 ms | 3.531 ms |
| 64,000 | **14.911 ms** | 24.629 ms | 18.117 ms | 20.904 ms |

Compare the 16,000 row with the book's: MSD 3.1, quicksort 3.3, merge sort 3.5.
The cutoff-20 column reproduces all three to within a few percent. The cutoff-256
column does not, and is not close.

### Why the caption cannot simply be honoured

Suppose the row were re-measured at a true cutoff of 256, to match its label. At
16,000 MSD becomes 5.285 ms against quicksort's 3.254 and merge sort's 3.531 — so
MSD would be the *slowest* of the three, and the chapter's conclusion that MSD is
"consistently faster than other sorts... Only when used for problems of size 4k or
smaller is MSD not the fastest algorithm" would be false.

**The correction is to the caption, not to the data.** Read the row as
"MSD string sort (ext. ASCII with 20 cutoff)" and everything the chapter says
about it holds.

### A second, smaller correction: p. 141

"A cutoff of 256 works well" is true only for small inputs. On this corpus 256 is
about 10–20% faster below about 8,000 words, and from 16,000 upwards a cutoff of 20
is **1.6–1.7× faster**. Since Table 8.1 draws its conclusion at 16,000 and above,
the sentence is wrong exactly where it matters most.

### What is not established

- **Only n up to 64,000 was re-measured.** The corpus in this repository yields
81,546 distinct words, and `getWords` takes distinct words, so the larger sizes
in the published table cannot be reproduced here. The crossover might narrow or
widen further out; nothing here says.
- **The two smallest rows do not match the book** — 0.469 ms against 0.16 at
n = 1,000. At that size the sort takes well under a millisecond and fixed costs
dominate, and the book's figures were produced by a different harness on a
different machine. The agreement at 16,000 and 64,000 is the meaningful signal,
and those are the sizes the chapter reasons about.
- **One corpus, one machine, one afternoon.** See `Java vs Python.md` for the same
caveat at greater length.

### Reproducing it

Time `MSDStringSort` directly rather than through `SortBenchmark`, so that the
cutoff can be varied without anything else changing:

```java
Config config = Config.load()
.copy("helper", "instrument", "false") // a timing run is uninstrumented
.copy("helper", "msdcutoff", "20"); // or "256"
try (SortWithHelper<String> sorter =
new MSDStringSort(CodePointMapper.ASCIIExt, words.length, 1, config)) {
sorter.sort(words, 0, words.length);
}
```

Assert `sorter.getHelper().MSDCutoff()` is the value you asked for before believing
any timing: that assertion is what would have caught this in the first place.
Loading