Suffix Array em Rust

Suffix Array em Rust: construcao com ordenacao, array LCP, busca de substrings e maior substring repetida com diagramas visuais e analise Big-O.

O Suffix Array (array de sufixos) e uma estrutura de dados que armazena todos os sufixos de uma string em ordem lexicografica. E uma alternativa eficiente em espaco a Suffix Tree, ocupando apenas O(n) de memoria enquanto permite busca de substrings em O(m*log n) – e em O(m + log n) quando combinado com o array LCP (Longest Common Prefix).

Inventado por Udi Manber e Gene Myers em 1993, o Suffix Array e amplamente utilizado em bioinformatica (alinhamento de sequencias de DNA), compressao de dados (BWT - Burrows-Wheeler Transform), e sistemas de busca full-text. Nesta pagina, construiremos o Suffix Array com ordenacao em O(n*log^2 n), calcularemos o array LCP em O(n), e resolveremos problemas classicos de strings.

Como Funciona

Construcao do Suffix Array

Dado o texto “banana”, listamos todos os sufixos e os ordenamos lexicograficamente:

Texto: "banana"  (n=6)

Todos os sufixos:
  Indice 0: "banana"
  Indice 1: "anana"
  Indice 2: "nana"
  Indice 3: "ana"
  Indice 4: "na"
  Indice 5: "a"

Ordenados lexicograficamente:
  Posicao 0: "a"       (indice original: 5)
  Posicao 1: "ana"     (indice original: 3)
  Posicao 2: "anana"   (indice original: 1)
  Posicao 3: "banana"  (indice original: 0)
  Posicao 4: "na"      (indice original: 4)
  Posicao 5: "nana"    (indice original: 2)

Suffix Array: SA = [5, 3, 1, 0, 4, 2]

Array LCP (Longest Common Prefix)

O array LCP armazena o comprimento do maior prefixo comum entre sufixos adjacentes no Suffix Array:

SA    Sufixo      LCP
[5]   "a"          -
[3]   "ana"        1   (prefixo comum com "a": "a" -> len=1)
[1]   "anana"      3   (prefixo comum com "ana": "ana" -> len=3)
[0]   "banana"     0   (prefixo comum com "anana": "" -> len=0)
[4]   "na"         0   (prefixo comum com "banana": "" -> len=0)
[2]   "nana"       2   (prefixo comum com "na": "na" -> len=2)

LCP = [0, 1, 3, 0, 0, 2]
      (LCP[0] = 0 por definicao)

Busca de Substring com Busca Binaria

Para buscar “ana” no Suffix Array, fazemos busca binaria comparando o prefixo:

Busca: "ana" (m=3)

SA = [5, 3, 1, 0, 4, 2]

low=0, high=5, mid=2:
  SA[2]=1 -> sufixo "anana"
  "anana"[0..3] = "ana" == "ana" -> ENCONTRADO na posicao SA[2]=1

Pode haver mais ocorrencias adjacentes:
  SA[1]=3 -> sufixo "ana"
  "ana"[0..3] = "ana" == "ana" -> TAMBEM encontrado na posicao 3

Resultado: "ana" encontrado nas posicoes 1 e 3.

Complexidade

OperacaoTempoEspaco
Construcao (ordenacao)O(n*log^2 n)O(n)
Construcao (SA-IS)O(n)O(n)
Array LCP (Kasai)O(n)O(n)
Busca de substringO(m*log n)O(1)
Busca com LCPO(m + log n)O(1)
Maior substring repetidaO(n)O(n)
Espaco totalO(n)

A construcao via ordenacao e mais simples de implementar. O algoritmo SA-IS (Suffix Array Induced Sorting) constroi em O(n) mas e significativamente mais complexo.

Implementacao em Rust

/// Constroi o Suffix Array usando ordenacao por rank em O(n * log^2 n).
/// Cada iteracao dobra o comprimento dos prefixos comparados.
fn construir_suffix_array(texto: &str) -> Vec<usize> {
    let bytes = texto.as_bytes();
    let n = bytes.len();

    if n == 0 {
        return Vec::new();
    }

    // Inicializa: rank de cada sufixo = valor do primeiro caractere
    let mut sa: Vec<usize> = (0..n).collect();
    let mut rank: Vec<i64> = bytes.iter().map(|&b| b as i64).collect();
    let mut nova_rank = vec![0i64; n];
    let mut k = 1; // comprimento do prefixo atual

    while k < n {
        // Ordena pelo par (rank[i], rank[i+k])
        let rank_ref = &rank;
        sa.sort_by(|&a, &b| {
            let ra = (rank_ref[a], if a + k < n { rank_ref[a + k] } else { -1 });
            let rb = (rank_ref[b], if b + k < n { rank_ref[b + k] } else { -1 });
            ra.cmp(&rb)
        });

        // Recalcula os ranks
        nova_rank[sa[0]] = 0;
        for i in 1..n {
            let anterior = (
                rank[sa[i - 1]],
                if sa[i - 1] + k < n { rank[sa[i - 1] + k] } else { -1 },
            );
            let atual = (
                rank[sa[i]],
                if sa[i] + k < n { rank[sa[i] + k] } else { -1 },
            );
            nova_rank[sa[i]] = nova_rank[sa[i - 1]] + if atual != anterior { 1 } else { 0 };
        }

        rank.copy_from_slice(&nova_rank);

        // Se todos os ranks sao unicos, ja terminamos
        if rank[sa[n - 1]] as usize == n - 1 {
            break;
        }

        k *= 2;
    }

    sa
}

/// Constroi o array LCP usando o algoritmo de Kasai em O(n).
fn construir_lcp(texto: &str, sa: &[usize]) -> Vec<usize> {
    let bytes = texto.as_bytes();
    let n = bytes.len();

    if n == 0 {
        return Vec::new();
    }

    // rank[i] = posicao do sufixo i no Suffix Array
    let mut rank = vec![0usize; n];
    for (i, &s) in sa.iter().enumerate() {
        rank[s] = i;
    }

    let mut lcp = vec![0usize; n];
    let mut h: usize = 0;

    for i in 0..n {
        if rank[i] > 0 {
            let j = sa[rank[i] - 1]; // sufixo anterior no SA

            // Compara caracteres a partir da posicao h
            while i + h < n && j + h < n && bytes[i + h] == bytes[j + h] {
                h += 1;
            }

            lcp[rank[i]] = h;

            if h > 0 {
                h -= 1; // propriedade de Kasai: h diminui no maximo 1
            }
        } else {
            h = 0;
        }
    }

    lcp
}

/// Busca uma substring no texto usando o Suffix Array e busca binaria.
/// Retorna todas as posicoes onde a substring aparece.
fn buscar_substring(texto: &str, sa: &[usize], padrao: &str) -> Vec<usize> {
    let texto_bytes = texto.as_bytes();
    let padrao_bytes = padrao.as_bytes();
    let n = texto_bytes.len();
    let m = padrao_bytes.len();

    if m == 0 || n == 0 {
        return Vec::new();
    }

    // Busca binaria: encontra o limite inferior
    let mut low = 0usize;
    let mut high = n;

    while low < high {
        let mid = low + (high - low) / 2;
        let sufixo = &texto_bytes[sa[mid]..];
        let cmp_len = m.min(sufixo.len());
        if sufixo[..cmp_len] < padrao_bytes[..cmp_len]
            || (cmp_len < m && sufixo.len() < m)
        {
            low = mid + 1;
        } else {
            high = mid;
        }
    }

    let inicio = low;

    // Busca binaria: encontra o limite superior
    high = n;
    while low < high {
        let mid = low + (high - low) / 2;
        let sufixo = &texto_bytes[sa[mid]..];
        let cmp_len = m.min(sufixo.len());
        if sufixo[..cmp_len] <= padrao_bytes[..cmp_len]
            && (cmp_len >= m || sufixo.len() >= m)
        {
            low = mid + 1;
        } else {
            high = mid;
        }
    }

    // Coleta todas as posicoes no intervalo [inicio, low)
    let mut posicoes: Vec<usize> = (inicio..low)
        .filter(|&i| {
            let sufixo = &texto_bytes[sa[i]..];
            sufixo.len() >= m && sufixo[..m] == padrao_bytes[..]
        })
        .map(|i| sa[i])
        .collect();

    posicoes.sort();
    posicoes
}

/// Encontra a maior substring que se repete no texto.
fn maior_substring_repetida(texto: &str, sa: &[usize], lcp: &[usize]) -> Option<String> {
    if lcp.is_empty() {
        return None;
    }

    let mut max_lcp = 0;
    let mut max_pos = 0;

    for (i, &valor) in lcp.iter().enumerate() {
        if valor > max_lcp {
            max_lcp = valor;
            max_pos = sa[i];
        }
    }

    if max_lcp == 0 {
        None
    } else {
        Some(texto[max_pos..max_pos + max_lcp].to_string())
    }
}

fn main() {
    let texto = "banana";

    println!("Texto: \"{}\"", texto);
    println!();

    // Constroi Suffix Array
    let sa = construir_suffix_array(texto);
    println!("Suffix Array:");
    println!("  SA  Sufixo");
    for (i, &pos) in sa.iter().enumerate() {
        println!("  [{}]  \"{}\"", pos, &texto[pos..]);
    }
    println!();

    // Constroi LCP
    let lcp = construir_lcp(texto, &sa);
    println!("Array LCP:");
    println!("  SA  LCP  Sufixo");
    for (i, &pos) in sa.iter().enumerate() {
        println!("  [{}]  {}    \"{}\"", pos, lcp[i], &texto[pos..]);
    }
    println!();

    // Busca de substring
    let padrao = "ana";
    let posicoes = buscar_substring(texto, &sa, padrao);
    println!("Busca por \"{}\": posicoes {:?}", padrao, posicoes);
    println!();

    // Maior substring repetida
    match maior_substring_repetida(texto, &sa, &lcp) {
        Some(sub) => println!("Maior substring repetida: \"{}\"", sub),
        None => println!("Nenhuma substring repetida encontrada."),
    }
}

Exemplo de Execucao

Texto: "banana"

Suffix Array:
  SA  Sufixo
  [5]  "a"
  [3]  "ana"
  [1]  "anana"
  [0]  "banana"
  [4]  "na"
  [2]  "nana"

Array LCP:
  SA  LCP  Sufixo
  [5]  0    "a"
  [3]  1    "ana"
  [1]  3    "anana"
  [0]  0    "banana"
  [4]  0    "na"
  [2]  2    "nana"

Busca por "ana": posicoes [1, 3]

Maior substring repetida: "ana"

Diagrama visual da busca binaria:

Busca: "ana" no Suffix Array de "banana"

SA:     [5]   [3]   [1]   [0]   [4]   [2]
Sufixo: "a"  "ana" "anana" "banana" "na" "nana"
         0     1     2       3       4     5

Passo 1: low=0, high=6, mid=3
  SA[3]=0 -> "banana" > "ana" -> high=3

Passo 2: low=0, high=3, mid=1
  SA[1]=3 -> "ana" >= "ana" -> high=1

Passo 3: low=0, high=1, mid=0
  SA[0]=5 -> "a" < "ana" -> low=1

inicio=1

Busca limite superior: low=1, high=6
  mid=3: "banana" > "ana" -> high=3
  mid=2: "anana"[0..3]="ana" == "ana" -> low=3

Intervalo: [1, 3) -> posicoes SA[1]=3, SA[2]=1
Resultado: "ana" nas posicoes 1 e 3.

Variacoes e Otimizacoes

1. Numero de Substrings Distintas

Usando o LCP, podemos contar substrings distintas sem enumera-las:

/// Conta o numero total de substrings distintas.
/// Total de substrings = n*(n+1)/2 - soma(LCP)
fn contar_substrings_distintas(texto: &str) -> usize {
    let n = texto.len();
    if n == 0 {
        return 0;
    }

    let sa = construir_suffix_array(texto);
    let lcp = construir_lcp(texto, &sa);

    let total_substrings = n * (n + 1) / 2;
    let soma_lcp: usize = lcp.iter().sum();

    total_substrings - soma_lcp
}

fn main() {
    let texto = "banana";
    println!(
        "Substrings distintas de \"{}\": {}",
        texto,
        contar_substrings_distintas(texto)
    );
    // Resposta: 15 (a, an, ana, anan, anana, b, ba, ban, bana, banan, banana, n, na, nan, nana)
}

2. Maior Substring Comum entre Duas Strings

/// Encontra a maior substring comum entre dois textos.
/// Tecnica: concatena as strings com separador e usa LCP.
fn maior_substring_comum(texto1: &str, texto2: &str) -> String {
    let n1 = texto1.len();
    // Usa '#' como separador (deve nao aparecer nos textos)
    let combinado = format!("{}#{}", texto1, texto2);

    let sa = construir_suffix_array(&combinado);
    let lcp = construir_lcp(&combinado, &sa);

    let mut max_len = 0;
    let mut max_pos = 0;

    for i in 1..sa.len() {
        // Verifica se os sufixos adjacentes pertencem a strings diferentes
        let pertence_1_atual = sa[i] < n1;
        let pertence_1_anterior = sa[i - 1] < n1;

        // Um deve estar em texto1 e outro em texto2
        if pertence_1_atual != pertence_1_anterior && lcp[i] > max_len {
            max_len = lcp[i];
            max_pos = sa[i];
        }
    }

    if max_len == 0 {
        String::new()
    } else {
        combinado[max_pos..max_pos + max_len].to_string()
    }
}

fn main() {
    let t1 = "abcdefg";
    let t2 = "xyzdefabc";
    let lcs = maior_substring_comum(t1, t2);
    println!(
        "Maior substring comum entre \"{}\" e \"{}\": \"{}\"",
        t1, t2, lcs
    );
}

3. Busca de Todas as Substrings Repetidas

/// Encontra todas as substrings que aparecem pelo menos `min_vezes` vezes.
fn substrings_frequentes(texto: &str, min_vezes: usize) -> Vec<(String, usize)> {
    let sa = construir_suffix_array(texto);
    let lcp = construir_lcp(texto, &sa);
    let n = sa.len();

    let mut resultados: Vec<(String, usize)> = Vec::new();
    let mut i = 1;

    while i < n {
        if lcp[i] > 0 {
            // Encontra o maior bloco onde LCP >= lcp[i]
            let comprimento = lcp[i];
            let mut contagem = 2; // os dois sufixos adjacentes
            let mut j = i + 1;
            while j < n && lcp[j] >= comprimento {
                contagem += 1;
                j += 1;
            }

            if contagem >= min_vezes {
                let substring = texto[sa[i]..sa[i] + comprimento].to_string();
                resultados.push((substring, contagem));
            }
        }
        i += 1;
    }

    // Remove duplicatas mantendo a contagem maxima
    resultados.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
    resultados.dedup_by(|a, b| a.0 == b.0);
    resultados
}

Aplicacoes Praticas

Busca Full-Text em Documentos

/// Indice de busca full-text usando Suffix Array.
struct IndiceBusca {
    texto: String,
    sa: Vec<usize>,
    // Mapeia posicao no texto concatenado -> (indice do doc, posicao no doc)
    mapa_docs: Vec<(usize, usize)>,
    nomes_docs: Vec<String>,
}

impl IndiceBusca {
    fn construir(documentos: &[(&str, &str)]) -> Self {
        let mut texto = String::new();
        let mut mapa_docs = Vec::new();
        let mut nomes_docs = Vec::new();

        for (idx, (nome, conteudo)) in documentos.iter().enumerate() {
            let inicio = texto.len();
            texto.push_str(conteudo);
            texto.push('\0'); // separador entre documentos

            for pos in 0..conteudo.len() {
                // Mapeia posicao global -> (doc_idx, posicao_local)
                while mapa_docs.len() <= inicio + pos {
                    mapa_docs.push((idx, pos));
                }
            }
            // Posicao do separador
            mapa_docs.push((idx, conteudo.len()));

            nomes_docs.push(nome.to_string());
        }

        let sa = construir_suffix_array(&texto);

        IndiceBusca {
            texto,
            sa,
            mapa_docs,
            nomes_docs,
        }
    }

    fn buscar(&self, consulta: &str) -> Vec<(String, usize)> {
        let posicoes = buscar_substring(&self.texto, &self.sa, consulta);
        let mut resultados = Vec::new();

        for pos in posicoes {
            if pos < self.mapa_docs.len() {
                let (doc_idx, pos_local) = self.mapa_docs[pos];
                resultados.push((self.nomes_docs[doc_idx].clone(), pos_local));
            }
        }

        resultados
    }
}

fn main() {
    let docs = vec![
        ("manual.txt", "rust e uma linguagem de programacao segura e rapida"),
        ("artigo.txt", "a linguagem rust garante seguranca de memoria"),
        ("tutorial.txt", "aprenda rust do zero com exemplos praticos"),
    ];

    let indice = IndiceBusca::construir(&docs);
    let consulta = "rust";
    let resultados = indice.buscar(consulta);

    println!("Busca por \"{}\":", consulta);
    for (doc, pos) in &resultados {
        println!("  Encontrado em \"{}\" na posicao {}", doc, pos);
    }
}

Comparacao com Outros Algoritmos

EstruturaConstrucaoBusca SubstringEspacoPrefixo Comum
Forca BrutaO(1)O(n*m)O(1)O(n*m)
KMPO(m)O(n+m)O(m)N/A
Suffix ArrayO(n*log^2 n)O(m*log n)O(n)O(n) c/LCP
Suffix TreeO(n)O(m)O(n)*O(n)
TrieO(soma m)O(m)O(N*A)O(m)

* Suffix Tree usa ~20x mais memoria que Suffix Array na pratica.

O Suffix Array oferece o melhor equilibrio entre espaco e funcionalidade. Embora a Suffix Tree tenha busca O(m) contra O(m*log n) do SA, o Suffix Array usa muito menos memoria e possui melhor localidade de cache.

Exercicios Praticos

  1. K-esima menor substring: Dado um texto, encontre a k-esima menor substring distinta em ordem lexicografica. Use o Suffix Array e LCP para pular substrings duplicadas.

  2. Maior palindromo: Combine o Suffix Array com a string reversa para encontrar o maior palindromo em um texto. Concatene s + "#" + reverse(s) e use LCP.

  3. Compressao LZ77: Implemente o nucleo do algoritmo de compressao LZ77 usando Suffix Array para encontrar eficientemente a maior correspondencia anterior para cada posicao.

  4. String periodica: Dado um texto, encontre o menor periodo (menor string que, repetida, gera o texto). Use o Suffix Array e propriedades do LCP.

Veja Tambem