Você está na página 1de 2

To make Medium work, we log user data.

By using Medium, you agree to our Privacy Policy, Sign in Get started
including cookie policy.

Introdução à Complexidade de
Algoritmos
Wilder Pereira Follow
Mar 26, 2019 · 14 min read

Algoritmos e estruturas de dados são temas frequentemente ignorados


por diversas pessoas da área de desenvolvimento e engenharia de
software, desde universitários aos programadores mais experientes. Os
principais argumentos utilizados giram em torno do fato de que "as
linguagens de programação e frameworks atuais já implementam os
algoritmos da maneira mais eficiente" ou de que "os recursos de hardware
disponíveis hoje (como memória e processador) tornam a preocupação com
algoritmos desnecessária".

Hoje em dia também existe uma discussão muito recorrente sobre


linguagens de programação serem melhores ou mais rápidas que outras.
Apesar de uma poder ser melhor que outra em situações diferentes, de
nada importa se a pessoa que estiver escrevendo o código não tiver uma
noção fundamental de algoritmos.

Neste post falarei sobre o que é um algoritmo, a importância de estudá-


los e apresentarei brevemente um dos temas mais importantes da
Ciência da Computação para engenharia de software: Análise de
Complexidade de Algoritmos.

Algoritmos
No mundo da ciência da computação, algoritmos são processos
computacionais bem definidos que podem receber uma entrada,
processá-la e produzir um ou mais valores como saída.

Alguns exemplos de problemas que envolvem algoritmos comuns são:

Calcular a rota mais curta entre duas ruas

Contar o número de amigos em comum em uma rede social

Organizar de maneira eficiente tarefas de acordo com sua prioridade,


prazo e duração

Organizar a lista de contatos por ordem alfabética

Encontrar uma mensagem no histórico de conversas

De forma geral, podemos pensar em algoritmos como uma ferramenta


para resolver um problema bem definido. A definição de um problema se
baseia em um conjunto de dados que se deseja processar, com suas
especificidades, e o resultado que é desejado alcançar.

Como podemos perceber, existem diversos problemas que podem ser


resolvidos utilizando algoritmos. Não existe uma "bala de prata" para
resolvê-los de maneiras eficiente. Porém existem categorias nas quais os
problemas e seus subproblemas podem ser classificados. Abstraindo o
problema, podemos identificar a sua categoria e aplicar técnicas
específicas conhecidas para resolvê-los da maneira mais eficiente.

Algumas das principais categorias/técnicas são:


- Greedy Algorithms
- Dynamic Programming
- Divide and conquer
- Backtracking
- Search and Sorting

Existem também Estrutura de Dados que são utilizados nessas e outras


técnicas para ajudar na eficiência desses algoritmos. Como por exemplo:
(Grafos, Árvores, Heaps, Tabelas Hash, Pilhas e Filas)

Eficiência dos algoritmos


Já sabemos que existem diversas ferramentas parar analisar a
performance de um programa, os conhecidos Profilers. Porém, apesar de
sua eficiência, eles não são úteis para Complexidade de Algoritmos.
Complexidade de Algoritmos analisa um algoritmo em "nível de
idealização/definição"— isto é, ignorando qualquer implementação de
linguagens específicas ou hardware. Nós queremos comparar algoritmos
em termos do que eles são: Ideias de como algo é computado.

Comparar o tempo que algoritmos levam para executar em


milissegundos não são parâmetros para dizer que um algoritmo é melhor
que o outro.

É possível que códigos mal escritos em Golang rodem muito mais rápido
(até certo ponto) do que o mesmo código escrito em C# ou Java.

Vamos analisar duas soluções para o famoso problema Two Sum, que
verifica se existem 2 números cuja soma resulte na entrada específica
(alvo) e retorna as posições dos números encontrados:

1 func twoSum(nums []int, target int) (index1, index2 int) {


2 for index1, _ := range nums {
3 for index2, _ := range nums {
4 if nums[index1]+nums[index2] == target {
5 return index1, index2
6 }
7 }
8 }
9 return -1, -1
10 }

two_sum.go hosted with ❤ by GitHub view raw

Algoritmo em Golang

1 fun twoSum(nums: IntArray, target: Int): Pair<Int, Int> {


2 val seen = hashMapOf<Int, Int>()
3
4 nums.forEachIndexed { index, value ->
5 if (seen.contains(target - value)) {
6 return Pair(seen[target - value]!!, index)
7 }
8 seen.put(value, index)
9 }
10
11 return Pair(-1, -1)
12
13 }

TwoSum.kt hosted with ❤ by GitHub view raw

Algoritmo em Kotlin

Rodando as duas soluções com o arrays de até 1000 elementos, o


algoritmo em Go é muito mais rápido que Kotlin, demorando apenas
alguns nanosegundos para encontrar o resultado, enquanto Kotlin
demora poucos milisegundos na maioria dos casos.

Será que podemos afirmar que o algoritmo em Go é melhor que o em


Kotlin?

Para isso, precisamos definir o que realmente é um “algoritmo melhor”.

Podemos dizer que o melhor algoritmo para resolver um problema é


aquele que possui a menor complexidade de tempo e espaço. Em outras
palavras, é o algoritmo que, conforme a entrada cresce tendendo ao
infinito, é aquele que apresenta a menor variação de tempo e memória
utilizada para terminar.

Rodando os mesmos algoritmos com entradas variando de 0 a 1 milhão e


extraindo o tempo de execução podemos montar o seguinte gráfico:

Variação do tempo de execução dos algoritmos de acordo com o tamanho da entrada

Conforme a entrada cresce, o tempo de execução em Kotlin varia muito


pouco, apresentando valores entre 0 e 234 milisegundos. Já Go,
apresenta tempos de execução entre 0 e 15 minutos com entradas muito
grandes.

Um algoritmo pode ser melhor que outro quando processa poucos


dados, porém pode ser muito pior conforme o dado cresce.

A Análise de complexidade nos permite medir o quão rápido um


programa executa suas computações. Exemplos de computações são:
Operações de adição e multiplicação; comparações; pesquisa de
elementos em um conjunto de dados; determinar o caminho mais curto
entre diferentes pontos; ou até verificar a presença de uma expressão
regular em uma string.
Claramente, a computação está sempre presente em programas de
computadores.

Entender este conceito permitirá que continue estudando algoritmos


com um melhor entendimento da teoria por trás deles.

Contando Instruções
Para começar a entender a análise de complexidade de algoritmos,
precisamos conseguir identificar superficialmente o número de
instruções que um algoritmo possui.

O maior elemento de um array de N elementos pode ser encontrado com


o seguinte algoritmo:

1 maior := lista[0]
2
3 for indice := 0; indice < n; indice++ {
4 if lista[indice] > maior {
5 maior = lista[indice]
6 }
7 }

max_snippet.go hosted with ❤ by GitHub view raw

Agora, a primeira coisa que deveremos fazer é contar quantas instruções


fundamentais este código executa. Primeiro, separaremos o código em
pequenas instruções e consideraremos que o processador as executem
como uma instrução cada.

A primeira instrução é uma atribuição do primeiro elemento da lista à


uma variável que será utilizada para ser comparada com os outros
elementos da lista e armazenar o maior elemento.

maior := lista[0]

Após isso, o loop será inicializado indice := 0 e seu bloco será executado
caso o array não seja vazio.

Após isso temos duas instruções:

indice := 0
indice < n

E ao final de cada laço teremos mais duas instruções:


indice++ que incrementa o contador do loop e
indice < n que verifica se o loop já percorreu todo o array.

Desconsiderando o corpo do for-loop podemos definir uma função


matemática f(n) que, dado um n, nos mostra o número de instruções que
o algoritmo precisará.

Desconsiderando o corpo do loop, chegamos à função :


f(n) = 4 + 2n.

Análise de pior caso


Considerando o corpo do loop, temos um acesso a um array e uma
comparação (2 instruções), porém caso o elemento atual seja maior que
o maior elemento até o momento, teremos mais um acesso ao array e
uma atribuição (+2 instruções). Isso torna um pouco mais difícil de
definirmos uma função para o número de instruções.

Já percebemos que o número de instruções executadas varia de acordo


com a entrada.

Na maioria das vezes quando vamos analisar um algoritmo temos que


pensar em qual é o cenário onde o nosso algoritmo precisaria do maior
número de instruções para executar. Nesse caso, é quando o vetor estiver
ordenado. Caso o array esteja ordenado [1,2,4,5,6], o código dentro do if
executará em toda iteração. Enquanto para a entrada [5,6,3,5,1,2] ele só
executaria uma vez.

Cientistas da computação chamam isso de Análise de Pior Caso.

Então, no pior caso, nós temos 4 instruções dentro do laço de repetição.


No final, podemos chegar na seguinte função que nos dá o número de
instruções necessárias no pior caso para o algoritmo:
f( n ) = 4 + 2n + 4n = 6n + 4

Comportamento Assintótico
Seria uma tarefa muito maçante ficar contando o número de instruções
para cada trecho de código que escrevemos. Além de que o número de
instruções varia muito de linguagem para linguagem, compiladores e até
mesmo o processador que estamos utilizando.

Na análise de complexidade nós apenas nos importamos com o termo


que mais cresce de acordo com a entrada. Para chegarmos nesse
termo, podemos remover todas as constantes e manter o termo que mais
cresce.

Na função f(n) = 6n + 4, claramente, 4 continua 4 independentemente


da entrada, porém 6n fica cada vez maior. Removendo ele ficamos com a
função f(n) = 6n.

Como o número 6 é uma constante, podemos removê-lo e chegar na


função f(n) = n. Isso simplifica muito a análise da complexidade do
algoritmo.

Agora, vamos encontrar o comportamento assintótico das seguintes


funções:

f(n) = 5n + 12 nos dá f(n) = n


Pelo mesmo motivo do exemplo anterior.

f(n) = 915 nos dá f(n) = 1


Estamos removendo o multiplicador 915 * 1

f(n) = n² + 2n + 300 nos dá f(n) = n²


Aqui, o termo n² cresce mais rápido do que 2n

f(n) = n² + 2000n + 5929 nos dá f(n) = n²


Mesmo que o fator antes de n é bem grande, podemos encontrar um
valor para n onde n² se torna maior que 2000n.

Complexidade e Notação Big-O


Para descrever o comportamento assintótico de um algoritmo, cientistas
da computação adotaram a Notação Big-O. Ela é utilizada para delimitar
assintóticamente o crescimento (tempo ou espaço) superior do
algoritmo.

Utilizando o algoritmo para encontrar o maior elemento como exemplo,


podemos encontrar casos de entrada que fará ele executar um número
menor de operações. Não é para todo caso de entrada que sua função
para o número de instruções será f(n).

Utilizando a notação Big-O, podemos dizer que a complexidade do


algoritmo é "Big-O de O(n)", ou seja, no pior dos casos cresce em
ordem de n.

Para algoritmos simples, é muito fácil de identificar a complexidade do


algoritmo. Geralmente, quando um algoritmo possui apenas 1 laço de
repetição sua complexidade é O(n), quando possui 2 laços encadeados
O(n²) e nenhum laço de repetição O(1). Mas cuidado, isso nem sempre se
aplica!

Complexidade de espaço
Todas as análises feitas até agora foram em função do número de
operações que os algoritmos requerem, e isso é o equivalente à
complexidade de tempo.

A complexidade de espaço de um algoritmo não é muito diferente da


complexidade de tempo em questão de análise, e também utilizamos a
notação Big-O.

Para analisar a complexidade de espaço de um algoritmo devemos


identificar o quanto de memória nosso algoritmo precisa alocar para
resolver o problema no pior dos casos.

Analisando alguns algoritmos


Vamos analisar novamente os algoritmos utilizados para resolver o Two
Sum no começo desse post.

Exemplo Two Sum em Kotlin

1 fun twoSum(nums: IntArray, target: Int): Pair<Int, Int> {


2 val seen = hashMapOf<Int, Int>()
3
4 nums.forEachIndexed { index, value ->
5 if (seen.contains(target - value)) {
6 return Pair(seen[target - value]!!, index)
7 }
8 seen.put(value, index)
9 }
10
11 return Pair(-1, -1)
12
13 }

TwoSum.kt hosted with ❤ by GitHub view raw

Algoritmo em Kotlin

Para cada um dos itens do array, caso o item atual não esteja no mapa,
ele é adicionado, e caso a diferença do elemento atual com o target já
estiver no mapa, o elemento que faltava para a soma ser igual ao target
é encontrado.

O algoritmo requer, no pior dos casos, que todos os itens do array sejam
percorridos e adicionados no mapa. Como o acesso e adição no mapa
são constantes (O(1)), sua complexidade de tempo é O(n).

Podemos dizer que ele utiliza n espaços de memória extra (1 entrada no


mapa para cada um dos elementos do array). Sendo assim, sua
complexidade de espaço também é O(n).

Exemplo Two Sum em Go

1 func twoSum(nums []int, target int) (index1, index2 int) {


2 for index1, _ := range nums {
3 for index2, _ := range nums {
4 if nums[index1]+nums[index2] == target {
5 return index1, index2
6 }
7 }
8 }
9 return -1, -1
10 }

two_sum.go hosted with ❤ by GitHub view raw

Algoritmo em Golang

O algoritmo feito em Go é um pouco diferente.

Para cada um dos itens do array, todos os elementos do array são


percorridos novamente. Caso a soma dos dois elementos atuais seja igual
ao target, o valor é retornado. No pior dos casos, o algoritmo deverá
percorrer n*n vezes o array para informar que não existem 2 elementos
que somados resultam no target. Por isso, sua complexidade de tempo é
quadrática (O(n²)).

Como essa solução não aloca nenhum espaço extra, podemos dizer que
sua complexidade de espaço é constante (O(1)).

Busca binária — O(log n) tempo e O(1) espaço.

1 def binarySearch(arr, begin, end, x):


2
3 while begin <= end:
4
5 mid = begin + (end - begin)/2;
6
7 # Check if x is present at mid
8 if arr[mid] == x:
9 return mid
10
11 # If x is greater, ignore left half
12 elif arr[mid] < x:
13 begin = mid + 1
14
15 # If x is smaller, ignore right half
16 else:
17 end = mid - 1
18
19 # If we reach here, then the element
20 # was not present
21 return -1

binarySearch.py hosted with ❤ by GitHub view raw

Busca binária em Python — GeekforGeeks

O algoritmo de busca binária consiste em retornar o índice do elemento


procurado em um array ordenado, caso ele exista.

O algoritmo começa verificando se o elemento buscado está na posição


inicial. Caso positivo, retorna sua posição.

Caso o elemento na posição do meio for menor que o elemento buscado,


ele aplica o mesmo algoritmo considerando a posição do meio como
inicial. E caso o elemento na posição do meio for maior que o elemento
buscado, ele aplica o algoritmo considerando a posição do meio como
final.

Esse algoritmo se repete até que o elemento procurado seja encontrado


ou caso a posição inicial for a mesma que a final.

A cada iteração no loop, o número de elementos a serem percorridos cai


pela metade. Por isso, complexidade te tempo é logarítmica (O(log n)).

Como o algoritmo só requer uma variável extra para rodar (mid) sua
complexidade de espaço é O(1). As variáveis arr, begin, end e x já
estavam alocadas, e por isso não influenciaram na complexidade de
tempo.

Merge Sort — O(n log n) tempo e O(n) espaço


O algoritmo Merge Sort utiliza a técnica Divide and Conquer. Durante
sua execução, o array de entrada é dividido em duas metades, e a função
mergeSort é chamada recursivamente para cada uma delas, intercalando
as duas metades de forma ordenada no final.

1 def mergesort(arr):
2
3 if len(arr) > 1:
4 mid = int(len(arr) / 2)
5
6 left_list = mergesort(arr[:mid])
7 right_list = mergesort(arr[mid:])
8
9 return merge(left_list, right_list)
10
11 return arr
12
13
14 def merge(left_list, right_list):
15
16 left_size = len(left_list)
17 right_size = len(right_list)
18
19 merged_list = [0] * (left_size + right_size)
20
21 merged_index = 0
22 left_index = 0
23 right_index = 0
24
25 while left_index < left_size and right_index < right_size:
26
27 if left_list[left_index] <= right_list[right_index]:
28 merged_list[merged_index] = left_list[left_index]
29 left_index += 1
30
31 else:
32 merged_list[merged_index] = right_list[right_index]
33 right_index += 1
34
35 merged_index += 1
36
37
38 while left_index < left_size:
39 merged_list[merged_index] = left_list[left_index]
40 left_index += 1
41 merged_index += 1
42
43 while right_index < right_size:
44 merged_list[merged_index] = right_list[right_index]
45 right_index += 1
46 merged_index += 1
47
48 return merged_list

mergesort.py hosted with ❤ by GitHub view raw

Analisar a complexidade do merge sort é um pouco mais complexo.

Mas basicamente, podemos dizer que o tempo para executar o


mergesort é igual ao tempo para ordenar o lado esquerdo + o tempo
para ordenar o lado direito + tempo para mesclar as duas metades (c).

E quando o tamanho da entrada for 1, ele demora um tempo constante.

A partir disso, podemos chegar na seguinte equação de recorrência:

Resolvendo a equação, é possível chegar na complexidade de tempo O(n


log n).

Para entender sua complexidade de espaço, vamos utilizar uma árvore


representando o espaço que o algoritmo precisa a cada chamada de
função.

Árvore de execução do Merge Sort. Os números representam o espaço extra a cada chamada recursiva.

Devido ao fato do merge sort utilizado nesse exemplo não executar em


paralelo, sua execução acontece de uma maneira parecida com um
Depth First Traversal (ou busca em profundidade). Dessa forma, apenas
um "galho" da árvore será expandido por vez, durante a stack de
recursão, e o número de espaço extra utilizado será de no máximo 3n.

A stack de execução acontecerá da seguinte forma:

Primeiro "galho" formado representado com os números em destaque.

Somando os números destacados, temos o valor 16 + 8 + 4 + 2 +1 +1


= 32 (equivalente a 2n).

E em seguida:

Cuja a soma é: 16+8+4+2+ 2+1+1 = 34.

E assim por diante. Até que no final do algoritmo teremos os seguintes


valores extra em memória:

Essa é a ultima stack de execução e é onde o algoritmo tem alocado um


maior número de espaço extra. Todos os arrays mesclados
anteriormente ainda estão em memória.

No final chegamos no seguinte valor:


16 + 8 + 8 + 4 + 4 + 2 + 2 + 1 + 1 = 46.

Se analisarmos casos com valores de entradas maiores, é possível


perceber que o espaço extra para todos os casos não passa de 3n.

Analisando assintóticamente, este valor é equivalente a O(n).

Abstração das linguagens de programação


Como mencionado no começo desse post, a maioria das linguagens de
programação utilizadas hoje já implementam diversas estruturas de
dados e algoritmos conhecidos de forma eficiente. Mesmo assim,
precisamos conhecer, ou ter uma ideia, dos algoritmos que são utilizados
na implementação das funções das linguagens que estamos utilizando.

É muito comum encontrar códigos no dia a dia que utilizam estruturas de


dados incorretas como parte de um algoritmo e utilizam suas funções
como se o custo delas fossem "grátis".

O código a seguir utiliza a função in do python de uma lista n vezes para


filtrar todos os usuários que estão em uma block list.

1 blocklist = ["User900", "User6", "User45"]


2 filtered_users = filter(lambda user: user not in blocklist, users)

inefficient_blocklist.py hosted with ❤ by GitHub view raw

A complexidade de tempo do código acima é O(n²).

Verificar se um elemento está presente em uma lista, na maioria das


linguagens de programação, possui complexidade O(n). No pior dos
casos é necessário percorrer a lista inteira para verificar se o elemento
está presente nela.

O código a seguir produz o mesmo resultado, porém de maneira mais


eficiente.

1 blocklist = set(["User900", "User6", "User45"])


2 filtered_users = filter(lambda user: user not in blocklist, users)

blocklist.py hosted with ❤ by GitHub view raw

A mudança no código é quase imperceptível, mas mesmo assim, foi


possível reduzir consideravelmente a complexidade e salvar muitos
segundos quando comparado com a solução anterior (lembra-se do
exemplo no começo do post?).

O set, em Python, utiliza uma tabela hash em sua implementação. E


verificar se um elemento está presente em uma tabela hash tem
complexidade O(1) no caso médio. Portanto, a complexidade do código
acima é O(n).

A função in, em diferentes estruturas de dados, possui complexidades


diferentes.

No dia a dia como desenvolvedor é muito fácil se deparar com um caso


semelhante ao algoritmo acima.

Não é necessário conhecer a linguagem de programação muito a fundo


para imaginar qual a complexidade que os algoritmos que ela
implementa apresentam. Conhecer os principais algoritmos e suas
complexidades nos permitem fazer uma melhor análise sobre a eficiência
do código que estamos escrevendo. E assim, podemos escrever um
código melhor.

Conclusão
Esse post foi apenas uma breve introdução ao tópico de Algoritmos e
Complexidade para mostrar a sua importância. Para entender melhor o
assunto é necessário estudar mais a fundo cada um dos tópicos citados,
além de vários outros, utilizando recursos apropriados.

Implementar algoritmos de maneiras eficientes continua sendo de


extrema importância para conseguir construir aplicações que permitam
com que o produto seja escalável e o usuário tenha uma boa experiência.

Conforme o tempo passa, apesar dos recursos de hardware ficarem cada


vez mais baratos e eficientes, surge cada vez mais a necessidade de
processar um número maior de dados.

Não é a toa que as maiores empresas de tecnologia, como Google,


Facebook e Amazon possuem processos seletivos que avaliam
rigidamente o nível do conhecimento em algoritmos de seus candidatos.

Mas é claro, "algoritmos" não é o tópico mais importante para um


profissional da área dominar. De nada adianta dominar a fundo
algoritmos sem conseguir escrever um Código Limpo, sem saber
construir um sistema, sem saber trabalhar em equipe, ou até mesmo, sem
ter uma visão do negócio onde está atuando.

Aqui esta um repositório que utilizo para praticar algoritmos e estruturas


de dados. Ele possui a implementação de diversos algoritmos diferentes e
soluções para vários problemas de sites como LeetCode, HackerRank, etc.

wilder/datastructures-and-algorithms
Repository for studying/practicing Data Structures and Algorithms. -
wilder/datastructures-and-algorithms
github.com

Para finalizar, ficaremos com o trecho retirado do livro Algoritmos de


Thomas Cormen:

Uma sólida base de conhecimento e técnica de


algoritmos é uma característica que separa os
programadores verdadeiramente qualificados dos
novatos. Com a moderna tecnologia
computacional, você pode executar algumas
tarefas sem saber muito sobre algoritmos; porém,
com uma boa base em algoritmos, é possível fazer
muito, muito mais.

Referências

Introduction to Algorithms, Third Edition


The latest edition of the essential text and professional reference,
with substantial new material on such topics as…
mitpress.mit.edu

A Gentle Introduction to Algorithm Complexity


Analysis
However, theoretical computer science has its uses and
applications and can turn out to be quite practical. In this…
discrete.gr

Loading...
Level up your coding skills and quickly land a job. This is the best
place to expand your knowledge and get prepared…
leetcode.com

Big-O Algorithm Complexity Cheat Sheet (Know Thy


Complexities!) @ericdrowell
Hi there! This webpage covers the space and time Big-O
complexities of common algorithms used in Computer Science.…
bigocheatsheet.com

Merge Sort Time and Space Complexity


Let's take this implementation of Merge Sort as an example void
mergesort(Item a[], int l, int r) { if (r <= l) return…
stackoverflow.com

Algoritmos Python Kotlin Golang Engenharia De Software

616 1

WRITTEN BY

Wilder Pereira Follow

Software Engineer — https://wilder.github.io

Nagoya Foundation Follow

https://github.com/nagoya-foundation

Learn more. Make Medium yours. Write a story on Medium.


Medium is an open platform where 170 million readers come Follow the writers, publications, and topics that matter to you, If you have a story to tell, knowledge to share, or a
to find insightful and dynamic thinking. Here, expert and and you’ll see them on your homepage and in your inbox. perspective to offer — welcome home. It’s easy and free to
undiscovered voices alike dive into the heart of any topic and Explore post your thinking on any topic. Start a blog
bring new ideas to the surface. Learn more

About Write Help Legal

Você também pode gostar