Derivadas de expressões regulares explicadas usando o Pac-Man
Comer cerejas vermelhas dá a você a habilidade de comer fantasmas azuis. A ideia de que derivados podem ser usados para criar um algoritmo de correspondência de expressão regular é quase tão ridícula. Deixe-me explicar como esse algoritmo funciona e como ele se relaciona com o Pac-Man.
Em 1964, Brzozowski publicou o primeiro artigo sobre Derivadas de Expressões Regulares . Este é de longe um dos meus algoritmos favoritos. Usando derivadas de expressões regulares, podemos implementar um algoritmo para fazer correspondência de expressão regular. Este algoritmo é muito:
- simples
- funcional
- facilmente extensível com seus próprios operadores
Neste artigo, mostrarei como podemos combinar uma string com uma expressão regular usando apenas duas funções puras e algumas analogias do Pac-Man. Se preferir, você pode assistir ao vídeo a seguir em vez de ler o artigo, pois ele aborda o mesmo material:
Recapitulação de Expressões Regulares
Primeiro, vamos fazer uma revisão rápida das expressões regulares para garantir que estamos na mesma página. A expressão a(a|b)*
corresponde a uma string que começa com um a
, que é seguida por qualquer número de a
' se b
'.
- A string
ab
corresponderáa(a|b)*
. Vamos indicar isso com um fantasma comestível azul. - A string
aabbba
também correspondea(a|b)*
, pois começa com uma
e é seguida por váriosa
's eb
's. - Em seguida, a string
ac
não correspondea(a|b)*
, pois o regex não correspondec
a nenhum e nosso regex não faz nenhuma correspondência de substring. Indicaremos isso usando um fantasma vermelho que está perseguindo o Pac-Man. - Por fim, a string
ba
também não correspondea(a|b)*
porque não começa com uma
.
Visão geral do algoritmo
Antes de nos aprofundarmos nos detalhes, vamos obter uma visão geral de como esse algoritmo funciona. Eu criei um jogo estranho de Pac-Man onde você só come os fantasmas se comer a fruta em uma sequência que corresponda ao regex. Nosso Pac-Man representa o regex aba*
. Tem a seguinte sequência de frutas para comer: uma maçã, depois uma banana e depois uma maçã: aba
.
- Quando começamos, o fantasma está nos perseguindo e a expressão regular que resta para corresponder é
aba*
. - Comemos a primeira maçã, e a expressão regular que resta para corresponder é
ba*
. O fantasma ainda está nos perseguindo, pois a fruta que comemos até agora, a maçã, não corresponde ao regex. - Em seguida, comemos a banana. O regex que resta para corresponder é
a*
. Agora o fantasma começa a fugir porque, tecnicamente,ab
já combinaaba*
. - Podemos tentar comer o fantasma ou comer outra maçã, caso em que a expressão regular que falta corresponder ainda é
a*
. O fantasma ainda está fugindo, poisaba
também corresponde à expressão regularaba*
.
Há mais uma função em ação aqui. A função verifica se o fantasma está perseguindo o Pac-Man ou se o Pac-Man já correspondeu ao regex e está perseguindo o fantasma. Essa função é chamada de função anulável; ele verifica se o regex que falta corresponder corresponde à string vazia. Ele pode fazer isso porque, se o regex restante corresponder à string vazia, a fruta que comeu já deve ter sido suficiente para satisfazer o regex.
Algoritmo de Combinação de Derivadas
Isso significa que precisamos apenas de duas funções para escrever o algoritmo de correspondência derivada:
- a função derivada
- a função anulável
Um em Golang para os programadores imperativos:
e outro em Haskell para programadores funcionais:
Essas duas funções são equivalentes e apenas escritas em diferentes estilos de programação. No código Haskell, foldl
também chamado de dobrar à esquerda ou reduzir em outras linguagens, faz o trabalho do loop for para você. Além disso, em Haskell, não precisamos de vírgulas para passar parâmetros para funções; como a aplicação de função é a operação mais comum em uma linguagem de programação funcional, usamos espaço para delimitar os parâmetros.
Agora, vamos nos aprofundar em como implementar as funções anuláveis e derivadas.
Digressão da história da origem do Pac-Man
Mas antes de fazermos isso, não sei se você já se perguntou sobre a história da origem do Pac-Man. Eu afirmo que não houve barril de lixo nuclear no qual Pac-Man caiu, resultando em Pac-Man ganhando o poder de comer fantasmas. A lógica é bem mais simples.
Pac-Man é uma fruta! Quando Pac-Man come outra fruta, Pac-Man está sendo um canibal. Portanto, se você for perseguido por um fantasma, terá que comer um pouco de carne humana, e o fantasma deve, pelo menos temporariamente, começar a fugir de você. Agora, eu não tentei isso sozinho, mas a lógica parece sólida.
Isso explica por que os zumbis estão sempre perseguindo os humanos. Como David Attenborough disse uma vez:
“Os zumbis que estão nos perseguindo estão sendo perseguidos por fantasmas que não podemos ver. Depois que os zumbis comerem um pouco de nossa carne humana, você os verá exibindo o estranho comportamento de mastigar o ar, este é o zumbi comendo o fantasma que o perseguia antes.
Operadores Básicos
A implementação das funções anuláveis e derivadas requer que primeiro definamos os operadores básicos disponíveis em nossas expressões regulares.
Podemos pensar em uma expressão regular como descrevendo um conjunto de strings.
- Isso significa que o conjunto vazio representa um operador que não corresponde a strings.
- A string vazia representa um conjunto singleton de uma única string que corresponde apenas à string vazia.
- O caractere também representa um conjunto singleton que corresponde apenas ao caractere único
a
. - Podemos então combinar essas expressões regulares de base usando operadores como:
or
,concatenation
eKleene star
, onder
es
representa as duas expressões de expressão regular que estamos combinando.
Função anulável
Podemos começar com a função anulável. Vamos ver alguns exemplos e descobrir qual dessas expressões regulares corresponde à string vazia para entender como essa função é implementada.
a*
corresponde à string vazia, pois zero ou mais inclui zero.(a*|b)
corresponde à string vazia, pois o lado esquerdo de or corresponde à string vazia.ab
não corresponde à string vazia, pois corresponde apenas à stringab
ab*
também não corresponde à string vazia, poisab*
requer uma string que comece com uma
(a|b)
não corresponde à string vazia, pois nem o lado esquerdo nem o direito deor
correspondem à string vazia.
Aqui está a implementação da função anulável. O lado esquerdo representa os valores que são passados para a função e o lado direito representa a implementação da função nesse caso. Os fantasmas vermelhos representam falsos e os fantasmas azuis representam verdadeiros:
- O conjunto vazio não corresponde à string vazia, pois não corresponde a nenhuma string.
- A string vazia corresponde à string vazia porque corresponde apenas à string vazia.
- O caractere
a
não corresponde à string vazia porque corresponde apenas ao caracterea
. - Se tivermos um lógico
or
, temos que verificar os dois lados. Se qualquer um corresponder à string vazia, o lógicoor
corresponderá à string vazia. - Para
concatenation
que duas expressões regulares correspondam à string vazia, ambas devem corresponder à string vazia. - E, finalmente, se tivermos
zero or more
algo, isso inclui zero, o que significa que sempre corresponde à string vazia.
- Nosso operador superior é o
or
meio que temos para verificar a nulidade dos lados esquerdo e direito:b
ea*
. - Verificamos e vemos que o caractere
b
do lado esquerdo não é anulável:false
. - Em seguida, verificamos e vemos que o
a*
do lado direito é anulável:true
. - Agora voltamos
false
etrue
podemosor
pegá-lostrue
.
exercícios anuláveis
Tente percorrer a implementação e verifique se as expressões regulares a seguir são anuláveis. Você pode clicar neles para verificar sua resposta:
- uma
- a*(b*|∅)
- εa
- ∅*
- (∅|b)*(abc|ε)
Antes de examinarmos a implementação da função, vejamos exemplos de uma derivada. Aqui vamos obter a derivada de algumas expressões regulares, todas em relação ao caractere a
:
- A expressão regular que resta para corresponder depois
a*
de comer umaa
maçã ainda éa*
. - A derivada de
ab*
em relação aa
éb*
, pois já combinamos o prefixoa
. - A derivada de
(a|b)b
em relação aa
éb
. - A derivada de
b|(a*b)
em relação aa
éa*b
. A esquerdab
não combinava, então poderíamos jogá-la fora ea
foi consumida pelazero or more
a
direita. - Em seguida, temos
ab*
, este é um pouco complicado. Depois que ele come a maçã, a expressão regular que falta corresponder éb(ab)*
. Como só combinamos oa
, esperamos ver pelo menos mais umb
.
- A derivada do conjunto vazio é sempre o conjunto vazio. Não há como recuperar, pois o conjunto vazio não corresponde a nada.
- A derivada da string vazia referente a qualquer caractere é o conjunto vazio. Não esperava corresponder a um personagem. Ele corresponderá apenas à string vazia.
- A derivada de um único caractere para um caractere semelhante (neste caso, o
a
pple) é a string vazia, pois depois que ela corresponde a si mesma, tudo o que resta para corresponder é a string vazia. - A derivada de um caractere em relação a um caractere diferente que não é igual, neste caso, o
b
anana, é o conjunto vazio, pois não combinamos o caractere específico. - A derivada de uma
or
expressão é aor
das derivadas. Ele simplesmente empurra o problema para seus filhos. - A derivada da
concat
expressão deve considerar se pode pular a primeira expressão. Ele só pode ignorar a primeira expressão se a primeira expressão corresponder à string vazia e for anulável. Então, a primeira coisa que fazemos é verificar isso. Vamos pensar no caso em que não é possível pular a primeira expressão quando a expressãor
não é anulável. Então a derivada é a derivada da primeira expressãoconcatenated
com a segunda expressãos
. Se pudermos pular a primeira regex, teremos que considerar uma alternativa que seja apenas a derivada da segunda expressão. Podemos, então,or
as duas alternativas de pularr
e não pularr
e retornar isso como resultado. - Finalmente, temos o
star
operador. Corresponde a uma expressão zero ou mais vezes. Como estamos passando um caractere, esse não é o caso zero. Portanto, temos que considerar oone or more
caso. Isso significa que temos que derivar a expressão dentro destar
econcatenate
novamente com azero or more
expressão.
Exemplo de derivada 1
Vamos calcular a derivada de (ab)*
em relação a a
.
(ab)*
é uma zero or more
expressão, então olhamos para a zero or more
regra. Vemos que isso requer a derivada da expressão dentro de star
.
Este é o concatenation
de a
e b
. Portanto, verificamos se o lado esquerdo é anulável e o caractere a
não é anulável. Isso significa que não podemos ignorá-lo. Temos que calcular a derivada de a
em relação a a
. Mas essa é a string vazia, então se nós concatenate
a string vazia com o lado direito, que era b
, obtemos b
.
Agora, recursamos de volta para zero or more
, lembre-se de que tiramos a derivada de ab
em relação a a
e retornamos a b
. Agora podemos concatenar isso com (ab)*
novamente e obtemos b(ab)*
.
Exemplo de derivada 2
Vamos calcular a derivada de (a*ba)
em relação a b
.
a*
é concatenadoba
, então vamos dar uma olhada na regra de concatenação.- Verificamos se o lado esquerdo
a*
é anulável, o que é. Isso significa que podemos ignorá-lo, o que também significa que temos que criar umaor
de duas derivadas. - O lado esquerdo acaba não combinando, já
a*
que não combinab
. - Felizmente, temos uma alternativa, o arquivo
ba
. A derivada deba
em relação ab
é ea
.
Eu pulei alguns detalhes aqui. Considere um exercício verificar meu trabalho percorrendo você mesmo a função.
exercícios derivados
Tente percorrer a implementação e verifique quais são as derivadas das seguintes expressões regulares em relação a b
. Você pode clicar neles para verificar sua resposta:
- εb
- b*(b|c)
- a*(b|c)
- bεb
- ∅*b
Espero que agora você entenda por que comer cerejas vermelhas lhe dá a capacidade de comer fantasmas azuis e como implementar um correspondente de expressão regular usando o algoritmo derivado.
Cobrimos o algoritmo de trabalho básico aqui, mas há muitas maneiras de tornar esse algoritmo ainda melhor com pequenos ajustes. Nós trapaceamos e encobrimos as regras de simplificação neste post, usando-as sem explicá-las, o que se tornará especialmente óbvio se você percorrer os exercícios. Também não discutimos como você pode usar memoização para construir preguiçosamente um autômato eficiente.
Também podemos facilmente estender o algoritmo para incluir novos operadores como, not
e interleave
até mesmo suportar gramáticas livres de contexto. Vou discutir alguns desses tópicos no próximo artigo .
Enquanto isso, eu adoraria ver sua implementação desse algoritmo em uma linguagem de programação com a qual você se sinta mais confortável. Por favor, envie-me um link nos comentários.
Obrigado
- Brink van der Merwe por dedicar seu tempo para me explicar esse algoritmo.
- Brzozowski, Janusz A. “Derivadas de expressões regulares.” Journal of the ACM (JACM) 11.4 (1964): 481–494.
- Owens, Scott, John Reppy e Aaron Turon. “Derivados de expressão regular reexaminados.” Journal of Functional Programming 19.2 (2009): 173–190.