Ordenação Básica

Ordenação12 min de leituraFácilPython

Bubble, selection e insertion sort são os três algoritmos que ninguém coloca em produção e todo mundo precisa entender. Eles são o laboratório onde se aprende a ler o custo de um algoritmo: são curtos o bastante para caber na cabeça inteiros, e diferentes o bastante para que a mesma entrada custe três preços distintos. Quem entende por que o insertion sort ganha do selection sort num array quase ordenado já entende metade do que as bibliotecas modernas fazem por baixo.

Três jeitos de arrumar as mesmas oito cartas

Ordenar é rearranjar uma sequência de forma que cada elemento seja menor ou igual ao próximo. Só isso. Para conseguir, basta uma coisa: um critério de comparação entre dois elementos. Pode ser o valor de um número, o tamanho de uma string, a data de um registro ou uma combinação deles. Sem critério não existe ordenação; com critério, qualquer algoritmo deste artigo funciona sobre qualquer tipo de dado.

Os três que vamos ver resolvem o problema com estratégias que não se parecem em nada:

Bubble sort

Compara vizinhos e troca quem estiver fora de ordem. Repete até uma passada inteira não trocar nada.

Selection sort

Procura o menor de todo o resto, e só então o coloca na posição que está sendo preenchida.

Insertion sort

Pega o próximo elemento e abre espaço para ele no trecho já arrumado, empurrando os maiores.

Acompanhe os três sobre o mesmo array. Troque de algoritmo mantendo a entrada, e depois troque a entrada mantendo o algoritmo: são dois experimentos diferentes, e os dois têm resposta.

Visualizador · os três O(n²) sobre o mesmo array
passo 1 de 49

O caso comum. Rode os três e compare o card de escritas no array: os oito valores são os mesmos, o resultado é o mesmo, e o trabalho para chegar lá não é nem parecido.

Bubble sortnada resolvido ainda
O array verde = posição final
0513221313415766715

Entrada: 5, 3, 21, 13, 1, 7, 6, 15. Bubble sort vai ordenar dentro deste mesmo array, sem alocar nada. Acompanhe os dois contadores: comparações e escritas no array.

bubble_sort.py
1def bubble_sort(a):
2 n = len(a)
3 for fim in range(n - 1, 0, -1):
4 trocou = False
5 for j in range(fim):
6 if a[j] > a[j + 1]:
7 a[j], a[j + 1] = a[j + 1], a[j]
8 trocou = True
9 if not trocou:
10 break # já estava ordenado
Variáveis
a[j] (esquerda do par)-
a[j+1] (direita do par)-
posições finais0
tamanho do array8
comparações0
escritas no array0
inversões da entrada12

Rode os três no preset "embaralhado" e anote as comparações: 25, 28 e 17. Agora rode em "já ordenado": 7, 28 e 7. O selection sort faz as mesmas 28 comparações nas duas entradas, porque ele precisa varrer o resto inteiro antes de ter certeza de quem é o menor. É esse detalhe que tira dele qualquer melhor caso.

passo · espaço roda

Um detalhe que parece decoração e não é: a faixa verde tem dois significados. No bubble e no selection ela marca posições definitivas, que o algoritmo nunca mais toca. No insertion ela marca um trecho ordenado entre si, que ainda vai receber elementos no meio. Confundir os dois é o erro mais comum de quem está aprendendo, porque leva a achar que o insertion sort já resolveu posições que ele ainda vai empurrar.

Bubble sort: só troca com o vizinho

A regra é uma linha: percorra o array comparando cada elemento com o seguinte e troque quando estiverem fora de ordem.

Python
def bubble_sort(a):
    n = len(a)
    for fim in range(n - 1, 0, -1):
        trocou = False
        for j in range(fim):
            if a[j] > a[j + 1]:
                a[j], a[j + 1] = a[j + 1], a[j]
                trocou = True
        if not trocou:
            break        # já estava ordenado

Depois da primeira passada existe uma certeza: o maior valor está na última posição. Ele não tem como ter ficado em outro lugar, porque toda comparação que ele venceu o empurrou uma casa adiante. Por isso a passada seguinte pode parar uma posição antes, e é isso que o fim decrescente faz. Sem esse detalhe o algoritmo continua correto e faz trabalho jogado fora.

A trocou é a otimização que muda o comportamento de verdade. Se uma passada inteira não trocou nada, então cada elemento já é menor ou igual ao seguinte, ou seja, o array está ordenado e não há mais nada a fazer. Com um array já ordenado de 8 posições, isso reduz o trabalho de 28 comparações para 7.

Essa otimização não muda a classe de complexidade. O melhor caso vira O(n), o pior continua O(n²), e é o pior que a notação Big O descreve. Vale medir a diferença mesmo assim: num sistema real, entrada quase ordenada é muito mais comum do que entrada aleatória.

Repare no tipo de movimento: toda troca do bubble sort acontece entre vizinhos. Nenhum elemento pula por cima de outro. Guarde isso, porque é daí que sai a estabilidade dele.

Selection sort: escolhe primeiro, escreve depois

Aqui a lógica se inverte. Em vez de arrumar aos poucos, o algoritmo decide de uma vez quem é o dono de cada posição, da esquerda para a direita.

Python
def selection_sort(a):
    n = len(a)
    for i in range(n - 1):
        menor = i
        for j in range(i + 1, n):
            if a[j] < a[menor]:
                menor = j
        if menor != i:
            a[i], a[menor] = a[menor], a[i]

Para saber quem é o menor do trecho restante, não tem jeito: é preciso olhar todos. E é aqui que mora a característica mais estranha do selection sort. Ele faz exatamente n(n-1)/2 comparações sempre, para qualquer entrada. Com 8 elementos são 28 comparações no array embaralhado, 28 no array invertido e 28 no array que já chegou pronto. Melhor caso, caso médio e pior caso são o mesmo número.

Em compensação, ele é o algoritmo que menos escreve. Cada rodada termina em no máximo uma troca, então o total nunca passa de n - 1 trocas, ou 2(n-1) escritas. No array invertido de 8 posições, o selection sort faz 8 escritas contra 56 do bubble sort. Isso importa quando escrever é caro: memória flash com número limitado de ciclos, registros grandes que custam a copiar, estruturas que disparam um log a cada alteração.

E existe o preço. A troca do selection sort é longa: ele pega um elemento que pode estar do outro lado do array e o traz direto para a posição atual, passando por cima de tudo que estiver no caminho. Nada no algoritmo confere se algum desses elementos atropelados tinha a mesma chave.

Insertion sort: a mão de cartas

Este é o algoritmo que você já usa sem saber. Quando alguém distribui cartas e você organiza a mão, pega uma carta por vez e a encaixa no lugar certo entre as que já estão arrumadas, deslizando as maiores para abrir espaço. Isso é insertion sort.

Python
def insertion_sort(a):
    for i in range(1, len(a)):
        atual = a[i]      # a carta na mão
        j = i - 1
        while j >= 0 and a[j] > atual:
            a[j + 1] = a[j]   # abre espaço
            j -= 1
        a[j + 1] = atual  # encaixa

Duas decisões merecem atenção. A primeira é o laço começar em 1: o trecho de um elemento só já está ordenado por definição, então não há o que fazer com a posição 0. A segunda é a variável atual. Sem ela o algoritmo se perde: o primeiro deslocamento (a[j + 1] = a[j]) sobrescreve justamente a posição de onde veio o elemento que está sendo colocado. Guardar o valor antes é o que permite empurrar os vizinhos sem perdê-lo.

A condição de parada é a[j] > atual, com maior estrito. Quando o insertion sort encontra alguém igual, ele para e encaixa logo depois. Trocar esse > por >= continua ordenando certo e destrói a estabilidade do algoritmo, o que é um bom lembrete de quanto pode depender de um caractere.

O ponto forte é o melhor caso. Se o array já está quase ordenado, cada elemento anda pouquíssimo para trás e o while quase não roda. No preset "quase ordenado", com uma única inversão, o insertion sort resolve em 8 comparações, contra 13 do bubble sort e as 28 inevitáveis do selection sort. É por isso que ele é o único dos três que sobrevive dentro das bibliotecas modernas.

Inversões: a conta que os três pagam de forma diferente

Existe um número que explica o custo dos três de uma vez, e ele não depende de nenhum deles: a quantidade de inversões da entrada. Uma inversão é um par de posições i < j em que a[i] > a[j], ou seja, um par que está fora de ordem entre si. Um array já ordenado tem 0 inversões; um array invertido de 8 posições tem as 28 possíveis.

Visualizador · o mesmo array custa três preços diferentes
12 inversões na entrada · piso 7, teto 28 comparações

O caso comum. Rode os três e compare o card de escritas no array: os oito valores são os mesmos, o resultado é o mesmo, e o trabalho para chegar lá não é nem parecido.

Bubble sort
comparações
25
escritas no array
24
2 x 12 inversões = 24
Selection sort
comparações
28
escritas no array
12
2 por rodada que precisou trocar
Insertion sort
comparações
17
escritas no array
19
12 inversões + 7 colocações = 19

A entrada tem 12 inversões, ou seja, 12 pares em que o valor da esquerda é maior que o da direita. Esse número não é decoração: o bubble sort troca exatamente uma vez por inversão (24 escritas) e o insertion sort desloca exatamente uma vez por inversão, mais uma colocação por elemento (19 escritas). O selection sort é o único que não paga por inversão: ele faz no máximo 7 trocas, aconteça o que acontecer, porque cada rodada dele termina com uma troca só.

Troque a entrada e olhe quem ganha cada barra. O selection sort tem sempre as mesmas 28 comparações, nos quatro presets, porque a varredura dele não depende dos dados. O insertion sort vai de 28 comparações no invertido a 7 no já ordenado. Os três são O(n²) e mesmo assim não são intercambiáveis: O(n²) é o teto, não a conta.

A relação é exata, não aproximada:

  • Bubble sort troca exatamente uma vez por inversão. Cada troca entre vizinhos desfaz uma inversão, e só uma. Logo, escritas = 2 × inversões.
  • Insertion sort desloca exatamente uma vez por inversão, mais uma escrita por elemento colocado. Logo, escritas = inversões + (n - 1).
  • Selection sort não paga por inversão nenhuma. Ele desfaz várias inversões de uma vez com um único salto longo, e é por isso que escreve tão pouco.

Isso dá um jeito honesto de ler a diferença entre os três. Bubble e insertion são sensíveis à desordem da entrada; o selection é sensível apenas ao tamanho. Nenhum deles escapa do O(n²), porque o número de inversões pode chegar a n(n-1)/2, e aí a conta fecha no quadrado.

Estável quer dizer que o empate não se mexe

Um algoritmo de ordenação é estável quando dois elementos de chave igual saem na mesma ordem em que entraram. Não é sobre estar certo: as três saídas abaixo estão corretas pelo critério de ordenação. É sobre o que acontece no empate.

Visualizador · a distância da troca decide a estabilidade
2 chamados fora da ordem de chegada

A fila chegou em ordem e o critério de ordenação é a prioridade. O esperado é que, dentro da mesma prioridade, quem chegou antes continue sendo atendido antes.

Entradacomo chegou
chegou em 0p2#41chegou em 1p2#42chegou em 2p1#43chegou em 3p4#44chegou em 4p3#45chegou em 5p5#46
Bubble sortempates preservados
chegou em 2p1#43chegou em 0p2#41chegou em 1p2#42chegou em 4p3#45chegou em 3p4#44chegou em 5p5#46

3 trocas, a mais longa com distância 1

Insertion sortempates preservados
chegou em 2p1#43chegou em 0p2#41chegou em 1p2#42chegou em 4p3#45chegou em 3p4#44chegou em 5p5#46

3 trocas, a mais longa com distância 1

Selection sortempates trocados
chegou em 2p1#43chegou em 1p2#42chegou em 0p2#41chegou em 4p3#45chegou em 3p4#44chegou em 5p5#46

2 trocas, a mais longa com distância 2

As três saídas estão corretas pela prioridade: a sequência de prioridades é idêntica nas três. O que mudou foi o desempate. #42 chegou na posição 1 e #41 na 0, os dois com prioridade 2, e o selection sort devolveu #42 na frente. Repare na causa, logo acima: a maior troca do bubble e do insertion tem distância 1, e a do selection tem distância 2. Uma troca de distância 1 não consegue pular por cima de ninguém, então empate nunca muda de ordem. Uma troca longa passa por cima de quem estiver no caminho, e nada no algoritmo confere se aquele alguém tem a mesma chave.

Onde isso morde de verdade: ordenações encadeadas. "Ordene por nome, depois por prioridade" só produz o resultado esperado se o segundo sort for estável. Com um instável você precisa comparar os dois critérios na mesma função de comparação, em vez de ordenar duas vezes. E repare no card de trocas: o selection sort é o mais desastrado com empates e, ao mesmo tempo, o que menos escreve no array. As duas coisas têm a mesma causa.

Bubble e insertion são estáveis, selection não é, e a causa é mecânica em vez de decorada. Repare no card de distância da maior troca. Bubble e insertion só mexem em vizinhos, então toda troca tem distância 1, e uma troca de distância 1 não tem como pular por cima de ninguém. O selection troca com um elemento que pode estar a n - 1 casas dali, e é esse salto que atropela o empate.

Onde isso importa de verdade é em ordenação encadeada. Ordenar uma lista por nome e depois por prioridade só produz o resultado esperado ("por prioridade e, dentro dela, por nome") se o segundo sort for estável. Com um sort instável, o segundo passo embaralha o que o primeiro tinha arrumado.

Instável não quer dizer "sempre inverte", quer dizer "não garante nada". O segundo preset mostra o selection sort devolvendo exatamente o mesmo resultado dos estáveis, por acaso. Código apoiado nesse acaso passa em todos os testes e quebra quando um dado muda. Se você precisa de dois critérios, compare os dois na mesma função de comparação em vez de ordenar duas vezes.

Em Python isso é explícito: list.sort() e sorted() são garantidamente estáveis pela documentação da linguagem, e ordenar em duas passadas é uma técnica documentada justamente por causa disso. Em outras linguagens a garantia varia, e vale conferir antes de contar com ela.

O(n²) é o teto, não a sentença

A pergunta natural é por que estudar algoritmos que ninguém usa. A resposta é que um deles é usado, e muito.

Nenhuma biblioteca padrão séria implementa um algoritmo de ordenação só. O sorted do Python usa Timsort, e o std::sort do C++ usa introsort: os dois trocam para insertion sort quando o trecho fica pequeno, tipicamente abaixo de algumas dezenas de elementos. O motivo é o que este artigo mediu. Num trecho pequeno e quase ordenado, o insertion sort faz pouquíssimas comparações, não aloca nada, percorre memória contígua e não paga o custo fixo de chamada recursiva que um O(n log n) cobra. O termo quadrático existe, mas com n pequeno ele perde para as constantes.

Vale também acertar um vocabulário que costuma sair torto. O, Ω e Θ são limites de uma função, não sinônimos de pior, melhor e caso médio:

NotaçãoO que afirma
O(f)cresce no máximo como f (limite superior)
Ω(f)cresce no mínimo como f (limite inferior)
Θ(f)cresce exatamente como f (os dois ao mesmo tempo)

Melhor caso, caso médio e pior caso são um eixo independente: cada um desses cenários é uma função, e cada função pode ser descrita com qualquer uma das três notações. Dá para dizer que o pior caso do insertion sort é Θ(n²), que é mais forte e mais preciso do que dizer O(n²). O costume de falar só em O vem de a garantia superior ser a que interessa na hora de dimensionar um sistema. Se o assunto ainda estiver nebuloso, vale voltar em Big O.

Por fim, o limite que fecha o assunto: nenhum algoritmo baseado em comparações consegue ordenar em menos de O(n log n) no pior caso. Não é falta de criatividade, é contagem: com n elementos existem n! arranjos possíveis, cada comparação distingue no máximo dois casos, e são necessárias pelo menos log₂(n!) comparações para separar todos, o que dá aproximadamente n log₂ n. Os três algoritmos deste artigo estão longe desse limite; merge sort, quick sort e heap sort o alcançam, cada um por um caminho diferente. E counting sort escapa dele por não comparar nada, o que só é possível sob condições bem específicas.

Vídeo da aula

Direto do canal da comunidade Craft & Code Club · 1:52:10.

Problemas para praticar

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

FácilMove ZeroesLeetCode 283
FácilSort the PeopleLeetCode 2418
FácilMerge Sorted ArrayLeetCode 88
MédioInsertion Sort ListLeetCode 147
MédioSort an ArrayLeetCode 912

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 faz parte de

Ver todos →

Ordenação Básica aparece num percurso com objetivo próprio. O conteúdo é o mesmo; o que muda é a pergunta que ele responde ali, e o que vem antes e depois.