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.