Ordenação Básica

Ordenação⏱ 12 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.