Python

Implementação e análise de algoritmos, ordenação e busca em arrays, otimização de funções, complexidade de tempo com notação Big O, manipulação de listas e dicionários, estruturas de dados (pilhas e filas) e simulação de máquinas e processos.

Por Fábio Linhares • Instituto Infnet

PARA RECORDAR!
Big-O
O que é e o mede? Classes de tempo e espaço e como usar no dia a dia.

Big-O é a linguagem que usamos para falar sobre como o “custo” de um algoritmo cresce quando a entrada aumenta. Em geral, medimos dois custos: tempo (quantas operações) e espaço (quanta memória extra). Ignoramos detalhes de hardware e constantes, focando no crescimento assintótico: como o custo escala quando N vai ficando muito grande.

Intuição prática: pense em “escala”. Se eu dobrar o tamanho da entrada, o tempo dobra, multiplica por ~log N, por N², ou explode exponencialmente? É isso que Big-O responde.
Complexidade de tempo mais comum (da mais “barata” à mais “cara”)
O(1) — constante: custo não cresce com N. Ex.: acessar um índice de lista.
O(log N) — logarítmica: divide o problema ao meio. Ex.: busca binária.
O(N) — linear: visita cada elemento uma vez. Ex.: somar elementos.
O(N log N) — quase linear: geralmente “ordenar e varrer”. Ex.: sort eficiente.
O(N²) — quadrática: laços duplos sobre N. Ex.: comparar todos com todos.
O(2^N), O(N!) — explosiva: cresce muito rápido. Ex.: força bruta em combinatória.
Complexidade de espaço (memória extra alocada pelo algoritmo)
O(1) — constante: poucas variáveis auxiliares, tamanho fixo.
O(N) — linear: estruturas que crescem com a entrada (ex.: cópia de uma lista).
O(N²) — matrizes, tabelas densas, grafos completos armazenados.
Tempo vs Espaço: duas alavancas. Às vezes dá para trocar memória por velocidade (pré-computar e armazenar para consultar rápido) ou economizar memória aceitando mais tempo de processamento.
# Exemplos 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 (retorno antecipado ajuda no melhor/médio caso)
        def tem_x(s):
                for c in s:
                        if c == 'X':
                                return True
                return False

        # O(N log N) tempo (ordenação), O(N) espaço (implementação típica do sort)
        def ordenar(a):
                return sorted(a)

        # O(N^2) 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

        # Mesmo tempo O(N), mas espaços diferentes:
        # O(1) espaço (sem lista auxiliar)
        def soma_dobrada_inplace(a):
                s = 0
                for x in a:
                        s += 2*x
                return s

        # O(N) espaço (cria lista auxiliar)
        def copia_e_dobra(a):
                b = [2*x for x in a]
                return sum(b)
Regras de bolso
Ignore constantes e termos menores: O(2N + 10) → O(N); O(N + N log N) → O(N log N).
Loops aninhados costumam multiplicar custos: dois loops sobre N → ~O(N²).
Estruturas certas mudam a ordem: set/dict dão busca média O(1), sort dá O(N log N).
Evite cópias desnecessárias para reduzir espaço: processe em fluxo quando possível.
Considere melhor, médio e pior caso; Big-O geralmente comunica o pior caso.
Armadilha comum: “O(N) + O(N) = O(2N), então é O(2N)”. Em Big-O, constantes caem: é O(N). Otimize primeiro a ordem, depois as constantes.
Medir importa: Big-O guia escolhas, mas valide com dados reais (perfil, benchmarks) nas entradas relevantes.

Use Big-O como mapa para projetar e comparar soluções, e combine com medições para decidir o que entregar em produção. Com essa bússola, os exercícios a seguir ganham contexto e propósito.

EXERCÍCIO 01
Mediana em O(1) para lista ordenada
Objetivo: Reescrever a função de mediana para tempo constante.

Dada a lista ordenada crescentemente abaixo, implemente uma função que retorne a mediana em O(1). Para N ímpar, retorne o elemento central; para N par, a média dos dois centrais; se vazia, retorne None.

def mediana(lista_ord):
    soma = 0
    cont = 0
    for i in range(len(lista_ord)):
        if i == len(lista_ord) // 2 or (len(lista_ord) % 2 == 0 and i == len(lista_ord) // 2 - 1):
            soma += lista_ord[i]
            cont += 1
    return soma / cont
Como a lista já está ordenada; basta acessar diretamente o(s) elemento(s) central(is). Acesso por índice em lista é O(1).

def mediana_otimizada(lista_ord):
    n = len(lista_ord)
    if n == 0:
        return None
    k = n // 2
    if n % 2 == 1:
        return lista_ord[k]
    return (lista_ord[k-1] + lista_ord[k]) / 2

# Exemplos
print(mediana_otimizada([10, 20, 30, 40, 50]))   # 30
print(mediana_otimizada([10, 20, 30, 40, 50, 60]))  # 35.0
Complexidade: O(1). Operações constantes independentemente de N.
EXERCÍCIO 02
Selection Sort por nome (candidatos)
Objetivo: Ordenar registros por nome (A→Z) com Selection Sort.

Dada uma lista de candidatos (dicionários com ni, nome, idade, pontos), ordene-a alfabeticamente por nome (A→Z) utilizando Selection Sort e retorne a lista ordenada.


exemplo = [
    {"ni": 1, "nome": "José",  "idade": 28, "pontos": 97},
    {"ni": 2, "nome": "Márcia","idade": 36, "pontos": 95},
    {"ni": 3, "nome": "André", "idade": 22, "pontos": 90},
    {"ni": 4, "nome": "Bruna", "idade": 38, "pontos": 95},
    ]
def selection_sort_por_nome(cands):
    n = len(cands)
    for i in range(n):
        idx_min = i
        for j in range(i+1, n):
            if cands[j]["nome"] < cands[idx_min]["nome"]:
                idx_min = j
        cands[i], cands[idx_min] = cands[idx_min], cands[i]
    return cands

print(selection_sort_por_nome(exemplo))

[{'ni': 3, 'nome': 'André', 'idade': 22, 'pontos': 90},
 {'ni': 4, 'nome': 'Bruna', 'idade': 38, 'pontos': 95},
 {'ni': 1, 'nome': 'José',  'idade': 28, 'pontos': 97},
 {'ni': 2, 'nome': 'Márcia','idade': 36, 'pontos': 95}]
Complexidade: O(N²). Selection Sort realiza ~N²/2 comparações no pior caso.
EXERCÍCIO 03
Insertion Sort por (pontos desc, idade desc)
Objetivo: Ordenar com dois critérios de desempate.

Considerando as mesmas definições da questão acima, ordene os candidatos por pontos em ordem decrescente e, em caso de empate, por idade também em ordem decrescente, utilizando Insertion Sort (estável).

def insertion_sort_por_pontos_idade(cands):
    for i in range(1, len(cands)):
        atual = cands[i]
        j = i - 1
        while j >= 0:
            a, b = atual, cands[j]
            melhor = (a["pontos"] > b["pontos"]) or (
                a["pontos"] == b["pontos"] and a["idade"] > b["idade"]
            )
            if melhor:
                cands[j+1] = cands[j]
                j -= 1
            else:
                break
        cands[j+1] = atual
    return cands

exemplo = [
    {"ni": 1, "nome": "José",  "idade": 28, "pontos": 97},
    {"ni": 2, "nome": "Márcia","idade": 36, "pontos": 95},
    {"ni": 3, "nome": "André", "idade": 22, "pontos": 90},
    {"ni": 4, "nome": "Bruna", "idade": 38, "pontos": 95},
]
print(insertion_sort_por_pontos_idade(exemplo))
Complexidade: O(N²) no pior caso; estável e simples de implementar.
EXERCÍCIO 04
Big-O da função soma_100
Objetivo: Analisar complexidade temporal.

Analise a complexidade temporal da função soma_100 que verifica pares simétricos somando 100. Determine a ordem assintótica e justifique.

def soma_100(lista):
    esq, dir = 0, len(lista)-1
    while esq < len(lista) // 2:
        if lista[esq] + lista[dir] != 100:
            return False
        esq += 1
        dir -= 1
    return True
A complexidade da função é O(N)(linear). Mas como esse assunto merece, vamos tentar explicar o porquê disso.


1. A Estratégia da Função: "Ponteiros que se Encontram"

Imagine que a lista é uma rua, e você quer verificar casas que ficam em posições espelhadas (a primeira com a última, a segunda com a penúltima, etc.). A função usa dois "ponteiros" (as variáveis esq e dir) que funcionam como dois carteiros:


  • O carteiro esq começa no início da rua (posição 0).
  • O carteiro dir começa no final da rua (posição N-1).

A cada iteração do laço os carteiros dão um passo em direção ao outro e verificam os números das casas onde pararam. O objetivo deles é se encontrar no meio da rua.


2. Onde Está o "Trabalho" do Algoritmo? No laço!

O principal trabalho acontece dentro do laço while. Para saber a complexidade, nos perguntamos:


  • Quantas vezes o laço vai se repetir?
    Quantas forem necessárias para chegar a metade da lista. Ou seja, o laço continua enquanto esq < len(lista) // 2. Como esq avança um passo de cada vez, ele dará exatamente N/2 passos para chegar ao meio, onde N é o tamanho da lista.

  • Qual é o custo de cada passo (iteração)?
    O custo é constante. Ou seja, dentro do laço, o algoritmo faz operações de tempo constante (O(1)): soma dois números, compara o resultado com 100, e incrementa/decrementa os ponteiros. O tempo para fazer isso não depende do tamanho da lista.

3. Calculando a Complexidade Total (Pior Caso)

O "pior caso" é quando a lista é perfeitamente simétrica (todos os pares somam 100) e o algoritmo precisa percorrer todo o caminho até o meio para confirmar. Nesse caso:


  • Custo Total = (Número de Passos) × (Custo por Passo)
  • Custo Total = (N/2) × O(1)

4. A Simplificação da Notação Big O

Como a análise de complexidade se preocupa com a escala do problema, queremos saber como o tempo de execução cresce à medida que a lista fica enormemente grande. Como podemos deduzir, o tempo de execução cresce de forma diretamente proporcional ao tamanho da lista. Portanto, dizemos que o crescimento é linear.


Logo, como na notação Big O ignoramos constantes (como o "dividido por 2"), já que elas não alteram a natureza do crescimento, crescer a uma taxa de N ou N/2 é, em essência, a mesma "coisa". Por isso, simplificamos O(N/2) para O(N).


“Na prática, se a lista tiver 1 milhão de elementos, o algoritmo vai precisar de aproximadamente 500 mil passos. Mas, em análise assintótica, tanto 500 mil quanto 1 milhão são tratados como O(N), pois o que importa é que o tempo cresce proporcionalmente ao tamanho da lista.”

EXERCÍCIO 05
Big-O de maior_produto (3 números)
Objetivo: Justificar a ordem de complexidade.

Considere a função abaixo procura o maior produto entre três elementos distintos usando três laços aninhados. Qual a complexidade temporal dela, no pior caso? Justifique.

def maior_produto(lista):
    maior = lista[0] * lista[1] * lista[2]
    n = len(lista)
    for i in range(n):
        for j in range(i+1, n):
            for k in range(j+1, n):
                prod = lista[i]*lista[j]*lista[k]
                if prod > maior:
                    maior = prod
    return maior
Complexidade: O(N³).

Conforme o enunciado, a função percorre todas as combinações possíveis de 3 elementos distintos da lista. Para calcular o número exato de iterações, usamos a fórmula da combinação (escolher 3 elementos dentre n):

C(n, 3) = n! / (3! × (n-3)!)

O que significa o "!" (fatorial)?
O símbolo ! indica fatorial, que é o produto de todos os números inteiros positivos até aquele número. Por exemplo:

  • 3! = 3 × 2 × 1 = 6
  • 4! = 4 × 3 × 2 × 1 = 24
  • 5! = 5 × 4 × 3 × 2 × 1 = 120

O fatorial ajuda a contar todas as formas de organizar elementos, e a combinação divide para eliminar repetições indesejadas.

Mas por que dividimos por 3! × (n-3)!?
  • Porque 3! remove as repetições **dentro dos 3 elementos escolhidos**. Ex.: {a, b, c} pode aparecer como (a,b,c), (b,a,c), (c,b,a), etc. São 6 ordens, mas o trio é o mesmo.
  • E porque (n-3)! remove as repetições causadas pela ordem dos elementos **não escolhidos**. Exemplo: se temos n = 5 elementos {A, B, C, D, E} e escolhemos o trio {A, B, C}, os elementos não escolhidos são {D, E}. Considerando todos os 5! arranjos de 5 elementos, {A, B, C} aparece várias vezes em diferentes posições de D e E:
                                    A B C D E
                                    A B C E D
                                    B A C D E
                                    C B A E D
                                    
    Como esses arranjos extras **não alteram o trio escolhido**, dividimos por (n-3)! = 2! = 2, para contar cada trio distinto **uma única vez**.

Assim, contamos apenas os trios **distintos** que realmente importam. Se não ficou claro ainda, veja este exemplo com 3 elementos: {a, b, c}. Os laços aninhados poderiam gerar:
                            (a, b, c)
                            (a, c, b)
                            (b, a, c)
                            (b, c, a)
                            (c, a, b)
                            (c, b, a)
                            
Como resta evidente, todas essas combinações representam o mesmo trio {a, b, c}. É por isso que dividimos por 6 (3!), para contar apenas uma vez. Capisce?

Notinha importante sobre o "n":
Não se engane. Na fórmula C(n,3), n não é 3. n é o **tamanho da lista**, que pode ser grande. O número 3 é apenas a quantidade de elementos que queremos escolher. Sendo assim, se a lista tiver 5 elementos, n = 5:
                            C(5,3) = (5 × 4 × 3) / 6 = 10
                            
Se a lista tiver 10 elementos, n = 10:
                            C(10,3) = (10 × 9 × 8) / 6 = 120
                            
Como são 3 elementos, no primeiro caso, dos 5 elementos, o fatorial vai atá a quantidade de elemetnos: 5, 4, 3. Sacaram? No segundo caso, com 10 elementos, mesma coisa: 10, 9, 8. O resto do fatorial (2, 1) é eliminado na divisão.

Esse raciocínio mostra que, quando n aumenta, o número de iterações cresce aproximadamente como n³, por isso a notação Big-O é O(N³).

Assim, o número exato de iterações é:
n(n-1)(n-2)/6, que é da ordem de em notação Big-O.

Portanto, no pior caso, a complexidade temporal é O(N³).

Observação: existe um algoritmo mais eficiente, de complexidade O(N), que percorre a lista apenas uma vez mantendo os três maiores valores e os dois menores valores (para lidar com números negativos). Ao final, basta comparar:
maior1 × maior2 × maior3 com menor1 × menor2 × maior1.
Dê uma olhadinha e, caso se anime, faça um teste de mesa para tentar entendê-lo. Bons estudos.
def maior_produto_eficiente(lista):
    # Inicializa os três maiores e os dois menores
    maior1 = maior2 = maior3 = float('-inf')
    menor1 = menor2 = float('inf')

    for x in lista:
        # Atualiza os três maiores valores
        if x > maior1:
            maior3 = maior2
            maior2 = maior1
            maior1 = x
        elif x > maior2:
            maior3 = maior2
            maior2 = x
        elif x > maior3:
            maior3 = x

        # Atualiza os dois menores valores
        if x < menor1:
            menor2 = menor1
            menor1 = x
        elif x < menor2:
            menor2 = x

    # Calcula os dois possíveis maiores produtos
    produto_maior = maior1 * maior2 * maior3
    produto_misturado = maior1 * menor1 * menor2  # Considera dois negativos

    # Retorna o maior produto possível
    return max(produto_maior, produto_misturado)


# Exemplo de uso
lista = [-10, -10, 5, 2, 3]
print(maior_produto_eficiente(lista))  # Saída: 500
EXERCÍCIO 06
Complexidade e otimização de dobra_e_soma
Objetivo: Analisar Big-O e reduzir passos.

A função abaixo retorna a soma de todos os números de um array dobrados. Use a notação Big O para descrever a complexidade da função, justificando sua resposta.

def dobra_e_soma(array):
array_dobrado = []
for num in array:
    array_dobrado.append(num * 2)
soma = 0
for num in array_dobrado:
    soma += num
return soma

Complexidade temporal: O(N). A função percorre o array original uma vez para dobrar os valores e depois percorre o array resultante para somá-los. Cada operação é O(1), então somando os percursos lineares temos O(N + N) = O(N).
Complexidade espacial: O(N), devido à criação do array auxiliar array_dobrado, que armazena N elementos.

É possível otimizar essa função de modo que ela realize a mesma tarefa, mas tenha uma complexidade de ordem menor? Se a resposta for sim, explique como. Se for não, justifique.

Sim, é possível otimizar a função, mas apenas em termos de espaço, não de tempo. A complexidade temporal já é O(N), que é mínima para percorrer todos os elementos de um array. Podemos evitar a criação do array auxiliar array_dobrado e calcular a soma diretamente, como mostrado abaixo:

def dobra_e_soma(array):
soma = 0
for num in array:
    soma += num * 2
return soma

É possível otimizar essa função de modo que ela realize a mesma tarefa, mas com uma quantidade total de passos menor? Se a resposta for sim, explique como. Se for não, justifique.

Sim, é possível reduzir a quantidade total de passos da função, embora a complexidade temporal permaneça O(N), combinando os dois laços em um só, como demonstramos acima.


Por fim, apesar da Complexidade temporal continuar O(N), já que cada elemento ainda precisa ser processado, a Complexidade espacial, entretanto, foi reduzida para O(1), haja vista a inexistência de array auxiliar.

Essa abordagem mantém a mesma ordem de complexidade, mas diminui o número total de operações realizadas, tornando a função mais eficiente na prática.

EXERCÍCIO 07
Complexidade de soma_louca
Objetivo: Analisar Big-O e discutir otimizações.

A função abaixo itera sobre um array de números e, para cada nº cujo índice é par, imprime a soma daquele nº com cada nº do array. Use a notação Big O para descrever a complexidade da função, justificando sua resposta.

def soma_louca(array):
    for i in range(len(array)):
        if i % 2 == 0:
            for num in array:
                print(array[i] + num)

Resposta: Tempo: O(n^2); Espaço: O(1).

Justificativa: o laço externo percorre n índices e, para aproximadamente n/2 índices pares, o laço interno percorre n elementos. Total ≈ (n/2) × n = n^2/2 ⇒ O(n^2). A checagem i % 2 só reduz por constante, ignorada em Big O.

É possível otimizar essa função de modo que ela realize a mesma tarefa, mas tenha uma complexidade de ordem menor? Se a resposta for sim, explique como. Se for não, justifique.

Não. A tarefa exige imprimir ~N²/2 resultados (um por par com índice par), o que impõe limite inferior Ω(N²) apenas para produzir a saída. Reduzir a ordem exigiria mudar o problema (por exemplo, sumarizar em vez de imprimir cada soma).

É possível otimizar essa função de modo que ela realize a mesma tarefa, mas com uma quantidade total de passos menor? Se a resposta for sim, explique como. Se for não, justifique.

Sim, é possível reduzir o número de passos/overhead mantendo O(N²): percorra apenas índices pares (sem if), evite buscas repetidas e minimize chamadas de I/O.

import sys

def soma_louca_otimizada(array):
    pr = sys.stdout.write
    nl = '\n'
    arr = array  # alias local para evitar lookup global
    for i in range(0, len(arr), 2):  # sem teste de paridade por iteração
        base = arr[i]
        # escreve um bloco por linha de i, reduzindo chamadas de I/O
        pr(nl.join(str(base + x) for x in arr) + nl)
  • Ordem assintótica permanece O(N²) (tamanho da saída é quadrático).
  • Menos constantes: sem i % 2, menos lookups e menos chamadas de I/O, resultando em execução prática mais rápida.

Complexidade: O(N²), pois para ~N/2 índices pares imprimimos N somas. Não há redução de ordem mantendo a mesma tarefa (todas as somas precisam ser produzidas). Pode-se micro-otimizar constantes, mas não a ordem.
EXERCÍCIO 08
Complexidade de contem_x e early return
Objetivo: Analisar pior caso e otimizar melhor/médio casos.

A função abaixo verifica se um “X” maiúsculo está presente em uma string ou não. Use a notação Big O para descrever a complexidade da função, justificando sua resposta.

def contem_x(string):
    encontrou_x = False
    for c in string:
        if c == 'X':
            encontrou_x = True
    return encontrou_x

Resposta: Tempo: O(N); Espaço: O(1).

Justificativa: percorre todos os N caracteres fazendo comparações O(1) e sem estruturas auxiliares. Como não há retorno antecipado, mesmo se 'X' aparecer cedo o laço segue até o fim; portanto melhor, médio e pior casos são O(N). O uso de uma variável booleana não altera a ordem e mantém o espaço constante.

É possível otimizar essa função de modo que ela realize a mesma tarefa, mas tenha uma complexidade de ordem menor no pior caso? Se a resposta for sim, explique como. Se for não, justifique.

Não. No pior caso (quando não há 'X'), é necessário inspecionar todos os N caracteres para concluir a ausência, impondo limite inferior Ω(N). Só é possível reduzir constantes (retorno antecipado, I/O menor), não a ordem. Em cenários com múltiplas consultas sobre a mesma string, dá para pré-processar em O(N) e responder cada consulta em O(1), mas a primeira passagem permanece O(N).

É possível otimizar essa função de modo que ela realize uma quantidade total de passos menor em alguns casos? Se a resposta for sim, explique como. Se for não, justifique.

def contem_x_otimizado(string):
        for c in string:
                if c == 'X':
                        return True
        return False

Sim. A ordem assintótica no pior caso continua sendo O(N), já que em uma string sem 'X' será necessário examinar todos os caracteres. No entanto, ao usar retorno antecipado, a função pode terminar mais cedo no caso médio e no melhor caso, reduzindo a quantidade total de passos. Por exemplo, se o caractere 'X' aparecer logo no início da string, a função encerra imediatamente, trazendo um ganho prático mesmo sem mudar a complexidade assintótica.

EXERCÍCIO 09
Par com soma = 10 em O(N)
Objetivo: Otimizar de O(N²) para O(N) usando set.

O código abaixo define uma função que verifica se um array de números contém um par de números cuja soma é 10. Esta implementação da função tem complexidade O(N²). Otimize o código de modo que a função realize a mesma tarefa, mas com complexidade O(N).

def soma_10(array):
    for i in range(len(array)):
        for j in range(len(array)):
            if i != j and array[i] + array[j] == 10:
                return True
    return False

Solução O(N) usando um set:

def soma_10_otimizado(array):
    vistos = set()
    for x in array:
        if 10 - x in vistos:
            return True
        vistos.add(x)
    return False

print(soma_10_otimizado([1,2,3,9]))  # True (1+9)
print(soma_10_otimizado([1,2,3,4,5]))  # False
Uso de conjunto dá busca média O(1), resultando em O(N) total.
EXERCÍCIO 10
Interseção de arrays em O(N)
Objetivo: Usar set para interseção eficiente.

Escreva uma função que retorna a interseção de dois arrays. A interseção é um terceiro array que contém todos os valores contidos ao mesmo tempo em ambos os arrays de entrada. Por exemplo, a interseção de [1, 2, 3, 4, 5] e [0, 2, 4, 6, 8] é [2, 4]. Sua função deve ter complexidade O(N), onde N é o comprimento do maior array.

def intersecao(a, b):
    if len(a) < len(b):
        s, arr = set(a), b
    else:
        s, arr = set(b), a
    res = []
    for x in arr:
        if x in s:
            res.append(x)
            s.discard(x)  # evita duplicar no resultado
    return res

print(intersecao([1,2,3,4,5], [0,2,4,6,8]))  # [2, 4]
Essa implementação atinge O(N) porque converte o array menor em um conjunto, garantindo busca em tempo constante na média (O(1)). Em seguida, percorre apenas o array maior, checando interseções de forma eficiente. Além disso, o uso de discard() evita elementos repetidos no resultado, mantendo a resposta correta mesmo quando há duplicatas nas entradas.
EXERCÍCIO 11
Primeiro valor duplicado em O(N)
Objetivo: Devolver o primeiro número que aparece duas vezes.

Dada uma lista de inteiros, retorne o primeiro valor que aparece duas vezes ao percorrer da esquerda para a direita; caso contrário, retorne None.

def primeiro_duplicado(arr):
    vistos = set()
    for x in arr:
        if x in vistos:
            return x
        vistos.add(x)
    return None

print(primeiro_duplicado([1,2,3,4,5,0,2,4,6,8]))  # 2
Complexidade: O(N) tempo e O(N) espaço no pior caso.
EXERCÍCIO 12
Primeiro caractere não duplicado (string)
Objetivo: Resolver em O(N) com 2 passagens.

Dada uma string, devolva o primeiro caractere não repetido. Se todos repetirem, retorne None. Faça em O(N) com duas passagens.

def primeiro_nao_duplicado(s):
    freq = {}
    for ch in s:
        freq[ch] = freq.get(ch, 0) + 1
    for ch in s:
        if freq[ch] == 1:
            return ch
    return None

print(primeiro_nao_duplicado("minimamente"))  # 'a'
Complexidade: O(N) tempo, O(K) espaço (K = alfabeto em uso).
EXERCÍCIO 13
Inverter string usando uma pilha
Objetivo: Implementar com estrutura LIFO.

Inverta uma string usando uma pilha implementada por você. Não utilize atalhos como [::-1] ou reversed.

class Pilha:
    def __init__(self):
        self._d = []
    def push(self, x):
        self._d.append(x)
    def pop(self):
        return self._d.pop() if self._d else None
    def is_empty(self):
        return len(self._d) == 0

def inverter_com_pilha(s):
    p = Pilha()
    for ch in s:
        p.push(ch)
    out = []
    while not p.is_empty():
        out.append(p.pop())
    return ''.join(out)

print(inverter_com_pilha("abcde"))  # edcba
Complexidade: O(N) tempo, O(N) espaço auxiliar devido à pilha.
PARA RECORDAR!
Pilha (Stack)
O que é uma pilha, como funciona a lógica LIFO e por que ela resolve naturalmente o problema de inverter sequências?

Uma pilha é uma estrutura de dados simples e poderosa. Ela segue a regra LIFO (Last In, First Out), ou seja: o último elemento a entrar é o primeiro a sair. Pense numa pilha de pratos: você coloca sempre por cima (push) e retira sempre por cima (pop).

Para que servem as pilhas? Para muitas "coisas", Dãh! Resposta óbvia, né? Mas vamos lá. Pense nelas como uma lista (horizontal) que alguém colocou de pé (vertical). E é basicamente isso mesmo. Pode confiar. A lógica também é a mesma da lista: os elementos são adicionados um acima do outro. A partir disso, podemos pensar em muitas serventias diferentes para as pilhas, entre elas, “desfazer” ações ou inverter ordens, já que essa dinâmica, assim como nas listas, facilita a recuperação dos elementos adicionados no sentido oposto ao que foram inseridos.

Mas para que isso então? Se pilhas e listas são a mesma coisa, por que não usar listas para tudo? Como diria o professor Guanabara: "excelente pergunta, pequeno Gafanhoto". Porque a pilha é uma abstração que nos ajuda a pensar em certos problemas de forma mais clara e isso, de certo modo, nos força a seguir a lógica LIFO, o que pode ser exatamente o que precisamos em situações como a do exercício 14, mais abaixo.

Ei, não se preocupe! Essa confusão entre listas e pilhas é normal. Principalmente se você aprendeu outra linguagem de programação antes, pois em Python não existe um tipo de dado nativo chamado pilha (stack), como temos em JAVA, C++, C# (.NET) ou Ruby, entre outras linguagens. Para o bem ou para o mal, o "Guido van Rossum, desenvolveu o Python para ser uma linguagem mais simples, legível e poderosa. Talvez por isso: dada a semelhança entre listas e pilhas, não criou uma estrutura própria para a última, apenas adaptando a primeira (lista) para tal fim. Ou ele estava com preguiça de escrever. Vai saber. kkk. Mas o que importa, pelo menos aqui, é o seguinte:

📌 Listas são uma estrutura genérica de armazenamento ordenado que suporta múltiplos métodos:
    • append()
    • pop()
    • insert()
    • remove()
    • sort()
    • reverse()
    • slicing (lista[2:5])


📌 E por ser assim, podem de ser usadas tanto como pilha e fila, quanto array dinâmico. Veja alguns exemplos:

    pilha = []
    
    pilha.append(10)  # push
    pilha.append(20)
    pilha.append(30)
    print(pilha)      # [10, 20, 30]

    pilha.pop()       # pop → remove 30
    print(pilha)      # [10, 20]
    

Fica a dica: implementar uma classe (limitando os métodos) ajuda a treinar o raciocínio e enxergar a pilha como entidade independente. Veja:

                  
            class Pilha:

            def __init__(self):
                self._d = []
            def push(self, x):
                self._d.append(x)
            def pop(self):
                return self._d.pop() if self._d else None
            def is_empty(self):
                return len(self._d) == 0
        
Embora no Python possamos inverter diretamente com s[::-1], usar a pilha tem valor pedagógico: ela mostra como uma estrutura de dados simples resolve problemas de forma natural e generalizável, como na análise de expressões, verificação de parênteses ou histórico de ações em editores. Se você chegou até aqui e ainda se questiona sobre a importância das pilhas, esta é a resposta:

  • Porque a pilha é uma abstração, e abstrações ajudam a pensar com clareza sobre problemas complexos.
  • Ela impõe a lógica LIFO (Last In, First Out), útil para:
    • desfazer ações (CTRL+Z);
    • navegar para frente e para trás em históricos;
    • avaliar expressões matemáticas ou sintáticas;
    • inverter ordens de elementos de forma sistemática.
  • Mesmo em Python, onde usamos listas ou deque, conhecer pilhas ajuda a entender e usar estruturas nativas em outras linguagens (Java, C++, C#) de forma correta.
  • Estudar pilhas cria um vocabulário comum em Ciência da Computação: ao ouvir “use uma pilha”, todos entendem a lógica LIFO sem precisar discutir detalhes de implementação.

Em resumo: a pilha não é apenas uma lista com outro nome. É uma forma de organizar o pensamento e resolver problemas de maneira elegante e generalizável.

EXERCÍCIO 14
Emulador simples de máquina de pilha
Objetivo: Interpretar comandos push/pop/op aritméticas/print.
As máquinas virtuais dos ambientes de execução de Java e C# utilizam linguagens intermediárias e operam sobre uma arquitetura orientada a pilha. Os comandos da máquina virtual operam sobre uma pilha de dados, onde eles podem consumir e escrever dados.
Para entendermos melhor essa arquitetura, vamos implementar um emulador de uma máquina bem simples. Essa máquina irá operar apenas sobre valores inteiros, com comandos básicos de empilhar, desempilhar e imprimir, além das quatro operações aritméticas básicas.
Iremos ainda simular uma memória RAM, como uma lista com 64 posições (0 a 63), sendo referenciadas como R0, R1, R2, …, R63.
Os comandos disponíveis na máquina estão descritos na tabela abaixo:
Comando Descrição
push <val> Empilha um valor constante. Ex: push 20 (empilha o valor 20).
push R<pos> Empilha um valor que está na memória RAM. Ex: push R2 (empilha o valor da célula 2 da RAM).
pop R<pos> Consome o valor do topo da pilha e armazena na RAM. Ex: pop R1 (remove o topo e salva na célula 1).
add Consome dois valores da pilha, soma e empilha o resultado.
mul Consome dois valores da pilha, multiplica e empilha o resultado.
div Consome dois valores da pilha, divide o elemento que está abaixo do topo pelo elemento do topo, e empilha o resultado (divisão inteira).
sub Consome dois valores da pilha, subtrai o elemento que está no topo do elemento abaixo do topo, e empilha o resultado.
print Consome um valor da pilha e o imprime em uma linha.

Entrada: Seu programa deverá receber (como argumento de linha de comando) o caminho de um arquivo contendo o código a ser executado. Cada linha do arquivo deve conter um dos comandos descritos acima.

Saída: Seu programa deverá emular a execução do código contido no arquivo de entrada, utilizando 2 estruturas: uma pilha de dados e uma lista simulando a memória RAM de 64 células. Sempre que um comando print for encontrado no código, o valor no topo da pilha deverá ser consumido e impresso no console. Esta será a saída do seu programa.

Exemplos:

push 10
push 20
add
print
30
push 10
push 20
add
pop R0
push 3
push 5
mul
pop R1
push 44
push R0
push R1
div
sub
print
42

Resolução (código)

import sys

class Pilha:
    def __init__(self):
        self._d = []
    def push(self, x):
        self._d.append(x)
    def pop(self):
        return self._d.pop() if self._d else None

def emular(caminho):
    p = Pilha()
    ram = [0]*64
    with open(caminho) as f:
        for linha in f:
            partes = linha.strip().split()
            if not partes: continue
            op = partes[0].lower()
            if op == 'push':
                v = partes[1]
                if v.upper().startswith('R'):
                    idx = int(v[1:])
                    p.push(ram[idx])
                else:
                    p.push(int(v))
            elif op == 'pop':
                dest = partes[1]
                idx = int(dest[1:])
                ram[idx] = p.pop()
            elif op in ('add','sub','mul','div'):
                b, a = p.pop(), p.pop()
                if op == 'add': p.push(a+b)
                if op == 'sub': p.push(a-b)
                if op == 'mul': p.push(a*b)
                if op == 'div': p.push(a//b)
            elif op == 'print':
                print(p.pop())

if __name__ == '__main__':
    if len(sys.argv) != 2:
        print('Uso: python emulador.py ')
    else:
        emular(sys.argv[1])
Como solicitado no enunciado, este código implementa um **emulador de operações usando uma pilha** e uma "memória" de 64 registradores (R0 a R63). Mas vamos com calma, caso tenha ficado com dúvidas, dê uma olhadinha abaixo:

  • Pilha: a classe Pilha permite empilhar valores (push) e desempilhá-los (pop), seguindo a lógica LIFO.
  • Memória RAM simulada: a lista ram com 64 posições armazena valores nos registradores R0..R63.
  • Operações: o arquivo de entrada contém instruções como push, pop, add, sub, mul, div e print. O código lê linha a linha e executa a operação correspondente.
  • Push: coloca na pilha um número literal ou o valor de um registrador (R0..R63).
  • Pop: remove o valor do topo da pilha e armazena em um registrador.
  • Operações aritméticas: sempre retiram os dois elementos do topo da pilha, realizam a operação e empilham o resultado. Por exemplo, div usa **divisão inteira** (//), garantindo que o resultado seja um número inteiro.
  • Print: desempilha o valor do topo e exibe na tela.

Lembre-se: essa abordagem permite entender na prática como a pilha e a memória interagem, tornando conceitos abstratos mais concretos (ou tentando). O uso de divisão inteira e índices de registradores são "detalhes" da simulação que garantem consistência e evitam erros.

EXERCÍCIO 15
Simulador de filas de supermercado (A, B, C, D)
Objetivo: Processar string de eventos com 4 filas.

Diferentemente das Pilhas, as Filas são frequentemente usadas para simular o fluxo de pessoas, carros, aviões, transações e assim por diante. Lembre-se: Filas utilizam o sistema FIFO (First In, First Out), ou seja, o primeiro a entrar é o primeiro a sair, como as filas de um supermercado, onde os clientes chegam e se alinham para pagar suas compras. Cada caixa possui sua própria fila, e os clientes são atendidos na ordem em que chegaram.

E é justamente isso que faremos aqui. A ideia é escrever um programa que modele as filas de caixas de um supermercado usando estruturas de dados do tipo fila. O sistema deve modelar 4 filas, inicialmente vazias, para os caixas A, B, C e D. Para facilitar, usaremos uma string para modelar os eventos de entrada em uma fila e conclusão do pagamento. Uma letra minúscula indicará um novo cliente entrando em uma das filas, e uma letra maiúscula indicará um cliente concluindo o pagamento na fila correspondente. Por exemplo, a string aababbAbA representa 3 pessoas entrando na fila do caixa A com 2 delas concluindo o pagamento, e 4 pessoas entrando na fila do caixa B.

Cada cliente será representado pela letra C seguida de um número único. Esta string deverá ser adicionada na fila em que o cliente entrar. Por exemplo, ao processar a string aabdcb, teríamos a seguinte sequência de entradas: os clientes “C1” e “C2” entram na fila A, “C3” na B, “C4” na D, “C5” na C, e “C6” na B. Além disso, o caractere “,” será usado na string para sinalizar que o conteúdo atual de cada uma das filas deve ser impresso. Confuso? Pera lá! Veja:

Regras declaradas:
  • letra minúscula (a, b, c, d): indica novo cliente entrando na fila do caixa correspondente;
  • letra maiúscula (A, B, C, D): o primeiro cliente daquela fila conclui o pagamento (sai);
  • vírgula (,): imprime o estado atual das quatro filas (A, B, C, D) e adiciona uma linha em branco ao final.
Exemplo: aababbAbA

Evt  | Fila A            | Fila B            | Ação
-----+-------------------+-------------------+-----------------------------
a    | [C1]              | []                | entra C1 em A
a    | [C1, C2]          | []                | entra C2 em A
b    | [C1, C2]          | [C3]              | entra C3 em B
a    | [C1, C2, C4]      | [C3]              | entra C4 em A
b    | [C1, C2, C4]      | [C3, C5]          | entra C5 em B
b    | [C1, C2, C4]      | [C3, C5, C6]      | entra C6 em B
A    | [C2, C4]          | [C3, C5, C6]      | paga C1 em A
b    | [C2, C4]          | [C3, C5, C6, C7]  | entra C7 em B
A    | [C4]              | [C3, C5, C6, C7]  | paga C2 em A

Resumo final:
A: [C4]
B: [C3, C5, C6, C7]

1. Entrada de clientes:
Cada letra minúscula representa um cliente entrando na fila de um caixa. A string aaaa, indica que quatro clientes entram na fila A na ordem de chegada:

  • 1ª letra a → C1
  • 2ª letra a → C2
  • 3ª letra a → C3
  • 4ª letra a → C4

2. Conclusão do pagamento:
Cada letra maiúscula representa um cliente que concluiu o pagamento na fila correspondente. - A fila segue a lógica FIFO (First In, First Out), ou seja, o cliente que entrou primeiro paga primeiro. - Assim, a sequência AA informa que os dois primeiros cliente da fila A pagaram e saíram:

  • 1º A → C1 paga
  • 2º A → C2 paga

- Os clientes restantes na fila A agora são: C3 e C4.

Lembre-se: a letra maiúscula não pula clientes: ela sempre remove o próximo cliente que entrou na fila.

Dito isso, vamos então implementar a função que simula isso. Atenção para as entradas e saídas:

Entrada: o programa deverá receber (como argumento de linha de comando) uma string representando a sequência de eventos a ser simulada, conforme especificado acima.

Saída: o programa deverá simular os eventos listados na string de entrada. Sempre que uma vírgula for encontrada na string, o estado atual das filas deverá ser impresso no console (imprima uma quebra de linha adicional no final para facilitar a visualização, conforme exemplo abaixo). Esta será a saída do seu programa. Exemplos:

String de eventos
aaaa,AAbcd,
A: ['C1', 'C2', 'C3', 'C4'] B: [] C: [] D: [] A: ['C3', 'C4'] B: ['C5'] C: ['C6'] D: ['C7']
String de eventos
abababcabc,Adb,Adb,Ca,
A: ['C1', 'C3', 'C5', 'C8'] B: ['C2', 'C4', 'C6', 'C9'] C: ['C7', 'C10'] D: [] A: ['C3', 'C5', 'C8'] B: ['C2', 'C4', 'C6', 'C9', 'C12'] C: ['C7', 'C10'] D: ['C11'] A: ['C5', 'C8'] B: ['C2', 'C4', 'C6', 'C9', 'C12', 'C14'] C: ['C7', 'C10'] D: ['C11', 'C13'] A: ['C5', 'C8', 'C15'] B: ['C2', 'C4', 'C6', 'C9', 'C12', 'C14'] C: ['C10'] D: ['C11', 'C13']

Resolução (código)


class Fila:

    def __init__(self):
        self._d = []
    def enqueue(self, x):
        self._d.append(x)
    def dequeue(self):
        return self._d.pop(0) if self._d else None
    def __repr__(self):
        return str(self._d)

def simular(eventos):
    filas = {c: Fila() for c in 'ABCD'}
    cid = 0
    for ev in eventos:
        if ev in 'abcd':
            cid += 1
            filas[ev.upper()].enqueue(f'C{cid}')
        elif ev in 'ABCD':
            filas[ev].dequeue()
        elif ev == ',':
            print(f"A: {filas['A']}")
            print(f"B: {filas['B']}")
            print(f"C: {filas['C']}")
            print(f"D: {filas['D']}\n")

simular("aaaa,AAbcd,")

Cada letra minúscula adiciona um cliente à fila correspondente, numerando-o sequencialmente como C1, C2, C3..., cada letra maiúscula remove o cliente que está no início da fila (FIFO), representando o pagamento concluído e a vírgula , serve para imprimir o estado atual de todas as filas no console. Por fim, a simulação acima mmostra as entradas, saídas e o estado a qualquer momento, conforme solicitado.

EXERCÍCIO 16
Fila única com N caixas e tempos v_i
Objetivo: Calcular tempo total de atendimento (arquivo de cenário).

Uma abordagem alternativa adotada para caixas em alguns estabelecimentos comerciais é o sistema de fila única. Neste sistema, todos os clientes são organizados em uma única fila. Sempre que um caixa fica livre, o 1º cliente da fila é alocado para ser atendido no caixa disponível. Suponha agora que você trabalhará em um projeto para desenvolver outro simulador que modela um sistema de fila única com o objetivo de estimar quanto tempo levará para que todos os clientes sejam atendidos em cada cenário simulado.

Para este exercicio, vamos considerar que o estabelecimento tem N funcionários que trabalham nos caixas, identificados por números de 1 a N. Cada funcionário i leva um determinado tempo v_i (em segundos) para processar um item de um cliente. Ou seja, se um cliente j tem c_j itens em sua cesta, o funcionário i levaria v_i * c_j segundos para processar todos os itens deste cliente. Facinho, né?

Quando um cliente entra na fila para ser atendido, ele espera até que um funcionário esteja livre para atendê-lo. Se mais de um funcionário estiver livre ao mesmo tempo, o cliente será atendido pelo funcionário de menor número de identificação. Tal funcionário só estará livre novamente após processar todos os itens deste cliente.

Neste modelo de simulação simplificado, não consideraremos o tempo gasto para recebimento dos pagamentos nem os intervalos entre a finalização de um atendimento e o início de outro no mesmo caixa. Poderíamos adicionar algumas constantes para isso, mas como não é o caso, deixa para lá!

Para esta simulação, contamos apennas com os funcionários dos caixas e M clientes que estão na fila para serem atendidos, cada um com um determinado número de itens na sua cesta. A partir disso, o programa deve descobrir quanto tempo levará para que todos os clientes sejam atendidos. Simples assim! Mas fique atento à Entrada e à Saída exigidas:

Entrada: o programa deverá receber (como argumento de linha de comando) o caminho de um arquivo contendo os dados do cenário a ser simulado, no formato descrito a seguir. A primeira linha do arquivo conterá dois inteiros positivos N e M, indicando o número de funcionários nos caixas e o número de clientes, respectivamente. Em seguida haverá uma linha com N inteiros positivos v_i, indicando quantos segundos leva para o funcionário i processar um item. Em seguida haverá uma linha com M inteiros positivos c_j, indicando quantos itens o cliente j tem em sua cesta.

Saída: o programa deverá imprimir uma linha contendo um inteiro, indicando quanto tempo (em segundos) levará para que todos os clientes sejam atendidos de acordo com a simulação do cenário descrito no arquivo de entrada.

Exemplos:

Arquivo de entrada:
1 1
30
6
180
Arquivo de entrada:
1 2
10
5 3
80
Arquivo de entrada:
2 3
10 20
10 5 3
130

Resolução (código)

import sys

class Fila:
    def __init__(self, dados=None):
        self._d = list(dados or [])
    def dequeue(self):
        return self._d.pop(0) if self._d else None
    def is_empty(self):
        return not self._d

def simular_fila_unica(caminho):
    with open(caminho) as f:
        n, m = map(int, f.readline().split())
        v = list(map(int, f.readline().split()))
        itens = list(map(int, f.readline().split()))
    fila = Fila(itens)
    livre = [0]*n  # tempo em que cada caixa estará livre
    while not fila.is_empty():
        c = fila.dequeue()
        i_min = min(range(n), key=lambda i: (livre[i], i))
        livre[i_min] += v[i_min]*c
    print(max(livre))

if __name__ == '__main__':
    if len(sys.argv) == 2:
        simular_fila_unica(sys.argv[1])
    else:
        print('Uso: python fila_unica.py ')

Como de costume,vamos com calma para conseguirmos organizar mentalmente a simulação do sistema de fila única acima. O supermercado tem vários caixas, mas apenas uma fila para todos os clientes, blá, blá, blá... Cada cliente tem sua cesta ou carrinho de compras com um certo número de itens, e cada caixa tem seu próprio ritmo: alguns são mais rápidos, outros mais lentos. Aff... Às vezes, bem lentos. kkkk!

A regra principal é simples:

  • O cliente que está na frente da fila vai para o primeiro caixa que ficar livre.
  • Se mais de um caixa estiver livre ao mesmo tempo, o cliente escolhe o de menor número de identificação (por exemplo, caixa 1 antes do caixa 2).
  • O tempo que cada cliente vai demorar no caixa é v_i * c_j, ou seja, o ritmo do caixa multiplicado pelo número de itens do cliente.


No código, simulamos isso mantendo:

  • Uma fila única com os clientes na ordem de chegada.
  • Uma lista livre que indica em que instante cada caixa estará disponível.
  • Um loop que pega o cliente da frente, escolhe o caixa livre mais cedo (ou de menor número em caso de empate) e atualiza o tempo de disponibilidade do caixa.


A cada passo, visualizamos mentalmente:

  • Fila: quem ainda espera
  • Caixas: quanto tempo falta para cada um terminar o atendimento atual
  • Cliente da vez: qual caixa será escolhido e quanto tempo levará


Por fim, o tempo total para atender todos os clientes é o instante em que o último caixa termina de processar o cliente final, que corresponde ao valor máximo da lista livre. Dessa forma, conseguimos simular de forma clara e precisa o funcionamento de uma fila única com múltiplos caixas e ritmos diferentes, sem complicação! Não falei que era fácinho?