Análise de complexidade algorítmica, implementação e estudo de algoritmos de ordenação (selection e insertion sort), uso de tabelas hash para recuperação eficiente de informações, manipulação de expressões aritméticas em notação infixa e pós-fixa com pilhas, e desenvolvimento de estruturas de dados como filas de prioridade encadeadas.
Antes de analisar algoritmos, precisamos de uma linguagem comum para falar de desempenho. Essa linguagem é a notação Big-O. Criada na matemática (Bachmann–Landau) e popularizada na computação por Donald Knuth, ela descreve como o tempo ou a memória crescem em função do tamanho da entrada (n), de forma independente do hardware.
# Rápidos (tempo e espaço)
# O(1) tempo, O(1) espaço
def pega_central(a):
return a[len(a)//2] if a else None
# O(N) tempo, O(1) espaço
def tem_x(s):
for c in s:
if c == 'X':
return True
return False
# O(N log N) tempo, O(N) espaço
def ordenar(a):
return sorted(a)
# O(N²) tempo, O(1) espaço
def tem_par_igual(a):
for i in range(len(a)):
for j in range(i+1, len(a)):
if a[i] == a[j]:
return True
return False
Agora que entendemos a linguagem, vem a análise de complexidade: é o processo de aplicar Big-O para medir e comparar algoritmos. O objetivo é prever custos, escolher soluções mais eficientes e identificar gargalos.
A análise de complexidade orienta decisões essenciais no desenvolvimento de algoritmos. Por exemplo, a escolha do algoritmo certo pode significar a diferença entre horas e segundos: ordenar 1 milhão de itens com um algoritmo O(n²) levaria horas, enquanto um O(n log n) resolve em segundos. Além disso, entender como o custo cresce com a entrada permite prever consumo de tempo e memória, evitando surpresas na produção. A análise também ajuda a identificar gargalos, como laços aninhados ou buscas lineares em grandes estruturas, que podem ser substituídos por tabelas hash (acesso médio O(1)) ou árvores balanceadas (O(log n)) para ganhos significativos. Por fim, a combinação de teoria e prática — análise assintótica com medições reais — valida hipóteses, ajusta constantes e confirma que os algoritmos escolhidos realmente se comportam como esperado em cenários reais.
Explique como a análise de complexidade de algoritmos pode nos auxiliar na otimização de programas de computadores.
A análise de complexidade é fundamental para orientar decisões de otimização em programas de computador,
pois permite prever como tempo e memória crescem à medida que o volume de dados aumenta.
Em primeiro lugar, ela auxilia na escolha de algoritmos. Para ordenar um milhão de itens,
um algoritmo quadrático O(n²), como Bubble Sort ou Selection Sort, exigiria cerca de um trilhão de comparações,
levando horas de execução. Já um algoritmo mais eficiente, como Merge Sort ou Quick Sort, com O(n log n),
resolve o mesmo problema em segundos.
Também possibilita a previsão de desempenho e consumo de recursos. Um programa O(n²) que leva
apenas 1 segundo para processar mil itens pode demandar 100 segundos para dez mil e várias horas para cem mil,
tornando-se inviável em escala.
A análise ajuda ainda a identificar gargalos. Estruturas com laços aninhados (O(n²)) ou buscas
lineares em listas extensas são pontos típicos de lentidão. Nesses casos, substituir uma busca sequencial por
uma tabela hash (O(1) em média) ou por uma árvore balanceada (O(log n)) pode reduzir drasticamente a latência.
Por fim, a boa prática é combinar teoria e experimento. A análise assintótica fornece previsões
úteis, mas é essencial confrontá-las com medições reais para validar hipóteses, ajustar constantes e confirmar
ganhos de desempenho no mundo prático.
O algoritmo de ordenação por seleção (Selection Sort) é um dos mais antigos e simples. Sua origem remonta aos primórdios da computação (décadas de 1950-1960), uma época de recursos computacionais extremamente limitados. Ele foi criado não por um único inventor, mas como uma solução intuitiva e direta para o problema de ordenar dados, sendo fácil de entender e implementar.
Seu propósito principal sempre foi mais educacional do que prático para grandes volumes, servindo como uma excelente porta de entrada para conceitos fundamentais de algoritmos, como laços aninhados e manipulação de arrays in-place (sem uso de memória extra).
A lógica do Selection Sort é muito humana. Se pedirmos a uma pessoa para ordenar uma lista de números, é provável que ela use uma abordagem similar: encontrar o menor número, colocá-lo no início; depois, encontrar o segundo menor, colocá-lo na segunda posição, e assim por diante.
Formalmente, o algoritmo divide a lista em duas partes: uma sub-lista ordenada à esquerda (que começa vazia) e uma sub-lista desordenada à direita (que começa com a lista inteira).
Iteração 1:
[64, 25, 12, 22, 11] -> Menor é 11. Troca 64 com 11.
[11, 25, 12, 22, 64]
Iteração 2:
[11, 25, 12, 22, 64] -> Menor na parte desordenada é 12. Troca 25 com 12.
[11, 12, 25, 22, 64]
Iteração 3:
[11, 12, 25, 22, 64] -> Menor é 22. Troca 25 com 22.
[11, 12, 22, 25, 64]
Iteração 4:
[11, 12, 22, 25, 64] -> Menor é 25. Nenhuma troca necessária.
[11, 12, 22, 25, 64]
Fim: A lista está ordenada.
Escreva uma função em python que implemente o algoritmo de ordenação selection sort.
O Selection Sort divide a lista em duas partes: a sublista ordenada à esquerda e a sublista não ordenada à direita. A cada passo, ele expande a parte ordenada.
O laço externo percorre cada posição da lista, escolhendo onde o próximo menor elemento deve ir. O laço interno procura o menor elemento na sublista não ordenada.
Quando encontra o menor elemento, compara com o da posição atual. Se forem diferentes, faz a troca. O menor elemento vai para a sublista ordenada.
É in-place: não precisa de memória extra, só uma variável temporária para a troca. Simples e direto.
Complexidade de tempo: O(n²) em todos os casos. Mesmo que a lista já esteja ordenada, ele não sabe e percorre toda a sublista para garantir que cada mínimo está na posição certa.
Na prática, é didático, fácil de implementar e entender. Em resumo: escolha o menor, troque, repita até ordenar toda a lista.
def selection_sort(lista):
"""
Implementa o algoritmo de ordenação Selection Sort.
Complexidade de tempo: O(N²). Espaço: O(1).
"""
n = len(lista)
# O laço externo define a fronteira entre a parte ordenada e a desordenada.
# Ele executa n-1 vezes, pois o último elemento não precisa ser verificado.
for i in range(n - 1):
# Assume que o menor elemento é o primeiro da parte não ordenada (posição i).
indice_menor = i
# O laço interno percorre a parte desordenada (de i+1 até o fim)
# para encontrar o verdadeiro menor elemento.
for j in range(i + 1, n):
# Se encontrar um elemento menor, atualiza o índice.
if lista[j] < lista[indice_menor]:
indice_menor = j
# Após encontrar o menor, se ele não for o próprio elemento i,
# troca-o de lugar com o elemento no início da parte desordenada.
if i != indice_menor:
lista[i], lista[indice_menor] = lista[indice_menor], lista[i]
return lista
# Exemplo de uso
dados = [64, 25, 12, 22, 11]
print(f"Lista original: {dados}")
lista_ordenada = selection_sort(dados)
print(f"Lista ordenada: {lista_ordenada}")
# Saída: Lista ordenada: [11, 12, 22, 25, 64]
O Insertion Sort é um dos algoritmos de ordenação mais simples e intuitivos. Sua lógica é muito parecida com a que usamos para organizar cartas de baralho em nossas mãos: pegamos uma carta de cada vez e a inserimos em sua posição correta na parte que já está ordenada.
Por ser fácil de implementar e muito eficiente para listas pequenas ou quase ordenadas, ele é uma ferramenta importante no arsenal de qualquer programador, sendo frequentemente usado como parte de algoritmos mais complexos e otimizados, como o Timsort (o algoritmo de ordenação padrão do Python).
A estratégia do Insertion Sort é dividir a lista em duas partes: uma sub-lista ordenada à esquerda e uma sub-lista não ordenada à direita. A cada passo, ele pega o primeiro elemento da parte não ordenada e o "insere" no lugar certo da parte ordenada.
Início: [5, 2, 4, 6, 1, 3]
i=1 (chave=2): 2 < 5. Desloca 5. Insere 2.
[2, 5, 4, 6, 1, 3]
i=2 (chave=4): 4 < 5. Desloca 5. 4 > 2. Insere 4.
[2, 4, 5, 6, 1, 3]
i=3 (chave=6): 6 > 5. Nenhuma mudança. 6 já está na posição correta.
[2, 4, 5, 6, 1, 3]
i=4 (chave=1): 1 < 6, 1 < 5, 1 < 4, 1 < 2. Desloca todos. Insere 1.
[1, 2, 4, 5, 6, 3]
i=5 (chave=3): 3 < 6, 3 < 5, 3 < 4. Desloca 6, 5 e 4. 3 > 2. Insere 3.
[1, 2, 3, 4, 5, 6]
Fim: A lista está ordenada.
Conclusão da Análise: O Insertion Sort é adaptativo. Seu desempenho melhora drasticamente conforme a lista se aproxima da ordenação, tornando-o uma escolha excelente para dados "quase ordenados".
Escreva uma função em python que implemente o algoritmo de ordenação insertion sort.
O Insertion Sort constrói a lista ordenada à esquerda, elemento por elemento. Para cada item, ele encontra o lugar certo dentro da sublista ordenada e insere ali, deslocando os elementos maiores para a direita.
O laço externo percorre cada elemento da lista, começando do segundo, assumindo que o primeiro já está ordenado. O laço interno (`while`) compara o elemento atual com os da sublista à esquerda, movendo-os até abrir espaço para a inserção.
É um algoritmo in-place: não precisa de memória extra além de uma variável temporária para o elemento que está sendo inserido.
Complexidade: pior e médio caso são O(n²), porque cada inserção pode deslocar toda a sublista ordenada. Melhor caso O(n), quando a lista já está ordenada e não há deslocamentos.
Na prática, ele é rápido para listas pequenas ou quase ordenadas e geralmente mais eficiente que Selection Sort, que sempre faz O(n²) comparações, mesmo quando a lista já está quase ordenada.
def insertion_sort(lista):
"""
Implementa o algoritmo de ordenação Insertion Sort.
Ele percorre a lista e insere cada elemento em sua posição
correta na sub-lista ordenada à esquerda.
"""
# Começa do segundo elemento (índice 1), pois o primeiro
# já é considerado uma sub-lista ordenada.
for i in range(1, len(lista)):
chave = lista[i] # O elemento que queremos posicionar.
j = i - 1 # O índice do último elemento da sub-lista ordenada.
# Move os elementos da sub-lista ordenada que são maiores
# que a chave para a direita, abrindo espaço.
while j >= 0 and chave < lista[j]:
lista[j + 1] = lista[j]
j -= 1
# Insere a chave na posição correta (após o elemento
# que é menor ou igual a ela).
lista[j + 1] = chave
return lista
# Exemplo
dados = [5, 2, 4, 6, 1, 3]
print(f"Lista original: {dados}")
lista_ordenada = insertion_sort(dados)
print(f"Lista ordenada: {lista_ordenada}")
# Saída: Lista ordenada: [1, 2, 3, 4, 5, 6]
A análise de complexidade nos ajuda a entender como o custo (em tempo ou memória) de um algoritmo cresce em relação ao tamanho da entrada (N). Para algoritmos de ordenação quadrática como estes, é crucial entender a diferença entre o número de comparações (leituras) e o número de trocas/deslocamentos (escritas na memória).
Comparações: $O(N^2)$ O Selection Sort possui dois laços aninhados. O laço externo define a fronteira da parte ordenada, e o laço interno sempre varre todo o restante da lista para encontrar o menor valor. Isso resulta em uma soma de $(N-1) + (N-2) + ... + 1$ comparações, que é uma P.A. totalizando $\frac{N(N-1)}{2}$. Em Big-O, isso é $O(N^2)$.
Trocas (Swaps): $O(N)$ Esta é a única "vantagem" do Selection Sort. Como ele só realiza uma troca ao final de cada iteração do laço externo (colocando o mínimo encontrado em sua posição final), ele realiza no máximo $N-1$ trocas.
Conclusão (Selection): A complexidade total é $O(N^2)$ em todos os casos (Melhor, Médio e Pior). Mesmo que a lista já esteja ordenada (Melhor Caso), o algoritmo não tem como saber disso e é "pessimista": ele obrigatoriamente fará todas as $\sim N^2/2$ comparações para confirmar que o menor elemento já está no lugar certo. O custo das comparações sempre domina o custo das trocas.
O Insertion Sort também tem dois laços, mas o comportamento do laço interno (o `while`) é dependente dos dados. Ele não varre a lista inteira cegamente; ele para assim que encontra a posição correta.
Pior Caso: $O(N^2)$ Ocorre quando a lista está em ordem inversa. Para cada novo elemento (a "chave"), o laço `while` precisa compará-lo com toda a sub-lista ordenada à esquerda, deslocando todos eles. Isso resulta em $\sim N^2/2$ comparações e $\sim N^2/2$ deslocamentos.
Melhor Caso: $O(N)$ Este é o superpoder do Insertion Sort. Ocorre quando a lista já está ordenada. O laço externo roda N vezes, mas a condição do `while` (`chave < lista[j]`) é imediatamente falsa em todas as iterações. O algoritmo faz apenas $\sim N$ comparações e zero deslocamentos. Por isso, ele é chamado de adaptativo.
Caso Médio: $O(N^2)$ Para dados aleatórios, espera-se que cada elemento seja inserido, em média, no meio da sub-lista ordenada. Isso leva a $\sim N^2/4$ comparações e $\sim N^2/4$ deslocamentos.
Explique como e por que as tabelas Hash possibilitam uma maior eficiência na recuperação de informações armazenadas no computador.
Tabelas Hash (ou Dicionários em Python) são estruturas de dados extremamente eficientes para armazenar e recuperar informações porque, em média, elas realizam essas operações em tempo constante, ou seja, $O(1)$. Isso significa que o tempo para encontrar um item não depende do número total de itens armazenados, seja um milhão ou dez itens.
1. Função Hash (O Endereçador): O núcleo de uma tabela hash é a função hash. Ela pega uma chave de entrada (por exemplo, o nome "Maria") e a converte em um número inteiro, que é usado como um índice (endereço) em um array (ou vetor) subjacente. Esse processo é determinístico: a mesma chave sempre produzirá o mesmo índice.
2. Armazenamento Direto: O valor associado à chave ("Maria", telefone "9999-8888") é armazenado diretamente na posição do array indicada pelo índice gerado.
3. Recuperação Instantânea: Para recuperar o telefone de "Maria", não precisamos percorrer uma lista. Simplesmente aplicamos a mesma função hash à chave "Maria", obtemos o mesmo índice de antes e vamos diretamente para aquela posição no array para encontrar o valor.
Escreva uma função em python que converta uma expressão aritmética na forma infixa para a forma pós-fixa. Usando uma pilha de controle, escreva também uma função em python que permita avaliar a expressão no formato pós-fixo que é obtida da saída da primeira função do seu programa.
Para converter, usamos um algoritmo famoso chamado Shunting-Yard (pátio de manobras de trens), que usa uma pilha para guardar temporariamente os operadores. A lógica é:
Esta é a parte mais fácil e o motivo pelo qual essa notação é tão útil. Usamos uma segunda pilha, desta vez para os operandos:
# Classe para representar a estrutura de dados LIFO (Last-In, First-Out)
class Pilha:
def __init__(self):
self.items = []
def esta_vazia(self):
return not self.items
def empilhar(self, item):
self.items.append(item)
def desempilhar(self):
if not self.esta_vazia():
return self.items.pop()
def topo(self):
if not self.esta_vazia():
return self.items[-1]
# Função 1: Conversão de Infixa para Pós-Fixa
def infixa_para_posfixa(expressao):
precedencia = {'+': 1, '-': 1, '*': 2, '/': 2, '^': 3}
operadores = Pilha()
posfixa = []
tokens = expressao.split()
for token in tokens:
# Se for um número, vai direto para a saída.
if token.isalnum():
posfixa.append(token)
# Se for '(', empilha para marcar o início de uma sub-expressão.
elif token == '(':
operadores.empilhar(token)
# Se for ')', desempilha operadores até encontrar o '('.
elif token == ')':
op_topo = operadores.desempilhar()
while op_topo != '(':
posfixa.append(op_topo)
op_topo = operadores.desempilhar()
# Se for um operador...
else:
# Desempilha operadores de maior ou igual precedência.
while (not operadores.esta_vazia() and
operadores.topo() != '(' and
precedencia.get(operadores.topo(), 0) >= precedencia.get(token, 0)):
posfixa.append(operadores.desempilhar())
# Empilha o operador atual.
operadores.empilhar(token)
# Desempilha todos os operadores restantes.
while not operadores.esta_vazia():
posfixa.append(operadores.desempilhar())
return " ".join(posfixa)
# Função 2: Avaliação da Expressão Pós-Fixa
def avaliar_posfixa(expressao):
operandos = Pilha()
tokens = expressao.split()
for token in tokens:
# Se for um número, empilha na pilha de operandos.
if token.isdigit():
operandos.empilhar(int(token))
# Se for um operador...
else:
# Desempilha os dois últimos operandos.
op2 = operandos.desempilhar()
op1 = operandos.desempilhar()
# Realiza a operação e empilha o resultado.
if token == '+': resultado = op1 + op2
elif token == '-': resultado = op1 - op2
elif token == '*': resultado = op1 * op2
elif token == '/': resultado = op1 / op2
elif token == '^': resultado = op1 ** op2
operandos.empilhar(resultado)
# O resultado final é o único item na pilha.
return operandos.desempilhar()
# --- Exemplo de Uso ---
# IMPORTANTE: A expressão infixa deve ter espaços entre cada token.
expressao_infixa = "10 + 3 * 5 / ( 16 - 4 )"
print(f"Expressão Infixa: {expressao_infixa}")
expressao_posfixa = infixa_para_posfixa(expressao_infixa)
print(f"Expressão Pós-fixa: {expressao_posfixa}")
# Saída: Expressão Pós-fixa: 10 3 5 * 16 4 - / +
resultado = avaliar_posfixa(expressao_posfixa)
print(f"Resultado da Avaliação: {resultado}")
# Saída: Resultado da Avaliação: 11.25
-- Acompanhando a Avaliação Pós-Fixa --
Expressão: 10 3 5 * 16 4 - / +
10 -> Pilha: [10] 3 -> Pilha: [10, 3] 5 -> Pilha: [10, 3, 5] * -> Desempilha 5 e 3. Calcula 3*5=15. Empilha 15. Pilha: [10, 15] 16 -> Pilha: [10, 15, 16] 4 -> Pilha: [10, 15, 16, 4] - -> Desempilha 4 e 16. Calcula 16-4=12. Empilha 12. Pilha: [10, 15, 12] / -> Desempilha 12 e 15. Calcula 15/12=1.25. Empilha 1.25. Pilha: [10, 1.25] + -> Desempilha 1.25 e 10. Calcula 10+1.25=11.25. Empilha 11.25. Pilha: [11.25] Fim! -> Resultado final é 11.25
Implemente uma fila de prioridades, usando encadeamento.
A regra do FIFO só se aplica como critério de desempate: se dois pacientes chegam com o mesmo nível de urgência (ex: duas dores de garganta), aí sim, o que chegou primeiro é atendido primeiro.
Onde isso é usado? Elas são fundamentais em sistemas operacionais (para agendar qual processo a CPU deve executar primeiro), em algoritmos de rede (como o Algoritmo de Dijkstra, que sempre explora o "caminho mais próximo") e em qualquer cenário que precise gerenciar tarefas por ordem de urgência, e não por ordem de chegada.
Implementaremos isso usando uma lista encadeada (ou ligada), onde cada nó contém o dado, a prioridade e uma referência para o próximo nó. A inserção é feita de forma a manter a fila sempre ordenada pela prioridade.
class No:
"""Nó da lista encadeada para a fila de prioridades."""
def __init__(self, dado, prioridade):
self.dado = dado
self.prioridade = prioridade
self.proximo = None
class FilaDePrioridades:
"""Implementa uma Fila de Prioridades usando lista encadeada."""
def __init__(self):
self.inicio = None
def esta_vazia(self):
return self.inicio is None
def inserir(self, dado, prioridade):
novo_no = No(dado, prioridade)
# Caso 1: A fila está vazia ou o novo nó tem maior prioridade que o início
if self.esta_vazia() or prioridade < self.inicio.prioridade:
novo_no.proximo = self.inicio
self.inicio = novo_no
else:
# Caso 2: Percorre a fila para encontrar o local de inserção
atual = self.inicio
while atual.proximo is not None and atual.proximo.prioridade <= prioridade:
atual = atual.proximo
# Insere o novo nó
novo_no.proximo = atual.proximo
atual.proximo = novo_no
def remover(self):
"""Remove e retorna o elemento de maior prioridade (o primeiro)."""
if self.esta_vazia():
print("A fila está vazia.")
return None
dado_removido = self.inicio.dado
self.inicio = self.inicio.proximo
return dado_removido
def exibir(self):
if self.esta_vazia():
print("Fila vazia.")
return
atual = self.inicio
fila_str = ""
while atual:
fila_str += f"[{atual.dado} (P:{atual.prioridade})] -> "
atual = atual.proximo
print(fila_str + "None")
# --- Exemplo de Uso ---
# Usaremos prioridades menores para indicar maior importância (ex: P1 > P2)
fila_processos = FilaDePrioridades()
fila_processos.inserir("Processo C - Baixa", 3)
fila_processos.inserir("Processo A - Urgente", 1)
fila_processos.inserir("Processo D - Baixa", 3)
fila_processos.inserir("Processo B - Média", 2)
print("Fila de prioridades atual:")
fila_processos.exibir()
# Saída: [Processo A - Urgente (P:1)] -> [Processo B - Média (P:2)] ->
# [Processo C - Baixa (P:3)] -> [Processo D - Baixa (P:3)] -> None
print("\nRemovendo processos por ordem de prioridade:")
print(f"Removido: {fila_processos.remover()}") # Remove A
print(f"Removido: {fila_processos.remover()}") # Remove B
print(f"Removido: {fila_processos.remover()}") # Remove C (entrou antes de D)
print("\nFila após remoções:")
fila_processos.exibir()
# Saída: [Processo D - Baixa (P:3)] -> None