Shell Sort

⏱ 12 min de leituraMédioPython

O shell sort é o que acontece quando alguém olha para o insertion sort, identifica exatamente qual detalhe o torna lento, e troca uma constante por uma variável. O resultado é um algoritmo que cabe em dez linhas, não aloca um byte, não usa recursão, e ainda assim escapa do quadrático. Ele também é o único algoritmo clássico cuja complexidade de caso médio continua sem fórmula fechada conhecida.

O insertion sort com a constante 1 virando variável

O insertion sort é excelente numa coisa e péssimo em outra. Excelente: quando um elemento está perto do lugar dele, achar esse lugar custa quase nada. Péssimo: quando um elemento está longe do lugar dele, ele precisa andar uma posição por vez.

Esse "uma posição por vez" é a origem do problema inteiro. Num array como 2 3 4 5 6 7 8 1, trazer o 1 da última posição para a primeira custa 7 deslocamentos, um para cada vizinho, mesmo o array estando quase pronto.

Donald Shell publicou em 1959 a ideia que ataca exatamente isso: e se a comparação não fosse com o vizinho, mas com quem está a gap posições de distância? Assim um elemento pode viajar longe pagando pouco. Depois, basta ir diminuindo o gap até chegar a 1, e a última rodada é o insertion sort de sempre, só que sobre um array quase arrumado.

Compare os dois códigos lado a lado. A única diferença é o 1 ter virado gap:

Python
def insertion_sort(a):
    for i in range(1, len(a)):
        atual = a[i]
        j = i
        while j >= 1 and a[j - 1] > atual:
            a[j] = a[j - 1]
            j -= 1
        a[j] = atual
Python
def shell_sort(a):
    n = len(a)
    gap = n // 2                     # sequência original de Shell
    while gap > 0:
        for i in range(gap, n):
            atual = a[i]
            j = i
            while j >= gap and a[j - gap] > atual:
                a[j] = a[j - gap]    # empurra pelo gap
                j -= gap
            a[j] = atual
        gap //= 2                    # e no fim gap = 1: insertion sort

Não há estrutura nova, recursão, pilha, buffer, nem caso especial. Há um laço externo a mais e a constante trocada.

Visualizador · shell sort: o insertion sort com gap
passo 1 de 65

Com oito elementos os gaps são 4, 2 e 1. Repare no tamanho do caminho para trás em cada rodada: no gap 4 os saltos são longos e raros, no gap 1 eles são curtíssimos, porque o trabalho pesado já foi feito.

gap 4o array é lido como 4 subsequências entrelaçadas, uma a cada 4 posições
O array roxo = a subsequência do elemento na mão, ou seja, com quem ele pode conversar
0513221313415766715

Entrada: 5, 3, 21, 13, 1, 7, 6, 15. O shell sort é o insertion sort com uma diferença: em vez de comparar com o vizinho imediato, ele compara com quem está a gap posições de distância. O gap começa grande e termina em 1.

shell_sort.py
1def shell_sort(a):
2 n = len(a)
3 gap = n // 2 # sequência original de Shell
4 while gap > 0:
5 for i in range(gap, n):
6 atual = a[i]
7 j = i
8 while j >= gap and a[j - gap] > atual:
9 a[j] = a[j - gap] # empurra pelo gap
10 j -= gap
11 a[j] = atual
12 gap //= 2 # e no fim gap = 1: insertion sort
Variáveis
gap4
atual (na mão)-
j (posição candidata)-
tamanho do array8
comparações0
escritas no array0
subsequências deste gap4

Uma honestidade necessária: com oito elementos e a sequência original de Shell, o algoritmo quase sempre perde para o insertion sort, e isso não é defeito de implementação. São três rodadas de laço para um array minúsculo, e o custo fixo não se paga. Das quatro entradas aqui, a invertida é a única em que ele já ganha (22 comparações contra 28). O visualizador seguinte mostra as duas saídas para isso: trocar a sequência de gaps, que já vira a conta neste tamanho, e deixar o array crescer, que com 128 elementos abre uma diferença de mais de quatro vezes.

←→ passo · espaço roda

Uma rodada de gap h são h insertion sorts entrelaçados

A pergunta que fica depois do código é: o que uma rodada de gap 4 significa? Comparar posições distantes parece arbitrário.

Não é. Com gap h, a posição i só é comparada com i - h, i - 2h, i - 3h e assim por diante. Ou seja, o array se parte em h subsequências entrelaçadas, uma para cada resto da divisão do índice por h, e cada uma é ordenada por inserção como se fosse um array independente. Elas dividem a mesma fita de memória e não se enxergam.

Visualizador · uma rodada de gap h são h insertion sorts entrelaçados
gap 4 · 4 subsequências de 2 elementos

Com gap 4, o elemento da posição i só é comparado com i - 4, i - 8, e assim por diante. Isso parte o array em 4 subsequências que não se enxergam, e cada uma delas é ordenada por inserção como se fosse um array independente.

Antes da rodada5, 3, 21, 13, 1, 7, 6, 15
0513221313415766715
Subsequência dos índices 0, 45, 1 vira 1, 5
0145
Subsequência dos índices 1, 53, 7 vira 3, 7 (nada a fazer)
1357
Subsequência dos índices 2, 621, 6 vira 6, 21
26621
Subsequência dos índices 3, 713, 15 vira 13, 15 (nada a fazer)
313715
Depois da rodadao array mudou
0113263134557621715
O que já está garantido depois desta rodada h-ordenado = todo elemento é menor ou igual ao que está h casas à frente
4-ordenado: sim2-ordenado: ainda não1-ordenado: ainda não

Repare no selo de 4-ordenado nas três rodadas: uma vez conquistado, ele nunca mais é perdido. Esse é o resultado que sustenta o shell sort inteiro: um array h-ordenado continua h-ordenado depois de ser ordenado com qualquer outro gap. Sem essa garantia, cada rodada desmancharia a anterior e o algoritmo não teria como funcionar. Com ela, a última rodada (gap 1, que é o insertion sort puro) recebe um array em que todo elemento já está a poucas casas do lugar definitivo, e o caminho para trás fica curtíssimo.

É também daqui que vem a instabilidade do shell sort. Dentro de uma subsequência, um elemento salta 4 posições de uma vez e passa por cima de tudo que está no meio, inclusive de valores com a mesma chave que ele. O insertion sort não consegue fazer isso porque só compara vizinhos, e é por isso que ele é estável e o shell sort não é. Trocar a constante 1 por uma variável dá velocidade e cobra a estabilidade como preço.

Isso muda a leitura do algoritmo. Uma rodada de gap 4 num array de 8 posições não é uma passada esquisita: são 4 insertion sorts de 2 elementos cada, e um insertion sort de 2 elementos é uma comparação. Barato por construção.

Por que as rodadas anteriores não são desperdício

Aqui está a pergunta que decide se o algoritmo funciona: ordenar com gap 2 não desmancha o que a rodada de gap 4 tinha arrumado?

Chame um array de h-ordenado quando todo elemento é menor ou igual ao que está h casas à frente. A rodada de gap h deixa o array h-ordenado, por construção. E existe um resultado que sustenta o shell sort inteiro:

Um array h-ordenado continua h-ordenado depois de ser ordenado com qualquer outro gap.

Dá para acompanhar isso nos selos do visualizador acima: uma vez que o 4-ordenado acende, ele não apaga mais, nem depois da rodada de gap 2, nem depois da de gap 1. As garantias se acumulam em vez de se substituírem.

E é daí que vem a economia. Quando a última rodada começa, o array já é 4-ordenado e 2-ordenado ao mesmo tempo, o que quer dizer que todo elemento está a pouquíssimas casas do lugar definitivo. A rodada de gap 1 é o insertion sort puro, o pior algoritmo do trio básico, rodando exatamente na única situação em que ele é o melhor do mundo: entrada quase ordenada.

O shell sort não substitui o insertion sort, ele prepara o terreno para ele. É por isso que a última rodada tem que ser gap 1: sem ela não há garantia nenhuma de que o array esteja ordenado, porque ser 2-ordenado não implica ser 1-ordenado.

A sequência de gaps, e o que ela muda

A proposta original de Shell é a mais simples possível: comece em n/2 e divida por 2 a cada rodada. Ela funciona, e tem um defeito conhecido que vale entender porque é puramente aritmético.

Todos os gaps de n/2, n/4, n/8, ... são potências de dois. Isso significa que, até a última rodada, uma posição par só é comparada com posições pares e uma ímpar só com ímpares. Se a entrada colocar todos os valores pequenos nas posições pares, as rodadas anteriores praticamente não ajudam, e a rodada final recebe um array quase tão bagunçado quanto o original.

O visualizador tem uma entrada montada exatamente para isso, e o resultado é dramático: com 128 elementos, a sequência original gasta 4.609 comparações enquanto a de Hibbard (gaps sempre ímpares) gasta 1.038.

Visualizador · a partir de que tamanho o gap compensa
n = 32 · melhor com gap: Knuth (1973), 142 comparações · insertion sort: 320

Um embaralhamento de verdade, com semente fixa para o número ser sempre o mesmo e poder ser citado. É o caso típico, e é aqui que as quatro sequências ficam na mesma faixa.

Shell (1959) n/2, depois metade a cada rodada. A proposta original, e a mais fácil de escrever.
comparações
192
gaps: 16, 8, 4, 2, 1
escritas no array
212
5 rodadas · saída ordenada
Hibbard (1963) 2^k - 1: 1, 3, 7, 15, 31... Gaps sempre ímpares, o que evita que uma rodada compare só os pares com os pares.
comparações
156
gaps: 31, 15, 7, 3, 1
escritas no array
176
5 rodadas · saída ordenada
Knuth (1973) 3k + 1: 1, 4, 13, 40, 121... A mais citada em livro-texto, com pior caso conhecido de O(n^(3/2)), ou seja, n elevado a 1,5.
comparações
142
gaps: 13, 4, 1
escritas no array
157
3 rodadas · saída ordenada
Ciura (2001) 1, 4, 10, 23, 57, 132, 301, 701. Achada por busca empírica, sem fórmula fechada, e é a que costuma medir melhor na prática.
comparações
143
gaps: 23, 10, 4, 1
escritas no array
157
4 rodadas · saída ordenada
Só gap 1 (insertion sort) A sequência degenerada. É o mesmo código deste visualizador com uma rodada só, ou seja, o insertion sort puro.
comparações
320
gaps: 1
escritas no array
330
1 rodada · saída ordenada

Com 32 elementos, o shell sort compensa: a melhor sequência faz 142 comparações contra as 320 do insertion sort. E repare no que não muda muito: as quatro sequências de gap ficam na mesma faixa entre si. Numa entrada típica, a decisão que importa é usar gap maior que 1, não qual fórmula exata gera os gaps.

Este é o argumento a favor do shell sort escrito em números: ele é o insertion sort com uma linha diferente, cabe em dez linhas de código, não aloca nada, e mesmo assim escapa do quadrático. Não existe fórmula fechada conhecida para o caso médio da maioria das sequências de gaps, o que é raro em computação: a sequência de Ciura foi encontrada por busca empírica, testando combinações, e não deduzida.

Foi esse defeito que gerou uma linhagem de propostas, cada uma mexendo só na lista de gaps e deixando o algoritmo intacto:

SequênciaGapsPior caso conhecido
Shell (1959)n/2, n/4, n/8, ...Θ(n²)
Hibbard (1963)1, 3, 7, 15, 31, ... (2ᵏ - 1)Θ(n^(3/2))
Knuth (1973)1, 4, 13, 40, 121, ... (3k + 1)O(n^(3/2))
Sedgewick (1986)1, 5, 19, 41, 109, ...O(n^(4/3))
Ciura (2001)1, 4, 10, 23, 57, 132, 301, 701sem prova, melhor medida

A última linha é a mais curiosa da tabela. A sequência de Ciura não foi deduzida de nenhuma fórmula: ela foi encontrada por busca empírica, testando combinações e medindo. Não existe termo geral publicado para continuá-la, e mesmo assim é ela que costuma medir melhor. Isso é raro em computação, e é sintoma de um fato maior: o caso médio do shell sort segue em aberto. Não há fórmula fechada conhecida para a maioria das sequências.

Uma ressalva contra o exagero: numa entrada típica, sem estrutura adversária, as quatro sequências ficam na mesma faixa entre si. A decisão que muda a ordem de grandeza é usar gap maior que 1; qual fórmula gera os gaps é ajuste fino, exceto quando a entrada conspira contra os fatores em comum.

Onde ele ganha e onde ele perde

O shell sort tem um custo fixo que o insertion sort não tem: ele varre o array inteiro uma vez por gap. Com poucos elementos, esse custo domina.

Com 8 elementos e a sequência original de Shell, ele não ganha nada: empata em 24 comparações com o insertion sort numa entrada embaralhada, e perde em três dos quatro presets do visualizador de passo a passo (só ganha no array invertido, com 22 comparações contra 28). Com 32 elementos a mesma sequência já faz 192 contra 320, e a melhor do conjunto faz 142. Com 128, são 961 contra 4.273, uma diferença de mais de quatro vezes. O ponto de virada fica entre uma e duas dezenas de elementos, e não é um número mágico: é onde n cresce o bastante para o termo quadrático do insertion sort superar o custo fixo das rodadas extras. Trocar a sequência de gaps antecipa esse ponto, e no visualizador a de Hibbard já ganha com 8 elementos.

Existe um caso, porém, em que aumentar n não vira a conta: entrada quase ordenada. Se o array tem poucas inversões, o insertion sort é praticamente linear, e nenhuma sequência de gaps consegue vencer isso, porque o shell sort varre o array inteiro uma vez por gap aconteça o que acontecer. Com 128 elementos e 16 inversões, o insertion sort faz 142 comparações e a melhor sequência de gaps faz 476.

Essa é uma limitação real e vale nomeá-la: o shell sort não é adaptativo. Ele não fica mais barato quando a entrada já está quase pronta, e é justamente essa capacidade que o Timsort persegue quando procura trechos já ordenados antes de qualquer outra coisa (vale ver como isso é usado em merge sort).

In-place e instável

In-place

Todas as escritas acontecem dentro do array original. A memória extra é O(1): uma variável para o valor na mão e outra para o índice. Não há recursão, então também não há pilha.

Instável

Um elemento salta gap posições de uma vez e passa por cima de tudo que está no meio, inclusive de valores com a mesma chave.

A instabilidade merece atenção porque ela é a consequência direta da única mudança feita no insertion sort. O insertion sort é estável porque só compara vizinhos e só desloca quem for estritamente maior: um empate para o laço, e a ordem original se preserva. O shell sort compara elementos distantes, e o valor que fica entre eles nunca é consultado. Se esse valor do meio tem a mesma chave, ele é ultrapassado sem que ninguém perceba.

O padrão é o mesmo do selection sort e do quick sort: movimento longo custa estabilidade. Os algoritmos estáveis do grupo, insertion e merge sort, são exatamente os que nunca movem um elemento por cima de outro sem compará-los.

Onde o shell sort ainda vive

Com merge sort, quick sort e heap sort disponíveis, faz sentido perguntar quem ainda usa um algoritmo cuja complexidade ninguém sabe calcular direito. A resposta é um nicho pequeno e real, e ele se define pelo que o shell sort não precisa:

  • Não precisa de memória extra, ao contrário do merge sort.
  • Não precisa de pilha de recursão, ao contrário do quick sort. Isso importa em firmware, bootloader e interrupção, onde a pilha é medida em kilobytes.
  • Não precisa de código grande. São dez linhas sem casos especiais, e binário pequeno é requisito em sistema embarcado.
  • Não tem pior caso catastrófico como o do quick sort, ainda que a garantia seja mais fraca que a do heap sort.

Isso explica onde ele aparece de verdade. A uClibc, biblioteca C usada em sistemas embarcados, implementa o qsort dela como shell sort. O bzip2 usa um shell sort com os incrementos de Knuth para ordenar trechos pequenos dentro do algoritmo de compressão. Nos dois casos o critério é o mesmo: simplicidade e ausência de recursão valem mais do que os últimos por cento de velocidade.

E há o motivo pedagógico, que é o melhor de todos: o shell sort é a demonstração mais barata que existe de que melhorar um algoritmo nem sempre significa trocá-lo. A ideia toda foi olhar para o gargalo do insertion sort (o movimento de uma casa por vez), perguntar o que aconteceria se ele fosse maior, e descobrir que a resposta muda a classe de complexidade. Vale carregar essa pergunta para os próximos tópicos, e o caminho natural daqui é Ordenação Básica para revisitar de onde ele saiu, ou quick sort e merge sort para ver o que se ganha aceitando recursão e memória extra.

Vídeo da aula

Direto do canal da comunidade Craft & Code Club · 1:58:45.

Problemas para praticar

Na ordem em que recomendamos resolver. Marque os que você já fez, fica salvo aqui.

FácilSort Array By ParityLeetCode 905↗
MédioSort an ArrayLeetCode 912↗
FácilRelative Sort ArrayLeetCode 1122↗
MédioH-IndexLeetCode 274↗

Referências

Artigos e materiais externos para se aprofundar.

Travou em algum passo? Traga sua questão para o Discord da comunidade ou para os encontros semanais.

Entrar
Concluiu este tópico?
Marque para acompanhar seu progresso.

Este tópico também tem página própria, fora deste roadmap: Shell Sort.