-
Notifications
You must be signed in to change notification settings - Fork 153
Algoritmos Elementares
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 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).
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 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.
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 é 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:
- 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); - 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; - 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 são palavras formadas pelo rearranjo dos caracteres de um conjunto fixo. Por exemplo, "iracema" e "america" são anagramas, enquanto que "amora" e "roma" não são anagramas ("roma" tem os mesmo caracteres, mas não a mesma quantidade: tem um "a" a menos que "amora").
Para se determinar se determinar se duas strings s
e t
são anagramas há
dois abordagens possíveis:
- obter os histogramas de ambas strings e compará-los: caso sejam iguais, as strings serão anagramas;
- ordenar ambas strings segundo a ordem lexicográfica: se após a ordenação as strings são iguais, ambas são anagramas.
Embora seja possível implementar a primeira estratégia em O(n)
, em geral a
segunda abordagem, embora seja O(n log n)
, resulta num implementação mais
simples e rápida, se o tamanho das strings for razoável (isto é, até 1 milhão de
carateres).
Abaixo segue uma implementação, em C++, de uma rotina que determinar se duas strings são ou não anagramas, utilizando a segunda estratégia.
#include <algorithm>
bool is_anagram(const string& s, const string& t)
{
string a(s), b(t);
sort(a.begin(), a.end());
sort(b.begin(), b.end());
return a == b;
}
Outro problema comum é determinar o número de anagramas distintos que uma
determinada palavra tem. Segundo a Análise Combinatória, este número é dado
por um arranjo com repetição: se s
tem n
caracteres (r
deles distintos) e
n1, n2, ..., nr
é o número de ocorrências de cada um dos r
caracteres em
s
, então o número de anagramas distintos A(s)
de s
é dado por
A(s) = n!/(n1!n2!...nr!)
Para listar todos os possíveis anagramas de uma string s
, pode-se utilizar a
função next_permutation()
da biblioteca algorithm
do C++: ela retorna
verdadeiro, e modifica a string passada, enquanto houver uma próxima permutação
distinta de seus caracteres. Deve-se tomar o cuidado, porém, de ordenar a
string s
antes das sucessivas chamadas da função next_permutation()
.
O código abaixo gera todos as 60 possíveis anagramas da palavra "banana".
#include <iostream>
#include <algorithm>
using namespace std;
int main()
{
string s = "banana";
int number = 0;
sort(s.begin(), s.end());
do {
printf("%02d. %s\n", ++number, s.c_str());
} while (next_permutation(s.begin(), s.end()));
printf("%s has %d anagrams\n". s.c_str(), number);
return 0;
}
Como dito na introdução, expressões regulares (regular expressions ou regex) são uma representação que utiliza símbolos especiais para marcar sequências de caracteres ou repetições. As regex são uma forma compacta e poderosa de representar textos e padrões, mas sem o devido cuidado pode levar a bugs sutis relacionados a falsos positivos (o texto identificado não é o desejado) e a falsos negativos (textos desejados não são identificados).
Abaixo uma breve descrição das principais características das regexes:
- cada caractere na regex corresponde ao mesmo caractere no texto;
- existem combinações especiais de caracteres para corresponder à sequências de
caracteres: por exemplo,
\d
corresponde a qualquer um dos dez dígitos decimais,\D
a qualquer caractere exceto os dez dígitos decimais; - o caractere
.
é o coringa: ele representa qualquer caractere. O ponto final é representado pela sequência\.
; - um conjunto de caracteres válidos para o padrão pode ser representado
por meio de colchetes. Por exemplo, a notação
[abc]
significa oua
, oub
. ouc
. Se os caracteres são consecutivos, esta notação pode ser abreviada com o uso do símbolo-
. Por exemplo,[0-9]
tem o mesmo significado que\d
; - a notação de colchetes pode ser usada para excluir caracteres, se
usada com conjunto com o símbolo
^
. Por exemplo, a notação[^abc]
significa "todos os caracteres, excetoa
,b
ec
"; - a sequência
\w
corresponde aos caracteres alfanuméricos[a-zA-Z0-9_]
; - Sequências especiais de caracteres podem ser usadas para representar
repetições de caracteres ou padrões:
- um número entre chaves após o caractere/padrão indica o número de repetições ou as quantidades válidas de repetições. Por exemplo,
a{5}
significa o mesmo queaaaaa
;a{1-3}
indica "de um a três caracteresa
";[abc]{2}
indica dois caracteres seguidos dentres os indicados no colchete; - o caractere
*
significa "zero ou mais repetições"; o caractere+
significa "uma ou mais repetições"; - o símbolo
?
significa que o caractere ou padrão é opcional, isto é, que pode ou não ocorrer no texto. O caractere'?'
pode ser representado pela sequência de escape\?
; - a sequẽncia
\s
indica espaços em branco (' ', '\t', '\r', '\n'
); - os símbolos
^
e$
representam, respectivamente, o início e o fim do texto; - parêntesis podem ser utilizados para armazenar trechos do texto que correspondem à expressão entre parêntesis para posterior uso. Eles podem ser aninhados;
- o símbolo
|
pode ser utilizado como ou lógico para separar grupos de padrões possíveis: a expressão(abc|123)
significa "oua, b, c
ou1, 2, 3
.
- um número entre chaves após o caractere/padrão indica o número de repetições ou as quantidades válidas de repetições. Por exemplo,
Algumas linguagens de programação, como Java e Python, tem suporte nativo para expressões regulares. A linguagem C++ incorporou suporte às regexes a partir da versão C++11, embora nem todas as implementações suportem ainda estas funcionalidades (por exemplo, ainda não há suporte no g++), e com padrão de sintaxe distinta das outras duas linguagens já citadas.
Abaixo um exemplo de um código Java que ilustra a identificação de um CPF:
public class Main {
public static void main(String[] args) {
String regex = "(\\d{3}\\.){2}\\d{3}-\\d{2}";
String cpf = "123.456.789-10";
System.out.println(cpf.matches(regex)); // true
String text = "12345678910";
System.out.println(text.matches(regex)); // false
text = "123-456-789-10";
System.out.println(text.matches(regex)); // false
text = "123.456.789-1";
System.out.println(text.matches(regex)); // false
text = "23.456.789-10";
System.out.println(text.matches(regex)); // false
}
}
Outros métodos úteis de strings Java que podem ser utilizados com as
expressões regulares são o método replaceAll()
(que substituem os matches das
regexes pelo texto dado), trim()
(remove os espaços antes e depois do texto)
e split()
(que tokeniza a string a partir dos marcadores informados).
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()).
REGEXONE. Lesson 1: An Introduction, and the ABCs
WIKIPEDIA. Tokenization [https://en.wikipedia.org/wiki/Tokenization_(lexical_analysis)], Acesso em 22/01/17.