← Todas las estructuras

Skip List

Linear · ver código fuente en GitHub

Leer en: English · Português · Español

Categoría: Linear

El problema

El Binary Search Tree de este repositorio logra búsqueda O(log n) y recorrido ordenado, pero solo cuando el orden de inserción coopera — una entrada ordenada o adversarial lo degenera a una cadena O(n), y corregir eso estructuralmente implica rotaciones y control de balanceo (rebalanceo en cada inserción). ¿Existe una forma más simple de lograr búsqueda, inserción y eliminación ordenadas con O(log n) esperado, sin ninguna lógica de rotación?

La solución

Apila varias listas enlazadas una sobre otra. El nivel 0 es una lista enlazada ordenada simple que contiene todas las claves. Cada nivel por encima contiene un subconjunto aleatorio de las claves del nivel de abajo — aproximadamente la mitad, en promedio — de modo que una búsqueda puede comenzar en el nivel más alto y "saltar" grandes tramos de la lista, bajando un nivel solo cuando el siguiente nodo del nivel actual sobrepasaría la clave buscada. La estructura surge de un lanzamiento de moneda hecho una vez por nodo insertado (p = 0.5: participar en un nivel más, o detenerse) — nunca de rotar algo después del hecho. En promedio, ese lanzamiento de moneda entrega el mismo costo de búsqueda logarítmico que a un árbol balanceado le cuesta mucho más trabajo lograr.

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
Operación Esperado Por qué
get / put / remove / contains O(log n) cada nivel saltado reduce aproximadamente a la mitad el espacio de búsqueda restante, la misma forma que la altura de un árbol balanceado
firstKey O(1) el sucesor de nivel 0 de la sentinela head siempre es la clave más pequeña

Ejemplo clásico

classic/SkipList es una lista enlazada en capas construida desde cero — sin java.util.concurrent.ConcurrentSkipListMap. Un nodo sentinela head mantiene un array de punteros forward dimensionado para un nivel máximo limitado (16); el array forward de cada nodo insertado se dimensiona según el nivel que determinó su lanzamiento de moneda (p = 0.5 por nivel extra, vía ThreadLocalRandom). Nada aquí rota ni rebalancea — la forma O(log n) emerge estadísticamente de muchos lanzamientos de moneda independientes, no de ningún control por operación. SkipListTest no fija la semilla del Random ni hace aserciones sobre la estructura exacta de niveles (ambas cosas son explícitamente lo incorrecto para probar en una estructura probabilística); en cambio, inserta 500 claves en orden aleatorio, lo que alcanza ambos resultados del lanzamiento de moneda — el nivel de un nodo creciendo más allá de 1, y un nodo permaneciendo en el nivel 1 — con probabilidad abrumadora, y luego hace aserciones puramente sobre corrección funcional: toda clave es recuperable, remove desconecta correctamente un nodo en cada nivel en el que participaba, y el nivel general de la lista se reduce correctamente a medida que se eliminan los nodos más altos.

Ejemplo aplicado: ventana deslizante de limitación de tasa

applied/RateLimitWindow es un índice ordenado para una ventana deslizante de limitación de tasa, indexado por timestamp de la solicitud (epoch millis) → cantidad de solicitudes, respaldado directamente por SkipList<Long, Integer>. Este es un contraste deliberado con el IdempotencyKeyCache#evictOlderThan del módulo Hash Table de este repositorio — lee esa clase primero. Una hash table no tiene orden, así que expirar sus entradas antiguas es honestamente un recorrido completo O(n); no tiene una opción mejor disponible. Aquí, evictOlderThan en cambio recorre el propio orden ascendente de claves de la skip list: firstKey() es O(1) (la clave más pequeña siempre es el sucesor de nivel 0 de la sentinela) y cada remove es O(log n), así que expirar k timestamps vencidos cuesta O(k log n), no O(n) sobre cada timestamp que aún está en la ventana — el orden de la skip list es lo que hace eso posible, y una hash table estructuralmente no puede ofrecerlo. RateLimitWindowTest cubre solicitudes repetidas en el mismo timestamp, una expiración parcial que solo elimina timestamps vencidos, un corte anterior a todos los timestamps (sin efecto), y un corte que vacía toda la ventana.

Benchmark

./gradlew :linear:skip-list:jmh

Ejecución real en esta máquina (JMH 1.37, JDK 26.0.2, 2 iteraciones de calentamiento + 3 de medición, 1 fork). Mismo estilo que el benchmark del módulo Binary Search Tree: un único get contra una estructura ya poblada de cada tamaño.

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

Pasar de size=100 a size=10,000 (un aumento de 100x en los datos) hace que get sea solo ~3.4x más lento; pasar de size=10,000 a size=100,000 (un aumento adicional de 10x) lo hace solo ~1.4x más lento — multiplicadores decrecientes para el mismo crecimiento proporcional en los datos, la firma de un escalado sublineal, similar al logarítmico. Para comparar, log2 crece exactamente con esa misma forma de multiplicador decreciente (log2(100) ≈ 6.6, log2(10,000) ≈ 13.3, log2(100,000) ≈ 16.6 — aproximadamente 2x y luego aproximadamente 1.25x). Ni plano (el caso promedio O(1) de una hash table) ni lineal (un recorrido completo) — exactamente la forma O(log n) que se supone debe producir la estructura de niveles basada en lanzamientos de moneda.

Cuándo no usarlo

Cobertura de pruebas

100% de cobertura de instrucciones, 100% de cobertura de ramas (JaCoCo). Reprodúcelo tú mismo:

./gradlew :linear:skip-list:jacocoTestReport

Informe en linear/skip-list/build/reports/jacoco/test/html/index.html.

Pruebas unitarias

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 informe completo de cobertura JaCoCo →