Eu acho que há várias perguntas enterradas neste tópico:
- Como você implementa
buildHeap
para que seja executado em O (n) tempo?
- Como você mostra que
buildHeap
é executado no tempo O (n) quando implementado corretamente?
- Por que essa mesma lógica não funciona para fazer a classificação de heap ser executada em O (n) tempo em vez de O (n log n) ?
Como você implementa buildHeap
para que seja executado em O (n) tempo?
Freqüentemente, as respostas a essas perguntas concentram-se na diferença entre siftUp
e siftDown
. Fazer a escolha correta entre siftUp
e siftDown
é essencial para obter o desempenho de O (n)buildHeap
, mas não ajuda a entender a diferença entre buildHeap
e heapSort
em geral. De fato, as implementações adequadas de ambos buildHeap
e heapSort
será única utilizar siftDown
. A siftUp
operação é necessária apenas para executar inserções em um heap existente, portanto, ela seria usada para implementar uma fila de prioridade usando um heap binário, por exemplo.
Eu escrevi isso para descrever como um heap máximo funciona. Esse é o tipo de heap normalmente usado para classificação de heap ou para uma fila de prioridade em que valores mais altos indicam prioridade mais alta. Uma pilha mínima também é útil; por exemplo, ao recuperar itens com chaves inteiras em ordem crescente ou cadeias em ordem alfabética. Os princípios são exatamente os mesmos; basta mudar a ordem de classificação.
A propriedade heap especifica que cada nó em um heap binário deve ser pelo menos tão grande quanto os dois filhos. Em particular, isso implica que o maior item do heap está na raiz. Peneirar e peneirar são essencialmente a mesma operação em direções opostas: mova um nó ofensivo até satisfazer a propriedade heap:
siftDown
troca um nó que é muito pequeno com seu maior filho (movendo-o para baixo) até que ele seja pelo menos tão grande quanto os dois nós abaixo dele.
siftUp
troca um nó que é muito grande com seu pai (movendo-o para cima) até que não seja maior que o nó acima dele.
O número de operações necessárias siftDown
e siftUp
é proporcional à distância que o nó pode ter que se mover. Pois siftDown
, é a distância até o fundo da árvore, por isso siftDown
é caro para os nós no topo da árvore. Com siftUp
, o trabalho é proporcional à distância até o topo da árvore, por isso siftUp
é caro para os nós na parte inferior da árvore. Embora ambas as operações sejam O (log n) no pior caso, em um heap, apenas um nó está no topo, enquanto metade dos nós está na camada inferior. assim ele não deve ser muito surpreendente que, se temos de aplicar uma operação para cada nó, nós preferimos siftDown
mais siftUp
.
A buildHeap
função pega uma matriz de itens não classificados e os move até que todos satisfaçam a propriedade heap, produzindo um heap válido. Existem duas abordagens que você pode buildHeap
usar para usar as operações siftUp
e siftDown
que descrevemos.
Comece na parte superior da pilha (o início da matriz) e chame siftUp
cada item. Em cada etapa, os itens peneirados anteriormente (os itens anteriores ao item atual na matriz) formam um heap válido, e a peneiração do próximo item o coloca em uma posição válida no heap. Após selecionar cada nó, todos os itens atendem à propriedade heap.
Ou siga na direção oposta: comece no final da matriz e mova-se para trás em direção à frente. A cada iteração, você peneira um item até que ele esteja no local correto.
Qual implementação buildHeap
é mais eficiente?
Ambas as soluções produzirão um heap válido. Sem surpresa, a mais eficiente é a segunda operação que utiliza siftDown
.
Deixe h = log n representar a altura da pilha. O trabalho necessário para a siftDown
abordagem é dado pela soma
(0 * n/2) + (1 * n/4) + (2 * n/8) + ... + (h * 1).
Cada termo na soma tem a distância máxima que um nó na altura especificada terá que se mover (zero para a camada inferior, h para a raiz) multiplicado pelo número de nós nessa altura. Por outro lado, a soma para chamar siftUp
cada nó é
(h * n/2) + ((h-1) * n/4) + ((h-2)*n/8) + ... + (0 * 1).
Deve ficar claro que a segunda soma é maior. O primeiro termo por si só é hn / 2 = 1/2 n log n , portanto, essa abordagem tem complexidade na melhor das hipóteses O (n log n) .
Como provamos que a soma da siftDown
abordagem é realmente O (n) ?
Um método (existem outras análises que também funcionam) é transformar a soma finita em uma série infinita e depois usar a série de Taylor. Podemos ignorar o primeiro termo, que é zero:
Se você não sabe ao certo por que cada uma dessas etapas funciona, eis uma justificativa para o processo em palavras:
- Os termos são todos positivos; portanto, a soma finita deve ser menor que a soma infinita.
- A série é igual a uma série de potências avaliada em x = 1/2 .
- Essa série de potências é igual a (um tempo constante) a derivada da série de Taylor para f (x) = 1 / (1-x) .
- x = 1/2 está dentro do intervalo de convergência dessa série de Taylor.
- Portanto, podemos substituir a série de Taylor por 1 / (1-x) , diferenciar e avaliar para encontrar o valor da série infinita.
Como a soma infinita é exatamente n , concluímos que a soma finita não é maior e, portanto, é O (n) .
Por que a classificação de heap requer tempo O (n log n) ?
Se é possível executar buildHeap
em tempo linear, por que a classificação de heap requer tempo O (n log n) ? Bem, a classificação de heap consiste em dois estágios. Primeiro, chamamos buildHeap
a matriz, que requer tempo O (n) se implementada de maneira ideal. O próximo estágio é excluir repetidamente o maior item da pilha e colocá-lo no final da matriz. Como excluímos um item do heap, sempre há um ponto aberto logo após o final do heap, onde podemos armazenar o item. Portanto, a classificação da pilha atinge uma ordem classificada, removendo sucessivamente o próximo item maior e colocando-o na matriz, começando na última posição e movendo-se para a frente. É a complexidade desta última parte que domina na classificação da pilha. O loop fica assim:
for (i = n - 1; i > 0; i--) {
arr[i] = deleteMax();
}
Claramente, o loop executa O (n) vezes ( n - 1 para ser mais preciso, o último item já está no lugar). A complexidade de deleteMax
um heap é O (log n) . Geralmente, é implementado removendo a raiz (o maior item deixado na pilha) e substituindo-a pelo último item da pilha, que é uma folha e, portanto, um dos itens menores. Essa nova raiz quase certamente violará a propriedade heap, portanto, você deve ligar siftDown
até movê-la de volta para uma posição aceitável. Isso também tem o efeito de mover o próximo item maior para a raiz. Observe que, ao contrário do buildHeap
local para a maioria dos nós que estamos chamando Embora a árvore esteja encolhendo, ela não encolhe rápido o suficientesiftDown
da parte inferior da árvore, agora estamos chamando siftDown
do topo da árvore em cada iteração!: A altura da árvore permanece constante até você remover a primeira metade dos nós (quando você limpa a camada inferior completamente). Então, para o próximo trimestre, a altura é h - 1 . Portanto, o trabalho total para esta segunda etapa é
h*n/2 + (h-1)*n/4 + ... + 0 * 1.
Observe a opção: agora o caso de trabalho zero corresponde a um único nó e o caso de trabalho h corresponde à metade dos nós. Essa soma é O (n log n), assim como a versão ineficiente dobuildHeap
disso é implementada usando o siftUp. Mas, neste caso, não temos escolha, pois estamos tentando classificar e exigimos que o próximo maior item seja removido em seguida.
Em resumo, o trabalho para classificação de heap é a soma dos dois estágios: O (n) tempo para buildHeap e O (n log n) para remover cada nó em ordem , portanto, a complexidade é O (n log n) . Você pode provar (usando algumas idéias da teoria da informação) que, para uma classificação baseada em comparação, O (n log n) é o melhor que você poderia esperar de qualquer maneira; portanto, não há razão para se decepcionar com isso ou esperar que a classificação da pilha atinja o O (n) tempo limite que buildHeap
sim.