Skip to content

Algoritmos Elementares

Edson Alves edited this page Jan 22, 2017 · 14 revisions

Nesta seção são ilustrados algoritmos elementares sobre strings, com o intuito de ilustrar a manipulação, identificação e extração de informações de strings. As técnicas apresentadas servirão tanto para solidificar os conceitos fundamentais quanto para preprar o leitor para os algoritmos mais elaborados das seções subsequentes.

Palíndromos

Palíndromos são strings que são idênticas quando lidas tanto do início para o fim quanto do fim para o início (por exemplo, MUSSUM, SAIAS e HANNAH são palíndromos). Mais formalmente, um palíndromo P pode ser definido como

    P[1..N] = "" | P[1..1] | P[2..N-1] and P[1] == P[N],

ou seja, strings vazias, strings com um único caractere ou strings resultantes da concatenação de um mesmo caractere no ínicio e no fim de um palíndromo resulta em palíndromos.

Uma maneira de se verificar se uma string s é ou não palíndromo é checar se o primeiro caractere coincide com o último, o segundo com o penúltimo, e assim por diante.

bool is_palindrome(const string& s)
{
    size_t N = s.size();

    for (size_t i = 0; i < N; ++i)
        if (s[i] != s[N - 1 - i])
            return false;

    return true;
}

Este algoritmo tem complexidade O(N). Embora ele identifique corretamente se s é ou não um palíndromo, é possível torná-lo mais eficiente ao observar que só é necessário fazer tal verificação até a metade de s (pois, se i >= N/2, temos i = N - 1 - j, j < N/2 e a comparação s[i] == s[N - 1 - i] equivale a comparação s[N - 1 - j] == s[N - 1 - (N - 1 - j)], isto é, s[N - 1 - j] == s[j], j < N/2. Mesmo que a complexidade permaneça O(N), a versão abaixo executa duas vezes mais rápido que a anterior.

bool is_palindrome2(const string& s)
{
    size_t N = s.size();

    for (size_t i = 0; i < N/2; ++i)
        if (s[i] != s[N - 1 - i])
            return false;

    return true;
}

Observe que is_palindrome2() identifica, corretamente, strings s de tamanho par e ímpar (verifique este fato observando como o algoritmo lida com os caracteres centrais da strings em ambos casos).

Histograma

Uma técnica, oriunda da estatística, que permite identificar características importantes de uma strings é o histograma, que consiste em uma mapeamento entre os caracteres do alfabeto e o número de ocorrências dos mesmos em uma string s dada. Por exemplo, para a string "abacaxi", teríamos um histograma h com

    h['a'] = 4
    h['b'] = h['c'] = h['x'] = h['i'] = 1
    h[c] = 0,   c not in "abcxi"

Há 3 técnicas para a construção de histogramas. A primeira delas é utilizar a classe map do C++, que permite uma construção bastante intuitiva e fácil de histogramas, tendo como revezes a quantidade de memória necessária (o que, em geral, não chega a ser um problema) e a complexidade dos acessos (O(log N) para leitura e escrita).

Abaixo segue uma implementação bastante sintética em C++11, com uso de auto e range for.

#include <map>

std::map<char, int> histogram(const string& s)
{
    std::map<char, int> h;

    for (auto c : s)
        ++h[c];

    return h;
}

Uma segunda forma de gerar o histograma é utilizar um array estático com 256 posições (que cobre todos os possíveis valores de um char em C/C++). Esta estratégia permite atualizar/consultar os valores em O(1), mas a identificação dos caracteres com valores diferentes de zero é linear neste array (o que, na prática, geralmente não constitui problema).

#include <cstring>

void histogram(int h[256], const string& s)
{
    memset(h, 0, sizeof h);

    for (auto c : s)
        ++h[c];
}

Na implementação acima, h é um parâmetro de saída, que deve ser inicializado com zeros no início da rotina.

A terceira e última maneira é uma otimização, em espaço, da segunda. Aqui o vetor h deve ter o tamanho M do alfabeto, e os caracteres do alfabeto devem ser indexados de 0 a M - 1. Se o alfabeto for constituído das letras maiúsculas e minúsculas, estas indexação é feita de forma direta, em O(1). Caso contrário,é preciso buscar o caractere no alfabeto em O(N) (ou O(log N), se o alfabeto estiver ordenado). Neste cenário a perda de performance é compensada pela redução da memória necessária (esta é a abordagem mais econômica em termos de memória).

Abaixo um exemplo onde o alfabeto é composto pelas 26 letras maiúsculas.

void histogram(int h[26], const string& s)
{
    memset(h, 0, sizeof h);

    for (auto c : s)
        ++h[c - 'A'];
}

Mapas, Filtros e Reduções

Mapas, filtros e reduções são técnicas de programação funcional que permitem alterar os elementos de um vetor, gerar um novo vetor a partir da seleção de elementos específicos de um vetor dado ou gerar um único objeto ou elemento a partir dos elementos de um vetor. Sendo uma string um vetor de caracteres, estas técnicas podem ser adaptadas para o contexto da manipulação de textos e letras.

Mapas

Um mapa (ou mapeamento) consiste em uma função m_f: S_N -> S_N, onde S_N é o conjunto de todas as strings de tamanho N. e f: A -> A é uma função cujo domínio é o alfabeto A, tal que se y = m_f(s), então y[i] = f(s[i]). Em termos mais simples, m mapeia cada caractere de s de acordo com a função f.

Por exemplo, se A é formado pelas letras alfabéticas maiúsculas e minúsculas e f é a função toupper(), o mapeamento m_f tornaria maiúsculas todas as letras de uma string s dada. Uma implementação possível deste mapeamento é dada abaixo: a função uppercase() é utilizada apenas para corrigir o retorno da função toupper() e tornar o mapeamento reutilizável. O nome smap foi utilizado apenas para não conflitar com a classe map da biblioteca padrão do C++.

#include <cctype>

char uppercase(char c)
{
    return (char) toupper(c);
}

void smap(string& s, char (*f)(char))
{
    for (size_t i = 0; i < s.size(); ++i)
        s[i] = f(s[i]);
}

Outra forma de implementar um mapeamento em C++ é através da função transform(), que está declarado na biblioteca algorithm. Deve-se atentar, porém, que ela não suporta arrays primitivos, apenas containers. Os dois primeiros parâmetros indicam o intervalo a ser considerado, o terceiro é o método de inserção no container que conterá o resultado, e o último é um operador unário que recebe um elemento do container e retorna outro de mesmo tipo.

Abaixo uma implementação da Cifra de César usando a função transform(). Observe o uso de lambda no quarto parâmetro.

#include <iostream>
#include <algorithm>

using namespace std;

int main()
{
    string text { "cesar cipher xyz" };
    string res;

    transform(text.begin(), text.end(), back_inserter(res), [](char c)
        {
            return c == ' ' ? c : (((c - 'a') + 3) % 26) + 'a';
        }
    );

    cout << res << endl;

    return 0;
}

Filtros

Um filtro f_p : S_N -> S_M consiste em uma função que gera uma string de tamanho M <= N, a partir de uma string de tamanho N, através da seleção dos M caracteres que cujo predicado p: A -> Bool retorna verdadeiro.

Abaixo segue uma implementação simples que seleciona as vogais de uma string dada.

#include <cctype>

bool is_vowel(char c)
{
    const string vowels { "aeiou" };

    return vowels.find(tolower(c)) != string::npos;
}

string filter_vowels(const string& s)
{
    string v = "";

    for (auto c : s)
        if (is_vowel(c))
            v += c;

    return v;
}

Outra maneira de implementar o mesmo código é utilizar a função copy_if(), que tem sintaxe semelhante ao da função transform(). A função remove_copy_if() tem comportamento análogo, mas copia os caracteres que negarem o predicado.

#include <iostream>
#include <algorithm>

using namespace std;

int main()
{
    string text { "cesar cipher xyz" };
    string res;

    copy_if(text.begin(), text.end(), back_inserter(res), [](char c)
        {
            const string vowels { "aeiou" };

            return vowels.find(c) != string::npos;
        }
    );

    cout << res << endl;

    return 0;
}

Reduções

Uma redução r_b : S_N -> T gera um elemento do tipo T através da aplicação sucessiva, do operador binário b, da esquerda para a direita, em cada elemento de s, tendo como operador esquerdo inicial o valor passado como parâmetro.

Uma implementação direta da redução que soma os dígitos contidos na string s é dada abaixo.

int sum(const string& s)
{
    int s = 0;

    for (auto c : s)
        s += (c - '0');

    return s;
}

Em C++, é possível utilizar a função accumulate() para reduções. O mesmo código acima pode ser reimplementado da seguinte maneira.

#include <iostream>
#include <algorithm>

using namespace std;

int main()
{
    string text { "12345" };

    int res = accumulate(text.begin(), text.end(), 0, [](int L, int R)
        {
            return L + (R - '0');
        }
    );

    cout << res << endl;

    return 0;
}

Observe que o uso das funções transform(), copy_if() e accumulate() evitam o uso de laços de repetição.

Tabelas de substituição

Uma tabela de substituição é uma aplicação prática de um mapeamento. Ela é definida por uma função f: A -> A que mapeia cada caractere do alfabeto A em outro caractere do mesmo alfabeto. Sistemas criptográficos mais simples utilizam tabelas de substituição, como a Cifra de César, já citada, e a criptografia baseada em xor.

Abaixo segue um exemplo de criptografia xor. A tabela de substituição table é precomputada e é utilizado um mapeamento para cifrar e decifrar as mensagens.

#include <iostream>
#include <algorithm>

using namespace std;

#define MAX 256

char table[MAX];

void fill_table(char key)
{
    for (int i = 0; i < MAX; ++i)
        table[i] = i ^ key;
}

string cipher(const string& text)
{
    string res;

    transform(text.begin(), text.end(), back_inserter(res), [](char c)
        {
            return table[(int) c];
        }
    );

    return res;
}

string decipher(const string& c)
{
    return cipher(c);
}

int main()
{
    fill_table(0x3B);

    string message { "Xor cipher example" };
    string c = cipher(message);

    printf("c =");
    for (auto t : c)
        printf(" %02x", t);
    printf("\n");

    string d = decipher(c);

    printf("d = [%s]\n", d.c_str());

    return 0;
}

Tokenização

Tokenização é o processo de quebra de um texto em palavras, frases, símbolos, etc, denominados tokens. Para realizar a tokenização, é necessário primeiramente definir precisamente um token, e esta definição depende do contexto onde os tokens serão utilizados.

Uma abordagem simples é definir uma lista de caracteres delimitadores (como espaços em branco, pontuações, etc) e dividir a string a cada ocorrência destes. Por exemplo, a frase "O rato roeu a roupa do rei de Roma." seria dividia em 9 tokens: "O", "rato", "roeu", "a", "roupa", "do", "rei", "de", "Roma".

A linguagem C oferece uma função para tokenização na biblioteca string.h, denominada strtok(). Ela recebe como primeiro parâmetro um ponteiro para a string a ser tokenizada, e como segundo parâmetro uma lista de delimitadores. Ela retorna um ponteiro para o início do próximo token, ou NULL, caso não existam mais tokens.

Esta função tem um comportamento incomum, devido três aspectos importantes:

  1. ela mantém, internamente, um ponteiro para o início do próximo token. Desta forma, para obter vários tokens de uma mesma string, as chamadas subsequentes à primeira devem receber NULL como primeiro parâmetro (este comportamento faz com que esta função não seja segura num contexto multithread);
  2. esta função altera o parâmetro de entrada, escrevendo o caractere \0 nas posições onde são encontrados delimitadores. Assim, se for necessário preservar o conteúdo original da string, deve ser passada uma cópia da mesma como primeiro parâmetro;
  3. a função strtok() não retorna tokens vazios (isto é, de tamanho zero).

O código abaixo ilustra o uso desta função para extrair os tokens de um CPF.

#include <stdio.h>
#include <string.h>

int main()
{
    char cpf[] = "123.456.789-10";
    char *token = NULL;

    token = strtok(cpf, ".");
    printf("token = [%s]\n", token);    /* token = "123" */

    token = strtok(NULL, ".");
    printf("token = [%s]\n", token);    /* token = "456" */

    token = strtok(NULL, "-");
    printf("token = [%s]\n", token);    /* token = "798" */

    token = strtok(NULL, "\0");
    printf("token = [%s]\n", token);    /* token = "10" */

    printf("cpf = %s\n", cpf);          /* cpf = "123" */

    return 0;
}

A mesma biblioteca oferece uma versão thread safe de strtok(), denominada strtok_r(). Nesta versão, há um terceiro parâmetro, que é utilizado para memorizar a posição do próximo token.

Em C++, a função getline() da biblioteca string pode ser utilizada para tokenização. Ela recebe como primeiro parâmetro um fluxo de entrada (cin, um arquivo, um fluxo de caracteres, etc), como segundo parâmetro a string que conterá o token identificado e, como terceiro parâmetro, o caractere delimitador (se omitido, o caractere \0 será considerado o delimitador). Esta função é útil quando há apenas um delimitador e os tokens são lidos diretamente da entrada.

O código de extração de tokens do CPF por ser reescrito em C++ da seguinte forma:

#include <iostream>
#include <sstream>

using namespace std;

int main()
{
    istringstream is("123.456.789-10");
    string token;

    getline(is, token, '.');
    cout << token << endl;      // token = "123";

    getline(is, token, '.');
    cout << token << endl;      // token = "456";

    getline(is, token, '-');
    cout << token << endl;      // token = "789";

    getline(is, token);
    cout << token << endl;      // token = "10";

    return 0;
}

Para processos de tokenização mais elaborados pode ser necessário escrever um parser para tal. Os parsers serão discutidos mais adiante.

Anagramas

Parsing

Expressões Regulares

Referências

HALIM, Steve; HALIM, Felix. Competitive Programming 3, Lulu, 2013.

CROCHEMORE, Maxime; RYTTER, Wojciech. Jewels of Stringology: Text Algorithms, WSPC, 2002.

Inserir referências ao CPPreference (transform(), copy_if(), accumulate()).

WIKIPEDIA. Tokenization [https://en.wikipedia.org/wiki/Tokenization_(lexical_analysis)], Acesso em 22/01/17.

Clone this wiki locally