diff --git a/src/main/java/com/thealgorithms/datastructures/tries/PatriciaTrie.java b/src/main/java/com/thealgorithms/datastructures/tries/PatriciaTrie.java new file mode 100644 index 000000000000..26a60b5865d6 --- /dev/null +++ b/src/main/java/com/thealgorithms/datastructures/tries/PatriciaTrie.java @@ -0,0 +1,301 @@ +package com.thealgorithms.datastructures.tries; + +import java.util.HashMap; +import java.util.Map; + +/** + * Patricia (radix) trie for String keys and generic values. + * + *

A Patricia Trie is a memory-optimized trie where nodes with a single child + * are merged, so edges are labeled with strings instead of single characters.

+ * + *

Operations are O(L) where L is the key length.

+ * + *

Contracts: + *

+ *

+ */ +public final class PatriciaTrie { + + /** Node with compressed outgoing edges (label -> child). */ + private static final class Node { + private final Map> children = new HashMap<>(); + private boolean hasValue; + private V value; + } + + private final Node root = new Node<>(); + private int size; // number of stored keys + + /** Creates an empty Patricia trie. */ + public PatriciaTrie() { + } + /** + * Inserts or updates the value associated with {@code key}. + * + * @param key non-null key (empty allowed) + * @param value non-null value + */ + public void put(String key, V value) { + if (key == null) { + throw new IllegalArgumentException("key must not be null"); + } + if (value == null) { + throw new IllegalArgumentException("value must not be null"); + } + insert(root, key, value); + } + + /** + * Returns the value associated with {@code key}, or {@code null} if absent. + * + * @param key non-null key + * @return stored value or null + */ + public V get(String key) { + if (key == null) { + throw new IllegalArgumentException("key must not be null"); + } + Node n = findNode(root, key); + return (n != null && n.hasValue) ? n.value : null; + } + + /** + * Returns true if the trie contains {@code key}. + * + * @param key non-null key + * @return true if the key is present + */ + public boolean contains(String key) { + if (key == null) { + throw new IllegalArgumentException("key must not be null"); + } + Node n = findNode(root, key); + return n != null && n.hasValue; + } + + /** + * Removes {@code key} if present. + * + *

Also compacts pass-through nodes (no value + single child) by concatenating + * edge labels to keep the trie compressed.

+ * + * @param key non-null key + * @return true if the key existed and was removed + */ + public boolean remove(String key) { + if (key == null) { + throw new IllegalArgumentException("key must not be null"); + } + if (key.isEmpty()) { + if (root.hasValue) { + root.hasValue = false; + root.value = null; + size--; + return true; + } + return false; + } + return removeRecursive(root, key); + } + + /** + * Returns true if any key in the trie starts with {@code prefix}. + * + * @param prefix non-null prefix + * @return true if a key starts with the given prefix + */ + public boolean startsWith(String prefix) { + if (prefix == null) { + throw new IllegalArgumentException("prefix must not be null"); + } + if (prefix.isEmpty()) { + return size > 0; + } + Node n = findPrefixNode(root, prefix); + return n != null; + } + + /** Returns the number of stored keys. */ + public int size() { + return size; + } + + /** Returns true if no keys are stored. */ + public boolean isEmpty() { + return size == 0; + } + + // ---------------------------------------------------------------------- + // Internal helpers + // ---------------------------------------------------------------------- + + private void insert(Node node, String key, V value) { + if (key.isEmpty()) { + if (!node.hasValue) { + size++; + } + node.hasValue = true; + node.value = value; + return; + } + + for (Map.Entry> e : node.children.entrySet()) { + String edge = e.getKey(); + int cpl = commonPrefixLen(edge, key); + + if (cpl == 0) { + continue; // No common prefix, try next edge. + } + + // Case 1: edge == key + if (cpl == edge.length() && cpl == key.length()) { + Node child = e.getValue(); + if (!child.hasValue) { + size++; + } + child.hasValue = true; + child.value = value; + return; + } + + // Case 2: edge is prefix of key + if (cpl == edge.length() && cpl < key.length()) { + Node child = e.getValue(); + String rest = key.substring(cpl); + insert(child, rest, value); + return; + } + + // Case 3: partial overlap → split + String prefix = edge.substring(0, cpl); + String edgeSuffix = edge.substring(cpl); + String keySuffix = key.substring(cpl); + + Node mid = new Node<>(); + node.children.remove(edge); + node.children.put(prefix, mid); + + // Reattach old child under the split node + Node oldChild = e.getValue(); + mid.children.put(edgeSuffix, oldChild); + + if (keySuffix.isEmpty()) { + // New key ends at split + if (!mid.hasValue) { + size++; + } + mid.hasValue = true; + mid.value = value; + } else { + // New branch after split + Node leaf = new Node<>(); + leaf.hasValue = true; + leaf.value = value; + mid.children.put(keySuffix, leaf); + size++; // important: new key added + } + return; + } + + // Case 4: no shared prefix; create a new edge + Node leaf = new Node<>(); + leaf.hasValue = true; + leaf.value = value; + node.children.put(key, leaf); + size++; + } + + private Node findNode(Node node, String key) { + if (key.isEmpty()) { + return node; + } + for (Map.Entry> e : node.children.entrySet()) { + String edge = e.getKey(); + if (key.startsWith(edge)) { + String rest = key.substring(edge.length()); + return findNode(e.getValue(), rest); + } + } + return null; + } + + private Node findPrefixNode(Node node, String prefix) { + if (prefix.isEmpty()) { + return node; + } + for (Map.Entry> e : node.children.entrySet()) { + String edge = e.getKey(); + if (prefix.startsWith(edge)) { + String rest = prefix.substring(edge.length()); + return findPrefixNode(e.getValue(), rest); + } + if (edge.startsWith(prefix)) { + return e.getValue(); + } + } + return null; + } + + /** Recursive removal with cleanup (merging pass-through nodes). */ + private boolean removeRecursive(Node parent, String key) { + // Iterate on a snapshot to allow modification during the loop + for (Map.Entry> entry : new HashMap<>(parent.children).entrySet()) { + String edge = entry.getKey(); + Node child = entry.getValue(); + + if (!key.startsWith(edge)) { + continue; + } + + String rest = key.substring(edge.length()); + boolean removed; + if (rest.isEmpty()) { + if (!child.hasValue) { + return false; // key not present + } + child.hasValue = false; + child.value = null; + size--; + removed = true; + } else { + removed = removeRecursive(child, rest); + } + + if (!removed) { + return false; + } + + // Cleanup/compaction + if (!child.hasValue) { + int degree = child.children.size(); + if (degree == 0) { + parent.children.remove(edge); + } else if (degree == 1) { + Map.Entry> gc = child.children.entrySet().iterator().next(); + String grandEdge = gc.getKey(); + Node grandchild = gc.getValue(); + parent.children.remove(edge); + parent.children.put(edge + grandEdge, grandchild); + } + } + return true; // processed on this path + } + return false; + } + + /** Returns the length of the common prefix of two strings. */ + private static int commonPrefixLen(String a, String b) { + int n = Math.min(a.length(), b.length()); + for (int i = 0; i < n; i++) { + if (a.charAt(i) != b.charAt(i)) { + return i; + } + } + return n; + } +} diff --git a/src/test/java/com/thealgorithms/datastructures/tries/PatriciaTrieTest.java b/src/test/java/com/thealgorithms/datastructures/tries/PatriciaTrieTest.java new file mode 100644 index 000000000000..5f4f566d25d8 --- /dev/null +++ b/src/test/java/com/thealgorithms/datastructures/tries/PatriciaTrieTest.java @@ -0,0 +1,76 @@ +package com.thealgorithms.datastructures.tries; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +public class PatriciaTrieTest { + + @Test + void insert_splitCausesNewBranch_incrementsSize() { + var t = new PatriciaTrie(); + t.put("romane", 1); + assertEquals(1, t.size()); + + // Split "romane" at "roman", create branch "us" + t.put("romanus", 2); + + assertEquals(2, t.size(), "Size should increment when a split creates a new key branch"); + assertTrue(t.contains("romane")); + assertTrue(t.contains("romanus")); + assertEquals(1, t.get("romane")); + assertEquals(2, t.get("romanus")); + } + + @Test + void basicPutGetContainsAndRemove() { + var t = new PatriciaTrie(); + assertTrue(t.isEmpty()); + + t.put("", "root"); // empty key + t.put("a", "x"); + t.put("ab", "y"); + t.put("abc", "z"); + + assertEquals(4, t.size()); + assertEquals("root", t.get("")); + assertEquals("x", t.get("a")); + assertEquals("y", t.get("ab")); + assertEquals("z", t.get("abc")); + + assertTrue(t.contains("ab")); + assertFalse(t.contains("abcd")); + + assertTrue(t.startsWith("ab")); + assertTrue(t.startsWith("abc")); + assertFalse(t.startsWith("zzz")); + + assertTrue(t.remove("ab")); + assertFalse(t.contains("ab")); + assertEquals(3, t.size()); + + // removing non-existent + assertFalse(t.remove("ab")); + assertEquals(3, t.size()); + } + + @Test + void updatesDoNotIncreaseSize() { + var t = new PatriciaTrie(); + t.put("apple", 1); + t.put("apple", 2); + assertEquals(1, t.size()); + assertEquals(2, t.get("apple")); + } + + @Test + void nullContracts() { + var t = new PatriciaTrie(); + assertThrows(IllegalArgumentException.class, () -> t.put(null, 1)); + assertThrows(IllegalArgumentException.class, () -> t.put("x", null)); + assertThrows(IllegalArgumentException.class, () -> t.get(null)); + assertThrows(IllegalArgumentException.class, () -> t.contains(null)); + assertThrows(IllegalArgumentException.class, () -> t.remove(null)); + assertThrows(IllegalArgumentException.class, () -> t.startsWith(null)); + } +}