diff --git a/src/main/java/com/thealgorithms/compression/LZ77.java b/src/main/java/com/thealgorithms/compression/LZ77.java new file mode 100644 index 000000000000..d02307aa57b5 --- /dev/null +++ b/src/main/java/com/thealgorithms/compression/LZ77.java @@ -0,0 +1,168 @@ +package com.thealgorithms.compression; + +import java.util.ArrayList; +import java.util.List; + +/** + * An implementation of the Lempel-Ziv 77 (LZ77) compression algorithm. + *

+ * LZ77 is a lossless data compression algorithm that works by finding repeated + * occurrences of data in a sliding window. It replaces subsequent occurrences + * with references (offset, length) to the first occurrence within the window. + *

+ *

+ * This implementation uses a simple sliding window and lookahead buffer approach. + * Output format is a sequence of tuples (offset, length, next_character). + *

+ *

+ * Time Complexity: O(n*W) in this naive implementation, where n is the input length + * and W is the window size, due to the search for the longest match. More advanced + * data structures (like suffix trees) can improve this. + *

+ *

+ * References: + *

+ *

+ */ +public final class LZ77 { + + private static final int DEFAULT_WINDOW_SIZE = 4096; + private static final int DEFAULT_LOOKAHEAD_BUFFER_SIZE = 16; + private static final char END_OF_STREAM = '\u0000'; + private LZ77() { + } + + /** + * Represents a token in the LZ77 compressed output. + * Stores the offset back into the window, the length of the match, + * and the next character after the match (or END_OF_STREAM if at end). + */ + public record Token(int offset, int length, char nextChar) { + } + + /** + * Compresses the input text using the LZ77 algorithm. + * + * @param text The input string to compress. Must not be null. + * @param windowSize The size of the sliding window (search buffer). Must be positive. + * @param lookaheadBufferSize The size of the lookahead buffer. Must be positive. + * @return A list of {@link Token} objects representing the compressed data. + * @throws IllegalArgumentException if windowSize or lookaheadBufferSize are not positive. + */ + public static List compress(String text, int windowSize, int lookaheadBufferSize) { + if (text == null) { + return new ArrayList<>(); + } + if (windowSize <= 0 || lookaheadBufferSize <= 0) { + throw new IllegalArgumentException("Window size and lookahead buffer size must be positive."); + } + + List compressedOutput = new ArrayList<>(); + int currentPosition = 0; + + while (currentPosition < text.length()) { + int bestMatchDistance = 0; + int bestMatchLength = 0; + + // Define the start of the search window + int searchBufferStart = Math.max(0, currentPosition - windowSize); + // Define the end of the lookahead buffer (don't go past text length) + int lookaheadEnd = Math.min(currentPosition + lookaheadBufferSize, text.length()); + + // Search for the longest match in the window + for (int i = searchBufferStart; i < currentPosition; i++) { + int currentMatchLength = 0; + + // Check how far the match extends into the lookahead buffer + // This allows for overlapping matches (e.g., "aaa" can match with offset 1) + while (currentPosition + currentMatchLength < lookaheadEnd) { + int sourceIndex = i + currentMatchLength; + + // Handle overlapping matches (run-length encoding within LZ77) + // When we've matched beyond our starting position, wrap around using modulo + if (sourceIndex >= currentPosition) { + int offset = currentPosition - i; + sourceIndex = i + (currentMatchLength % offset); + } + + if (text.charAt(sourceIndex) == text.charAt(currentPosition + currentMatchLength)) { + currentMatchLength++; + } else { + break; + } + } + + // If this match is longer than the best found so far + if (currentMatchLength > bestMatchLength) { + bestMatchLength = currentMatchLength; + bestMatchDistance = currentPosition - i; // Calculate offset from current position + } + } + + char nextChar; + if (currentPosition + bestMatchLength < text.length()) { + nextChar = text.charAt(currentPosition + bestMatchLength); + } else { + nextChar = END_OF_STREAM; + } + + // Add the token to the output + compressedOutput.add(new Token(bestMatchDistance, bestMatchLength, nextChar)); + + // Move the current position forward + // If we're at the end and had a match, just move by the match length + if (nextChar == END_OF_STREAM) { + currentPosition += bestMatchLength; + } else { + currentPosition += bestMatchLength + 1; + } + } + + return compressedOutput; + } + + /** + * Compresses the input text using the LZ77 algorithm with default buffer sizes. + * + * @param text The input string to compress. Must not be null. + * @return A list of {@link Token} objects representing the compressed data. + */ + public static List compress(String text) { + return compress(text, DEFAULT_WINDOW_SIZE, DEFAULT_LOOKAHEAD_BUFFER_SIZE); + } + + /** + * Decompresses a list of LZ77 tokens back into the original string. + * + * @param compressedData The list of {@link Token} objects. Must not be null. + * @return The original, uncompressed string. + */ + public static String decompress(List compressedData) { + if (compressedData == null) { + return ""; + } + + StringBuilder decompressedText = new StringBuilder(); + + for (Token token : compressedData) { + // Copy matched characters from the sliding window + if (token.length > 0) { + int startIndex = decompressedText.length() - token.offset; + + // Handle overlapping matches (e.g., when length > offset) + for (int i = 0; i < token.length; i++) { + decompressedText.append(decompressedText.charAt(startIndex + i)); + } + } + + // Append the next character (if not END_OF_STREAM) + if (token.nextChar != END_OF_STREAM) { + decompressedText.append(token.nextChar); + } + } + + return decompressedText.toString(); + } +} diff --git a/src/main/java/com/thealgorithms/compression/LZ78.java b/src/main/java/com/thealgorithms/compression/LZ78.java new file mode 100644 index 000000000000..904c379cc2a2 --- /dev/null +++ b/src/main/java/com/thealgorithms/compression/LZ78.java @@ -0,0 +1,136 @@ +package com.thealgorithms.compression; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * An implementation of the Lempel-Ziv 78 (LZ78) compression algorithm. + *

+ * LZ78 is a dictionary-based lossless data compression algorithm. It processes + * input data sequentially, building a dictionary of phrases encountered so far. + * It outputs pairs (dictionary_index, next_character), representing + * the longest match found in the dictionary plus the character that follows it. + *

+ *

+ * This implementation builds the dictionary dynamically during compression. + * The dictionary index 0 represents the empty string (no prefix). + *

+ *

+ * Time Complexity: O(n) on average for compression and decompression, assuming + * efficient dictionary lookups (using a HashMap), where n is the + * length of the input string. + *

+ *

+ * References: + *

+ *

+ */ +public final class LZ78 { + + /** + * Special character used to mark end of stream when needed. + */ + private static final char END_OF_STREAM = '\u0000'; + + /** + * Private constructor to prevent instantiation of this utility class. + */ + private LZ78() { + } + + /** + * Represents a token in the LZ78 compressed output. + * Stores the index of the matching prefix in the dictionary and the next character. + * Index 0 represents the empty string (no prefix). + */ + public record Token(int index, char nextChar) { + } + + /** + * A node in the dictionary trie structure. + * Each node represents a phrase and can have child nodes for extended phrases. + */ + private static final class TrieNode { + Map children = new HashMap<>(); + int index = -1; // -1 means not assigned yet + } + + /** + * Compresses the input text using the LZ78 algorithm. + * + * @param text The input string to compress. Must not be null. + * @return A list of {@link Token} objects representing the compressed data. + */ + public static List compress(String text) { + if (text == null || text.isEmpty()) { + return new ArrayList<>(); + } + + List compressedOutput = new ArrayList<>(); + TrieNode root = new TrieNode(); + int nextDictionaryIndex = 1; + + TrieNode currentNode = root; + int lastMatchedIndex = 0; + + for (int i = 0; i < text.length(); i++) { + char currentChar = text.charAt(i); + + if (currentNode.children.containsKey(currentChar)) { + currentNode = currentNode.children.get(currentChar); + lastMatchedIndex = currentNode.index; + } else { + // Output: (index of longest matching prefix, current character) + compressedOutput.add(new Token(lastMatchedIndex, currentChar)); + + TrieNode newNode = new TrieNode(); + newNode.index = nextDictionaryIndex++; + currentNode.children.put(currentChar, newNode); + + currentNode = root; + lastMatchedIndex = 0; + } + } + + // Handle remaining phrase at end of input + if (currentNode != root) { + compressedOutput.add(new Token(lastMatchedIndex, END_OF_STREAM)); + } + + return compressedOutput; + } + + /** + * Decompresses a list of LZ78 tokens back into the original string. + * + * @param compressedData The list of {@link Token} objects. Must not be null. + * @return The original, uncompressed string. + */ + public static String decompress(List compressedData) { + if (compressedData == null || compressedData.isEmpty()) { + return ""; + } + + StringBuilder decompressedText = new StringBuilder(); + Map dictionary = new HashMap<>(); + int nextDictionaryIndex = 1; + + for (Token token : compressedData) { + String prefix = (token.index == 0) ? "" : dictionary.get(token.index); + + if (token.nextChar == END_OF_STREAM) { + decompressedText.append(prefix); + } else { + String currentPhrase = prefix + token.nextChar; + decompressedText.append(currentPhrase); + dictionary.put(nextDictionaryIndex++, currentPhrase); + } + } + + return decompressedText.toString(); + } +} diff --git a/src/test/java/com/thealgorithms/compression/LZ77Test.java b/src/test/java/com/thealgorithms/compression/LZ77Test.java new file mode 100644 index 000000000000..86732d48a54a --- /dev/null +++ b/src/test/java/com/thealgorithms/compression/LZ77Test.java @@ -0,0 +1,223 @@ +package com.thealgorithms.compression; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class LZ77Test { + + @Test + @DisplayName("Test compression and decompression of a simple repeating string") + void testSimpleRepeatingString() { + String original = "ababcbababaa"; + List compressed = LZ77.compress(original, 10, 4); + String decompressed = LZ77.decompress(compressed); + assertEquals(original, decompressed); + } + + @Test + @DisplayName("Test compression and decompression of a string with no repeats initially") + void testNoInitialRepeats() { + String original = "abcdefgh"; + List compressed = LZ77.compress(original); + String decompressed = LZ77.decompress(compressed); + assertEquals(original, decompressed); + } + + @Test + @DisplayName("Test compression and decompression of a longer example") + void testLongerExample() { + String original = "TOBEORNOTTOBEORTOBEORNOT"; + List compressed = LZ77.compress(original, 20, 10); + String decompressed = LZ77.decompress(compressed); + assertEquals(original, decompressed); + } + + @Test + @DisplayName("Test empty string compression and decompression") + void testEmptyString() { + String original = ""; + List compressed = LZ77.compress(original); + String decompressed = LZ77.decompress(compressed); + assertEquals(original, decompressed); + assertTrue(compressed.isEmpty()); + } + + @Test + @DisplayName("Test null string compression") + void testNullStringCompress() { + List compressed = LZ77.compress(null); + assertTrue(compressed.isEmpty()); + } + + @Test + @DisplayName("Test null list decompression") + void testNullListDecompress() { + String decompressed = LZ77.decompress(null); + assertEquals("", decompressed); + } + + @Test + @DisplayName("Test invalid buffer sizes throw exception") + void testInvalidBufferSizes() { + assertThrows(IllegalArgumentException.class, () -> LZ77.compress("test", 0, 5)); + assertThrows(IllegalArgumentException.class, () -> LZ77.compress("test", 5, 0)); + assertThrows(IllegalArgumentException.class, () -> LZ77.compress("test", -1, 5)); + assertThrows(IllegalArgumentException.class, () -> LZ77.compress("test", 5, -1)); + } + + @Test + @DisplayName("Test string with all same characters") + void testAllSameCharacters() { + String original = "AAAAAA"; + List compressed = LZ77.compress(original, 10, 5); + String decompressed = LZ77.decompress(compressed); + assertEquals(original, decompressed); + + // Should achieve good compression for repeated characters + assertTrue(compressed.size() < original.length()); + } + + @Test + @DisplayName("Test string with all unique characters") + void testAllUniqueCharacters() { + String original = "abcdefghijklmnop"; + List compressed = LZ77.compress(original, 10, 5); + String decompressed = LZ77.decompress(compressed); + assertEquals(original, decompressed); + + // No compression expected for unique characters + assertEquals(original.length(), compressed.size()); + } + + @Test + @DisplayName("Test single character string") + void testSingleCharacter() { + String original = "a"; + List compressed = LZ77.compress(original); + String decompressed = LZ77.decompress(compressed); + assertEquals(original, decompressed); + assertEquals(1, compressed.size()); + } + + @Test + @DisplayName("Test match that goes exactly to the end") + void testMatchToEnd() { + String original = "abcabc"; + List compressed = LZ77.compress(original, 10, 10); + String decompressed = LZ77.decompress(compressed); + assertEquals(original, decompressed); + } + + @Test + @DisplayName("Test with very small window size") + void testSmallWindowSize() { + String original = "ababababab"; + List compressed = LZ77.compress(original, 2, 4); + String decompressed = LZ77.decompress(compressed); + assertEquals(original, decompressed); + } + + @Test + @DisplayName("Test with very small lookahead buffer") + void testSmallLookaheadBuffer() { + String original = "ababcbababaa"; + List compressed = LZ77.compress(original, 10, 2); + String decompressed = LZ77.decompress(compressed); + assertEquals(original, decompressed); + } + + @Test + @DisplayName("Test repeating pattern at the end") + void testRepeatingPatternAtEnd() { + String original = "xyzabcabcabcabc"; + List compressed = LZ77.compress(original, 15, 8); + String decompressed = LZ77.decompress(compressed); + assertEquals(original, decompressed); + } + + @Test + @DisplayName("Test overlapping matches (run-length encoding case)") + void testOverlappingMatches() { + String original = "aaaaaa"; + List compressed = LZ77.compress(original, 10, 10); + String decompressed = LZ77.decompress(compressed); + assertEquals(original, decompressed); + } + + @Test + @DisplayName("Test complex pattern with multiple repeats") + void testComplexPattern() { + String original = "abcabcabcxyzxyzxyz"; + List compressed = LZ77.compress(original, 20, 10); + String decompressed = LZ77.decompress(compressed); + assertEquals(original, decompressed); + } + + @Test + @DisplayName("Test with special characters") + void testSpecialCharacters() { + String original = "hello world! @#$%^&*()"; + List compressed = LZ77.compress(original); + String decompressed = LZ77.decompress(compressed); + assertEquals(original, decompressed); + } + + @Test + @DisplayName("Test with numbers") + void testWithNumbers() { + String original = "1234567890123456"; + List compressed = LZ77.compress(original, 15, 8); + String decompressed = LZ77.decompress(compressed); + assertEquals(original, decompressed); + } + + @Test + @DisplayName("Test long repeating sequence") + void testLongRepeatingSequence() { + String original = "abcdefgh".repeat(10); + List compressed = LZ77.compress(original, 50, 20); + String decompressed = LZ77.decompress(compressed); + assertEquals(original, decompressed); + + // Should achieve significant compression + assertTrue(compressed.size() < original.length() / 2); + } + + @Test + @DisplayName("Test compression effectiveness") + void testCompressionEffectiveness() { + String original = "ababababababab"; + List compressed = LZ77.compress(original, 20, 10); + + // Verify that compression actually reduces the data size + // Each token represents potentially multiple characters + assertTrue(compressed.size() <= original.length()); + + // Verify decompression + String decompressed = LZ77.decompress(compressed); + assertEquals(original, decompressed); + } + + @Test + @DisplayName("Test with mixed case letters") + void testMixedCase() { + String original = "AaBbCcAaBbCc"; + List compressed = LZ77.compress(original); + String decompressed = LZ77.decompress(compressed); + assertEquals(original, decompressed); + } + + @Test + @DisplayName("Test default parameters") + void testDefaultParameters() { + String original = "This is a test string with some repeated patterns. This is repeated."; + List compressed = LZ77.compress(original); + String decompressed = LZ77.decompress(compressed); + assertEquals(original, decompressed); + } +} diff --git a/src/test/java/com/thealgorithms/compression/LZ78Test.java b/src/test/java/com/thealgorithms/compression/LZ78Test.java new file mode 100644 index 000000000000..7889b50b76f3 --- /dev/null +++ b/src/test/java/com/thealgorithms/compression/LZ78Test.java @@ -0,0 +1,295 @@ +package com.thealgorithms.compression; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class LZ78Test { + + @Test + @DisplayName("Test compression and decompression of a simple repeating string") + void testSimpleRepeatingString() { + String original = "ababcbababaa"; + List compressed = LZ78.compress(original); + String decompressed = LZ78.decompress(compressed); + assertEquals(original, decompressed); + } + + @Test + @DisplayName("Test compression and decompression example ABAABABAABAB") + void testStandardExample() { + String original = "ABAABABAABAB"; + List compressed = LZ78.compress(original); + String decompressed = LZ78.decompress(compressed); + assertEquals(original, decompressed); + + // Verify the compression produces expected tokens + // Expected: (0,A)(0,B)(1,A)(2,B)(3,A)(4,B) + // Where dictionary builds as: 1:A, 2:B, 3:AA, 4:BA, 5:ABA, 6:BAB + assertEquals(6, compressed.size()); + assertEquals(0, compressed.get(0).index()); + assertEquals('A', compressed.get(0).nextChar()); + assertEquals(0, compressed.get(1).index()); + assertEquals('B', compressed.get(1).nextChar()); + assertEquals(1, compressed.get(2).index()); + assertEquals('A', compressed.get(2).nextChar()); + } + + @Test + @DisplayName("Test compression and decompression of a longer example") + void testLongerExample() { + String original = "TOBEORNOTTOBEORTOBEORNOT"; + List compressed = LZ78.compress(original); + String decompressed = LZ78.decompress(compressed); + assertEquals(original, decompressed); + } + + @Test + @DisplayName("Test empty string compression and decompression") + void testEmptyString() { + String original = ""; + List compressed = LZ78.compress(original); + String decompressed = LZ78.decompress(compressed); + assertEquals(original, decompressed); + assertTrue(compressed.isEmpty()); + } + + @Test + @DisplayName("Test null string compression") + void testNullStringCompress() { + List compressed = LZ78.compress(null); + assertTrue(compressed.isEmpty()); + } + + @Test + @DisplayName("Test null list decompression") + void testNullListDecompress() { + String decompressed = LZ78.decompress(null); + assertEquals("", decompressed); + } + + @Test + @DisplayName("Test string with all same characters") + void testAllSameCharacters() { + String original = "AAAAAA"; + List compressed = LZ78.compress(original); + String decompressed = LZ78.decompress(compressed); + assertEquals(original, decompressed); + + // Should achieve good compression: (0,A)(1,A)(2,A)... + assertTrue(compressed.size() <= 4); // Builds: A, AA, AAA, etc. + } + + @Test + @DisplayName("Test string with all unique characters") + void testAllUniqueCharacters() { + String original = "abcdefg"; + List compressed = LZ78.compress(original); + String decompressed = LZ78.decompress(compressed); + assertEquals(original, decompressed); + + // No compression for unique characters + assertEquals(original.length(), compressed.size()); + + // Each token should have index 0 (empty prefix) + for (LZ78.Token token : compressed) { + assertEquals(0, token.index()); + } + } + + @Test + @DisplayName("Test single character string") + void testSingleCharacter() { + String original = "a"; + List compressed = LZ78.compress(original); + String decompressed = LZ78.decompress(compressed); + assertEquals(original, decompressed); + assertEquals(1, compressed.size()); + assertEquals(0, compressed.getFirst().index()); + assertEquals('a', compressed.getFirst().nextChar()); + } + + @Test + @DisplayName("Test two character string") + void testTwoCharacters() { + String original = "ab"; + List compressed = LZ78.compress(original); + String decompressed = LZ78.decompress(compressed); + assertEquals(original, decompressed); + assertEquals(2, compressed.size()); + } + + @Test + @DisplayName("Test repeating pairs") + void testRepeatingPairs() { + String original = "ababab"; + List compressed = LZ78.compress(original); + String decompressed = LZ78.decompress(compressed); + assertEquals(original, decompressed); + + // Should compress well: (0,a)(0,b)(1,b) or similar + assertTrue(compressed.size() < original.length()); + } + + @Test + @DisplayName("Test growing patterns") + void testGrowingPatterns() { + String original = "abcabcdabcde"; + List compressed = LZ78.compress(original); + String decompressed = LZ78.decompress(compressed); + assertEquals(original, decompressed); + } + + @Test + @DisplayName("Test dictionary building correctness") + void testDictionaryBuilding() { + String original = "aabaabaab"; + List compressed = LZ78.compress(original); + String decompressed = LZ78.decompress(compressed); + assertEquals(original, decompressed); + + // Verify first few tokens + // Expected pattern: (0,a)(1,b)(2,a)(3,b) building dictionary 1:a, 2:ab, 3:aa, 4:aab + assertTrue(compressed.size() > 0); + assertEquals(0, compressed.getFirst().index()); // First char always has index 0 + } + + @Test + @DisplayName("Test with special characters") + void testSpecialCharacters() { + String original = "hello world! hello!"; + List compressed = LZ78.compress(original); + String decompressed = LZ78.decompress(compressed); + assertEquals(original, decompressed); + } + + @Test + @DisplayName("Test with numbers") + void testWithNumbers() { + String original = "1234512345"; + List compressed = LZ78.compress(original); + String decompressed = LZ78.decompress(compressed); + assertEquals(original, decompressed); + + // Should achieve compression + assertTrue(compressed.size() < original.length()); + } + + @Test + @DisplayName("Test long repeating sequence") + void testLongRepeatingSequence() { + String original = "abcdefgh".repeat(5); + List compressed = LZ78.compress(original); + String decompressed = LZ78.decompress(compressed); + assertEquals(original, decompressed); + + // LZ78 should achieve some compression for repeating sequences + assertTrue(compressed.size() < original.length(), "Compressed size should be less than original length"); + } + + @Test + @DisplayName("Test alternating characters") + void testAlternatingCharacters() { + String original = "ababababab"; + List compressed = LZ78.compress(original); + String decompressed = LZ78.decompress(compressed); + assertEquals(original, decompressed); + } + + @Test + @DisplayName("Test compression effectiveness") + void testCompressionEffectiveness() { + String original = "the quick brown fox jumps over the lazy dog the quick brown fox"; + List compressed = LZ78.compress(original); + String decompressed = LZ78.decompress(compressed); + assertEquals(original, decompressed); + + // Should achieve some compression due to repeated phrases + assertTrue(compressed.size() < original.length()); + } + + @Test + @DisplayName("Test with mixed case letters") + void testMixedCase() { + String original = "AaBbCcAaBbCc"; + List compressed = LZ78.compress(original); + String decompressed = LZ78.decompress(compressed); + assertEquals(original, decompressed); + } + + @Test + @DisplayName("Test palindrome string") + void testPalindrome() { + String original = "abccba"; + List compressed = LZ78.compress(original); + String decompressed = LZ78.decompress(compressed); + assertEquals(original, decompressed); + } + + @Test + @DisplayName("Test highly compressible pattern") + void testHighlyCompressible() { + String original = "aaaaaaaaaa"; + List compressed = LZ78.compress(original); + String decompressed = LZ78.decompress(compressed); + assertEquals(original, decompressed); + + // Should achieve excellent compression ratio + assertTrue(compressed.size() <= 4); + } + + @Test + @DisplayName("Test empty list decompression") + void testEmptyListDecompress() { + List compressed = List.of(); + String decompressed = LZ78.decompress(compressed); + assertEquals("", decompressed); + } + + @Test + @DisplayName("Test binary-like pattern") + void testBinaryPattern() { + String original = "0101010101"; + List compressed = LZ78.compress(original); + String decompressed = LZ78.decompress(compressed); + assertEquals(original, decompressed); + } + + @Test + @DisplayName("Test nested patterns") + void testNestedPatterns() { + String original = "abcabcdefabcdefghi"; + List compressed = LZ78.compress(original); + String decompressed = LZ78.decompress(compressed); + assertEquals(original, decompressed); + } + + @Test + @DisplayName("Test whitespace handling") + void testWhitespace() { + String original = "a b c a b c"; + List compressed = LZ78.compress(original); + String decompressed = LZ78.decompress(compressed); + assertEquals(original, decompressed); + } + + @Test + @DisplayName("Test token structure correctness") + void testTokenStructure() { + String original = "abc"; + List compressed = LZ78.compress(original); + + // All tokens should have valid indices (>= 0) + for (LZ78.Token token : compressed) { + assertTrue(token.index() >= 0); + assertNotNull(token.nextChar()); + } + + String decompressed = LZ78.decompress(compressed); + assertEquals(original, decompressed); + } +}