Skip to main content
replaced http://codereview.stackexchange.com/ with https://codereview.stackexchange.com/
Source Link

(See the next iterationnext iteration as well.)

(See the next iteration as well.)

(See the next iteration as well.)

added 130 characters in body
Source Link
coderodde
  • 29.8k
  • 14
  • 77
  • 194

(See the next iteration as well.)

(See the next iteration as well.)

Tweeted twitter.com/StackCodeReview/status/800143413140144128
Source Link
coderodde
  • 29.8k
  • 14
  • 77
  • 194

Huffman compressor in Java

I have this Java program that can en-/decode files, both text and binary. What comes to critique I want to hear anything regarding these points:

  • Performance,
  • Modularity,
  • Coding conventions,
  • Naming conventions.

Code

BitString.java

package net.coderodde.compression.huffman;

import java.util.Arrays;

/**
 * This class implements a builder for creating bit strings.
 * 
 * @author Rodion "rodde" Efremov
 * @version 1.618 (Nov 14, 2016)
 */
public final class BitString {

    /**
     * The length of a {@code long} array storing the bits.
     */
    private static final int DEFAULT_NUMBER_OF_LONGS = 2;

    /**
     * Used for more efficient modulo arithmetics when indexing a particular
     * bit in a {@code long} value.
     */
    private static final int MODULO_MASK = 0b111111;

    /**
     * Number of bits per a {@code long} value;
     */
    private static final int BITS_PER_LONG = Long.BYTES * Byte.SIZE;

    /**
     * This field holds the actual array of long values for storing the bits.
     */
    private long[] storageLongs;

    /**
     * Current maximum of bits this builder can store.
     */
    private int storageCapacity;

    /**
     * Stores the number of bits this bit string builder actually represents.
     * We have an invariant {@code size <= storageCapacity}.
     */
    private int size;

    /**
     * Constructs an empty bit string builder.
     */
    public BitString() {
        this.storageLongs = new long[DEFAULT_NUMBER_OF_LONGS];
        this.storageCapacity = this.storageLongs.length * BITS_PER_LONG;
    }

    /**
     * Constructs a distinct bit string builder with the same content as in
     * {@code toCopy}. 
     * 
     * @param toCopy the bit string builder whose content to copy.
     */
    public BitString(BitString toCopy) {
        this.size = toCopy.size;
        this.storageCapacity = 
                Math.max(size, DEFAULT_NUMBER_OF_LONGS * BITS_PER_LONG);

        int numberOfLongs = storageCapacity / BITS_PER_LONG +
                         (((storageCapacity & MODULO_MASK) == 0) ? 0 : 1);

        this.storageLongs = Arrays.copyOf(toCopy.storageLongs, numberOfLongs);
    }

    public void appendBit(boolean bit) {
        checkBitArrayCapacity(size + 1);
        writeBitImpl(size, bit);
        ++size;
    }

    /**
     * Returns number of bits stored in this builder.
     * 
     * @return number of bits.
     */
    public int length() {
        return size;
    }

    /**
     * Appends all the bits in {@code bitStringBuilder} to the end of this 
     * builder.
     * 
     * @param bitStringBuilder the bit string builder whose bits to append.
     */
    public void appendBitsFrom(BitString bitStringBuilder) {
        checkBitArrayCapacity(size + bitStringBuilder.size);
        int otherSize = bitStringBuilder.size;

        for (int i = 0; i != otherSize; ++i) {
            appendBit(bitStringBuilder.readBitImpl(i));
        }
    }

    /**
     * Reads a specific bit.
     * 
     * @param index the index of the target bit.
     * @return {@code true} if the target bit is on, {@code false} otherwise.
     */
    public boolean readBit(int index) {
        checkAccessIndex(index);
        return readBitImpl(index);
    }

    /**
     * Removes the very last bit from this bit string builder.
     */
    public void removeLastBit() {
        if (size == 0) {
            throw new IllegalStateException(
            "Removing the last bit from an bit string builder.");
        }

        --size;
    }

    /**
     * Clears the entire bit string builder.
     */
    public void clear() {
        this.size = 0;
    }

    /**
     * Returns the number of bytes occupied by bits.
     * 
     * @return number of bytes occupied. 
     */
    public int getNumberOfBytesOccupied() {
        return size / 8 + ((size % 8 == 0) ? 0 : 1);
    }

    public byte[] toByteArray() {
        int numberOfBytes = (size / Byte.SIZE) +
                           ((size % Byte.SIZE == 0) ? 0 : 1);

        byte[] byteArray = new byte[numberOfBytes];

        for (int i = 0; i != numberOfBytes; ++i) {
            byteArray[i] = 
                    (byte)
                    ((storageLongs[i / Long.BYTES] 
                    >>> Byte.SIZE * (i % Long.BYTES)) & 0xff);
        }

        return byteArray;
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder(size);

        for (int i = 0; i != size; ++i) {
            sb.append(readBitImpl(i) ? '1': '0');
        }

        return sb.toString();
    }

    @Override
    public int hashCode() {
        int hash = 0;
        int fullLongs = size / BITS_PER_LONG;

        for (int i = 0; i != fullLongs; ++i) {
            long word = storageLongs[i];
            hash += (int)((word & 0xffffffff00000000L) ^ 
                          (word & 0xffffffff));
        }

        int leftoverBits = size & MODULO_MASK;

        for (int i = 0; i != leftoverBits; ++i) {
            hash += readBitImpl(fullLongs * BITS_PER_LONG + i) ? 1 : 0;
        }

        return hash;
    }

    @Override
    public boolean equals(Object o) {
        if (o == null) {
            return false;
        }

        if (o == this) {
            return true;
        }

        if (!getClass().equals(o.getClass())) {
            return false;
        }

        BitString other = (BitString) o;

        if (size != other.size) {
            return false;
        }

        int fullLongs = size / BITS_PER_LONG;

        for (int i = 0; i != fullLongs; ++i) {
            if (storageLongs[i] != other.storageLongs[i]) {
                return false;
            }
        }

        int leftoverBits = size & MODULO_MASK;

        for (int i = 0; i != leftoverBits; ++i) {
            if (readBitImpl(fullLongs * BITS_PER_LONG + i) !=
                other.readBitImpl(fullLongs * BITS_PER_LONG + i)) {
                return false;
            }
        }

        return true;
    }

    private void checkAccessIndex(int index) {
        if (size == 0) {
            throw new IllegalStateException("The bit string is empty.");
        }

        if (index < 0) {
            throw new IndexOutOfBoundsException(
            "The index is negative: " + index + ".");
        }

        if (index >= size) {
            throw new IndexOutOfBoundsException(
            "The bit index is too large (" + index + "). Must be at most " +
            (size - 1) + ".");
        }
    }

    private boolean readBitImpl(int index) {
        int longIndex = index / BITS_PER_LONG;
        int bitIndex  = index & MODULO_MASK;
        long mask = 1L << bitIndex;
        return (storageLongs[longIndex] & mask) != 0;
    }

    private void writeBitImpl(int index, boolean bit) {
        int longIndex = index / BITS_PER_LONG;
        int bitIndex  = index & MODULO_MASK;

        if (bit) {
            long mask = 1L << bitIndex;
            storageLongs[longIndex] |= mask;
        } else {
            long mask = ~(1L << bitIndex);
            storageLongs[longIndex] &= mask;
        }
    }

    private void checkBitArrayCapacity(int requestedCapacity) {
        if (requestedCapacity > storageCapacity) {
            int requestedWords1 =
                    requestedCapacity / BITS_PER_LONG + 
                 (((requestedCapacity & MODULO_MASK) == 0) ? 0 : 1);

            int requestedWords2 = (3 * storageCapacity / 2) / BITS_PER_LONG;

            int selectedRequestedWords = Math.max(requestedWords1,
                                                  requestedWords2);

            this.storageLongs = Arrays.copyOf(storageLongs, 
                                              selectedRequestedWords);

            this.storageCapacity = this.storageLongs.length * BITS_PER_LONG;
        }
    } 
}

ByteList.java

package net.coderodde.compression.huffman;

import java.util.Arrays;

/**
 * This class implements a simple, non-generic list of bytes.
 * 
 * @author Rodion "rodde" Efremov
 * @version 1.6 (Nov 17, 2016)
 */
final class ByteList {

    private byte[] data;
    private int size;

    public ByteList() {
        this.data = new byte[8];
    }

    public ByteList(int capacity) {
        this.data = new byte[capacity];
    }

    public void appendByte(byte b) {
        ensureCapacity(size + 1);
        data[size++] = b;
    }

    public byte[] toByteArray() {
        return Arrays.copyOfRange(data, 0, size);
    }

    private void ensureCapacity(int requestedCapacity) {
        if (requestedCapacity > data.length) {
            int selectedCapacity = Math.max(requestedCapacity,
                                            data.length * 2);

            data = Arrays.copyOf(data, selectedCapacity);
        }
    }
}

ByteWeightComputer.java

package net.coderodde.compression.huffman;

import java.util.HashMap;
import java.util.Map;

/**
 * This class provides a method for counting relative frequencies of characters
 * in any given corpus of text.
 * 
 * @author Rodion "rodde" Efremov
 * @version 1.618 (Nov 19, 2016)
 */
public final class ByteWeightComputer {

    /**
     * Computes the map mapping each character in the text {@code text} to its
     * relative frequency.
     * 
     * @param text the text for which to compute the frequencies.
     * @return the map mapping each character to its respective frequency.
     */
    public Map<Byte, Float> computeCharacterWeights(byte[] text) {
        Map<Byte, Float> map = new HashMap<>();
        int textLength = text.length;

        for (int i = 0; i != textLength; ++i) {
            byte currentByte = text[i];

            if (map.containsKey(currentByte)) {
                map.put(currentByte, map.get(currentByte) + 1.0f);
            } else {
                map.put(currentByte, 1.0f);
            }
        }

        float textLengthFloat = textLength;

        for (Map.Entry<Byte, Float> entry : map.entrySet()) {
            entry.setValue(entry.getValue() / textLengthFloat);
        }

        return map;
    }
}

HuffmanDecoder.java

package net.coderodde.compression.huffman;

import java.util.HashMap;
import java.util.Map;

/**
 * This class is responsible for recovering the encoded text.
 * 
 * @author Rodion "rodde" Efremov
 * @version 1.61 (Nov 19, 2016)
 */
public final class HuffmanDecoder {

    /**
     * Recovers the text encoded by the bit string {@code bits} and the encoder
     * map {@code encoderMap}.
     * 
     * @param bits       the actual encoded text.
     * @param encoderMap the map mapping each character to its respective
     *                   code word.
     * @return the recovered text.
     */
    public byte[] decode(BitString bits,
                         Map<Byte, BitString> encoderMap) {
        Map<BitString, Byte> decoderMap = invertEncoderMap(encoderMap);
        ByteList byteList = new ByteList();
        BitString bitAccumulator = new BitString();
        int totalBits = bits.length();

        for (int bitIndex = 0; bitIndex != totalBits; ++bitIndex) {
            bitAccumulator.appendBit(bits.readBit(bitIndex));
            Byte currentByte = decoderMap.get(bitAccumulator);

            if (currentByte != null) {
                byteList.appendByte(currentByte);
                bitAccumulator.clear();
            }
        }

        return byteList.toByteArray();
    }

    private Map<BitString, Byte>
            invertEncoderMap(Map<Byte, BitString> encoderMap) {
        Map<BitString, Byte> map = new HashMap<>(encoderMap.size());

        for (Map.Entry<Byte, BitString> entry 
                : encoderMap.entrySet()) {
            map.put(entry.getValue(), entry.getKey());
        }

        return map;
    }
}

HuffmanDeserializer.java

package net.coderodde.compression.huffman;

import java.util.HashMap;
import java.util.Map;

/**
 * This class is responsible for deserializing the text from a raw byte data.
 * 
 * @author Rodion "rodde" Efremov
 * @version 1.61 (Nov 19, 2016)
 */
public class HuffmanDeserializer {

    public static final class Result {

        private final BitString encodedText;
        private final Map<Byte, BitString> encoderMap;

        Result(BitString encodedText, 
               Map<Byte, BitString> encoderMap) {
            this.encodedText = encodedText;
            this.encoderMap  = encoderMap;
        }

        public BitString getEncodedText() {
            return encodedText;
        }

        public Map<Byte, BitString> getEncoderMap() {
            return encoderMap;
        }
    }

    /**
     * Deserialises and returns the data structures need for decoding the text.
     * 
     * @param data the raw byte data previously serialised.
     * @return the data structures needed for decoding the text.
     */
    public Result deserialize(byte[] data) {
        checkSignature(data);
        int numberOfCodeWords = extractNumberOfCodeWords(data);
        int numberOfBits = extractNumberOfEncodedTextBits(data);

        Map<Byte, BitString> encoderMap = extractEncoderMap(data, 
                                                            numberOfCodeWords);
        BitString encodedText = extractEncodedText(data, 
                                                   encoderMap, 
                                                   numberOfBits);
        return new Result(encodedText, encoderMap);
    }

    private void checkSignature(byte[] data) {
        if (data.length < 4) {
            throw new InvalidFileFormatException(
            "No file type signature. The file is too short: " + data.length);
        }

        for (int i = 0; i != HuffmanSerializer.MAGIC.length; ++i) {
            if (data[i] != HuffmanSerializer.MAGIC[i]) {
                throw new InvalidFileFormatException(
                "Bad file type signature.");
            }
        }
    }

    private int extractNumberOfCodeWords(byte[] data) {
        if (data.length < 8) {
            throw new InvalidFileFormatException(
            "No number of code words. The file is too short: " + data.length);
        }

        int numberOfCodeWords = 0;

        numberOfCodeWords |= (Byte.toUnsignedInt(data[7]) << 24);
        numberOfCodeWords |= (Byte.toUnsignedInt(data[6]) << 16);
        numberOfCodeWords |= (Byte.toUnsignedInt(data[5]) << 8);
        numberOfCodeWords |= (Byte.toUnsignedInt(data[4]));

        return numberOfCodeWords;
    }

    private Map<Byte, BitString> extractEncoderMap(byte[] data,
                                                   int numberOfCodeWords) {
        Map<Byte, BitString> encoderMap = new HashMap<>();

        try {
            int dataByteIndex =
                    HuffmanSerializer.MAGIC.length +
                    HuffmanSerializer.BYTES_PER_BIT_COUNT_ENTRY +
                    HuffmanSerializer.BYTES_PER_CODE_WORD_COUNT_ENTRY;

            for (int i = 0; i != numberOfCodeWords; ++i) {
                byte character = data[dataByteIndex++];
                int codeWordLength = data[dataByteIndex++];
                int bitIndex = 0;
                BitString codeWordBits = new BitString();

                for (int codeWordBitIndex = 0;
                        codeWordBitIndex != codeWordLength;
                        codeWordBitIndex++) {
                    byte currentByte = data[dataByteIndex];
                    boolean bit = (currentByte & (1 << bitIndex)) != 0;
                    codeWordBits.appendBit(bit);

                    if (++bitIndex == Byte.SIZE) {
                        bitIndex = 0;
                        dataByteIndex++;
                    }
                }

                encoderMap.put(character, codeWordBits);

                if (bitIndex != 0) {
                    dataByteIndex++;
                }
            }
        } catch (ArrayIndexOutOfBoundsException ex) {
            throw new InvalidFileFormatException("Invalid file format.");
        }

        return encoderMap;
    }

    private BitString extractEncodedText(byte[] data,
                                         Map<Byte, BitString> encoderMap,
                                         int numberOfEncodedTextBits) {
        int omittedBytes = HuffmanSerializer.MAGIC.length +
                           HuffmanSerializer.BYTES_PER_BIT_COUNT_ENTRY +
                           HuffmanSerializer.BYTES_PER_CODE_WORD_COUNT_ENTRY;

        for (Map.Entry<Byte, BitString> entry : encoderMap.entrySet()) {
            omittedBytes += 2 + entry.getValue().getNumberOfBytesOccupied();
        }

        BitString encodedText = new BitString();
        int currentByteIndex = omittedBytes;
        int currentBitIndex = 0;

        try {
            for (int bitIndex = 0; 
                    bitIndex != numberOfEncodedTextBits; 
                    bitIndex++) {
                boolean bit = 
                        (data[currentByteIndex] & (1 << currentBitIndex)) != 0;

                encodedText.appendBit(bit);

                if (++currentBitIndex == Byte.SIZE) {
                    currentBitIndex = 0;
                    currentByteIndex++;
                }
            }
        } catch (ArrayIndexOutOfBoundsException ex) {
            throw new InvalidFileFormatException("Invalid file format.");
        }

        return encodedText;
    }

    private int extractNumberOfEncodedTextBits(byte[] data) {
        if (data.length < 12) {
            throw new InvalidFileFormatException(
            "No number of encoded text bits. The file is too short: " + 
                    data.length);
        }

        int numberOfEncodedTextBits = 0;

        numberOfEncodedTextBits |= (Byte.toUnsignedInt(data[11]) << 24);
        numberOfEncodedTextBits |= (Byte.toUnsignedInt(data[10]) << 16);
        numberOfEncodedTextBits |= (Byte.toUnsignedInt(data[9] ) << 8);
        numberOfEncodedTextBits |= (Byte.toUnsignedInt(data[8]));

        return numberOfEncodedTextBits;
    }
}

HuffmanEncoder.java

package net.coderodde.compression.huffman;

import java.util.Map;

/**
 * This class provides a method for encoding the given text using a particular
 * encoder map.
 * 
 * @author Rodion "rodde" Efremov
 * @version 1.61 (Nov 19, 2016)
 */
public final class HuffmanEncoder {

    /**
     * Encodes the input text {@code text} using the encoder map 
     * {@code encoderMap}.
     * 
     * @param map  the encoder map.
     * @param text the text to encode.
     * @return a bit string representing the encoded text.
     */
    public BitString encode(Map<Byte, BitString> map, byte[] text) {
        BitString outputBitString = new BitString();
        int textLength = text.length;

        for (int index = 0; index != textLength; ++index) {
            byte currentByte = text[index];
            BitString codeWord = map.get(currentByte);
            outputBitString.appendBitsFrom(codeWord);
        }

        return outputBitString;
    }
}

HuffmanSerializer.java

package net.coderodde.compression.huffman;

import java.util.Map;

/**
 * This class is responsible for converting the encoded text and the encoder map
 * into a raw byte array.
 * 
 * @author Rodion "rodde" Efremov
 * @version 1.61 (Nov 19, 2016)
 */
public final class HuffmanSerializer {

    /**
     * The magic file signature for recognizing the file type.
     */
    static final byte[] MAGIC = new byte[]{ (byte) 0xC0,
                                            (byte) 0xDE,
                                            (byte) 0x0D,
                                            (byte) 0xDE };

    /**
     * The number of bytes it takes to serialize one mapping from a character
     * to its code word.
     */
    static final int BYTES_PER_ENCODER_MAP_ENTRY = 4;

    /**
     * The number of bytes it takes to serialize the number of code words.
     */
    static final int BYTES_PER_CODE_WORD_COUNT_ENTRY = 4;

    /**
     * The number of bytes it takes to serialize the number of bits in the 
     * actual encoded text.
     */
    static final int BYTES_PER_BIT_COUNT_ENTRY = 4;

    /**
     * Produces a byte array holding the compressed text along with its 
     * encoder map.
     * 
     * @param encoderMap  the encoder map used for encoding the text.
     * @param encodedText the encoded text.
     * @return an array of byte.
     */
    public byte[] serialize(Map<Byte, BitString> encoderMap,
                            BitString encodedText) {
        ByteList byteList = new ByteList(computeByteListSize(encoderMap, 
                                                             encodedText));
        // Emit the magic number:
        for (byte b : MAGIC) {
            byteList.appendByte(b);
        }

        int numberOfCodeWords = encoderMap.size();
        int numberOfBits = encodedText.length();

        // Emit the number of code words.
        byteList.appendByte((byte) (numberOfCodeWords & 0xff));
        byteList.appendByte((byte)((numberOfCodeWords >>= 8) & 0xff));
        byteList.appendByte((byte)((numberOfCodeWords >>= 8) & 0xff));
        byteList.appendByte((byte)((numberOfCodeWords >>= 8) & 0xff));

        // Emit the number of bits in the encoded text.
        byteList.appendByte((byte) (numberOfBits & 0xff));
        byteList.appendByte((byte)((numberOfBits >>= 8) & 0xff));
        byteList.appendByte((byte)((numberOfBits >>= 8) & 0xff));
        byteList.appendByte((byte)((numberOfBits >>= 8) & 0xff));

        // Emit the code words:
        for (Map.Entry<Byte, BitString> entry 
                : encoderMap.entrySet()) {
            byte character = entry.getKey();
            BitString codeWord = entry.getValue();

            // Emit the character:
            byteList.appendByte(character);

            // Emit the codeword length:
            byte codeWordLength = (byte) codeWord.length();
            byteList.appendByte(codeWordLength);

            // Emit the code word bits:
            byte[] codewordBytes = codeWord.toByteArray();

            for (byte b : codewordBytes) {
                byteList.appendByte(b);
            }
        }

        byte[] encodedTextBytes = encodedText.toByteArray();

        for (byte b : encodedTextBytes) {
            byteList.appendByte(b);
        }

        return byteList.toByteArray();
    }

    private int computeByteListSize(Map<Byte, BitString> encoderMap,
                                    BitString encodedText) {
        return MAGIC.length + BYTES_PER_CODE_WORD_COUNT_ENTRY
                            + BYTES_PER_BIT_COUNT_ENTRY
                            + encoderMap.size() * BYTES_PER_ENCODER_MAP_ENTRY 
                            + encodedText.getNumberOfBytesOccupied();
    }
}

HuffmanTree.java

package net.coderodde.compression.huffman;

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.PriorityQueue;
import java.util.Queue;
import java.util.Set;

/**
 * This class implements a Huffman tree for building a prefix code.
 * 
 * @author Rodion "rodde" Efremov
 * @version 1.61 (Nov 19, 2016)
 */
public final class HuffmanTree {

    private static final class HuffmanTreeNode 
            implements Comparable<HuffmanTreeNode> {

        byte character;
        float weight;
        boolean isLeaf;
        HuffmanTreeNode left;
        HuffmanTreeNode right;

        HuffmanTreeNode(byte character, float weight, boolean isLeaf) {
            checkWeight(weight);
            this.weight = weight;
            this.isLeaf = isLeaf;

            if (isLeaf) {
                this.character = character;
            }
        }

        @Override
        public int compareTo(HuffmanTreeNode o) {
            return Float.compare(weight, o.weight);
        }

        static HuffmanTreeNode merge(HuffmanTreeNode node1, 
                                     HuffmanTreeNode node2) {
            HuffmanTreeNode newNode = 
                    new HuffmanTreeNode((byte) 0,
                                        node1.weight + node2.weight,
                                        false);

            if (node1.weight < node2.weight) {
                newNode.left  = node1;
                newNode.right = node2;
            } else {
                newNode.left  = node2;
                newNode.right = node1;
            }

            return newNode;
        }

        private float checkWeight(float weight) {
            if (Double.isNaN(weight)) {
                throw new IllegalArgumentException("The input weight is NaN.");
            }

            if (weight <= 0.0f) {
                throw new IllegalArgumentException(
                "The input weight is not strictly positive: " + weight);
            }

            return weight;
        }
    }

    private HuffmanTreeNode root;

    /**
     * Constructs a Huffman tree from the character frequencies 
     * {@code weightMap}.
     * 
     * @param weightMap the map mapping each byte to its relative frequency.
     */
    public HuffmanTree(Map<Byte, Float> weightMap) {
        if (weightMap.isEmpty()) {
            throw new IllegalArgumentException(
                    "Compressor requires a non-empty text.");
        }

        Queue<HuffmanTreeNode> queue = new PriorityQueue<>();

        for (Map.Entry<Byte, Float> entry : weightMap.entrySet()) {
            queue.add(new HuffmanTreeNode(entry.getKey(),
                                          entry.getValue(),
                                          true));
        }

        while (queue.size() > 1) {
            HuffmanTreeNode node1 = queue.remove();
            HuffmanTreeNode node2 = queue.remove();
            queue.add(HuffmanTreeNode.merge(node1, node2));
        }

        root = queue.peek();
    }

    /**
     * Construct the encoder map from this tree.
     * 
     * @return the encoder map.
     */
    public Map<Byte, BitString> inferEncodingMap() {
        Map<Byte, BitString> map = new HashMap<>();

        if (root.isLeaf) {
            // Corner case. Only one byte value in the text.
            BitString bs = new BitString();
            bs.appendBit(false);
            map.put(root.character, bs);
            return map;
        }

        BitString bitStringBuilder = new BitString();
        inferEncodingMapImpl(bitStringBuilder, root, map);
        return map;
    }

    private void inferEncodingMapImpl(BitString currentCodeWord,
                                      HuffmanTreeNode currentTreeNode,
                                      Map<Byte, BitString> map) {
        if (currentTreeNode.isLeaf) {
            map.put(currentTreeNode.character, 
                    new BitString(currentCodeWord));
            return;
        }

        currentCodeWord.appendBit(false);
        inferEncodingMapImpl(currentCodeWord,
                             currentTreeNode.left,
                             map);
        currentCodeWord.removeLastBit();

        currentCodeWord.appendBit(true);
        inferEncodingMapImpl(currentCodeWord, 
                             currentTreeNode.right,
                             map);
        currentCodeWord.removeLastBit();
    }
}

InvalidFileFormatException.java

package net.coderodde.compression.huffman;

public class InvalidFileFormatException extends RuntimeException {

    public InvalidFileFormatException(String errorMessage) {
        super(errorMessage);
    }
}

App.java

package net.coderodde.app.huffman;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import net.coderodde.compression.huffman.BitString;
import net.coderodde.compression.huffman.ByteWeightComputer;
import net.coderodde.compression.huffman.HuffmanDecoder;
import net.coderodde.compression.huffman.HuffmanDeserializer;
import net.coderodde.compression.huffman.HuffmanEncoder;
import net.coderodde.compression.huffman.HuffmanSerializer;
import net.coderodde.compression.huffman.HuffmanTree;

public final class App {

    private static final String ENCODE_OPTION_SHORT = "-e";
    private static final String ENCODE_OPTION_LONG  = "--encode";
    private static final String DECODE_OPTION_SHORT = "-d";
    private static final String DECODE_OPTION_LONG  = "--decode";
    private static final String HELP_OPTION_SHORT = "-h";
    private static final String HELP_OPTION_LONG  = "--help";
    private static final String VERSION_OPTION_SHORT = "-v";
    private static final String VERSION_OPTION_LONG  = "--version";
    private static final String ENCODED_FILE_EXTENSION = "het";

    public static void main(String[] args) {
        Set<String> commandLineArgumentSet = getCommandLineOptions(args);

        if (commandLineArgumentSet.isEmpty() 
                || commandLineArgumentSet.contains(HELP_OPTION_LONG)
                || commandLineArgumentSet.contains(HELP_OPTION_SHORT)) {
            printHelpMessage();
            System.exit(0);
        }

        if (commandLineArgumentSet.contains(VERSION_OPTION_LONG)
                || commandLineArgumentSet.contains(VERSION_OPTION_SHORT)) {
            printVersion();
            System.exit(0);
        }

        boolean decode = commandLineArgumentSet.contains(DECODE_OPTION_LONG) ||
                         commandLineArgumentSet.contains(DECODE_OPTION_SHORT);

        boolean encode = commandLineArgumentSet.contains(ENCODE_OPTION_LONG) ||
                         commandLineArgumentSet.contains(ENCODE_OPTION_SHORT);

        if (!decode && !encode) {
            printHelpMessage();
            System.exit(0);
        }

        if (decode && encode) {
            printHelpMessage();
            System.exit(0);
        }

        commandLineArgumentSet.removeAll(Arrays.asList(ENCODE_OPTION_SHORT,
                                                       ENCODE_OPTION_LONG,
                                                       DECODE_OPTION_SHORT,
                                                       DECODE_OPTION_LONG));
        if (commandLineArgumentSet.isEmpty()) {
            System.err.println("Bad command line format.");
            System.exit(1);
        }

        File file = new File(commandLineArgumentSet.iterator().next());

        try {
            if (decode) {
                doDecode(args);
            } else if (encode) {
                doEncode(file);
            } 
        } catch (Exception ex) {
            System.err.println(ex.getMessage());
            System.exit(1);
        }
    }

    private static void doEncode(File file) throws FileNotFoundException {
        byte[] fileBytes = readBytes(file);

        Map<Byte, Float> weightMap =
                new ByteWeightComputer()
                        .computeCharacterWeights(fileBytes);

        Map<Byte, BitString> encodeMap = 
                new HuffmanTree(weightMap).inferEncodingMap();

        BitString encodedText = new HuffmanEncoder().encode(encodeMap,
                                                            fileBytes);

        byte[] data = new HuffmanSerializer().serialize(encodeMap,
                                                        encodedText);

        File outputFile = 
                new File(file.getName() + "." + ENCODED_FILE_EXTENSION);

        System.out.println(
            "Writing compressed text to \"" + outputFile.getName() + "\"...");

        writeBytes(data, outputFile);
    }

    private static void doDecode(String[] args) {
        String file1 = null;
        String file2 = null;

        try {
            int index = 0;

            for (int i = 0; i < args.length; ++i) {
                if (args[i].equals("DECODE_OPTION_SHORT") 
                        || args[i].equals("DECODE_OPTION_LONG")) {
                    index = i;
                    break;
                }
            }

            file1 = args[index + 1];
            file2 = args[index + 2];
        } catch (Exception ex) {
            System.err.println("Not enough tokens on command line.");
            System.exit(1);
        }

        byte[] inputData = readBytes(new File(file1));
        HuffmanDeserializer.Result result = 
                new HuffmanDeserializer().deserialize(inputData);
        byte[] originalData = new HuffmanDecoder()
                .decode(result.getEncodedText(),
                        result.getEncoderMap());

        writeBytes(originalData, new File(file2));
    }

    private static Set<String> getCommandLineOptions(String[] args) {
        Set<String> set = new HashSet<>();

        for (String arg : args) {
            set.add(arg);
        }

        return set;
    }

    private static void printHelpMessage() {
        String preamble = "usage: java -jar " + getThisJarName() + " ";
        int preambleLength = preamble.length();
        String indent = getIndent(preambleLength);

        StringBuilder sb = new StringBuilder();
        sb.append(preamble);

        sb.append("[")
          .append(HELP_OPTION_SHORT)
          .append(" | ")
          .append(HELP_OPTION_LONG)
          .append("]\n");

        sb.append(indent)
          .append("[")
          .append(VERSION_OPTION_SHORT)
          .append(" | ")
          .append(VERSION_OPTION_LONG)
          .append("]\n");

        sb.append(indent)
          .append("[")
          .append(ENCODE_OPTION_SHORT)
          .append(" | ")
          .append(ENCODE_OPTION_LONG)
          .append("] FILE\n");

        sb.append(indent)
          .append("[")
          .append(DECODE_OPTION_SHORT)
          .append(" | ")
          .append(DECODE_OPTION_LONG)
          .append("] FILE1 FILE2\n");

        sb.append("Where:\n");

        sb.append(HELP_OPTION_SHORT)
          .append(", ")
          .append(HELP_OPTION_LONG)
          .append("     Prints this message and exits.\n");

        sb.append(VERSION_OPTION_SHORT)
          .append(", ")
          .append(VERSION_OPTION_LONG)
          .append("  Prints the version info and exits.\n");

        sb.append(ENCODE_OPTION_SHORT)
          .append(", ")
          .append(ENCODE_OPTION_LONG)
          .append("   Encodes the text from standard input.\n");

        sb.append(DECODE_OPTION_SHORT)
          .append(", ")
          .append(DECODE_OPTION_LONG)
          .append("   Decodes the text from standard input.\n");

        System.out.println(sb.toString());
    }

    private static String getIndent(int preambleLength) {
        StringBuilder sb = new StringBuilder();

        for (int i = 0; i < preambleLength; ++i) {
            sb.append(' ');
        }

        return sb.toString();
    }

    private static void printVersion() {
        String msg = 
        "Huffman compressor tool, version 1.61 (Nov 19, 2016)\n" +
        "By Rodion \"rodde\" Efremov";

        System.out.println(msg);
    }

    private static String getThisJarName() {
        return new File(App.class.getProtectionDomain()
                        .getCodeSource()
                        .getLocation()
                        .getPath()).getName();
    }

    private static void writeBytes(byte[] data, File file) {
        try {
            FileOutputStream fos = new FileOutputStream(file);
            fos.write(data);
            fos.close();
        } catch (IOException ex) {
            throw new RuntimeException(
            "ERROR: File IO failed while writing encoded data.", ex);
        } 
    }

    private static byte[] readBytes(File file) {
        try {
            Path path = Paths.get(file.getAbsolutePath());
            return Files.readAllBytes(path);
        } catch (IOException ex) {
            throw new RuntimeException(
            "ERROR: File IO failed while reading a binary file.", ex);
        }
    }
}

Note 1 On War and Peace achieves compression ratio of about 43 percent.

Note 2 Project available at GitHub.