antonio leandro

estruturas e algoritmos

Skip Lists: A Probabilistic Alternative to Balanced Trees

paper · núcleo · William Pugh · · ~27 min de leitura do original

a tese

dá para trocar rotação por sorteio: se cada nó decide no dado quantos ponteiros de atalho carrega, a busca continua logarítmica e a inserção deixa de ser um algoritmo — vira um splice de ponteiros

o que fica

  1. O nível de um nó é sorteado na inserção e nunca muda, então insert e delete só mexem em ponteiros locais — não existe rotação nem rebalanceamento.
  2. A busca da skip list é levemente mais lenta que a de uma AVL não recursiva (0,91 do tempo dela); o ganho está em inserção e remoção, 1,55 e 1,46 vezes mais rápidas.
  3. Pugh recomenda p = 1/4 e não p = 1/2: o tempo de busca é praticamente o mesmo e o custo cai de 2 para 1,33 ponteiros por nó, ao preço de mais variância.
  4. A garantia é probabilística, não de pior caso: numa lista de 1.000 elementos, a chance de uma busca custar 5 vezes o esperado é cerca de 1 em 10^18.
  5. A aleatoriedade só protege enquanto o adversário não enxerga os níveis dos nós; quem consegue ler os níveis apaga todos os que não são nível 1 e recria o pior caso.
  6. Duas buscas pelo mesmo elemento custam exatamente o mesmo tempo, então repetir consulta multiplica a variância em vez de diluí-la.

o problema

Árvore binária de busca funciona bem quando os elementos chegam em ordem aleatória. Chegando em ordem crescente, ela degenera numa lista ligada e a busca vira linear. Se desse para permutar a entrada antes de inserir, o problema sumiria com alta probabilidade para qualquer sequência — mas consultas quase sempre são respondidas online, então permutar é impraticável. A resposta clássica é a árvore balanceada: AVL, 2-3, splay. Todas reorganizam a estrutura a cada operação para manter uma invariante e assim garantir desempenho.

O preço não aparece na notação assintótica, aparece no código. Pugh observa que implementar árvore balanceada é uma tarefa exigente e que, na prática, essas estruturas raramente são implementadas fora de exercício de disciplina de estrutura de dados. Pior: a complexidade desencoraja otimização. Insert e delete de árvore balanceada são normalmente descritos de forma recursiva porque é assim que ficam inteligíveis; a versão não recursiva, que elimina o overhead de chamada, é intimidante o bastante para que quase ninguém escreva. O algoritmo fica mais lento porque é difícil demais para ser mexido.

a ideia

Numa lista ligada ordenada, a busca examina até n nós. Se cada segundo nó também tiver um ponteiro apontando dois adiante, bastam n/2 + 1. Com ponteiro quatro adiante em cada quarto nó, n/4 + 2. Levando ao limite — cada (2^i)-ésimo nó com um ponteiro 2^i adiante —, a busca cai para log2 n dobrando apenas o número de ponteiros. Ótimo para procurar, inútil para escrever: inserir no meio obriga a reetiquetar meia estrutura.

O movimento de Pugh é olhar para a distribuição em vez do padrão. Naquele arranjo rígido, 50% dos nós são nível 1, 25% são nível 2, 12,5% são nível 3. E se os níveis fossem sorteados nessas mesmas proporções, sem exigir posição? O i-ésimo ponteiro de um nó deixa de apontar 2^(i-1) nós adiante e passa a apontar para o próximo nó de nível i ou maior. A estrutura resultante não depende da ordem em que os elementos chegaram: depende só de quantos são e do gerador de números aleatórios.

como funciona

O sorteio é uma moeda viciada em p:

randomLevel()
  lvl := 1
  while random() < p and lvl < MaxLevel do
    lvl := lvl + 1
  return lvl

A busca começa no nível mais alto presente na lista e desce:

Search(list, searchKey)
  x := list→header
  for i := list→level downto 1 do
    while x→forward[i]→key < searchKey do
      x := x→forward[i]
  x := x→forward[1]

Insert e delete usam exatamente essa varredura, guardando num vetor update[i] o nó mais à direita de nível i ou maior que fica à esquerda do ponto de operação. Depois é só splice. O nível não precisa ser guardado no nó, e não existe informação de balanceamento ou prioridade a manter. O loop interno da deleção compila para seis instruções no 68020.

MaxLevel é fixado em L(N) = log_{1/p} N, com N sendo um limite superior de elementos: para p = 1/2 e até 2^16 elementos, 16 basta. O custo esperado da busca é limitado por L(n)/p + 1/(1-p), ou seja, O(log n), e o número de comparações é o comprimento do caminho mais um. Há uma variante tentadora — se o sorteio devolve nível maior que o máximo atual mais um, use máximo mais um — que funciona bem na prática e destrói a análise, porque o nível deixa de ser aleatório.

o que isso custou

O pior caso continua ruim; o que muda é que nenhuma sequência de entrada o produz consistentemente. Isso é bom o suficiente para tempo real com margem de segurança, mas não é um bound de pior caso, e Pugh escreve isso na conclusão: do ponto de vista teórico não há necessidade de skip lists, porque árvores balanceadas fazem tudo e com garantia mais forte.

Três limites concretos. Primeiro, a proteção depende de o usuário não ver os níveis dos nós — quem os vê apaga os que não são nível 1 e reconstrói o pior caso. Segundo, a variância não se dilui com repetição: duas buscas pelo mesmo elemento custam o mesmo tempo, e buscar sempre o mesmo item maximiza a variância do total. Terceiro, a skip list faz mais comparações que os outros métodos, L(n)/p + 1/(1-p) + 1 em média; com chaves de ponto flutuante ela ficou um pouco mais lenta que a AVL não recursiva na busca, e a busca em árvore 2-3 também foi mais rápida. Nas medições em 2^16 elementos com chaves inteiras, a busca da skip list custou 0,051 ms contra 0,046 ms da AVL. O que ganha é escrita: 0,065 ms contra 0,10 ms na inserção.

Consulta enviesada também é um ponto fraco. Árvore auto-ajustável se adapta a distribuições muito assimétricas; skip list não, e a saída sugerida é combiná-la com um cache.

onde isso aparece hoje

A consequência prática maior não está no tempo de busca, está na ausência de rotação. Como toda escrita é local, dá para atualizar a estrutura concorrentemente com pouca contenção — Pugh já reporta no artigo um algoritmo de manutenção concorrente que admite leitores ilimitados e n escritores ativos numa lista de n elementos, e que é muito mais simples que o equivalente para árvore balanceada. É esse atributo, mais que a simplicidade do código, que fez a skip list virar o padrão para a estrutura em memória de motores de armazenamento: a memtable padrão do RocksDB é uma skip list, e é ela que absorve escrita concorrente antes do flush em The Log-Structured Merge-Tree (LSM-Tree), documentado em uso de produção em Evolution of Development Priorities in Key-value Stores.

A ideia de balanceamento por sorteio também subiu de escala: Skip Graphs leva a mesma construção para uma rede distribuída, onde não existe nó central para manter invariante nenhuma e o sorteio é a única coordenação disponível. E o próprio artigo aponta a variante que substitui o gerador aleatório por uma função de hash do elemento: aí cada conjunto tem uma representação única e, com alta probabilidade, aproximadamente balanceada.

lido na íntegra por pipeline de llm, revisado por antonio leandro antes de publicar ·