Algoritmos
Pense em algoritmos como instruções para resolver problemas. Esses problemas podem ser resolvidos de várias formas, mas existem formas melhores que gastam menos tempo, menos repetição e exigem menos trabalho.
E cada algoritmo tem seu melhor caso de uso, ou seja, o melhor lugar onde pode ser usado.
Ordenação
Bubble Sort
Prós ✅
-
Fácil de implementar: O algoritmo é direto e ideal para ensinar conceitos de ordenação.
-
Ordenação in-place: Requer apenas uma quantidade constante de memória adicional $O(1)$.
-
Ordenação estável: Mantém a ordem relativa de elementos iguais, o que é útil em certas aplicações.
-
Otimização possível para melhor caso: Com uma condição de saída antecipada (quando não ocorrem trocas), pode ter desempenho O(n) para dados quase ordenados.
Contras ❌
-
Muito ineficiente para grandes conjuntos de dados: Possui complexidade de tempo média e pior caso de O(n²), tornando-o impraticável para listas grandes.
-
Muitas trocas: Realiza mais trocas do que o necessário comparado a algoritmos mais eficientes como insertion sort ou quicksort.
-
Não adaptativo por padrão: Sem otimização, sempre percorre todas as iterações mesmo se a lista ficar ordenada prematuramente.
-
Raramente usado na prática: Existem algoritmos com melhor desempenho como Merge Sort, Quick Sort e Tim Sort usados em aplicações reais.
Pior caso: O(n²) | Melhor caso: O(n)
Youtube - Bubble Sort por Michael Sambol
def bubble_sort(arr):
n = len(arr)
for i in range(n - 1):
for j in range(0, n - i - 1):
if arr[j] > arr[j + 1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j]
return arr
sorted_arr = bubble_sort([64, 34, 25, 12, 22, 11, 90])
print(sorted_arr)
Insertion Sort
Prós ✅
-
Simples e intuitivo: O algoritmo é fácil de entender e implementar.
-
Melhor desempenho no melhor caso: Pode atingir complexidade de tempo O(n) para dados quase ordenados.
-
Ordenação estável: Mantém a ordem relativa de elementos iguais, o que é útil em certas aplicações.
Contras ❌
-
Muito ineficiente para grandes conjuntos de dados: Possui complexidade de tempo média e pior caso de O(n²), tornando-o impraticável para listas grandes.
-
Não adaptativo por padrão: Sem otimização, sempre percorre todas as iterações mesmo se a lista ficar ordenada prematuramente.
-
Raramente usado na prática: Existem algoritmos com melhor desempenho como Merge Sort, Quick Sort e Tim Sort usados em aplicações reais.
Pior caso: O(n²) | Melhor caso: O(n)
Youtube - Insertion Sort por Michael Sambol
def insertion_sort(arr):
for i in range(1, len(arr)):
key = arr[i]
j = i - 1
while j >= 0 and key < arr[j]:
arr[j + 1] = arr[j]
j -= 1
arr[j + 1] = key
return arr
sorted_arr = insertion_sort([64, 34, 25, 12, 22, 11, 90])
print(sorted_arr)
Merge Sort
Prós ✅
-
Ordenação estável: Mantém a ordem relativa de elementos iguais, o que é útil em certas aplicações.
-
Melhor desempenho no melhor caso: Pode atingir complexidade de tempo O(n) para dados quase ordenados.
-
Não adaptativo por padrão: Sem otimização, sempre percorre todas as iterações mesmo se a lista ficar ordenada prematuramente.
Contras ❌
-
Não é ordenação in-place: Requer memória adicional proporcional ao tamanho do array de entrada.
-
Não adaptativo por padrão: Sem otimização, sempre percorre todas as iterações mesmo se a lista ficar ordenada prematuramente.
Pior caso: O(n log n) | Melhor caso: O(n log n)
Youtube - Algoritmo Merge Sort por Michael Sambol
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
return merge(left, right)
def merge(left, right):
result = []
i = j = 0
while i < len(left) and j < len(right):
if left[i] < right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
result.extend(left[i:])
result.extend(right[j:])
return result
sorted_arr = merge_sort([64, 34, 25, 12, 22, 11, 90])
print(sorted_arr)
Ordenação Rápida
Prós ✅
-
Melhor desempenho no melhor caso: Pode atingir complexidade de tempo O(n) para dados quase ordenados.
-
Ordenação in-place: Requer apenas uma quantidade constante de memória adicional $O(1)$.
-
Não adaptativo por padrão: Sem otimização, sempre percorre todas as iterações mesmo se a lista ficar ordenada prematuramente.
Contras ❌
-
Não é estável por padrão: Elementos iguais podem ser reordenados, o que não é útil em certas aplicações.
-
Não adaptativo por padrão: Sem otimização, sempre percorre todas as iterações mesmo se a lista ficar ordenada prematuramente.
Pior caso: O(n²) | Melhor caso: O(n log n)
Youtube - Algoritmo Quick Sort por Michael Sambol
def quick_sort(arr):
if len(arr) <= 1:
return arr
pivot = arr[len(arr) // 2]
left = [x for x in arr if x < pivot]
middle = [x for x in arr if x == pivot]
right = [x for x in arr if x > pivot]
return quick_sort(left) + middle + quick_sort(right)
sorted_arr = quick_sort([64, 34, 25, 12, 22, 11, 90])
print(sorted_arr)
Busca
Busca Linear
Prós ✅
-
Simples e intuitivo: O algoritmo é fácil de entender e implementar.
-
Melhor desempenho no melhor caso: Pode atingir complexidade de tempo O(n) para dados quase ordenados.
Contras ❌
-
Muito ineficiente para grandes conjuntos de dados: Possui complexidade de tempo média e pior caso de O(n²), tornando-o impraticável para listas grandes.
-
Não adaptativo por padrão: Sem otimização, sempre percorre todas as iterações mesmo se a lista ficar ordenada prematuramente.
Pior caso: O(n) | Melhor caso: O(1)
Youtube - Algoritmo de Busca Linear por Bro Code
def linear_search(arr, target):
for i, value in enumerate(arr):
if value == target:
return i
return -1
target = 25
index = linear_search([64, 34, 25, 12, 22, 11, 90], target)
print(index)
Busca Binária
Prós ✅
-
Melhor desempenho no melhor caso: Pode atingir complexidade de tempo O(n) para dados quase ordenados.
-
Ordenação in-place: Requer apenas uma quantidade constante de memória adicional $O(1)$.
Contras ❌
-
Não é estável por padrão: Elementos iguais podem ser reordenados, o que não é útil em certas aplicações.
-
Não adaptativo por padrão: Sem otimização, sempre percorre todas as iterações mesmo se a lista ficar ordenada prematuramente.
Pior caso: O(log n) | Melhor caso: O(1)
Youtube - Busca Binária por Michael Sambol
def binary_search(arr, target):
left, right = 0, len(arr) - 1
while left <= right:
mid = (left + right) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
target = 25
index = binary_search([64, 34, 25, 12, 22, 11, 90], target)
print(index)
Recursão
Prós ✅
-
Simples e intuitivo: O algoritmo é fácil de entender e implementar.
-
Melhor desempenho no melhor caso: Pode atingir complexidade de tempo O(n) para dados quase ordenados.
Contras ❌
-
Não é estável por padrão: Elementos iguais podem ser reordenados, o que não é útil em certas aplicações.
-
Não adaptativo por padrão: Sem otimização, sempre percorre todas as iterações mesmo se a lista ficar ordenada prematuramente.
Pior caso: O(n) | Melhor caso: O(1)
Youtube - Recursão em 100 segundos por Fireship
def factorial(n):
if n == 0:
return 1
return n * factorial(n - 1)
result = factorial(5)
print(result)
BFS (Busca em Largura)
A Busca em Largura (BFS - Breadth-First Search) é um algoritmo para percorrer ou pesquisar estruturas de dados em árvore ou grafo. Ele começa no nó raiz e explora todos os nós vizinhos no nível atual antes de se mover para os nós do próximo nível.
Prós ✅
-
Completo: Garante encontrar o caminho mais curto em grafos não ponderados.
-
Ótimo para grafos não ponderados: Encontra o caminho com menor número de arestas.
-
Simples de implementar: Usa uma estrutura de dados de fila (queue).
Contras ❌
-
Uso de memória: Pode consumir muita memória em grafos muito grandes.
-
Não é ideal para caminhos longos: Pode ser ineficiente quando o caminho é muito longo.
Complexidade de tempo: O(V + E) | Complexidade de espaço: O(V)
Youtube - BFS por William Fiset
from collections import deque
def bfs(graph, start):
visited = set()
queue = deque([start])
visited.add(start)
while queue:
vertex = queue.popleft()
print(vertex, end=' ')
for neighbor in graph[vertex]:
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
# Exemplo de uso
graph = {
'A': ['B', 'C'],
'B': ['A', 'D', 'E'],
'C': ['A', 'F'],
'D': ['B'],
'E': ['B', 'F'],
'F': ['C', 'E']
}
bfs(graph, 'A')
DFS (Busca em Profundidade)
A Busca em Profundidade (DFS - Depth-First Search) é um algoritmo para percorrer ou pesquisar estruturas de dados em árvore ou grafo. Ele explora o máximo possível ao longo de cada ramo antes de retroceder.
Prós ✅
-
Eficiente em memória: Usa menos memória que BFS em grafos muito grandes.
-
Bom para caminhos longos: Pode ser mais eficiente quando o caminho é muito longo.
-
Útil para backtracking: Ideal para problemas que requerem backtracking.
Contras ❌
-
Não garante o caminho mais curto: Pode não encontrar o caminho mais curto em grafos não ponderados.
-
Pode ficar preso em ciclos: Requer marcação de nós visitados para evitar ciclos infinitos.
Complexidade de tempo: O(V + E) | Complexidade de espaço: O(V)
Youtube - DFS por William Fiset
def dfs(graph, start, visited=None):
if visited is None:
visited = set()
visited.add(start)
print(start, end=' ')
for neighbor in graph[start]:
if neighbor not in visited:
dfs(graph, neighbor, visited)
# Exemplo de uso
graph = {
'A': ['B', 'C'],
'B': ['A', 'D', 'E'],
'C': ['A', 'F'],
'D': ['B'],
'E': ['B', 'F'],
'F': ['C', 'E']
}
dfs(graph, 'A')
Algoritmo de Dijkstra
O algoritmo de Dijkstra é um algoritmo para encontrar o caminho mais curto entre dois vértices em um grafo com arestas de peso não negativo.
Prós ✅
-
Garante o caminho mais curto: Encontra o caminho com menor peso total.
-
Eficiente: Usa uma fila de prioridade para otimizar a busca.
-
Versátil: Pode ser usado em diversos tipos de problemas de roteamento.
Contras ❌
-
Não funciona com pesos negativos: Requer que todas as arestas tenham peso não negativo.
-
Complexidade de implementação: Mais complexo que BFS ou DFS.
Complexidade de tempo: O((V + E) log V) | Complexidade de espaço: O(V)
Youtube - Dijkstra's Algorithm por Computer Science Lessons
import heapq
def dijkstra(graph, start):
distances = {node: float('infinity') for node in graph}
distances[start] = 0
pq = [(0, start)]
while pq:
current_distance, current_node = heapq.heappop(pq)
if current_distance > distances[current_node]:
continue
for neighbor, weight in graph[current_node].items():
distance = current_distance + weight
if distance < distances[neighbor]:
distances[neighbor] = distance
heapq.heappush(pq, (distance, neighbor))
return distances
# Exemplo de uso
graph = {
'A': {'B': 1, 'C': 4},
'B': {'A': 1, 'C': 2, 'D': 5},
'C': {'A': 4, 'B': 2, 'D': 1},
'D': {'B': 5, 'C': 1}
}
distances = dijkstra(graph, 'A')
print(distances)
Conclusão
Cada algoritmo tem seu lugar e propósito específico. A escolha do algoritmo correto depende do problema que você está tentando resolver e das características dos seus dados. Entender as complexidades e trade-offs de cada algoritmo é crucial para escrever código eficiente e escalável.
Lembre-se:
- Use algoritmos simples (como Bubble Sort) para conjuntos pequenos de dados ou para fins educacionais
- Algoritmos mais complexos (como Quick Sort) são melhores para conjuntos grandes de dados
- BFS e DFS são fundamentais para problemas de grafos
- Dijkstra é a escolha certa para encontrar caminhos mais curtos em grafos ponderados
A prática constante e a compreensão profunda desses algoritmos ajudarão você a se tornar um programador mais eficiente e capaz de resolver problemas complexos de forma elegante.