Compactação de dados Huffman

Entrada


Neste artigo, falarei sobre o conhecido algoritmo Huffman, bem como sua aplicação na compactação de dados.

Como resultado, escreveremos um arquivador simples. Já havia um artigo sobre Habré , mas sem implementação prática. O material teórico da publicação atual é retirado das aulas de ciências da computação da escola e do livro de Robert Lafore "Estruturas de dados e algoritmos em Java". Então, está tudo em ordem!

Um pouco de reflexão


Em um arquivo de texto sem formatação, um caractere é codificado com 8 bits (codificação ASCII) ou 16 (codificação Unicode). Além disso, consideraremos a codificação ASCII. Por exemplo, pegue a linha s1 = "SUSIE DIZ QUE É FÁCIL \ n". No total, é claro que existem 22 caracteres na linha, incluindo espaços e o caractere de nova linha - '\ n'. Um arquivo contendo esta linha pesa 22 * ​​8 = 176 bits. Surge imediatamente a pergunta: é racional usar todos os 8 bits para codificar um caractere? Nós não usamos todos os caracteres ASCII. Mesmo se usado, seria mais racional para a letra mais frequente - S - fornecer o menor código possível e para a letra mais rara - T (ou U, ou '\ n') - fornecer um código mais autêntico. Este é o algoritmo de Huffman: você precisa encontrar a melhor opção de codificação, na qual o arquivo terá peso mínimo. É bastante normal que os comprimentos do código sejam diferentes para caracteres diferentes - essa é a base do algoritmo.

Codificação


Por que não atribuir ao caractere 'S' um código, por exemplo, com 1 bit de comprimento: 0 ou 1. Seja 1. Então, forneceremos o segundo caractere mais encontrado - '' (espaço) - 0. Imagine que você começou a decodificar sua mensagem - sequência codificada s1 - e você vê que o código começa com 1. Então, o que fazer: é um caractere S ou algum outro caractere, por exemplo A? Portanto, surge uma regra importante:

Nenhum código deve ser o prefixo de outro

Esta regra é a chave no algoritmo. Portanto, a criação do código começa com a tabela de frequências, que indica a frequência (número de ocorrências) de cada caractere:

Os caracteres com mais ocorrências devem ser codificados com o menor número possível de bits. Vou dar um exemplo de uma das tabelas de código possíveis:

Assim, a mensagem codificada ficará assim:

10 01111 10 110 1111 00 10 010 1110 10 00 110 0110 00 110 10 00 1111 010 10 1110 01110 

Separei o código de cada caractere com um espaço. Isso realmente não acontecerá em um arquivo compactado!
Surge a pergunta: como essa sálaga surgiu com um código ? Como criar uma tabela de códigos? Isso será discutido abaixo.

Construindo uma árvore huffman


Aqui, as árvores de busca binária vêm em socorro. Não se preocupe, aqui os métodos de busca, inserção e exclusão não são necessários. Aqui está a estrutura da árvore em java:

 public class Node { private int frequence; private char letter; private Node leftChild; private Node rightChild; ... } 

 class BinaryTree { private Node root; public BinaryTree() { root = new Node(); } public BinaryTree(Node root) { this.root = root; } ... } 

Este não é um código completo, o código completo estará abaixo.

Aqui está o próprio algoritmo de construção de árvore:

  1. Crie um objeto Nó para cada caractere da mensagem (linha s1). No nosso caso, haverá 9 nós (objetos Node). Cada nó consiste em dois campos de dados: símbolo e frequência
  2. Crie um objeto de árvore (BinaryTree) para cada um dos nós do nó. O nó se torna a raiz da árvore.
  3. Cole essas árvores na fila de prioridade. Quanto menor a frequência, maior a prioridade. Assim, ao extrair, o dervo é sempre selecionado com a menor frequência.

Em seguida, você precisa fazer o seguinte ciclicamente:

  1. Extraia duas árvores da fila de prioridade e torne-as descendentes do novo nó (o nó recém-criado sem uma letra). A frequência do novo nó é igual à soma das frequências de duas árvores descendentes.
  2. Para este nó, crie uma árvore com uma raiz nesse nó. Cole esta árvore de volta na fila de prioridade. (Como a árvore tem uma nova frequência, provavelmente chegará a um novo local na fila)
  3. Continue as etapas 1 e 2 até restar apenas uma árvore na fila - a árvore Huffman

Considere este algoritmo na linha s1:



Aqui, o símbolo "lf" (avanço de linha) indica a transição para uma nova linha, "sp" (espaço) é um espaço.

O que vem a seguir?


Temos uma árvore Huffman. Bem, tudo bem. E o que fazer com isso? Eles não o levam de graça e, então, você precisa acompanhar todos os caminhos possíveis, desde a raiz até as folhas da árvore. Vamos concordar em designar a aresta 0 se ela levar à descendente esquerda e 1 se à direita. A rigor, nessas anotações, o código do símbolo é o caminho da raiz da árvore até a planilha que contém esse mesmo símbolo.



Assim, a tabela de códigos acabou. Observe que, se considerarmos esta tabela, podemos concluir sobre o "peso" de cada caractere - este é o comprimento do seu código. O arquivo compactado pesará: 2 * 3 + 2 * 4 + 3 * 3 + 6 * 2 + 1 * 4 + 1 * 5 + 2 * 4 + 4 * 2 + 1 * 5 = 65 bits. Inicialmente, pesava 176 bits. Portanto, reduzimos em 176/65 = 2,7 vezes! Mas isso é utopia. É improvável que esse coeficiente seja obtido. Porque Isso será discutido um pouco mais tarde.

Decodificação


Bem, talvez a coisa mais simples que resta é a decodificação. Acho que muitos de vocês imaginaram que é impossível simplesmente criar um arquivo compactado sem nenhuma dica de como ele foi codificado - não poderemos decodificá-lo! Sim, foi difícil para mim perceber isso, mas tive que criar um arquivo de texto table.txt com uma tabela de compactação:

 01110 00 A010 E1111 I110 S10 T0110 U01111 Y1110 

Registre a tabela na forma de 'caractere' "código de caractere". Por que 01110 é sem caractere? De fato, é com um símbolo, apenas as ferramentas java usadas por mim ao gerar um arquivo, o caractere de nova linha - '\ n' - é convertido em uma nova linha (por mais estúpido que pareça). Portanto, a linha vazia na parte superior é o caractere para o código 01110. Para o código 00, o caractere é um espaço no início da linha. Devo dizer imediatamente que nosso método de armazenar esta tabela pode reivindicar o mais irracional. Mas é simples de entender e implementar. Terei o prazer de ouvir suas recomendações nos comentários sobre otimização.

Ter essa tabela é muito fácil de decodificar. Lembre-se de qual regra fomos guiados ao criar a codificação:

Nenhum código deve prefixar outro

É aqui que ele desempenha um papel facilitador. Lemos sequencialmente bit a bit e, assim que a sequência recebida d, composta por bits de leitura, corresponde à codificação correspondente ao caractere, sabemos imediatamente que o caractere foi codificado (e somente ele!). Em seguida, escrevemos caracteres na linha de decodificação (a linha que contém a mensagem decodificada), zeramos a linha d e lemos o arquivo codificado.

Implementação


É hora de humilhar meu código escrevendo um arquivador. Vamos chamá-lo de compressor.

Vamos começar do começo. Primeiro, escrevemos a classe Node:

 public class Node { private int frequence;// private char letter;// private Node leftChild;//  private Node rightChild;//  public Node(char letter, int frequence) { //,  this.letter = letter; this.frequence = frequence; } public Node() {}//    (.       ) public void addChild(Node newNode) {//  if (leftChild == null)//  =>  =>    leftChild = newNode; else { if (leftChild.getFrequence() <= newNode.getFrequence()) // ,   rightChild = newNode;// ,     else { rightChild = leftChild; leftChild = newNode; } } frequence += newNode.getFrequence();//  } public Node getLeftChild() { return leftChild; } public Node getRightChild() { return rightChild; } public int getFrequence() { return frequence; } public char getLetter() { return letter; } public boolean isLeaf() {//   return leftChild == null && rightChild == null; } } 

Agora a árvore:

 class BinaryTree { private Node root; public BinaryTree() { root = new Node(); } public BinaryTree(Node root) { this.root = root; } public int getFrequence() { return root.getFrequence(); } public Node getRoot() { return root; } } 

Fila prioritária:

 import java.util.ArrayList;//-,      class PriorityQueue { private ArrayList<BinaryTree> data;//  private int nElems;//-    public PriorityQueue() { data = new ArrayList<BinaryTree>(); nElems = 0; } public void insert(BinaryTree newTree) {// if (nElems == 0) data.add(newTree); else { for (int i = 0; i < nElems; i++) { if (data.get(i).getFrequence() > newTree.getFrequence()) {//     data.add(i, newTree);// . ,  c       1  break;//       } if (i == nElems - 1) data.add(newTree); } } nElems++;// -   1 } public BinaryTree remove() {//   BinaryTree tmp = data.get(0);//   data.remove(0);//,  nElems--;// -   1 return tmp;//  (   ) } } 

A classe que cria a árvore Huffman:

 public class HuffmanTree { private final byte ENCODING_TABLE_SIZE = 127;//   private String myString;// private BinaryTree huffmanTree;//  private int[] freqArray;//  private String[] encodingArray;//  //----------------constructor---------------------- public HuffmanTree(String newString) { myString = newString; freqArray = new int[ENCODING_TABLE_SIZE]; fillFrequenceArray(); huffmanTree = getHuffmanTree(); encodingArray = new String[ENCODING_TABLE_SIZE]; fillEncodingArray(huffmanTree.getRoot(), "", ""); } //--------------------frequence array------------------------ private void fillFrequenceArray() { for (int i = 0; i < myString.length(); i++) { freqArray[(int)myString.charAt(i)]++; } } public int[] getFrequenceArray() { return freqArray; } //------------------------huffman tree creation------------------ private BinaryTree getHuffmanTree() { PriorityQueue pq = new PriorityQueue(); //   for (int i = 0; i < ENCODING_TABLE_SIZE; i++) { if (freqArray[i] != 0) {//     Node newNode = new Node((char) i, freqArray[i]);//    Node BinaryTree newTree = new BinaryTree(newNode);//  Node  BinaryTree pq.insert(newTree);//   } } while (true) { BinaryTree tree1 = pq.remove();//    . try { BinaryTree tree2 = pq.remove();//     Node newNode = new Node();//  Node newNode.addChild(tree1.getRoot());//      newNode.addChild(tree2.getRoot()); pq.insert(new BinaryTree(newNode); } catch (IndexOutOfBoundsException e) {//     return tree1; } } } public BinaryTree getTree() { return huffmanTree; } //-------------------encoding array------------------ void fillEncodingArray(Node node, String codeBefore, String direction) {//   if (node.isLeaf()) { encodingArray[(int)node.getLetter()] = codeBefore + direction; } else { fillEncodingArray(node.getLeftChild(), codeBefore + direction, "0"); fillEncodingArray(node.getRightChild(), codeBefore + direction, "1"); } } String[] getEncodingArray() { return encodingArray; } public void displayEncodingArray() {//  fillEncodingArray(huffmanTree.getRoot(), "", ""); System.out.println("======================Encoding table===================="); for (int i = 0; i < ENCODING_TABLE_SIZE; i++) { if (freqArray[i] != 0) { System.out.print((char)i + " "); System.out.println(encodingArray[i]); } } System.out.println("========================================================"); } //----------------------------------------------------- String getOriginalString() { return myString; } } 

Classe que contém quais codificações / decodificações:

 public class HuffmanOperator { private final byte ENCODING_TABLE_SIZE = 127;//  private HuffmanTree mainHuffmanTree;//  (   ) private String myString;//  private int[] freqArray;//  private String[] encodingArray;//  private double ratio;//  public HuffmanOperator(HuffmanTree MainHuffmanTree) {//for compress this.mainHuffmanTree = MainHuffmanTree; myString = mainHuffmanTree.getOriginalString(); encodingArray = mainHuffmanTree.getEncodingArray(); freqArray = mainHuffmanTree.getFrequenceArray(); } public HuffmanOperator() {}//for extract; //---------------------------------------compression----------------------------------------------------------- private String getCompressedString() { String compressed = ""; String intermidiate = "";// (  ) //System.out.println("=============================Compression======================="); //displayEncodingArray(); for (int i = 0; i < myString.length(); i++) { intermidiate += encodingArray[myString.charAt(i)]; } //      .       8=> //    ( 1,  ) byte counter = 0;//     (   : 0<=counter<8<127) for (int length = intermidiate.length(), delta = 8 - length % 8; counter < delta ; counter++) {//delta -    intermidiate += "0"; } // -         compressed = String.format("%8s", Integer.toBinaryString(counter & 0xff)).replace(" ", "0") + intermidiate; //  setCompressionRatio(); //System.out.println("==============================================================="); return compressed; } private void setCompressionRatio() {//   double sumA = 0, sumB = 0;//A-the original sum for (int i = 0; i < ENCODING_TABLE_SIZE; i++) { if (freqArray[i] != 0) { sumA += 8 * freqArray[i]; sumB += encodingArray[i].length() * freqArray[i]; } } ratio = sumA / sumB; } public byte[] getBytedMsg() {//final compression StringBuilder compressedString = new StringBuilder(getCompressedString()); byte[] compressedBytes = new byte[compressedString.length() / 8]; for (int i = 0; i < compressedBytes.length; i++) { compressedBytes[i] = (byte) Integer.parseInt(compressedString.substring(i * 8, (i + 1) * 8), 2); } return compressedBytes; } //---------------------------------------end of compression---------------------------------------------------------------- //------------------------------------------------------------extract----------------------------------------------------- public String extract(String compressed, String[] newEncodingArray) { String decompressed = ""; String current = ""; String delta = ""; encodingArray = newEncodingArray; //displayEncodingArray(); // -   for (int i = 0; i < 8; i++) delta += compressed.charAt(i); int ADDED_ZEROES = Integer.parseInt(delta, 2); for (int i = 8, l = compressed.length() - ADDED_ZEROES; i < l; i++) { //i = 8, ..      -   current += compressed.charAt(i); for (int j = 0; j < ENCODING_TABLE_SIZE; j++) { if (current.equals(encodingArray[j])) {//  decompressed += (char)j;//   current = "";//    } } } return decompressed; } public String getEncodingTable() { String enc = ""; for (int i = 0; i < encodingArray.length; i++) { if (freqArray[i] != 0) enc += (char)i + encodingArray[i] + '\n'; } return enc; } public double getCompressionRatio() { return ratio; } public void displayEncodingArray() {//  System.out.println("======================Encoding table===================="); for (int i = 0; i < ENCODING_TABLE_SIZE; i++) { //if (freqArray[i] != 0) { System.out.print((char)i + " "); System.out.println(encodingArray[i]); //} } System.out.println("========================================================"); } } 

Classe que facilita a gravação em um arquivo:

 import java.io.File; import java.io.PrintWriter; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.Closeable; public class FileOutputHelper implements Closeable { private File outputFile; private FileOutputStream fileOutputStream; public FileOutputHelper(File file) throws FileNotFoundException { outputFile = file; fileOutputStream = new FileOutputStream(outputFile); } public void writeByte(byte msg) throws IOException { fileOutputStream.write(msg); } public void writeBytes(byte[] msg) throws IOException { fileOutputStream.write(msg); } public void writeString(String msg) { try (PrintWriter pw = new PrintWriter(outputFile)) { pw.write(msg); } catch (FileNotFoundException e) { System.out.println(" ,     !"); } } @Override public void close() throws IOException { fileOutputStream.close(); } public void finalize() throws IOException { close(); } } 

Classe que facilita a leitura de um arquivo:

 import java.io.FileInputStream; import java.io.EOFException; import java.io.BufferedReader; import java.io.InputStreamReader; import java.io.Closeable; import java.io.File; import java.io.IOException; public class FileInputHelper implements Closeable { private FileInputStream fileInputStream; private BufferedReader fileBufferedReader; public FileInputHelper(File file) throws IOException { fileInputStream = new FileInputStream(file); fileBufferedReader = new BufferedReader(new InputStreamReader(fileInputStream)); } public byte readByte() throws IOException { int cur = fileInputStream.read(); if (cur == -1)//   throw new EOFException(); return (byte)cur; } public String readLine() throws IOException { return fileBufferedReader.readLine(); } @Override public void close() throws IOException{ fileInputStream.close(); } } 

Bem, e a classe principal:

 import java.io.File; import java.nio.charset.MalformedInputException; import java.io.FileNotFoundException; import java.io.IOException; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Paths; import java.util.List; import java.io.EOFException; public class Main { private static final byte ENCODING_TABLE_SIZE = 127; public static void main(String[] args) throws IOException { try {//       if (args[0].equals("--compress") || args[0].equals("-c")) compress(args[1]); else if ((args[0].equals("--extract") || args[0].equals("-x")) && (args[2].equals("--table") || args[2].equals("-t"))) { extract(args[1], args[3]); } else throw new IllegalArgumentException(); } catch (ArrayIndexOutOfBoundsException | IllegalArgumentException e) { System.out.println("    "); System.out.println(" Readme.txt"); e.printStackTrace(); } } public static void compress(String stringPath) throws IOException { List<String> stringList; File inputFile = new File(stringPath); String s = ""; File compressedFile, table; try { stringList = Files.readAllLines(Paths.get(inputFile.getAbsolutePath())); } catch (NoSuchFileException e) { System.out.println(" ,     !"); return; } catch (MalformedInputException e) { System.out.println("    "); return; } for (String item : stringList) { s += item; s += '\n'; } HuffmanOperator operator = new HuffmanOperator(new HuffmanTree(s)); compressedFile = new File(inputFile.getAbsolutePath() + ".cpr"); compressedFile.createNewFile(); try (FileOutputHelper fo = new FileOutputHelper(compressedFile)) { fo.writeBytes(operator.getBytedMsg()); } //create file with encoding table: table = new File(inputFile.getAbsolutePath() + ".table.txt"); table.createNewFile(); try (FileOutputHelper fo = new FileOutputHelper(table)) { fo.writeString(operator.getEncodingTable()); } System.out.println("   : " + compressedFile.getAbsolutePath()); System.out.println("    " + table.getAbsolutePath()); System.out.println("     !"); double idealRatio = Math.round(operator.getCompressionRatio() * 100) / (double) 100;//  double realRatio = Math.round((double) inputFile.length() / ((double) compressedFile.length() + (double) table.length()) * 100) / (double)100;//  System.out.println("    " + idealRatio); System.out.println("      " + realRatio); } public static void extract(String filePath, String tablePath) throws FileNotFoundException, IOException { HuffmanOperator operator = new HuffmanOperator(); File compressedFile = new File(filePath), tableFile = new File(tablePath), extractedFile = new File(filePath + ".xtr"); String compressed = ""; String[] encodingArray = new String[ENCODING_TABLE_SIZE]; //read compressed file //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!check here: try (FileInputHelper fi = new FileInputHelper(compressedFile)) { byte b; while (true) { b = fi.readByte();//method returns EOFException compressed += String.format("%8s", Integer.toBinaryString(b & 0xff)).replace(" ", "0"); } } catch (EOFException e) { } //-------------------- //read encoding table: try (FileInputHelper fi = new FileInputHelper(tableFile)) { fi.readLine();//skip first empty string encodingArray[(byte)'\n'] = fi.readLine();//read code for '\n' while (true) { String s = fi.readLine(); if (s == null) throw new EOFException(); encodingArray[(byte)s.charAt(0)] = s.substring(1, s.length()); } } catch (EOFException ignore) {} extractedFile.createNewFile(); //extract: try (FileOutputHelper fo = new FileOutputHelper(extractedFile)) { fo.writeString(operator.extract(compressed, encodingArray)); } System.out.println("    " + extractedFile.getAbsolutePath()); } } 

O arquivo de instruções readme.txt depende de você escrever :-)

Conclusão


Provavelmente é tudo o que eu queria dizer. Se você tem algo a dizer sobre a minha incompetência de melhorias no código, algoritmo e qualquer otimização em geral, fique à vontade para escrever. Se eu entendi algo errado, também escreva. Ficarei feliz em ouvi-lo nos comentários!

PS


Sim, sim, ainda estou aqui, porque não esqueci o coeficiente. Para a string s1, a tabela de codificação pesa 48 bytes - muito mais que o arquivo original e eles não esqueceram de zeros adicionais (o número de zeros adicionados é 7) => a taxa de compactação será menor que um: 176 / (65 + 48 * 8 + 7) = 0,38. Se você também notou isso, então não apenas na cara, você está bem feito. Sim, essa implementação será extremamente ineficiente para arquivos pequenos. Mas o que acontece com arquivos grandes? Os tamanhos dos arquivos excedem em muito o tamanho da tabela de codificação. Aqui o algoritmo funciona como deveria! Por exemplo, para o monólogo Faust, o arquivador fornece um coeficiente real (não idealizado) igual a 1,46 - quase uma vez e meia! E sim, o arquivo deveria estar em inglês.

Source: https://habr.com/ru/post/pt438512/


All Articles