← Todas as estruturas

Skip List

Linear · ver código-fonte no GitHub

Leia em: English · Português · Español

Categoria: Linear

O problema

O Binary Search Tree deste repositório consegue busca O(log n) e travessia ordenada, mas apenas quando a ordem de inserção coopera — entradas ordenadas ou adversariais o degeneram para uma cadeia O(n), e corrigir isso estruturalmente significa rotações e controle de balanceamento (rebalanceamento a cada inserção). Existe uma forma mais simples de obter busca, inserção e remoção ordenadas com O(log n) esperado, sem nenhuma lógica de rotação?

A solução

Empilhe várias listas ligadas umas sobre as outras. O nível 0 é uma lista ligada ordenada simples, contendo todas as chaves. Cada nível acima dele contém um subconjunto aleatório das chaves do nível abaixo — aproximadamente metade, em média — de forma que uma busca pode começar no nível mais alto e "pular" grandes trechos da lista, descendo um nível apenas quando o próximo nó do nível atual ultrapassaria a chave-alvo. A estrutura vem de um lançamento de moeda feito uma vez por nó inserido (p = 0.5: participar de mais um nível, ou parar) — nunca de rotacionar algo posteriormente. Em média, esse lançamento de moeda entrega o mesmo custo de busca logarítmico que uma árvore balanceada precisa trabalhar muito mais para alcançar.

flowchart LR
    subgraph L2["level 2"]
        direction LR
        H2["head"] --> N30_2["30"] --> N70_2["70"]
    end
    subgraph L1["level 1"]
        direction LR
        H1["head"] --> N10_1["10"] --> N30_1["30"] --> N50_1["50"] --> N70_1["70"]
    end
    subgraph L0["level 0 (every key)"]
        direction LR
        H0["head"] --> N10_0["10"] --> N20_0["20"] --> N30_0["30"] --> N50_0["50"] --> N60_0["60"] --> N70_0["70"]
    end
Operação Esperado Por quê
get / put / remove / contains O(log n) cada nível pulado reduz aproximadamente pela metade o espaço de busca restante, o mesmo formato da altura de uma árvore balanceada
firstKey O(1) o sucessor de nível 0 da sentinela head é sempre a menor chave

Exemplo clássico

classic/SkipList é uma lista ligada em camadas construída do zero — sem java.util.concurrent.ConcurrentSkipListMap. Um nó sentinela head mantém um array de ponteiros forward dimensionado para um nível máximo limitado (16); o array forward de cada nó inserido é dimensionado para o nível que seu lançamento de moeda determinou (p = 0.5 por nível extra, via ThreadLocalRandom). Nada aqui rotaciona ou rebalanceia — o formato O(log n) emerge estatisticamente de muitos lançamentos de moeda independentes, não de nenhum controle por operação. SkipListTest não fixa a semente do Random nem faz asserções sobre a estrutura exata de níveis (ambos são explicitamente o tipo errado de coisa a testar em uma estrutura probabilística); em vez disso, insere 500 chaves em ordem embaralhada, o que atinge os dois resultados possíveis do lançamento de moeda — o nível de um nó crescendo além de 1, e um nó permanecendo no nível 1 — com probabilidade esmagadora, e então faz asserções puramente sobre correção funcional: toda chave é recuperável, remove desconecta corretamente um nó em cada nível em que ele participava, e o nível geral da lista diminui corretamente conforme os nós mais altos são removidos.

Exemplo aplicado: janela deslizante de limitação de taxa

applied/RateLimitWindow é um índice ordenado para uma janela deslizante de limitação de taxa, indexado por timestamp da requisição (epoch millis) → contagem de requisições, apoiado diretamente em SkipList<Long, Integer>. Este é um contraste deliberado com o IdempotencyKeyCache#evictOlderThan do módulo Hash Table deste repositório — leia aquela classe primeiro. Uma hash table não tem ordenação, então expirar suas entradas antigas é honestamente uma varredura completa O(n); não há opção melhor disponível para ela. Aqui, evictOlderThan percorre a própria ordem crescente de chaves da skip list: firstKey() é O(1) (a menor chave é sempre o sucessor de nível 0 da sentinela) e cada remove é O(log n), então expirar k timestamps vencidos custa O(k log n), não O(n) sobre cada timestamp ainda na janela — a ordenação da skip list é o que torna isso possível, e uma hash table estruturalmente não pode oferecer isso. RateLimitWindowTest cobre requisições repetidas no mesmo timestamp, uma expiração parcial que remove apenas timestamps vencidos, um corte anterior a todos os timestamps (sem efeito), e um corte que esvazia a janela inteira.

Benchmark

./gradlew :linear:skip-list:jmh

Execução real nesta máquina (JMH 1.37, JDK 26.0.2, 2 iterações de aquecimento + 3 de medição, 1 fork). Mesmo estilo do benchmark do módulo Binary Search Tree: um único get contra uma estrutura já populada de cada tamanho.

Custo de get size=100 size=10,000 size=100,000
skip list 35.94 ns 120.49 ns 164.57 ns

Ir de size=100 para size=10,000 (um aumento de 100x nos dados) torna o get apenas ~3.4x mais lento; ir de size=10,000 para size=100,000 (um aumento adicional de 10x) torna apenas ~1.4x mais lento — multiplicadores decrescentes para o mesmo crescimento proporcional dos dados, a marca registrada de uma escala sublinear, tipo logarítmica. Para comparação, log2 cresce exatamente nesse mesmo formato de multiplicador decrescente (log2(100) ≈ 6.6, log2(10,000) ≈ 13.3, log2(100,000) ≈ 16.6 — aproximadamente 2x e depois aproximadamente 1.25x). Nem plano (o caso médio O(1) de uma hash table) nem linear (uma varredura completa) — exatamente o formato O(log n) que a estrutura de níveis baseada em lançamento de moeda deve produzir.

Quando não usar

Cobertura de testes

100% de cobertura de instruções, 100% de cobertura de branches (JaCoCo). Reproduza você mesmo:

./gradlew :linear:skip-list:jacocoTestReport

Relatório em linear/skip-list/build/reports/jacoco/test/html/index.html.

Testes unitários

src/test/java/com/datastructures/linear/skiplist/classic/SkipListTest.java
package com.datastructures.linear.skiplist.classic;

import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class SkipListTest {

    @Test
    void startsEmpty() {
        SkipList<Integer, String> skipList = new SkipList<>();

        assertThat(skipList.isEmpty()).isTrue();
        assertThat(skipList.size()).isZero();
        assertThat(skipList.firstKey()).isNull();
    }

    @Test
    void putThenGetReturnsTheStoredValue() {
        SkipList<Integer, String> skipList = new SkipList<>();

        skipList.put(50, "fifty");

        assertThat(skipList.get(50)).isEqualTo("fifty");
        assertThat(skipList.contains(50)).isTrue();
        assertThat(skipList.isEmpty()).isFalse();
        assertThat(skipList.size()).isEqualTo(1);
    }

    @Test
    void getOnAMissingKeyReturnsNull() {
        SkipList<Integer, String> skipList = new SkipList<>();
        skipList.put(50, "fifty");

        assertThat(skipList.get(99)).isNull();
        assertThat(skipList.contains(99)).isFalse();
    }

    @Test
    void puttingAnExistingKeyOverwritesItsValueWithoutGrowingSize() {
        SkipList<Integer, String> skipList = new SkipList<>();
        skipList.put(50, "original");

        skipList.put(50, "replaced");

        assertThat(skipList.get(50)).isEqualTo("replaced");
        assertThat(skipList.size()).isEqualTo(1);
    }

    @Test
    void removeExistingKeyReturnsTrueAndTheKeyBecomesAbsent() {
        SkipList<Integer, String> skipList = new SkipList<>();
        skipList.put(50, "fifty");

        boolean removed = skipList.remove(50);

        assertThat(removed).isTrue();
        assertThat(skipList.contains(50)).isFalse();
        assertThat(skipList.get(50)).isNull();
        assertThat(skipList.isEmpty()).isTrue();
    }

    @Test
    void removingAMissingKeyReturnsFalseAndLeavesTheListUnchanged() {
        SkipList<Integer, String> skipList = new SkipList<>();
        skipList.put(50, "fifty");

        boolean removed = skipList.remove(99);

        assertThat(removed).isFalse();
        assertThat(skipList.size()).isEqualTo(1);
        assertThat(skipList.get(50)).isEqualTo("fifty");
    }

    @Test
    void removingFromAnEmptyListReturnsFalse() {
        SkipList<Integer, String> skipList = new SkipList<>();

        assertThat(skipList.remove(1)).isFalse();
    }

    @Test
    void removingAKeyThatFallsBetweenTwoExistingKeysReturnsFalse() {
        SkipList<Integer, String> skipList = new SkipList<>();
        skipList.put(10, "ten");
        skipList.put(30, "thirty");

        boolean removed = skipList.remove(20);

        assertThat(removed).isFalse();
        assertThat(skipList.size()).isEqualTo(2);
        assertThat(skipList.get(10)).isEqualTo("ten");
        assertThat(skipList.get(30)).isEqualTo("thirty");
    }

    @Test
    void firstKeyReturnsTheSmallestKeyRegardlessOfInsertionOrder() {
        SkipList<Integer, String> skipList = new SkipList<>();
        skipList.put(50, "fifty");
        skipList.put(10, "ten");
        skipList.put(80, "eighty");
        skipList.put(30, "thirty");

        assertThat(skipList.firstKey()).isEqualTo(10);
    }

    @Test
    void firstKeyTracksTheNewMinimumAfterTheOldMinimumIsRemoved() {
        SkipList<Integer, String> skipList = new SkipList<>();
        skipList.put(10, "ten");
        skipList.put(20, "twenty");

        skipList.remove(10);

        assertThat(skipList.firstKey()).isEqualTo(20);
    }

    @Test
    void putRejectsNullKeys() {
        SkipList<Integer, String> skipList = new SkipList<>();

        assertThatThrownBy(() -> skipList.put(null, "x")).isInstanceOf(NullPointerException.class);
    }

    /**
     * Inserts 500 keys in shuffled order and reads every one of them back. With p=0.5 across
     * 500 independent coin flips, both outcomes of "does this node's level grow past 1" are hit
     * with overwhelming probability many times over, without needing a seeded Random or any
     * assertion on the exact level structure — only functional correctness is asserted, which is
     * what the module actually promises.
     */
    @Test
    void manyRandomInsertsAreAllRetrievableAndSizeMatchesTheInsertCount() {
        SkipList<Integer, Integer> skipList = new SkipList<>();
        List<Integer> keys = new ArrayList<>();
        for (int i = 0; i < 500; i++) {
            keys.add(i);
        }
        Collections.shuffle(keys);

        for (int key : keys) {
            skipList.put(key, key * 10);
        }

        assertThat(skipList.size()).isEqualTo(500);
        for (int i = 0; i < 500; i++) {
            assertThat(skipList.get(i)).isEqualTo(i * 10);
            assertThat(skipList.contains(i)).isTrue();
        }
        assertThat(skipList.firstKey()).isEqualTo(0);
    }

    /**
     * Removes all 500 previously-inserted keys in a different shuffled order. This exercises
     * every remove-time branch across many nodes: unlinking at levels the removed node
     * participates in, leaving levels it doesn't participate in untouched, and shrinking the
     * list's overall level back down as the tallest nodes are removed.
     */
    @Test
    void removingEveryKeyAfterManyInsertsLeavesAnEmptySkipList() {
        SkipList<Integer, Integer> skipList = new SkipList<>();
        List<Integer> keys = new ArrayList<>();
        for (int i = 0; i < 500; i++) {
            keys.add(i);
            skipList.put(i, i);
        }
        Collections.shuffle(keys);

        for (int key : keys) {
            boolean removed = skipList.remove(key);
            assertThat(removed).isTrue();
        }

        assertThat(skipList.isEmpty()).isTrue();
        assertThat(skipList.size()).isZero();
        assertThat(skipList.firstKey()).isNull();
        for (int i = 0; i < 500; i++) {
            assertThat(skipList.contains(i)).isFalse();
        }
    }
}
src/test/java/com/datastructures/linear/skiplist/applied/RateLimitWindowTest.java
package com.datastructures.linear.skiplist.applied;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class RateLimitWindowTest {

    @Test
    void startsEmpty() {
        RateLimitWindow window = new RateLimitWindow();

        assertThat(window.size()).isZero();
        assertThat(window.requestCountAt(1_000L)).isZero();
    }

    @Test
    void recordingARequestTracksItsTimestamp() {
        RateLimitWindow window = new RateLimitWindow();

        window.recordRequest(1_000L);

        assertThat(window.requestCountAt(1_000L)).isEqualTo(1);
        assertThat(window.size()).isEqualTo(1);
    }

    @Test
    void recordingMultipleRequestsAtTheSameTimestampIncrementsItsCount() {
        RateLimitWindow window = new RateLimitWindow();

        window.recordRequest(1_000L);
        window.recordRequest(1_000L);
        window.recordRequest(1_000L);

        assertThat(window.requestCountAt(1_000L)).isEqualTo(3);
        assertThat(window.size()).isEqualTo(1);
    }

    @Test
    void differentTimestampsAreTrackedIndependently() {
        RateLimitWindow window = new RateLimitWindow();

        window.recordRequest(1_000L);
        window.recordRequest(2_000L);
        window.recordRequest(2_000L);

        assertThat(window.requestCountAt(1_000L)).isEqualTo(1);
        assertThat(window.requestCountAt(2_000L)).isEqualTo(2);
        assertThat(window.size()).isEqualTo(2);
    }

    @Test
    void evictOlderThanRemovesOnlyTimestampsBeforeTheCutoff() {
        RateLimitWindow window = new RateLimitWindow();
        window.recordRequest(1_000L);
        window.recordRequest(2_000L);
        window.recordRequest(3_000L);
        window.recordRequest(4_000L);

        window.evictOlderThan(3_000L);

        assertThat(window.requestCountAt(1_000L)).isZero();
        assertThat(window.requestCountAt(2_000L)).isZero();
        assertThat(window.requestCountAt(3_000L)).isEqualTo(1);
        assertThat(window.requestCountAt(4_000L)).isEqualTo(1);
        assertThat(window.size()).isEqualTo(2);
    }

    @Test
    void evictOlderThanOnAnEmptyWindowIsANoOp() {
        RateLimitWindow window = new RateLimitWindow();

        window.evictOlderThan(5_000L);

        assertThat(window.size()).isZero();
    }

    @Test
    void evictOlderThanWithACutoffBeforeEveryTimestampRemovesNothing() {
        RateLimitWindow window = new RateLimitWindow();
        window.recordRequest(1_000L);
        window.recordRequest(2_000L);

        window.evictOlderThan(500L);

        assertThat(window.size()).isEqualTo(2);
    }

    @Test
    void evictOlderThanCanDrainTheEntireWindow() {
        RateLimitWindow window = new RateLimitWindow();
        window.recordRequest(1_000L);
        window.recordRequest(2_000L);
        window.recordRequest(3_000L);

        window.evictOlderThan(10_000L);

        assertThat(window.size()).isZero();
    }
}

Ver relatório completo de cobertura JaCoCo →