2 pontos por GN⁺ 2024-07-26 | 1 comentários | Compartilhar no WhatsApp
  • A mediana pode ser encontrada facilmente com ordenação, mas por causa do custo de ordenar, até o problema de selecionar um único elemento fica preso ao limite de O(n log n)
  • quickselect explora recursivamente apenas o lado necessário e pode encontrar o k-ésimo elemento ou a mediana em O(n) em média
  • Um pivô aleatório funciona bem na prática, mas se continuar escolhendo pivôs ruins, remove apenas um elemento por vez e pode degradar até O(n²)
  • median-of-medians escolhe novamente uma mediana a partir das medianas de grupos de 5, permitindo remover pelo menos 30% dos elementos mesmo no pior caso
  • Em implementações reais, o custo de calcular o pivô pode ser alto, então o introselect, que combina quickselect e heapselect como na biblioteca padrão do C++, pode ser uma escolha mais prática

Limites de encontrar a mediana com ordenação

  • A forma mais simples de calcular a mediana é ordenar a lista e então pegar o valor no índice central
  • Em listas com tamanho ímpar, retorna-se o elemento do meio; em listas com tamanho par, retorna-se a média dos dois elementos centrais
  • Como a complexidade mais rápida de uma ordenação baseada em comparação é O(n log n), o tempo de execução desse método também é dominado pela ordenação
  • A vantagem é que o código é simples, mas ele faz mais trabalho do que o necessário quando se quer encontrar apenas uma mediana

quickselect e o O(n) médio

  • quickselect é um algoritmo recursivo criado por Tony Hoare e pode encontrar não só a mediana, mas qualquer k-ésimo elemento de uma lista
  • O fluxo básico é dividir a lista com base em um pivô e depois continuar a busca apenas no lado em que está o k-ésimo elemento
    • Escolhe-se um pivô (pivot) da lista
    • A lista é dividida em elementos menores ou iguais ao pivô e elementos maiores que o pivô
    • Determina-se em qual lado está o k-ésimo elemento procurado e faz-se a chamada recursiva apenas naquela sublista
    • Ao descer para a sublista da direita, ajusta-se o valor de k pelo número de elementos da esquerda que já foram descartados
  • Na lista de exemplo [9,1,0,2,3,4,6,8,7,10,5], como o tamanho é 11, procura-se o 6º menor elemento e, reduzindo o intervalo conforme o pivô, o algoritmo retorna 5 no fim
  • quickselect_median encontra um único índice central com quickselect quando o tamanho da lista é ímpar e, quando é par, encontra os dois índices centrais e tira a média
  • Se o pivô dividir a lista quase pela metade, o volume de trabalho será n + n/2 + n/4 + ... = 2n, o que resulta em O(n)

Para evitar o pior caso, é preciso um bom pivô

  • O O(n) médio do quickselect depende da condição de que a escolha do pivô seja suficientemente boa
  • Se houver azar e o pivô escolhido em cada etapa for, por exemplo, sempre o valor máximo, apenas um elemento será removido por etapa, levando a O(n²)
  • Para garantir tempo linear mesmo no pior caso, é preciso fornecer ao quickselect um pivô suficientemente bom em tempo linear
  • Esse algoritmo de escolha de pivô foi desenvolvido em 1973 por Blum, Floyd, Pratt, Rivest e Tarjan, e o artigo relacionado está em 1973 paper

Seleção de pivô com median-of-medians

  • median-of-medians é o procedimento para escolher um bom pivô que será usado pelo quickselect
  • O fluxo de implementação é o seguinte
    • Se houver menos de 5 elementos, usa-se a função tradicional de mediana baseada em ordenação
    • A lista é dividida em grupos de 5 elementos
    • Grupos incompletos com menos de 5 elementos são descartados para simplificar
    • Cada grupo é ordenado e a mediana no índice 2 é coletada
    • Em seguida, encontra-se novamente a mediana da lista dessas medianas e ela é retornada como pivô
  • Como o tamanho de cada grupo é fixo em 5, a ordenação de cada grupo é tratada como tempo constante e, no total, isso continua sendo um trabalho de O(n)
  • A chamada recursiva para encontrar a mediana das medianas entra na análise como um subproblema de tamanho n/5

Por que é possível remover pelo menos 30%

  • Se imaginarmos os grupos de 5 ordenados e dispostos em colunas, e depois ordenarmos novamente suas medianas para escolher a mediana das medianas, dá para analisar a qualidade do pivô
  • Mesmo no pior caso, em que o pivô fica o mais deslocado possível para a frente, ainda é garantido que certos quadrantes terão elementos menores ou maiores que o pivô
  • Pegando 3 elementos de cada coluna e considerando metade das colunas, é possível remover pelo menos 3/5 * 1/2 * n = 3/10 n elementos
  • Proporção garantida de remoção: {p:30}
  • O tempo total de execução pode ser expresso pela seguinte recorrência
T(n) = n + T(n/5) + T(7n/10)
  • Aqui, n corresponde ao trabalho de particionamento, T(n/5) ao cálculo de median-of-medians e T(7n/10) à busca recursiva do quickselect
  • Como essa recorrência tem dois termos recursivos, não dá para aplicar diretamente o teorema mestre simples, e uma prova por indução é o método mais intuitivo

Resultado da combinação: encontrar a mediana em tempo linear

  • O quickselect consegue encontrar a mediana em tempo linear quando recebe um pivô suficientemente bom
  • O median-of-medians consegue escolher em O(n) o bom pivô de que o quickselect precisa
  • Ao combinar os dois algoritmos, obtém-se um algoritmo para encontrar a mediana ou o n-ésimo elemento de uma lista em tempo linear

Escolhas em implementações reais

  • Na prática, escolher um pivô aleatório quase sempre é suficiente
  • Embora median-of-medians também seja linear, na prática ele pode ser lento por causa do alto custo de calcular o pivô
  • A biblioteca padrão do C++ usa introselect, que combina heapselect e quickselect e tem limite superior de O(n log n)
  • O introselect normalmente começa com um algoritmo rápido, mas com limite superior ruim, e muda para um algoritmo mais lento, porém com limite superior melhor, quando não consegue escolher um pivô eficaz
  • Na comparação pelo número de elementos examinados pela função quickselect, o pivô determinístico quase sempre considerou menos elementos do que o pivô aleatório, mas essa comparação não inclui o custo de calcular o median-of-medians
  • O new paper, publicado em 2017, apresenta um caminho para tornar a abordagem de median-of-medians competitiva com outros algoritmos de seleção

1 comentários

 
GN⁺ 2024-07-26
Comentários do Hacker News
  • Há cerca de 4 anos, comparei vários algoritmos de mediana, e o texto acabou ficando bem mais longo do que eu esperava :)
    https://danlark.org/2020/11/11/miniselect-practical-and-generic-selection-algorithms/

    • Entre eles, há algum algoritmo que possa ser facilmente adaptado para arg-median, ou seja, para retornar o índice que contém a mediana?
  • Há 10 a 15 anos, eu precisava encontrar regularmente a mediana de bilhões de valores extraídos de itens de log de vários quilobytes. Na época, usávamos MapReduce para processamento em grande escala, e, com esse volume de dados, precisávamos não só de tempo linear, mas, se possível, de uma abordagem distribuída em várias máquinas em uma única passagem
    Ajudava saber a precisão e o intervalo dos dados. Os valores eram tempos em milissegundos inteiros, portanto não negativos, e também sabíamos que o percentil 90 ficava bem abaixo de 1 segundo
    Normalmente, encontrar a mediana exige algo parecido com ordenação, mas, nessas condições, dá para usar bucket sort. Basta criar um dicionário, isto é, um histograma, com a chave sendo o tempo em milissegundos inteiros e o valor sendo o número de ocorrências
    Como não sabíamos o tempo máximo, para evitar que o dicionário explodisse de tamanho, colocávamos todos os valores acima de 999 ms no bucket de 999 ms; assim, tudo ficava limitado a cerca de 2000 inteiros no total, entre chaves e valores de 0 a 999. Essa parte era diferente de um bucket sort comum, era muito fácil de processar em uma única passagem mesmo distribuindo com MapReduce, e depois bastava extrair a mediana do histograma

    • Vocês realmente precisavam da mediana exata de bilhões de valores? Ou bastava um valor entre 49,9% e 50,1%? Se fosse o segundo caso, seria muito mais fácil. Bastaria amostrar 10.000 valores de forma aleatória e uniforme e usar a mediana deles
      O número 10.000 foi arbitrário, mas dá para calcular estatisticamente quantas amostras são necessárias para o nível de confiança desejado, e suspeito que não seja um número tão enorme assim
    • Não tenho certeza, mas, visto de fora, parece parecido com o que o Prometheus faz internamente
      Em alguns sistemas com que trabalhei, o Prometheus parecia impor um limite de cerca de 10 segundos para latência. Então requisições que ultrapassavam esse limite eram todas registradas como 10 segundos, mesmo que na prática pudessem ter demorado mais. Interessante
    • Por acaso isso era para criar uma métrica de disponibilidade, e você era estagiário na época? Esse sistema soa, hum, muito familiar
    • Não entendo por que usaram um dicionário com chaves 0…999. Não dava para usar um array indexado de 0…999?
  • Em 2017 saiu um novo artigo que tornou a abordagem median-of-medians competitiva com outros algoritmos de seleção, e há um pós-escrito dizendo que o autor do artigo, Andrei Alexandrescu, avisou sobre isso
    Ele também fez uma palestra sobre o próprio algoritmo em 2016. Ele é uma pessoa divertida de assistir, recomendo muito
    There's Treasure Everywhere - Andrei Alexandrescu
    https://www.youtube.com/watch?v=fd1_Miy1Clg

    • Andrei Alexandrescu é incrível. Por volta de 2000, ele fez uma apresentação sobre algoritmos lock-free e wait-free, e eu os apliquei imediatamente em um enorme projeto de rede de controle industrial em C++
      Se você usa software, recomendo assistir e ler todos os textos e palestras do Andrei que conseguir encontrar. Essa palestra também é um verdadeiro tesouro
    • É surpreendente como ele é uma pessoa bastante erudita, até pelos padrões da ciência da computação. Eu o conhecia por metaprogramação com templates, mas aqui ele está migrando de linguagens de programação para algoritmos
  • Na graduação, aprendi o algoritmo median-of-medians quickselect e fiquei muito impressionado. Implementei por conta própria, mas ele era terrivelmente lento. O tempo de execução crescia de forma linear, mas, para isso fazer diferença, a lista teria que ter pelo menos dezenas de bilhões de itens
    Conversando sobre isso com um amigo da pós-graduação, ouvi algo como: “Ele é lento, mas o ponto importante é que prova que é possível fazer seleção em uma lista não ordenada em tempo O(n). Houve uma época em que nem se sabia se isso era possível; agora que sabemos que é, pode haver algoritmos lineares mais rápidos também”
    Foi uma lição tão simples e ao mesmo tempo profunda que quase me candidatei à pós-graduação. Não sei se esse amigo se lembra dessa conversa, mas foi um momento de virada na minha formação

    • O fato de existir um algoritmo em tempo linear sugere a existência de um algoritmo em tempo linear mais rápido? Se não, qual é o ganho obtido com esse conhecimento?
      Também dá para pensar: “como já sabemos que existe algum algoritmo, então também pode existir um mais rápido”. Por que a existência de um algoritmo O(n) seria um sinal mais forte do que a existência de um algoritmo O(n log n)?
    • Acho que aprendi esse algoritmo no quarto ano de ciência da computação. Como foi dito, ele era abordado pelo lado teórico, mas também era usado como exemplo para mostrar que, na maioria dos casos reais, um algoritmo linear lento não é mais rápido do que um algoritmo n log n rápido
      Acho que o fator constante desse algoritmo era algo em torno de 22, embora talvez fosse algum algoritmo relacionado
  • Um dos pontos interessantes do algoritmo median-of-medians é que a lista de autores é completamente estelar
    Manuel Blum — vencedor do Prêmio Turing de 1995
    Robert Floyd — vencedor do Prêmio Turing de 1978
    Ron Rivest — vencedor do Prêmio Turing de 2002
    Bob Tarjan — vencedor do Prêmio Turing de 1986 e primeiro vencedor do Prêmio Nevanlinna, em 1982
    Vaughan Pratt — o único da lista que não ganhou o Prêmio Turing, mas é professor emérito de Stanford, liderou o projeto SUN antes de ele virar a Sun Microsystems, teve um papel importante no início da Sun como diretor de pesquisa e designer do logotipo da Sun, e deixou várias outras coisas legais, como os certificados de primalidade de Pratt
    Quatro Prêmios Turing independentes e ainda uma SPARCstation: este artigo tem de tudo

    • Pergunta de entrevista para dev frontend júnior: “Nos próximos 30 minutos, reproduza o trabalho de quatro vencedores do Prêmio Turing. Você tem um quadro branco sujo e uma caneta seca. O tempo começa agora”
    • Link direto para quem quiser ler o artigo original: https://people.csail.mit.edu/rivest/pubs/BFPRT73.pdf
      A lista de autores é realmente impressionante
    • Outro trabalho bacana de Pratt é o Pratt parsing. Discussão no HN: https://news.ycombinator.com/item?id=39066465
      O “P” do algoritmo KMP também é Pratt
  • return l[len(l) / 2]
    Não sou especialista em Python, mas o operador / em Python não retorna ponto flutuante? Por que não usar divisão inteira, //, em vez de usar um ponto flutuante como índice de array?
    Talvez não seja problema se o array não for muito grande, mas ainda assim tem bastante cheiro de código ruim. Se fosse um iniciante em Python que não sabia que existem dois operadores separados, até passaria; mas no texto há um código ainda mais estranho que usa divisão inteira em um ramo e divisão de ponto flutuante em outro
    def quickselect_median(l, pivot_fn=random.choice):
    if len(l) % 2 == 1:
    return quickselect(l, len(l) // 2, pivot_fn)
    else:
    return 0.5 * (quickselect(l, len(l) / 2 - 1, pivot_fn) +
    quickselect(l, len(l) / 2, pivot_fn))
    Como já havia 50 comentários e aparentemente ninguém percebeu isso, só reforça meu preconceito anterior sobre a qualidade média do código Python

    • Bem observado. No Python 2 havia apenas um operador, mas no Python 3 eles foram separados
      Acredito que indexar um array com ponto flutuante geraria uma exceção
    • Concordo que é um cheiro de código ruim. Dito isso, como é um artigo sobre algoritmos, não acho exatamente justo julgá-lo pela qualidade do código
      Em vez de pseudocódigo, escolheram uma linguagem de programação real que parece pseudocódigo, e, para fins explicativos, acredito que o código provavelmente execute bem
  • Gostei muito de ler o texto original, mas a parte “se escolhermos o maior elemento como pivô em cada etapa, pode virar O(n²) em vez de O(n)” me incomodou
    Se a preocupação é entrada adversarial, dá para embaralhar os dados antes em O(n) e impedir que isso seja forçado. Se os dados forem grandes demais para embaralhar, basta misturar uma vez quando os buckets ficarem pequenos o suficiente para serem embaralhados
    Se você embaralhou, a probabilidade garante que o pior caso praticamente não aconteça. Se alguém disser que “tecnicamente” é possível, eu responderia que, “tecnicamente”, um atacante também poderia acertar todos os bits de uma chave privada de 256 bits
    Nosso mundo é construído sobre probabilidades. Toda chave privada é protegida pela improbabilidade matemática de alguém adivinhá-la exatamente
    Pelo que li, quickselect após embaralhamento é, na prática, O(n)

    • Como ele já escolhe o pivô aleatoriamente usando sua própria aleatoriedade, não entendo por que embaralhar ajudaria mais
      Ainda assim, se você puder confiar na aleatoriedade, a probabilidade de obter tempo de execução acima de O(n) é muito baixa
    • “Se a preocupação é entrada adversarial, embaralhar os dados antes em O(n) garante isso” não garante evitar o pior caso; remove a possibilidade de forçar o pior caso
  • Floyd-Rivest também faz isso. Se me lembro bem, é um pouco mais eficiente
    Mas nunca consegui entender como ele funciona
    https://en.m.wikipedia.org/wiki/Floyd%E2%80%93Rivest_algorithm

  • Ao escolher o n-ésimo elemento, se n for muito pequeno ou muito grande, median-of-medians pode não ser a melhor opção
    Em vez disso, dá para usar um pivô enviesado, como em [1], ou uma abordagem que chamo de “j-ésimo de k”. Floyd-Rivest também pode acelerar
    Tenho um projeto de hobby que atinge throughput de 1,2 a 2,0 vezes maior em comparação com uma implementação bem feita de quickselect: https://github.com/koskinev/turboselect
    Tenho interesse em materiais sobre algoritmos rápidos e genéricos de seleção in-place
    [1] https://doi.org/10.4230/LIPIcs.SEA.2017.24

  • Também é possível usar algoritmos de streaming que calculam uma aproximação de quantis arbitrários sem armazenar todos os dados na memória

    • É uma abordagem ótima se você puder aceitar aproximações. Mas logo surgem perguntas incômodas
      Podemos aceitar cálculo aproximado? Que suposições sobre os dados são necessárias para definir o limite de erro? Como verificar se essas suposições continuam válidas?
      Pessoalmente, acho que eu tenderia para o algoritmo quickselect do texto original até chegar a uma situação em que fosse realmente necessário considerar uma abordagem de aproximação de mediana em streaming
    • Certamente houve situações em que um algoritmo de quantis em streaming teria sido útil. Há algum material de referência?