Ir para o conteúdo

Algoritmos de Pesquisa

Hoje vou falar sobre um tema que provavelmente já todos ouviram falar e que até, provavelmente, já precisaram de implementar em algum programa/script que fizeram. Vamos falar sobre os vários tipos de pesquisa que existem. Neste artigo vamos falar de:

  • Pesquisa Linear
  • Pesquisa Binária

Problema Clássico

Vamos começar por um problema simples. Dado um conjunto de números S com N elementos procurar nesse conjunto um número K. Se K \(\in\) S então imprimimos a posição em que K se encontra, noutro caso imprimimos uma mensagem que indica que K não pertence a S. Ora bem, este problema tem uma simples resolução. Percorremos S do início até ao final e em cada passo verificámos se o elemento que estamos a percorrer no momento é igual a K. Se for saímos do ciclo, se não for continuámos para o próximo elemento. Isto é chamada a pesquisa linear. Vou apresentar a seguir as diversas maneiras de resolver o problema e apresentar as características de cada solução.

Pesquisa Linear

A solução que apresentei anteriormente tem o nome de Pesquisa Linear. Vamos agora apresentar algumas características.

Este algoritmo cresce linearmente, ou seja, a cada passo o número de operações vai ser igual à quantidade de elementos que o conjunto tem. Ora bem, se vocês pegarem na vossa calculadora gráfica e meterem a função f(x) = x podem ver o crescimento da função que é, como já disse, linear.

Como já referi, a ideia desta função passa por fazer um ciclo de 1 até N (N é o número de elementos que o conjunto tem) e em cada passo no ciclo (a cada iteração) verificar se o elemento actual é igual a K.

Em pseudo-código:

array[1..N]
K = input ()

para i = 1 até N:
  se array[i] == K:
    imprimir (i)
    sair_do_ciclo

Em C++:

#include <cstdio>
#define MAX 100000000

using namespace std;

int array[MAX];

int find_in_array (int N, int K)
{
   for (int i = 0; i < N; i++)
      if (array[i] == K)
         return i;

   return -1;
}

int main ()
{
   int N = 0, K = 0, pos = 0;

   scanf ("%d", &K);
   scanf ("%d", &N);

   for (int i = 0; i < N; i++)
      scanf ("%d", &array[i]);

   pos = find_in_array (N, K);

   if (pos < 0)
      printf ("Elemento não foi encontrado\n");
   else
      printf ("Elemento encontrado na posição %d\n", pos + 1);

   return 0;
}

Em Python:

#!/usr/bin/python

def find_in_list (lst, N, K):
   for i in range (N):
      if lst[i] == K:
         return i

   return -1

lst = []
K = int (raw_input ())
N = int (raw_input ())

for i in range (N):
   lst.append (int (raw_input ()))

pos = find_in_list (lst, N, K)

if pos < 0:
   print 'Elemento nao foi encontrado'
else:
   print 'Elemento encontrado na posicao', pos + 1

Em VB.NET:

Module Module1

    Dim dimension, number As Integer
    Dim list() As Integer

    Sub Main()
        Console.Write("Lista de N elementos. Introduza N: ")
        dimension = Console.ReadLine
        Array.Resize(list, dimension)
        For i As Integer = 1 To dimension
            Console.Write("Introduza " & i.ToString & "º elemento da lista: ")
            list(i - 1) = Console.ReadLine
        Next
        Console.Write("Procurar K na lista. Introduza K: ")
        number = Console.ReadLine
        Console.WriteLine()
        Dim position As Integer = FindInArray(list, number)
        If position > 0 Then
            Console.WriteLine("Valor encontrado na lista, na posição " & position.ToString)
        Else
            Console.WriteLine("Valor NÃO encontrado na lista.")
        End If
        Console.Write("Qualquer tecla para terminar. . .")
        Console.ReadKey(True)
    End Sub

    Function FindInArray(ByRef array() As Integer, ByVal k As Integer) As Integer
        For i As Integer = 1 To array.Length
            If array(i - 1) = k Then
                Return i
            End If
        Next
        Return 0
    End Function

End Module

Complexidade

Se conhecerem a «Big O Notation» esta subsecção de pesquisa linear não vai ser difícil de entender. Já disse que o crescimento da função é linear e que o número de operações vai ser sempre igual à quantidade de elementos do conjunto. Para terem mais informações sobre a função que pode representar este algoritmo sigam este link: http://www.wolframalpha.com/input/?i=y+%3D+x.

Isto tudo leva-nos a uma complexidade \(O(N)\), em que N é a quantidade de elementos do conjunto.

Vamos agora, utilizando o exemplo que dei em C++, ver os tempos de execução para vários casos. Em cada caso vamos ver o N a crescer e vamos analisar consequentemente o crescimento do tempo também. Nota: Retirei a leitura dos dados e inicializei-os mesmo com um ciclo (para aumentar a eficiência).

  • \(N = 10^1 \rightarrow T = 0.006s\)
  • \(N = 10^2 \rightarrow T = 0.006s\)
  • \(N = 10^3 \rightarrow T = 0.007s\)
  • \(N = 10^4 \rightarrow T = 0.008s\)
  • \(N = 10^5 \rightarrow T = 0.013s\)
  • \(N = 10^6 \rightarrow T = 0.067s\)
  • \(N = 10^7 \rightarrow T = 0.468s\)
  • \(N = 10^8 \rightarrow T = 2.290s\)

Este último tempo já nem passa em concursos de programação como as ONI, por exemplo. Podemos então ver que o crescimento a nível de tempo desta função é grande. Se estivéssemos a trabalhar com outros tipos de dados, como strings, ainda era pior visto que tínhamos que percorrer todos os elementos e em cada passo comparar a string que queremos encontrar com a actual.

A complexidade deste último caso seria \(O(N * MAXLEN)\) onde MAXLEN é o tamanho máximo das strings.

Este tipo de pesquisa é útil quando sabemos que vamos ter poucos elementos no conjunto e quando não podemos/queremos perder muito tempo a implementar algo mais avançado e sofisticado. No entanto, para concursos de programação, onde os limites costumam ser altos propositadamente, esta pesquisa linear não chega. Na secção a seguir vamos ver uma outra pesquisa chamada «Pesquisa Binária».

Pesquisa Binária

Como vimos anteriormente a Pesquisa Linear não é suficiente visto que o tempo que demora para valores muito grandes também vai ser muito grande.

Temos outro algoritmo de pesquisa que nos vai diminuir muito o nosso tempo de execução. É chamada «Pesquisa Binária». Para podermos utilizar esta técnica, o conjunto que temos que utilizar tem que estar ordenado. Neste caso vou considerar que o conjunto está ordenado por ordem crescente. Todos os exemplos vão ser baseados nisso.

A ideia desta técnica é a cada passo dividir o conjunto em dois (daí se chamada pesquisa binária) e verificar em que lado se encontra a nossa resposta. Vamos imaginar que queremos procurar no conjunto {1, 2, 3, 4, 5} o elemento 5. Como disse, dividimos a nossa lista em metade (vamos ao elemento do meio - que neste caso é o que se encontra na posição 3 (começando a partir do 1)) e vemos em que lado está a resposta, isto é, se o elemento do meio for menor do que o elemento que queremos procurar então a nossa resposta encontra-se no lado direito do nosso conjunto, se fosse maior então a nossa resposta encontrava-se no lado esquerdo. Como é simples de perceber, isto só funciona se o conjunto estiver ordenado.

O que muitas vezes as pessoas que veem pela primeira vez esta técnica pensam é que apenas dividimos o nosso conjunto uma vez, ou seja, que percorríamos metade do nosso conjunto. Isto é falso. Aliás, se assim fosse o melhoramento do tempo não era muito e não nos adiantava implementar este algoritmo. A ideia é ir dividindo sempre o conjunto até apenas nos restar um único elemento. Vamos a um exemplo.

  • S = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}
  • K = 11

Vamos agora representar mais ou menos a resolução com pesquisa binária. S é o conjunto e M' é o elemento do meio do conjunto actual.

1- S = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}
   M' = 6
   6 < 11, logo a nossa resposta está no lado direito

2- S = {7, 8, 9, 10, 11}
   M' = 9
   9 < 11, logo a nossa resposta está no lado direito

3- S = {10, 11}
   M' ~ 10
   10 < 11, logo a nossa resposta está no lado direito

4- S = {11}
   M' = 11
   11 = 11, logo 11 existe no conjunto

Como podem ver a quantidade de passos que precisámos é muito pouco comparando com a pesquisa linear. Para verem o crescimento do algoritmo metam na vossa calculadora gráfica a seguinte função: f(x) = log(x) / log(2).

Vamos ver então o algoritmo em pseudocódigo:

array[1..N]
K = input ()

low = 1, high = N

enquanto low <= high:
   i = (low + high) / 2

   se array[i] < K:
      low = i + 1
   noutro caso:
      high = i - 1

se array[i] == K:
  imprimir(i)
noutro caso:
  imprimir('Nao existe')

Em C++:

#include <cstdio>
#define MAX 100000000

using namespace std;

int array[MAX];

int binary_search (int N, int K)
{
        int low = 0, high = N - 1, middle = 0;

        while (low <= high)
        {
                middle = (low + high) / 2;

                if (array[middle] < K)
                        low = middle + 1;
                else if (array[middle] > K)
                           high = middle - 1;
                     else
                           break;
        }

        if (array[middle] == K)
                printf ("Elemento existe na posição %d\n", middle + 1);
        else
                printf ("Não existe\n");
}

int main ()
{
        int N = 0, K = 0;

        scanf ("%d %d", &N, &K);
        for (int i = 0; i < N; i++)
                scanf ("%d", &array[i]);

        binary_search (N, K);

        return 0;
}

Em Python:

#!/usr/bin/python

def binary_search (lst, N, K):
    while low <= high:
        middle = (low + high) / 2

        if lst[middle] < K:
            low = middle + 1
        else:
            high = middle - 1

    if lst[middle] == K:
        print 'Posicao', middle + 1
    else:
        print 'Nao existe'

lst = []
K = int (raw_input ())
N = int (raw_input ())

for i in range (N):
   lst.append (int (raw_input ()))

binary_search (lst, N, K)

Complexidade

O tempo de execução para N = 10^8 é de 1.040s. Portanto, podemos concluir que a pesquisa binária é muito mais rápida do que a pesquisa linear.

Esta pesquisa binária tem uma complexidade de \(O(log_2 N)\).

A cada passo dividimos o conjunto em duas partes e escolhemos a parte em que se vai encontrar a nossa resposta. Isto leva-nos a uma complexidade logarítmica binária.

Para verem melhor o crescimento da função deixo aqui esta imagem.

Gráfico da função de logaritmo binário

Conclusão

Pelas complexidades podemos concluir que a pesquisa binária é mais rápida do que a pesquisa linear (bastante mais). Quando queremos apenas fazer uma pesquisa e não temos o conjunto já ordenado é melhor utilizar a pesquisa linear visto que qualquer ordenação vai ser mais lenta do que a pesquisa linear. No entanto, quando queremos fazer várias pesquisas é sem dúvida melhor ordenarmos o conjunto e depois utilizarmos a pesquisa binária para as pesquisas.

Tenho mais uma nota a deixar. Todos os tempos de execução dados neste artigo podem não ser _apenas_ da parte da pesquisa binária porque o preenchimento dos arrays também contam (e bem), logo não podemos considerar que para N = 10⁸ o tempo de execução de uma pesquisa binária é mesmo ~1s mas é muito menos. O tempo de preenchimento é que faz com que o tempo de execução seja maior. Podem também utilizar os dois algoritmos com strings e comparar os tempos de execução (neste caso a diferença entre os dois vai ser muito maior) e para verem como é diferente não precisam de ter um N muito grande.