Introdução à Programação em Python

Notebook 06 - Algoritmos de ordenação

Carlos Caleiro, Jaime Ramos

Dep. Matemática, IST - 2016

(actualizado em 18 de Outubro de 2018)

Ordenação

Ordenar valores é uma actividade particularmente importante, que tem sido objecto de aturado estudo em ciência da computação. Não é por acaso que, em francês, o computador se denomina ordinateur.

Sendo uma actividade quotidiana para o humano, na manipulação de pequenas quantidades de informação, a qualidade dos algoritmos de ordenação utilizados torna-se especialmente relevante quando lidamos, mecanicamente, com grandes quantidades de informação.

Pesquisa binária em lista ordenada

A pesquisa de valores numa lista ordenada é particularmente eficiente. Atente-se por exemplo no seguinte programa, que implementa a chamada pesquisa binária de um elemento x numa lista ordenada (por ordem crescente) w, devolvendo (um)a posição em que o elemento surge na lista, ou False caso o elemento não ocorra. É fácil de verificar que o número de comparações efectuado pelo algoritmo é da ordem de $\log_2(n)$ para uma lista com $n$ elementos.

In [2]:
def binsearch(x,w):
    found=False
    left=0
    right=len(w)-1
    while not(found) and left<=right:
        mid=(left+right)//2
        if x==w[mid]:
            found=True
        elif x<w[mid]:
            right=mid-1
        else:
            left=mid+1
    if found:
        return mid
    else:
        return False
In [7]:
w=list(range(1000))
%time binsearch(999,w)
CPU times: user 12 µs, sys: 1e+03 ns, total: 13 µs
Wall time: 17.9 µs
Out[7]:
999
In [10]:
w=list(range(2000))
%time binsearch(1999,w)
CPU times: user 12 µs, sys: 1 µs, total: 13 µs
Wall time: 19.1 µs
Out[10]:
1999
In [11]:
w=list(range(4000))
%time binsearch(3999,w)
CPU times: user 13 µs, sys: 0 ns, total: 13 µs
Wall time: 19.8 µs
Out[11]:
3999
In [6]:
w=list(range(8000))
%time binsearch(7999,w)
CPU times: user 14 µs, sys: 1e+03 ns, total: 15 µs
Wall time: 20 µs
Out[6]:
7999

Naturalmente, como já vimos, a linguagem Python oferece a função predefinida sorted e também o método sort do tipo lista, para ordenar listas de valores (ou outros objectos iteráveis). É útil, no entanto, ignorar a sua existência por forma a compreendermos a algoritmia por detrás da ordenação. Vamos assim percorrer, passo a passo, alguns dos algoritmos de ordenação mais populares.

Vamos ordenar listas de números por ordem crescente, mas é óbvio que os mesmos algoritmos se podem aplicar à ordenação de outros valores usando uma relação de ordem adequada.

Algoritmos de força bruta

Os primeiros algoritmos de ordenação que analisaremos, particularmente ineficientes, exploram o facto de que alguma permutação dos elementos dados terá de estar ordenada.

Pesquisa de permutações (slow sort)

Se percorrermos todas as permutações dos elementos (para $n$ elementos haverá $n!$ permutações a considerar), basta obviamente ir testando se estão ou não ordenadas.

In [1]:
def permutacoes(w):
    res=[[]]
    for x in w:
        nres=[]
        for u in res:
            for i in range(len(u)+1):
                novo=u[:]
                novo.insert(i,x)
                nres+=[novo]
        res=nres
    return res

def ordQ(w):
    i=1
    ok=True
    while ok and i<len(w):
        if w[i]<w[i-1]:
            ok=False
        else:
            i=i+1
    return ok

def slowsort(w):
    poss=permutacoes(w)
    i=0
    while not ordQ(poss[i]):
        i=i+1
    return poss[i]

Repescamos a definição de permutacoes desenvolvida anteriormente, definimos o predicado auxiliar ordQ, e usamo-lo para definir a função permutsort desejada. Atente-se no facto de ser particularmente ineficiente, mesmo para listas de valores de tamanho modesto, o que explica a designação slow sort. No pior caso, o algoritmo calcula o resultado em tempo proporcional a $n!$ para uma lista de valores com $n$ elementos.

Para testar experimentalmente este e outros algoritmos vamos tirar partido da possibilidade de gerar permutações (pseudo-)aleatórias de uma certa lista de valores, importando o módulo random.

In [3]:
from random import *
w=list(range(20))
shuffle(w)
In [8]:
%time slowsort(w)
---------------------------------------------------------------------------
KeyboardInterrupt                         Traceback (most recent call last)
<ipython-input-8-1b9896564260> in <module>()
----> 1 get_ipython().magic('time slowsort(w)')

/Users/ccal/anaconda/lib/python3.5/site-packages/IPython/core/interactiveshell.py in magic(self, arg_s)
   2161         magic_name, _, magic_arg_s = arg_s.partition(' ')
   2162         magic_name = magic_name.lstrip(prefilter.ESC_MAGIC)
-> 2163         return self.run_line_magic(magic_name, magic_arg_s)
   2164 
   2165     #-------------------------------------------------------------------------

/Users/ccal/anaconda/lib/python3.5/site-packages/IPython/core/interactiveshell.py in run_line_magic(self, magic_name, line)
   2082                 kwargs['local_ns'] = sys._getframe(stack_depth).f_locals
   2083             with self.builtin_trap:
-> 2084                 result = fn(*args,**kwargs)
   2085             return result
   2086 

<decorator-gen-60> in time(self, line, cell, local_ns)

/Users/ccal/anaconda/lib/python3.5/site-packages/IPython/core/magic.py in <lambda>(f, *a, **k)
    191     # but it's overkill for just that one bit of state.
    192     def magic_deco(arg):
--> 193         call = lambda f, *a, **k: f(*a, **k)
    194 
    195         if callable(arg):

/Users/ccal/anaconda/lib/python3.5/site-packages/IPython/core/magics/execution.py in time(self, line, cell, local_ns)
   1171         if mode=='eval':
   1172             st = clock2()
-> 1173             out = eval(code, glob, local_ns)
   1174             end = clock2()
   1175         else:

<timed eval> in <module>()

<ipython-input-1-adbc4cdcd95b> in slowsort(w)
     22 
     23 def slowsort(w):
---> 24     poss=permutacoes(w)
     25     i=0
     26     while not ordQ(poss[i]):

<ipython-input-1-adbc4cdcd95b> in permutacoes(w)
      7                 novo=u[:]
      8                 novo.insert(i,x)
----> 9                 nres+=[novo]
     10         res=nres
     11     return res

KeyboardInterrupt: 

Na verdade é até instrutivo definirmos a nossa própria versão de shuffle, recorrendo à função randrange cujo significado é óbvio.

In [4]:
def shufflecaseiro(w):
    for i in range(len(w)):
        j=randrange(i,len(w))
        w[i],w[j]=w[j],w[i]
    return w
In [5]:
shufflecaseiro(list(range(10)))
Out[5]:
[2, 3, 4, 5, 1, 9, 0, 6, 8, 7]
In [6]:
shufflecaseiro(list(range(10)))
Out[6]:
[8, 0, 1, 3, 7, 2, 6, 9, 4, 5]

Permutações aleatórias (lucky sort)

Neste contexto, podemos também explorar aleatoriamente as possíveis permutações da lista dada. Dessa forma obtemos um algoritmo probabilístico em que com sorte (muito baixa probabilidade) podemos conseguir obter o resultado desejado mais rapidamente, mas em que com azar podemos ter de esperar ainda mais do que com slow sort.

In [9]:
def luckysort(w):
    while not(ordQ(w)):
        shufflecaseiro(w)
    return w
In [12]:
%time luckysort(shufflecaseiro(list(range(10))))
CPU times: user 10.8 s, sys: 6.38 ms, total: 10.8 s
Wall time: 10.8 s
Out[12]:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
In [13]:
%time luckysort(shufflecaseiro(list(range(10))))
---------------------------------------------------------------------------
KeyboardInterrupt                         Traceback (most recent call last)
<ipython-input-13-102cb7a9240a> in <module>()
----> 1 get_ipython().magic('time luckysort(shufflecaseiro(list(range(10))))')

/Users/ccal/anaconda/lib/python3.5/site-packages/IPython/core/interactiveshell.py in magic(self, arg_s)
   2161         magic_name, _, magic_arg_s = arg_s.partition(' ')
   2162         magic_name = magic_name.lstrip(prefilter.ESC_MAGIC)
-> 2163         return self.run_line_magic(magic_name, magic_arg_s)
   2164 
   2165     #-------------------------------------------------------------------------

/Users/ccal/anaconda/lib/python3.5/site-packages/IPython/core/interactiveshell.py in run_line_magic(self, magic_name, line)
   2082                 kwargs['local_ns'] = sys._getframe(stack_depth).f_locals
   2083             with self.builtin_trap:
-> 2084                 result = fn(*args,**kwargs)
   2085             return result
   2086 

<decorator-gen-60> in time(self, line, cell, local_ns)

/Users/ccal/anaconda/lib/python3.5/site-packages/IPython/core/magic.py in <lambda>(f, *a, **k)
    191     # but it's overkill for just that one bit of state.
    192     def magic_deco(arg):
--> 193         call = lambda f, *a, **k: f(*a, **k)
    194 
    195         if callable(arg):

/Users/ccal/anaconda/lib/python3.5/site-packages/IPython/core/magics/execution.py in time(self, line, cell, local_ns)
   1171         if mode=='eval':
   1172             st = clock2()
-> 1173             out = eval(code, glob, local_ns)
   1174             end = clock2()
   1175         else:

<timed eval> in <module>()

<ipython-input-9-83e6a89158e6> in luckysort(w)
      1 def luckysort(w):
----> 2     while not(ordQ(w)):
      3         shufflecaseiro(w)
      4     return w

KeyboardInterrupt: 

Mais adiante teremos ocasião de voltar a falar de algoritmos aleatórios, mas vale a pena notar que se trata de um tópico muito importante em algoritmia e aplicações à engenharia, que é alvo de estudo de diversas disciplinas mais avançadas.

Algoritmos simples

Considerando agora algoritmos de facto relevantes, os mais simples que conseguimos conceber e mesmo executar manualmente, usam tempo quadrático no número de elementos a ordenar. No pior caso, o resultado é calculado em tempo proporcional a $n^2$ para uma lista de valores com $n$ elementos.

Há essencialmente duas opções, duais entre si. Colocar o esforço na pesquisa de elementos na lista dada, ou na sua colocação na lista ordenada.

Ordenação por inserção (insertion sort)

Na ordenação por inserção, os valores dados são percorridos sequencialmente. A lista ordenada vai sendo construída, passo a passo, inserindo correctamente cada elemento entre os restantes já avaliados por forma a manter a ordenação. O esforço é colocado na inserção.

In [14]:
def insere(x,w):
    i=0
    while i<len(w) and w[i]<x:
        i=i+1
    w.insert(i,x)
    return w

def insertsort(w):
    res=[]
    for i in range(len(w)):
        insere(w[i],res)
    return res
In [15]:
%time insertsort(shufflecaseiro(list(range(10))))
CPU times: user 56 µs, sys: 1 µs, total: 57 µs
Wall time: 60.8 µs
Out[15]:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
In [16]:
%time insertsort(shufflecaseiro(list(range(100))));None
CPU times: user 875 µs, sys: 0 ns, total: 875 µs
Wall time: 881 µs
In [17]:
%time insertsort(shufflecaseiro(list(range(1000))));None
CPU times: user 55.8 ms, sys: 2.83 ms, total: 58.6 ms
Wall time: 56.3 ms

É útil e ilustrativo, até para fins de comparação com os algoritmos seguintes, observar o seguinte filme que ilustra o funcionamento do algoritmo de ordenação por inserção para uma permutação aleatória dos números até 200.

In [18]:
from IPython.display import HTML

HTML("""
<video width="400" height="400" controls>
  <source src="insertionsort.mp4" type="video/mp4">
</video>
""")

## video exportado do sistema Mathematica em formato swf e convertido depois para mp4
Out[18]:

Note-se ainda que o algoritmo de ordenação por inserção é particularmente adequado para situações em que os valores a ordenar são recebidos sequencialmente, em vez de todos de uma vez.

Ordenação por selecção (selection sort)

Na ordenação por selecção, por oposição à ordenação por inserção, o custo associado está ligado à escolha do elemento a colocar na lista ordenada e não à colocação propriamente dita. À lista ordenada vai-se adicionando, sucessivamente, o valor mínimo ainda por analisar. O esforço é portanto colocado na selecção do mínimo.

In [27]:
def posmin(w,pos):
    for i in range(pos+1,len(w)):
        if w[i]<w[pos]:
            pos=i
    return pos

def selectsort(w):
    for i in range(len(w)-1):
        j=posmin(w,i)
        if j!=i:
            x=w.pop(j)
            w.insert(i,x)
    return w

Vale a pena notar que a lista é ordenada in situ, não sendo necessário trabalhar com um nome para ir armazenando resultados intermédios. Em cada momento, a lista ordenada dos elementos já processados é formada pelos primeiros i elementos de w.

In [28]:
%time selectsort(shufflecaseiro(list(range(10))))
CPU times: user 65 µs, sys: 94 µs, total: 159 µs
Wall time: 180 µs
Out[28]:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
In [29]:
%time selectsort(shufflecaseiro(list(range(100))));None
CPU times: user 908 µs, sys: 4 µs, total: 912 µs
Wall time: 917 µs
In [30]:
%time selectsort(shufflecaseiro(list(range(1000))));None
CPU times: user 76.5 ms, sys: 3.7 ms, total: 80.2 ms
Wall time: 77.8 ms

Vale a pena comparar, e notar as diferenças, entre o filme que usámos para ilustrar a ordenação por inserção e o filme seguinte, que ilustra o funcionamento do algoritmo de ordenação por selecção também para uma permutação aleatória dos números até 200.

In [31]:
HTML("""
<video width="400" height="400" controls>
  <source src="selectionsort.mp4" type="video/mp4">
</video>
""")

## video exportado do sistema Mathematica em formato swf e convertido depois para mp4
Out[31]:

Algoritmos eficientes

Os algoritmos de ordenação gerais mais eficientes são utilizados em inúmeras situações e usam, em média, tempo quase-linear no número de elementos a ordenar, isto é, o resultado é calculado em tempo proporcional a $n.\log(n)$ para uma lista de valores com $n$ elementos.

Mais uma vez há duas versões, duais entre si. A ideia básica consiste em, a cada passo, dividir a lista de elementos a ordenar em duas listas com metade dos elementos. Num caso, partiremos a lista ao meio, ordenando ambas, e construindo depois a fusão das duas sublistas. No outro caso colocamos o esforço na escolha das sublistas, que depois de ordenadas precisam apenas de ser concatenadas.

Algoritmo de Hoare (quick sort)

O algoritmo de Hoare (desenvolvido pelo cientista britânico Tony Hoare), ou quick sort, coloca o esforço na escolha das sublistas. Por simplicidade toma-se o primeiro elemento da lista, $x$ e constroem-se duas sublistas com os elementos menores ou iguais a $x$ numa delas, e os elementos maiores que $x$ na outra. Na situação ideal, as duas sublistas terão sensivelmente metade dos elementos.

In [32]:
def split(w):
    x=w[0]
    left=[]
    right=[]
    for i in range(1,len(w)):
        if w[i]<=x:
            left+=[w[i]]
        else:
            right+=[w[i]]
    return left,x,right

def quicksort(w):
    if len(w)<2:
        return w
    else:
        w1,x,w2=split(w)
        return quicksort(w1)+[x]+quicksort(w2)

A definição de quicksort é um exemplo paradigmático em que é de facto mais conveniente usar recursão. Veremos mais tarde como fazer uma definição estritamente imperativa.

Note-se, abaixo, como de facto há uma melhoria relativamente aos algoritmos anteriores, particularmente sensível para listas maiores.

In [33]:
%time quicksort(shufflecaseiro(list(range(10))))
CPU times: user 72 µs, sys: 1 µs, total: 73 µs
Wall time: 77 µs
Out[33]:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
In [34]:
%time quicksort(shufflecaseiro(list(range(100))));None
CPU times: user 667 µs, sys: 23 µs, total: 690 µs
Wall time: 696 µs
In [35]:
%time quicksort(shufflecaseiro(list(range(1000))));None
CPU times: user 7.94 ms, sys: 437 µs, total: 8.37 ms
Wall time: 8.15 ms

Como uma imagem, por vezes, vale mais do que mil palavras, o filme seguinte ilustra o funcionamento do algoritmo de Hoare na ordenação de uma permutação aleatória dos números até 200.

In [36]:
HTML("""
<video width="400" height="400" controls>
  <source src="quicksort.mp4" type="video/mp4">
</video>
""")

## video exportado do sistema Mathematica em formato swf e convertido depois para mp4
Out[36]:

Note-se que o algoritmo quicksort é ainda assim quadrático no pior caso (porquê?).

Fusão (merge sort)

O algoritmo de ordenação por fusão, ou merge sort, é semelhante, mas coloca o esforço na junção das sublistas ordenadas. As sublistas resultam apenas de separar pelo meio a lista dada.

In [1]:
def fusao(u,v):
    res=[]
    i=0
    j=0
    for k in range(len(u)+len(v)):
        if i<len(u) and (j==len(v) or u[i]<v[j]):
            res.append(u[i])
            i=i+1
        else:
            res.append(v[j])
            j=j+1
    return res

def mergesort(w):
    if len(w)<2:
        return w
    else:
        m=len(w)//2
        w1=mergesort(w[:m])
        w2=mergesort(w[m:])
        return fusao(w1,w2)
In [5]:
%time mergesort(shufflecaseiro(list(range(10))))
CPU times: user 77 µs, sys: 0 ns, total: 77 µs
Wall time: 81.1 µs
Out[5]:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
In [39]:
%time mergesort(shufflecaseiro(list(range(100))));None
CPU times: user 826 µs, sys: 26 µs, total: 852 µs
Wall time: 857 µs
In [40]:
%time mergesort(shufflecaseiro(list(range(1000))));None
CPU times: user 10.4 ms, sys: 479 µs, total: 10.9 ms
Wall time: 10.6 ms

O filme seguinte ilustra o funcionamento do algoritmo merge sort durante a ordenação de uma permutação aleatória dos números até 200.

In [41]:
HTML("""
<video width="400" height="400" controls>
  <source src="mergesort.mp4" type="video/mp4">
</video>
""")

## video exportado do sistema Mathematica em formato swf e convertido depois para mp4
Out[41]:

Vale a pena notar que o algoritmo mergesort é genuinamente quase-linear, mesmo no pior caso.

Algoritmos especiais

Os algoritmos que vimos acima são os mais eficientes de entre os algoritmos de ordenação gerais. Mas, em certas situações específicas, poderá haver algoritmos mais eficientes, nomeadamente de tempo linear, isto é, tempo proporcional a $n$ para uma lista de valores com $n$ elementos.

Uma situação típica, que dá origem a diversos algoritmos variantes, acontece quando sabemos que os valores a ordenar pertencem todos a um certo conjunto finito.

Contagem (counting sort)

(A versão que se implementa de seguida d)o algoritmo de contagem pressupõe que todos os valores da lista a ordenar são inteiros entre a e b (com a<b, claro).

In [42]:
def countsort(w,a,b):
    space=range(a,b+1)
    cont=[0 for val in space]
    for i in range(len(w)):
        cont[w[i]-a]+=1
    res=[]
    for val in space:
        res+=[val for quant in range(cont[val-a])]
    return res
In [43]:
%time countsort(shufflecaseiro(list(range(10))),0,10)
CPU times: user 72 µs, sys: 1 µs, total: 73 µs
Wall time: 77 µs
Out[43]:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
In [44]:
%time countsort(shufflecaseiro(list(range(100))),0,100);None
CPU times: user 387 µs, sys: 1 µs, total: 388 µs
Wall time: 392 µs
In [45]:
%time countsort(shufflecaseiro(list(range(1000))),0,1000);None
CPU times: user 4.07 ms, sys: 146 µs, total: 4.21 ms
Wall time: 4.14 ms

Note-se a acentuada melhoria relativamente a todos os algoritmos anteriores para listas maiores.

Para terminar, vale a pena recordar que nenhuma implementação que façamos se aproximará facilmente das primitivas predefinidas do Python.

In [46]:
%time sorted(shufflecaseiro(list(range(1000))));None
CPU times: user 2.57 ms, sys: 1 µs, total: 2.57 ms
Wall time: 2.58 ms
In [47]:
%time shufflecaseiro(list(range(1000))).sort()
CPU times: user 2.61 ms, sys: 64 µs, total: 2.67 ms
Wall time: 2.68 ms

Sumário

  • Os algoritmos de ordenação são um dos tópicos básicos da teoria dos algoritmos, nomeadamente do estudo da sua eficiência, sendo também um excelente exercício de programação.
  • Em várias situações fica patente a utilidade de tirar partido, interligadamente, de vários paradigmas de programação, algo que será aprofundado mais adiante.
  • Vimos os primeiros exemplos de algoritmos probabilísticos, um tópico bastante útil, interessante, e que voltaremos a abordar.
  • Os algoritmos de busca exaustiva são em geral péssimos, e a ordenação não é excepção.
  • Os algoritmos mais simples, suficientemente bons para ordenar pequenas quantidades de valores, são quadráticos, ao passo que os melhores algoritmos gerais são quase-lineares.
  • Em situações particulares, é possível conceber algoritmos de ordenação lineares.

Bibliografia

Introdução à Programação em Mathematica (3a edição): J. Carmo, A. Sernadas, C. Sernadas, F. M. Dionísio, C. Caleiro, IST Press, 2014.

Think Python: How to think like a computer scientist: A. Downey, Green Tea Press, 2012.

Introduction to Computation and Programming Using Python (revised and expanded edition): J. V. Guttag, MIT Press, 2013.

The Art of Computer Programming: D. E. Knuth, Addison-Wesley (volumes 1--3, 4A), 1998.

Learning Python (fifth edition): M. Lutz, O'Reilly Media, 2013.

Programação em Python: Introdução à programação utilizando múltiplos paradigmas: J. P. Martins, IST Press, 2015.

Introdução à Programação em MatLab: J. Ramos, A. Sernadas e P. Mateus, DMIST, 2005.

Learning IPython for Interactive Computing and Data Visualization: C. Rossant, Packt Publishing, 2013.

Programação em Mathematica: A. Sernadas, C. Sernadas e J. Ramos, DMIST, 2003.