Python

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.

Por Fábio Linhares • Instituto Infnet

PARA RECORDAR!
Big O
Como é a notação Big-O, Classes de tempo e espaço e como aplicar a análise de complexidade no dia a dia?

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.

Big-O mostra o ritmo de crescimento. Se a entrada dobrar, o custo vai dobrar, crescer em log N, explodir em N² ou até em 2ⁿ? Essa é a pergunta central. Constantes e hardware mudam, mas a ordem de crescimento decide se sua aplicação escala.

Complexidade de tempo mais comum (da mais barata à mais cara)

O(1) — constante: custo fixo. Ex.: acessar um índice de lista.
O(log N) — logarítmica: divide ao meio. Ex.: busca binária.
O(N) — linear: percorre todos os elementos. Ex.: somar lista.
O(N log N) — quase linear: ordenar e varrer. Ex.: Merge/Quick Sort.
O(N²) — quadrática: comparar todos com todos. Ex.: Bubble/Selection Sort.
O(2^N), O(N!) — explosiva: cresce absurdamente. Ex.: força bruta em combinatória.


Complexidade de espaço (memória extra usada)

O(1) — constante: poucas variáveis auxiliares.
O(N) — linear: estruturas que crescem com os dados.
O(N²) — matrizes, tabelas densas, grafos completos.


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
    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.

Como a análise de complexidade auxilia na prática?

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.

Pense sempre nas duas alavancas de projeto: tempo e espaço. Muitas vezes podemos trocar memória por velocidade (pré-computando resultados) ou economizar memória aceitando mais tempo de execução. Big-O orienta escolhas, mas teste no mundo real. Perfil, benchmarks e medições mostram se sua análise assintótica realmente se confirma para os tamanhos de entrada relevantes ao seu sistema.
Armadilha comum: “O(N) + O(N) = O(2N), então é O(2N)”.
Em Big-O, constantes desaparecem: o certo é O(N). Primeiro reduza a ordem de crescimento, depois se preocupe com micro-otimizações.

Referências e leituras sugeridas


EXERCÍCIO 01
Análise de Complexidade para Otimização
Objetivo: Explicar como a análise de complexidade ajuda a otimizar programas.

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.

PARA RECORDAR!
Selection Sort
Objetivo: entender o algoritmo, sua complexidade e implementação em Python.

Contexto Histórico e Criação

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).

O Algoritmo Clássico: "[...] detalhes tão pequenos de nós dois..."

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).

  1. Início: Comece no primeiro elemento (índice 0).
  2. Seleção: Percorra a sub-lista desordenada para encontrar o elemento de menor valor.
  3. Troca: Troque este menor elemento com o primeiro elemento da sub-lista desordenada.
  4. Avanço: A fronteira da sub-lista ordenada avança uma posição para a direita.
  5. Repetição: Repita o processo até que reste apenas um elemento na sub-lista desordenada.


Vamos ordenar a lista `[64, 25, 12, 22, 11]`. A parte azul é ordenada, e a parte branca é a desordenada.
    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.
Características Essenciais

  • Simplicidade: Extremamente fácil de entender e implementar.
  • Complexidade de Tempo: O(N²) (quadrática). Não é recomendado para listas grandes.
  • Memória (In-place): Utiliza espaço de memória constante, O(1), pois não cria listas auxiliares.
  • Estabilidade (Não é estável): Pode trocar a ordem relativa de elementos iguais. Exemplo: [1, 7a, 7b, 2]. Na segunda iteração, o menor é 2, que trocará com 7a, resultando em [1, 2, 7b, 7a]. A ordem original de 7a e 7b foi invertida.
  • Uso: Mais adequado para listas pequenas ou em cenários onde a simplicidade e o baixo uso de memória são cruciais.

Referências e leituras sugeridas


EXERCÍCIO 02
Implementando Selection Sort
Objetivo: Apresentar, explicar e implementar o algoritmo Selection Sort em Python.

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]
                    
PARA RECORDAR!
Insertion Sort
Como funciona o algoritmo, sua complexidade e implementação em Python?
Contexto e Intuição

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).

O Algoritmo: Dividindo para Conquistar (em menor escala)

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.

  1. Comece Simples: Imagine que o primeiro elemento (índice 0) já forma uma sub-lista ordenada de um item só.
  2. Escolha a Chave: Pegue o próximo elemento da lista (começando pelo índice 1) como a "chave" que você quer posicionar.
  3. Compare e Desloque: Compare a chave com os elementos na sub-lista ordenada, da direita para a esquerda. Se um elemento for maior que a chave, desloque-o uma posição para a direita para "abrir espaço".
  4. Encaixe a Chave: Continue deslocando até encontrar um elemento menor ou igual à chave (ou o início da lista). Insira a chave no espaço que você abriu.
  5. Repita: Faça isso para todos os elementos da lista, um por um. A cada passo, a sub-lista ordenada cresce em tamanho.


Vamos ordenar a lista `[5, 2, 4, 6, 1, 3]`. A parte azul é a sub-lista 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.
Análise de Complexidade (Onde o Algoritmo Brilha e Onde Sofre)

  • Melhor Caso: O(N) — Acontece quando a lista já está ordenada. O algoritmo percorre a lista uma única vez, fazendo apenas N-1 comparações e nenhum deslocamento. É extremamente rápido!
  • Pior Caso: O(N²) — Ocorre quando a lista está em ordem inversa. Para cada elemento, é preciso compará-lo com todos os outros na sub-lista ordenada e deslocar todos eles. O número de operações cresce quadraticamente.
  • Caso Médio: O(N²) — Para uma lista com elementos em ordem aleatória, o comportamento se aproxima do pior caso. Em média, cada elemento será inserido na metade da sub-lista 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".

Vantagens vs. Desvantagens

  • 👍 Vantagens:
    • Simples de implementar e entender.
    • Eficiente para listas pequenas.
    • Adaptativo: Desempenho O(N) em dados quase ordenados.
    • Estável: Não altera a ordem relativa de elementos com chaves iguais.
    • In-place: Exige pouquíssima memória extra (apenas para a variável `chave`).

  • 👎 Desvantagens:
    • Ineficiente para listas grandes e desordenadas, com complexidade O(N²).

Referências e leituras sugeridas

EXERCÍCIO 03
Implementando Insertion Sort
Objetivo: Escrever uma função em Python que implemente o algoritmo Insertion Sort.

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]
EXERCÍCIO 04
Análise de Complexidade: Selection vs. Insertion Sort
Objetivo: Analisar e comparar a complexidade de tempo dos algoritmos implementados.

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).

Selection Sort


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.

Insertion Sort


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.

Conclusão Prática: Insertion Sort vs. Selection Sort

Embora ambos sejam $O(N^2)$ no caso médio, o Insertion Sort é quase sempre mais rápido na prática do que o Selection Sort.

Isso acontece porque, em média, o Insertion Sort faz menos operações ( $\sim N^2/4$ comparações + $\sim N^2/4$ deslocamentos) do que o Selection Sort (que sempre faz $\sim N^2/2$ comparações + $N$ trocas). Além disso, o Insertion Sort é "preguiçoso" e para de comparar assim que acha o local certo.

A única situação onde o Selection Sort poderia ser preferível é em um cenário muito específico onde o custo de **escrita** na memória (swap) é astronomicamente mais alto que o custo de **leitura** (comparação), pois ele garante o número mínimo absoluto de trocas ($O(N)$).
EXERCÍCIO 05
Eficiência das Tabelas Hash
Objetivo: Explicar por que as Tabelas Hash são eficientes para recuperação de dados.

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.

Como Funciona a Magia? ✨


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.

Analogia: Uma lista ou array tradicional é como um fichário onde as fichas estão em ordem aleatória. Para encontrar a ficha de "Maria", você precisa olhar uma por uma (complexidade $O(N)$). Uma Tabela Hash é como um fichário com gavetas numeradas de 0 a 99. A função hash seria uma regra simples: "pegue as duas primeiras letras do nome e some seus valores no alfabeto para decidir a gaveta". Para guardar "Maria" (M=13, A=1), você a colocaria na gaveta 14. Para encontrá-la, você recalcula e vai direto para a gaveta 14. É um acesso direto e muito mais rápido!
E as colisões? O único desafio é quando duas chaves diferentes (ex: "Maria" e "Nathan") geram o mesmo índice. Isso é chamado de colisão. Existem estratégias para lidar com isso, como o encadeamento (criar uma pequena lista ligada em cada posição do array) ou o endereçamento aberto (procurar o próximo espaço vazio). Boas funções hash minimizam as colisões, mantendo a eficiência média próxima de $O(1)$.
EXERCÍCIO 06
Conversão e Avaliação de Expressões Aritméticas
Objetivo: Converter uma expressão infixa para pós-fixa e avaliá-la usando pilhas.

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.

Por que os computadores preferem a notação Pós-Fixa (RPN)?

Nós, humanos, usamos a notação infixa (`3 + 4`), que é ambígua sem regras de precedência (o que fazer primeiro em `3 + 4 * 2`?) e parênteses. Já a notação pós-fixa (`3 4 +`) não tem ambiguidade. Ela é lida da esquerda para a direita: sempre que um operador aparece, ele se aplica aos dois últimos números encontrados. Isso a torna perfeita para ser avaliada por um computador de forma simples e linear, usando uma estrutura de dados fundamental: a pilha.

Mas como Convertemos de Infixa para Pós-Fixa?


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 é:

  1. Operandos (números): Vão direto para a saída final.
  2. Operadores (+, *, /...): Antes de entrar na pilha, eles "olham" quem está no topo. Se o operador no topo tiver precedência maior ou igual, ele é desempilhado para a saída primeiro. Só depois o novo operador é empilhado.
  3. Parênteses: O `(` entra na pilha para criar um "contexto aninhado". O `)` força que todos os operadores até o `(` correspondente sejam desempilhados para a saída.


E como avaliamos a Expressão Pós-Fixa?


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:

  1. Percorremos a expressão pós-fixa da esquerda para a direita.
  2. Se encontrarmos um número, o empilhamos.
  3. Se encontrarmos um operador, desempilhamos os dois últimos números, aplicamos a operação e empilhamos o resultado de volta.
  4. No final, o único item que sobra na pilha é o resultado final da expressão.



# 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
EXERCÍCIO 07
Implementando uma Fila de Prioridades
Objetivo: Implementar uma fila de prioridades usando uma lista encadeada (ligada).

Implemente uma fila de prioridades, usando encadeamento.

Uma Fila de Prioridades é uma estrutura de dados abstrata onde cada elemento possui uma "prioridade" associada. Elementos com maior prioridade são processados antes dos elementos com menor prioridade. Isso difere de uma fila normal (FIFO - First In, First Out), onde a ordem de processamento é estritamente baseada na ordem de chegada.
Uma Fila de Prioridades funciona como um pronto-socorro. Imagine que, às 10h, chega um paciente com dor de garganta — prioridade baixa. Trinta minutos depois, às 10h30, chega outro paciente com um ferimento grave — prioridade alta. Quem será atendido primeiro? O paciente grave, claro. A ordem de chegada é ignorada porque, nesse caso, a urgência tem mais peso que a cronologia. A "justiça" do sistema é definida pela importância.

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