Como usar Hash Table em Python
Construa uma Tabela Hash em Python com TDD
por Bartosz Zaczyński (estrutura de dados, intermediário)
Introdução
Inventada há mais de meio século, a tabela hash é uma estrutura de dados clássica que tem sido fundamental para a programação. Até hoje, ela ajuda a resolver muitos problemas da vida real, como indexar tabelas de banco de dados, armazenar valores computados em cache ou implementar conjuntos. Ela é frequentemente mencionada em entrevistas de emprego e o Python usa tabelas hash em diversos lugares para tornar as pesquisas de nomes quase instantâneas.
Embora o Python já possua sua própria implementação de tabela hash chamada dict
, é útil entender como as tabelas hash funcionam nos bastidores. Pode ser que você precise implementar uma tabela hash como parte de uma avaliação de programação. Este tutorial o guiará pelos passos de implementação de uma tabela hash do zero, como se não houvesse uma implementação nativa no Python. Ao longo do caminho, você enfrentará alguns desafios que introduzirão conceitos importantes e lhe darão uma ideia do porquê as tabelas hash são tão rápidas.
Além disso, você terá uma introdução prática e detalhada sobre desenvolvimento orientado a testes (TDD) e a praticará ativamente ao construir a tabela hash, seguindo passo a passo. Você não precisa ter experiência prévia com TDD, mas também não ficará entediado mesmo se já tiver.
Tópicos abordados neste tutorial
Neste tutorial, você aprenderá:
- Como uma tabela hash difere de um dicionário
- Como implementar uma tabela hash do zero em Python
- Como lidar com colisões de hash e outros desafios
- Quais são as propriedades desejadas de uma função de hash
- Como a função
hash()
do Python funciona nos bastidores
Para acompanhar este tutorial, é útil estar familiarizado com os dicionários do Python e ter conhecimentos básicos sobre programação orientada a objetos.
1. Conheça a estrutura de dados da tabela hash
1.1. Tabela hash vs. dicionário
Embora tenham propósitos semelhantes, uma tabela hash e um dicionário no Python têm algumas diferenças fundamentais:
- Em uma tabela hash, os elementos são armazenados em uma matriz e são acessados por meio de uma função de hash.
- Em um dicionário, os elementos são armazenados em uma tabela de dispersão, onde cada elemento é armazenado em um determinado local calculado com base em uma função de dispersão.
No Python, os dicionários (dict
) são implementados usando uma estrutura de tabela hash. Portanto, eles compartilham muitos conceitos fundamentais com tabelas hash, como a utilização de funções de hash para localização eficiente de elementos.
1.2. Tabela hash: uma matriz com uma função de hash
Em sua implementação mais básica, uma tabela hash é uma matriz em que os elementos são armazenados nas posições calculadas a partir de um valor de chave. Essa posição é determinada por uma função de hash, que mapeia o valor da chave para um índice na matriz.
No Python, as tabelas hash são implementadas internamente como uma matriz de ponteiros para pares chave-valor. Cada elemento é armazenado em um determinado índice da matriz com base na saída da função de hash aplicada à chave correspondente.
2. Entenda a função de hash
Uma função de hash é uma função que mapeia um valor de entrada para um valor hash, que é um número inteiro. No contexto das tabelas hash, a função de hash é usada para calcular o índice onde um elemento será armazenado na matriz.
2.1. Examine a função de hash incorporada do Python
No Python, você pode obter o valor hash de um objeto usando a função hash()
. Essa função retorna um número inteiro que representa o valor hash de um objeto específico.
Por exemplo, você pode obter o valor hash de uma string da seguinte maneira:
2.2. Se aprofunde na função de hash do Python
Por trás dos panos, a função hash()
chama uma função de hash específica para cada tipo de objeto. Essa função é fornecida pela implementação do Python para cada tipo de objeto.
A função de hash de um objeto é responsável por gerar um número inteiro (hash
) com base no valor do objeto. Esse número é usado para calcular a posição do objeto na tabela hash.
2.3. Identificar as propriedades da função de hash
Uma função de hash ideal deve possuir as seguintes propriedades:
- Eficiência: a função deve ser rápida, pois é chamada frequentemente ao inserir, pesquisar e excluir elementos da tabela hash.
- Uniformidade: a função deve distribuir os elementos uniformemente na matriz subjacente da tabela hash.
- Determinismo: a função deve retornar o mesmo valor hash para o mesmo valor de entrada em diferentes momentos.
- Minimização de colisões: a função deve minimizar as colisões de hash, ou seja, diferentes valores de entrada devem gerar diferentes valores hash sempre que possível.
2.4. Comparar a identidade de um objeto com seu hash
Ao trabalhar com tabelas hash, é importante entender a diferença entre a identidade do objeto e seu valor hash. A identidade do objeto é única e não muda durante a vida útil do objeto. O valor hash de um objeto, por outro lado, é gerado pela função de hash e pode mudar ao longo do tempo.
No Python, você pode verificar se dois objetos têm a mesma identidade usando o operador is
ou id()
:
No entanto, a comparação de valores hash só é útil quando você deseja verificar se dois objetos são iguais em termos de seus valores hash, mas não em termos de sua identidade. Isso pode ser feito usando o operador de igualdade (==
):
2.5. Crie sua própria função de hash
Em determinadas situações, você pode precisar criar sua própria função de hash personalizada. Esta função deve seguir as propriedades desejadas de uma função de hash ideal mencionadas anteriormente.
Uma função de hash personalizada pode ser definida como qualquer função que mapeia um valor de entrada para um valor hash. Para objetos imutáveis, essa função pode ser implementada de maneira relativamente direta. No entanto, para objetos mutáveis, você precisará tomar cuidado para garantir que a função de hash não permita que o objeto mude após o cálculo do valor hash.
3. Construa um protótipo de tabela hash em Python com TDD
Para construir uma tabela hash funcional, é importante testar cada funcionalidade individualmente. O desenvolvimento orientado a testes permite que você crie seus testes antes de implementar as funcionalidades, garantindo que as mesmas funcionem corretamente.
3.1. Faça um curso intensivo de desenvolvimento orientado a testes
Test-driven development (Desenvolvimento Orientado a Testes - TDD) é uma abordagem de desenvolvimento de software em que você escreve testes automatizados antes de implementar o código funcional. Esses testes guiam o processo de desenvolvimento e garantem que o código funcione como esperado.
Para implementar uma tabela hash, é recomendável ter uma compreensão básica de TDD e como criá-lo. Você pode aprender mais sobre TDD no curso “Write Better Python Code With Pytest”.
3.2. Defina uma classe HashTable personalizada
A primeira etapa para construir uma tabela hash é definir uma classe que será responsável por armazenar os elementos e realizar as operações essenciais, como inserção, pesquisa, exclusão e atualização de pares chave-valor.
Na classe HashTable
, inicializamos duas listas vazias, keys
e values
, com um tamanho inicial de 10.
3.3. Inserir um par chave-valor
Para inserir um par chave-valor na tabela hash, você precisa calcular o índice usando a função de hash e armazenar o par nas listas keys
e values
nos respectivos índices.
Se o índice obtido pela função de hash já estiver ocupado, você deve lidar com a colisão de hash. Neste exemplo, usamos uma técnica chamada linear probing para encontrar o próximo índice disponível. Caso a chave já esteja presente na tabela, apenas atualizamos o valor.
3.4. Encontre um valor pela chave
Para encontrar um valor pela chave na tabela hash, basta obter o índice usando a função de hash e retornar o valor na lista values
no índice correspondente.
Se a chave não estiver presente na tabela hash, retornamos None
.
3.5. Excluir um par chave-valor
Para excluir um par chave-valor da tabela hash, você deve encontrar o índice correspondente à chave usando a função de hash e definir os elementos keys
e values
no índice como None
.
3.6. Atualizar o valor de um par existente
Para atualizar o valor de um par chave-valor existente na tabela hash, você deve encontrar o índice correspondente à chave usando a função de hash e atribuir o novo valor à lista values
no índice correspondente.
Se a chave não estiver presente na tabela hash, essa função não fará nada.
3.7. Obtenha os pares chave-valor
Para obter todos os pares chave-valor da tabela hash, você pode percorrer ambas as listas keys
e values
e retornar apenas os elementos que não sejam None
.
3.8. Utilize cópia defensiva
Ao retornar os pares chave-valor, é importante utilizar uma cópia defensiva para evitar que os valores sejam alterados acidentalmente fora da tabela hash.
3.9. Obtenha as chaves e os valores
Para obter apenas as chaves e valores da tabela hash, você pode percorrer as respectivas listas keys
e values
e retornar somente os elementos diferentes de None
.
3.10. Consulte o tamanho da tabela hash
Para saber o tamanho atual da tabela hash, você pode percorrer a lista de chaves e contar a quantidade de elementos diferentes de None
.
3.11. Torne a tabela hash iterável
Se você quiser utilizar a tabela hash em um laço for
ou desejar verificar se um determinado elemento está presente nela, é necessário torná-la iterável. Você pode fazer isso definindo o método __iter__()
na classe HashTable
.
3.12. Represente a tabela hash em texto
Para representar visualmente a tabela hash em formato de texto, você pode usar o método especial __str__()
.
3.13. Teste a igualdade das tabelas hash
Ao comparar duas tabelas hash, é importante verificar se sua estrutura e elementos são iguais. Você pode fazer isso definindo o método especial __eq__()
.
Isso permitirá que você compare duas tabelas hash usando o operador de igualdade (==
).
4. Resolva colisões de código hash
Quando você insere elementos em uma tabela hash, pode ocorrer uma colisão de código hash se dois elementos possuírem a mesma saída de função de hash. Várias estratégias de resolução de colisões são utilizadas para lidar com esse problema.
4.1. Encontre chaves colididas por linear probing
Uma maneira comum de lidar com colisões de código hash é usar o linear probing. Isso significa que, se o índice calculado para um elemento já estiver ocupado, você fará um rehashing linear para encontrar o próximo índice disponível.
A função rehash()
retorna o próximo índice disponível calculado a partir do índice atual.
4.2. Use linear probing na classe HashTable
Agora que você já sabe como funciona o linear probing, pode utilizá-lo em sua classe HashTable
.
4.3. Permita redimensionamento automático da tabela hash
Uma tabela hash deve ser redimensionada automaticamente quando o fator de carga excede um determinado limite. O fator de carga é calculado dividindo a contagem total de elementos pelo tamanho da tabela.
A função resize()
é responsável por redimensionar a tabela hash sempre que o fator de carga exceder o limite definido (0,7 neste caso). Quando isso acontece, o tamanho da tabela é duplicado e todos os elementos são reorganizados nos novos arrays keys
e values
de acordo com a nova função de hash.
4.4. Calcule o fator de carga
Para calcular o fator de carga, você pode utilizar o método length()
para contar o número de elementos presentes na tabela.
4.5. Isolate chaves colididas com chaining separado
Uma alternativa ao linear probing é o chaining separado, onde cada slot na tabela hash é uma lista ligada. Quando ocorre uma colisão de código hash, os elementos são adicionados à lista correspondente.
Na classe HashTable
, substituímos as listas keys
e values
por uma lista de listas, chamada slots
. Cada slot é uma lista que armazena pares chave-valor.
5. Mantenha a ordem de inserção em uma tabela hash
Por padrão, as tabelas hash não mantêm a ordem de inserção dos elementos. No entanto, é possível implementar uma tabela hash que preserve a ordem de inserção.
A classe OrderedHashTable
é semelhante à classe HashTable
, mas inclui uma lista adicional chamada order
, que mantém a ordem de inserção dos elementos. O método items()
percorre a lista order
em vez de percorrer toda a matriz para retornar os pares chave-valor.
6. Use chaves hasháveis
Para que um objeto possa ser usado como chave em uma tabela hash, ele precisa ser hashable. Isso significa que o objeto deve ter uma função de hash definida e ser imutável.
6.1. Hashabilidade vs. imutabilidade
Uma característica importante de um objeto hashable é ser imutável. Isso significa que seus atributos não podem ser alterados após a criação do objeto. Se o objeto for mutável, a função de hash pode retornar valores diferentes para o mesmo objeto.
6.2. O contrato Hash-Equal
O Python impõe um contrato entre as funções __hash__()
e __eq__()
de um objeto. O objeto deve ter uma função __hash__()
implementada e uma função __eq__()
para verificar a igualdade entre objetos. Se dois objetos forem considerados iguais pelo método __eq__()
, suas funções __hash__()
também devem retornar valores iguais.
7. Conclusão
As tabelas hash são uma estrutura de dados poderosa e amplamente utilizada em muitas áreas da programação. Elas permitem pesquisas rápidas e eficientes de elementos com base em uma chave. Neste tutorial, você aprendeu como construir uma tabela hash em Python, desde entender os conceitos básicos da tabela hash até a implementação de um protótipo com testes e lidando com colisões de hash.
Implementar sua própria tabela hash pode ser uma experiência valiosa, pois permite entender como elas funcionam nos bastidores. Além disso, o desenvolvimento orientado a testes (TDD) o ajudará a criar um código mais robusto e confiável.
Agora você está equipado com o conhecimento necessário para construir e trabalhar com tabelas hash em Python. Essa habilidade será útil em muitos cenários onde a eficiência e a velocidade de pesquisa são fundamentais.