trilha

Fundamentos de Programação/01 - Fundamentos Duros/Semana 02 - Estruturas de Dados I8 min

Tratamento de Colisão

Perguntas-guia
  • Por que colisão é inevitável, e não um bug da função de hash?
  • Encadeamento separado vs endereçamento aberto: o que cada um paga?
  • O que acontece com remoção em endereçamento aberto?
  • Como um atacante transformaria colisões em negação de serviço?

Conceito

Colisão não é falha da função de hash — é matematicamente inevitável. O espaço de chaves possíveis é maior que o de buckets (princípio da casa dos pombos), então existem necessariamente chaves diferentes que caem no mesmo lugar. Um hash map é definido tanto pela função de hash quanto pela política de colisão.

Encadeamento separado. Cada bucket guarda uma coleção (tipicamente uma 3. Lista Ligada) com todos os elementos que caíram nele.

  • simples de implementar e de raciocinar
  • tolera fator de carga > 1 (mais elementos que buckets, com degradação suave)
  • remoção é trivial
  • custa um ponteiro por elemento e péssima localidade — cada passo na cadeia é um possível cache miss

Endereçamento aberto. Não há cadeia: se o bucket está ocupado, você sonda o próximo segundo uma regra — linear (+1), quadrática (+i²), ou double hashing (passo derivado de um segundo hash).

  • excelente localidade: tudo no mesmo array contíguo
  • não aloca por elemento
  • exige fator de carga < 1, com degradação abrupta perto do limite
  • linear probing sofre de clustering: elementos agrupados fazem grupos crescerem, e grupos maiores aumentam a chance de crescerem mais
O problema da remoção em endereçamento aberto

Se você simplesmente esvazia o slot, quebra a cadeia de sondagem: uma busca que passava por ali para chegar num elemento mais adiante encontra um slot vazio, conclui "não existe" e para. O elemento existe e ficou inacessível.

A saída é o tombstone — marcar o slot como "removido, mas continue sondando". E tombstones acumulam: eles não liberam espaço para a busca, só para a inserção, então uma tabela com muita remoção degrada até precisar de rehash.

O ataque de colisão. Se o hash é determinístico e público, o atacante gera milhares de chaves que colidem, e cada inserção passa a percorrer uma cadeia enorme: O(1) vira O(n), e o serviço cai com pouquíssimo tráfego. Foi um CVE em massa em 2011, atingindo praticamente todas as linguagens. A mitigação padrão hoje é semente aleatória por processo (e SipHash como função), de modo que o atacante não pode prever os buckets.

Em Go

Você não escolhe a política — e essa é a diferença mais visível em relação a Java, onde HashMap expõe capacidade inicial e fator de carga.

Como o map de Go evoluiu:

Versão Política
até Go 1.23 buckets de 8 slots + array de tophash para descarte rápido, e overflow buckets encadeados quando o bucket enche — um híbrido de encadeamento com blocos
Go 1.24+ Swiss Tables: grupos de 8 slots com bytes de metadados de controle, sondados em paralelo — endereçamento aberto por grupos

O tophash (e o metadado das Swiss Tables) é a otimização que importa entender: em vez de comparar a chave inteira em cada slot, o map compara um byte do hash primeiro. Só quando esse byte casa é que a chave completa é comparada. Isso troca comparações caras por comparações baratas — o mesmo tipo de raciocínio de um índice de banco.

Proteção contra hash flooding: semente aleatória por processo, desde sempre. É por isso que a ordem de iteração varia entre execuções mesmo com as mesmas chaves.

Seu único controle: make(map[K]V, n) para dimensionar antecipadamente e evitar rehashes, e a escolha do tipo da chave — chave grande significa hash mais caro e comparação mais caro em toda operação.

Respostas às perguntas-guia

1. Por que colisão é inevitável, e não um bug da função de hash?

Conceito: casa dos pombos — há mais chaves possíveis que buckets. A função de hash boa minimiza colisões; nenhuma as elimina.

Em Go: o map foi projetado assumindo colisão como caso normal — os grupos de 8 slots existem justamente para absorvê-la sem sair da linha de cache.

2. Encadeamento separado vs endereçamento aberto: o que cada um paga?

Conceito: encadeamento paga ponteiros e cache misses, e ganha simplicidade, tolerância a carga alta e remoção trivial. Endereçamento aberto paga fragilidade (carga < 1, clustering, tombstones) e ganha localidade.

Em Go: o histórico da linguagem é um deslocamento de um para o outro — de buckets encadeados para Swiss Tables — porque em hardware moderno a localidade vale mais que a simplicidade.

3. O que acontece com remoção em endereçamento aberto?

Conceito: esvaziar o slot quebra a cadeia de sondagem e torna elementos posteriores inacessíveis. Precisa de tombstone, e tombstones degradam a busca até o rehash.

Em Go: o delete marca o slot (não é um "vazio" real) exatamente por isso. E é por isso que um map que teve muitas remoções não devolve memória — em Go o map nunca encolhe: para recuperar memória você cria um novo map e copia.

4. Como um atacante transformaria colisões em negação de serviço?

Conceito: enviando chaves escolhidas para colidir — cada inserção percorre a cadeia inteira, e O(1) vira O(n) com custo mínimo para o atacante.

Em Go: inviável de fora, pela semente aleatória por processo. Mas atenção: isso protege o map builtin. Se você implementa seu próprio índice com um hash fixo (FNV, CRC32) sobre entrada de usuário, você reintroduz a vulnerabilidade.

Trade-offs

Do conceito:

  • Encadeamento: robusto, simples, mais lento no caso comum.
  • Endereçamento aberto: mais rápido no caso comum, frágil no limite, remoção complicada.
  • Hash rápido e não-semeado vs semeado: o segundo custa alguns nanossegundos e remove uma classe inteira de DoS. Para chave que vem de fora, não é escolha.

Em Go:

  • Nada configurável: menos botões, menos tuning possível — e menos como errar.
  • Map que nunca encolhe é uma pegadinha de memória real em cache de vida longa; a solução é recriar o map periodicamente.
  • Semente aleatória custa a ordem de iteração determinística, que você não deveria querer de qualquer forma.

Exemplo prático

Os dois esquemas à mão, incluindo o bug da remoção sem tombstone:

package main

import "fmt"

func h(k string, n int) int { // hash proposital ruim: só o primeiro byte
	if k == "" {
		return 0
	}
	return int(k[0]) % n
}

// ---------- Encadeamento separado ----------
type Encadeado struct{ buckets [][]struct{ K string; V int } }

func NovoEncadeado(n int) *Encadeado {
	return &Encadeado{buckets: make([][]struct{ K string; V int }, n)}
}
func (m *Encadeado) Put(k string, v int) {
	i := h(k, len(m.buckets))
	for j := range m.buckets[i] {
		if m.buckets[i][j].K == k {
			m.buckets[i][j].V = v
			return
		}
	}
	m.buckets[i] = append(m.buckets[i], struct{ K string; V int }{k, v})
}
func (m *Encadeado) Get(k string) (int, bool) {
	for _, e := range m.buckets[h(k, len(m.buckets))] {
		if e.K == k {
			return e.V, true
		}
	}
	return 0, false
}

// ---------- Endereçamento aberto, com e sem tombstone ----------
const vazio, morto = 0, 2 // 0=vazio 1=ocupado 2=tombstone

type Aberto struct {
	ks     []string
	vs     []int
	estado []int
	usarTombstone bool
}

func NovoAberto(n int, tomb bool) *Aberto {
	return &Aberto{ks: make([]string, n), vs: make([]int, n),
		estado: make([]int, n), usarTombstone: tomb}
}
func (m *Aberto) Put(k string, v int) {
	i := h(k, len(m.ks))
	for n := 0; n < len(m.ks); n++ {
		j := (i + n) % len(m.ks)
		if m.estado[j] != 1 || m.ks[j] == k {
			m.ks[j], m.vs[j], m.estado[j] = k, v, 1
			return
		}
	}
}
func (m *Aberto) Get(k string) (int, bool) {
	i := h(k, len(m.ks))
	for n := 0; n < len(m.ks); n++ {
		j := (i + n) % len(m.ks)
		if m.estado[j] == vazio {
			return 0, false // para a sondagem: aqui está o bug, se houve remoção
		}
		if m.estado[j] == 1 && m.ks[j] == k {
			return m.vs[j], true
		}
	}
	return 0, false
}
func (m *Aberto) Delete(k string) {
	i := h(k, len(m.ks))
	for n := 0; n < len(m.ks); n++ {
		j := (i + n) % len(m.ks)
		if m.estado[j] == 1 && m.ks[j] == k {
			if m.usarTombstone {
				m.estado[j] = morto // "removido, mas continue sondando"
			} else {
				m.estado[j] = vazio // BUG: quebra a cadeia
			}
			return
		}
	}
}

func main() {
	// "ab", "ax", "az" colidem: mesmo primeiro byte
	e := NovoEncadeado(4)
	for i, k := range []string{"ab", "ax", "az"} {
		e.Put(k, i)
	}
	v, _ := e.Get("az")
	fmt.Println("encadeado, colidindo, acha az:", v == 2) // true

	for _, tomb := range []bool{false, true} {
		a := NovoAberto(8, tomb)
		a.Put("ab", 1)
		a.Put("ax", 2) // colide com ab -> sonda pro proximo slot
		a.Put("az", 3) // colide -> sonda mais
		a.Delete("ax") // remove o do MEIO da cadeia
		_, achou := a.Get("az")
		fmt.Printf("aberto tombstone=%-5v ainda acha az? %v\n", tomb, achou)
	}
}

A última saída é o ponto da nota: sem tombstone, remover um elemento do meio da cadeia esconde os que vinham depois — sem erro, sem panic, sem aviso.

Relacionado

  • 6. Hash Map — a estrutura que isto sustenta
  • 3. Lista Ligada — o que fica em cada bucket no encadeamento
  • 5. Índices — semana 9, o mesmo raciocínio de comparação barata antes da caro
  • 4. Mutabilidade — semana 1, chave que muda é o outro jeito de perder o elemento

Parte de Semana 02 - Estruturas de Dados I · 00 - MOC Fundamentos de Programação

Buscar

Busca por título, seção e texto das notas