Analisador de Frequência em Zig — Tutorial Passo a Passo
Neste tutorial, vamos construir um analisador de frequência de caracteres que lê texto e produz estatísticas detalhadas sobre a distribuição de letras, dígitos e símbolos. Este projeto é usado em criptoanálise, linguística computacional e compressão de dados.
O Que Vamos Construir
Nosso analisador vai:
- Contar a frequência de cada caractere em um texto
- Gerar histogramas visuais no terminal
- Calcular entropia de Shannon (medida de aleatoriedade)
- Comparar distribuições com idiomas conhecidos
- Identificar possíveis idiomas com base na frequência de letras
- Processar texto de stdin ou entrada interativa
Por Que Este Projeto?
A análise de frequência é fundamental em criptografia (como vimos na Cifra de César), compressão de dados e processamento de linguagem natural. Em Zig, podemos implementar isso de forma eficiente usando arrays de tamanho fixo para contagem — sem hash maps ou alocação dinâmica.
Pré-requisitos
- Zig 0.13+ instalado (guia de instalação)
- Familiaridade com arrays e loops em Zig (fundamentos)
Passo 1: Estrutura do Projeto
mkdir analisador-frequencia
cd analisador-frequencia
zig init
Passo 2: Estrutura de Dados para Frequência
const std = @import("std");
const io = std.io;
const fmt = std.fmt;
const mem = std.mem;
const math = std.math;
/// Tabela de frequência para todos os 256 valores de byte possíveis.
/// Usamos um array de 256 posições indexado pelo valor do byte.
/// Isso é mais eficiente que um HashMap para este caso —
/// O(1) para inserção e consulta, sem colisões, sem alocação.
const TabelaFrequencia = struct {
contagem: [256]u64,
total: u64,
// Contadores por categoria
letras: u64,
digitos: u64,
espacos: u64,
pontuacao: u64,
controle: u64,
outros: u64,
const Self = @This();
pub fn init() Self {
return .{
.contagem = [_]u64{0} ** 256,
.total = 0,
.letras = 0,
.digitos = 0,
.espacos = 0,
.pontuacao = 0,
.controle = 0,
.outros = 0,
};
}
/// Processa um único byte, atualizando contadores.
pub fn processar(self: *Self, byte: u8) void {
self.contagem[byte] += 1;
self.total += 1;
// Categorizar o byte
if ((byte >= 'a' and byte <= 'z') or (byte >= 'A' and byte <= 'Z')) {
self.letras += 1;
} else if (byte >= '0' and byte <= '9') {
self.digitos += 1;
} else if (byte == ' ' or byte == '\t' or byte == '\n' or byte == '\r') {
self.espacos += 1;
} else if (byte >= 33 and byte <= 47 or byte >= 58 and byte <= 64 or
byte >= 91 and byte <= 96 or byte >= 123 and byte <= 126)
{
self.pontuacao += 1;
} else if (byte < 32 or byte == 127) {
self.controle += 1;
} else {
self.outros += 1;
}
}
/// Processa um slice de bytes.
pub fn processarTexto(self: *Self, texto: []const u8) void {
for (texto) |byte| {
self.processar(byte);
}
}
/// Retorna a frequência relativa (0.0-1.0) de um byte.
pub fn frequenciaRelativa(self: *const Self, byte: u8) f64 {
if (self.total == 0) return 0.0;
return @as(f64, @floatFromInt(self.contagem[byte])) / @as(f64, @floatFromInt(self.total));
}
/// Retorna a frequência percentual de um byte.
pub fn frequenciaPercentual(self: *const Self, byte: u8) f64 {
return self.frequenciaRelativa(byte) * 100.0;
}
/// Retorna a frequência percentual considerando apenas letras.
pub fn frequenciaLetra(self: *const Self, byte: u8) f64 {
if (self.letras == 0) return 0.0;
// Agrupa maiúsculas e minúsculas
var total_letra: u64 = self.contagem[byte];
if (byte >= 'a' and byte <= 'z') {
total_letra += self.contagem[byte - 32]; // maiúscula
} else if (byte >= 'A' and byte <= 'Z') {
total_letra += self.contagem[byte + 32]; // minúscula
}
return @as(f64, @floatFromInt(total_letra)) / @as(f64, @floatFromInt(self.letras)) * 100.0;
}
};
Passo 3: Cálculo de Entropia
/// Calcula a entropia de Shannon do texto.
/// A entropia mede a "imprevisibilidade" do texto.
/// - Texto com todos os caracteres iguais: entropia = 0 (totalmente previsível)
/// - Texto com distribuição uniforme: entropia máxima
/// - Texto em linguagem natural: entropia intermediária (~4 bits para português)
///
/// Fórmula: H = -sum(p(x) * log2(p(x))) para cada símbolo x
fn calcularEntropia(tabela: *const TabelaFrequencia) f64 {
if (tabela.total == 0) return 0.0;
var entropia: f64 = 0.0;
const total_f: f64 = @floatFromInt(tabela.total);
for (tabela.contagem) |cnt| {
if (cnt > 0) {
const p: f64 = @as(f64, @floatFromInt(cnt)) / total_f;
entropia -= p * @log2(p);
}
}
return entropia;
}
/// Calcula o Índice de Coincidência (IC).
/// O IC é usado em criptoanálise para determinar se um texto
/// é cifrado por monoalfabético ou polialfabético.
/// Português: IC ~ 0.072-0.074
/// Texto aleatório: IC ~ 0.038
fn calcularIndiceCoincidencia(tabela: *const TabelaFrequencia) f64 {
if (tabela.letras <= 1) return 0.0;
var soma: f64 = 0.0;
const n_f: f64 = @floatFromInt(tabela.letras);
// Considera apenas letras a-z (agrupando maiúsculas/minúsculas)
var i: usize = 0;
while (i < 26) : (i += 1) {
const lower: u64 = tabela.contagem['a' + i];
const upper: u64 = tabela.contagem['A' + i];
const total = lower + upper;
const fi: f64 = @floatFromInt(total);
soma += fi * (fi - 1.0);
}
return soma / (n_f * (n_f - 1.0));
}
Passo 4: Referências de Idiomas
/// Frequência de letras em diferentes idiomas (percentual).
const FrequenciaIdioma = struct {
nome: []const u8,
frequencias: [26]f64,
ic_esperado: f64, // Índice de coincidência esperado
};
const idiomas = [_]FrequenciaIdioma{
.{
.nome = "Portugues",
.frequencias = .{
14.63, 1.04, 3.88, 4.99, 12.57, 1.02, 1.30, 1.28, 6.18, 0.40,
0.02, 2.78, 4.74, 5.05, 10.73, 2.52, 1.20, 6.53, 7.81, 4.34,
4.63, 1.67, 0.01, 0.21, 0.01, 0.47,
},
.ic_esperado = 0.074,
},
.{
.nome = "Ingles",
.frequencias = .{
8.17, 1.49, 2.78, 4.25, 12.70, 2.23, 2.02, 6.09, 6.97, 0.15,
0.77, 4.03, 2.41, 6.75, 7.51, 1.93, 0.10, 5.99, 6.33, 9.06,
2.76, 0.98, 2.36, 0.15, 1.97, 0.07,
},
.ic_esperado = 0.067,
},
.{
.nome = "Espanhol",
.frequencias = .{
12.53, 1.42, 4.68, 5.86, 13.68, 0.69, 1.01, 0.70, 6.25, 0.44,
0.01, 4.97, 3.15, 6.71, 8.68, 2.51, 0.88, 6.87, 7.98, 4.63,
3.93, 0.90, 0.02, 0.22, 0.90, 0.52,
},
.ic_esperado = 0.077,
},
};
/// Identifica o idioma mais provável com base na distribuição de frequências.
fn identificarIdioma(tabela: *const TabelaFrequencia) struct { nome: []const u8, score: f64 } {
var melhor_nome: []const u8 = "Desconhecido";
var melhor_score: f64 = math.floatMax(f64);
for (idiomas) |idioma| {
var chi2: f64 = 0.0;
var i: usize = 0;
while (i < 26) : (i += 1) {
const obs = tabela.frequenciaLetra('a' + @as(u8, @intCast(i)));
const esp = idioma.frequencias[i];
if (esp > 0.0) {
const diff = obs - esp;
chi2 += (diff * diff) / esp;
}
}
if (chi2 < melhor_score) {
melhor_score = chi2;
melhor_nome = idioma.nome;
}
}
return .{ .nome = melhor_nome, .score = melhor_score };
}
Passo 5: Visualização
/// Exibe um histograma horizontal das letras mais frequentes.
fn exibirHistograma(tabela: *const TabelaFrequencia, writer: anytype) !void {
try writer.print("\n --- Histograma de Letras ---\n\n", .{});
// Encontra a frequência máxima para escalar as barras
var max_freq: f64 = 0.0;
var i: usize = 0;
while (i < 26) : (i += 1) {
const freq = tabela.frequenciaLetra('a' + @as(u8, @intCast(i)));
if (freq > max_freq) max_freq = freq;
}
if (max_freq == 0.0) {
try writer.print(" (nenhuma letra encontrada)\n", .{});
return;
}
const largura_max: f64 = 40.0;
i = 0;
while (i < 26) : (i += 1) {
const letra: u8 = 'a' + @as(u8, @intCast(i));
const freq = tabela.frequenciaLetra(letra);
try writer.print(" {c} {d:>5.1}% ", .{ letra, freq });
const barras: usize = @intFromFloat(freq / max_freq * largura_max);
var b: usize = 0;
while (b < barras) : (b += 1) {
try writer.print("#", .{});
}
try writer.print("\n", .{});
}
}
/// Exibe o resumo estatístico completo.
fn exibirResumo(tabela: *const TabelaFrequencia, writer: anytype) !void {
const entropia = calcularEntropia(tabela);
const ic = calcularIndiceCoincidencia(tabela);
const idioma = identificarIdioma(tabela);
try writer.print(
\\
\\ === Resumo Estatistico ===
\\
\\ Total de bytes: {d}
\\ Letras: {d} ({d:.1}%)
\\ Digitos: {d} ({d:.1}%)
\\ Espacos: {d} ({d:.1}%)
\\ Pontuacao: {d} ({d:.1}%)
\\ Caracteres ctrl: {d}
\\ Outros: {d}
\\
\\ Entropia de Shannon: {d:.3} bits/byte
\\ Indice de Coincidencia: {d:.4}
\\ Idioma provavel: {s} (score: {d:.2})
\\
, .{
tabela.total,
tabela.letras, if (tabela.total > 0) @as(f64, @floatFromInt(tabela.letras)) / @as(f64, @floatFromInt(tabela.total)) * 100.0 else 0.0,
tabela.digitos, if (tabela.total > 0) @as(f64, @floatFromInt(tabela.digitos)) / @as(f64, @floatFromInt(tabela.total)) * 100.0 else 0.0,
tabela.espacos, if (tabela.total > 0) @as(f64, @floatFromInt(tabela.espacos)) / @as(f64, @floatFromInt(tabela.total)) * 100.0 else 0.0,
tabela.pontuacao, if (tabela.total > 0) @as(f64, @floatFromInt(tabela.pontuacao)) / @as(f64, @floatFromInt(tabela.total)) * 100.0 else 0.0,
tabela.controle,
tabela.outros,
entropia,
ic,
idioma.nome, idioma.score,
});
}
/// Exibe os N caracteres mais frequentes.
fn exibirTopCaracteres(tabela: *const TabelaFrequencia, n: usize, writer: anytype) !void {
// Ordenar índices por frequência (selection sort simples)
var indices: [256]u8 = undefined;
for (&indices, 0..) |*idx, i| {
idx.* = @intCast(i);
}
// Selection sort descendente por contagem
var i: usize = 0;
while (i < @min(n, 256)) : (i += 1) {
var max_idx = i;
var j = i + 1;
while (j < 256) : (j += 1) {
if (tabela.contagem[indices[j]] > tabela.contagem[indices[max_idx]]) {
max_idx = j;
}
}
const tmp = indices[i];
indices[i] = indices[max_idx];
indices[max_idx] = tmp;
}
try writer.print("\n --- Top {d} Caracteres ---\n\n", .{n});
i = 0;
while (i < n and i < 256) : (i += 1) {
const byte = indices[i];
const cnt = tabela.contagem[byte];
if (cnt == 0) break;
// Representação legível do caractere
const repr = if (byte >= 33 and byte <= 126) blk: {
var buf: [1]u8 = .{byte};
break :blk buf;
} else blk: {
break :blk [1]u8{'.'};
};
const nome: []const u8 = switch (byte) {
' ' => "ESPACO",
'\n' => "NEWLINE",
'\t' => "TAB",
'\r' => "CR",
else => &[_]u8{repr[0]},
};
try writer.print(" {d:>3} (0x{X:0>2}) {s:<8} {d:>8} ({d:.2}%)\n", .{
byte, byte, nome, cnt, tabela.frequenciaPercentual(byte),
});
}
}
Passo 6: Loop Principal
pub fn main() !void {
const stdout = io.getStdOut().writer();
const stdin = io.getStdIn().reader();
try stdout.print(
\\
\\ ==========================================
\\ ANALISADOR DE FREQUENCIA - Zig
\\ ==========================================
\\
, .{});
var buf: [4096]u8 = undefined;
while (true) {
try stdout.print(
\\
\\ [1] Analisar texto digitado
\\ [2] Comparar com idiomas
\\ [3] Sair
\\
\\ Opcao:
, .{});
const opcao_raw = stdin.readUntilDelimiterOrEof(&buf, '\n') catch continue orelse break;
const opcao = mem.trim(u8, opcao_raw, " \t\r\n");
if (mem.eql(u8, opcao, "3")) break;
if (mem.eql(u8, opcao, "1") or mem.eql(u8, opcao, "2")) {
try stdout.print("\n Digite o texto (ENTER para finalizar):\n ", .{});
const texto_raw = stdin.readUntilDelimiterOrEof(&buf, '\n') catch continue orelse continue;
const texto = mem.trim(u8, texto_raw, "\r\n");
if (texto.len == 0) {
try stdout.print(" Texto vazio.\n", .{});
continue;
}
var tabela = TabelaFrequencia.init();
tabela.processarTexto(texto);
try exibirResumo(&tabela, stdout);
try exibirHistograma(&tabela, stdout);
try exibirTopCaracteres(&tabela, 15, stdout);
if (mem.eql(u8, opcao, "2")) {
try stdout.print("\n --- Comparacao com Idiomas ---\n", .{});
for (idiomas) |idioma| {
var chi2: f64 = 0.0;
var i: usize = 0;
while (i < 26) : (i += 1) {
const obs = tabela.frequenciaLetra('a' + @as(u8, @intCast(i)));
const esp = idioma.frequencias[i];
if (esp > 0.0) {
const diff = obs - esp;
chi2 += (diff * diff) / esp;
}
}
try stdout.print(" {s:<12} chi2 = {d:.2}\n", .{ idioma.nome, chi2 });
}
try stdout.print(" (menor chi2 = melhor correspondencia)\n", .{});
}
} else {
try stdout.print(" Opcao invalida.\n", .{});
}
}
try stdout.print("\n Ate logo!\n", .{});
}
Passo 7: Testes
test "tabela frequencia - contagem basica" {
var tabela = TabelaFrequencia.init();
tabela.processarTexto("aab");
try std.testing.expectEqual(@as(u64, 2), tabela.contagem['a']);
try std.testing.expectEqual(@as(u64, 1), tabela.contagem['b']);
try std.testing.expectEqual(@as(u64, 3), tabela.total);
try std.testing.expectEqual(@as(u64, 3), tabela.letras);
}
test "tabela frequencia - categorias" {
var tabela = TabelaFrequencia.init();
tabela.processarTexto("abc 123 !?");
try std.testing.expectEqual(@as(u64, 3), tabela.letras);
try std.testing.expectEqual(@as(u64, 3), tabela.digitos);
try std.testing.expectEqual(@as(u64, 2), tabela.espacos);
try std.testing.expectEqual(@as(u64, 2), tabela.pontuacao);
}
test "entropia - texto uniforme" {
var tabela = TabelaFrequencia.init();
tabela.processarTexto("aaaa");
const entropia = calcularEntropia(&tabela);
try std.testing.expectApproxEqAbs(@as(f64, 0.0), entropia, 0.001);
}
test "entropia - dois simbolos iguais" {
var tabela = TabelaFrequencia.init();
tabela.processarTexto("ab");
const entropia = calcularEntropia(&tabela);
try std.testing.expectApproxEqAbs(@as(f64, 1.0), entropia, 0.001);
}
test "frequencia relativa" {
var tabela = TabelaFrequencia.init();
tabela.processarTexto("aaab");
try std.testing.expectApproxEqAbs(@as(f64, 0.75), tabela.frequenciaRelativa('a'), 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 0.25), tabela.frequenciaRelativa('b'), 0.001);
}
test "indice coincidencia - texto repetido" {
var tabela = TabelaFrequencia.init();
tabela.processarTexto("aaaa");
const ic = calcularIndiceCoincidencia(&tabela);
try std.testing.expectApproxEqAbs(@as(f64, 1.0), ic, 0.001);
}
Compilando e Executando
zig build test
zig build run
Conceitos Aprendidos
- Arrays de tamanho fixo como tabelas de lookup O(1)
- Cálculo de entropia de Shannon
- Índice de coincidência para criptoanálise
- Categorização de caracteres ASCII
- Histogramas visuais no terminal
- Comparação estatística de distribuições (qui-quadrado)
Próximos Passos
- Aplique a análise de frequência na Cifra de César para quebra automática
- Explore leitura de arquivos para analisar textos longos
- Avance para projetos intermediários como o Mini Grep