Python

Organização de dados, análise de operações, implementação de algoritmos eficientes, buscas lineares e binárias, avaliação de complexidade com notação Big O e ordenação com Bubble Sort em arrays.

Por Fábio Linhares • Instituto Infnet

EXERCÍCIO 01
List Comprehension para Filtrar Caracteres
Objetivo: Usar list comprehension em Python para retornar caracteres individuais de uma string que não são espaços em branco.

A "list comprehension" é uma forma concisa e elegante de criar listas em Python. A sintaxe é mais curta e, muitas vezes, mais legível do que usar um loop `for` tradicional com `append`.

A estrutura básica é `[expressão for item in iterável if condição]`. No nosso caso, a `expressão` é o próprio `caractere`, o `iterável` é a `string_original`, e a `condição` é verificar se o caractere não é um espaço em branco (`caractere != ' '`).
string_original = "Sítio do pica-pau amarelo \n 2023"
caracteres_filtrados = [char for char in string_original if not char.isspace()]
print(caracteres_filtrados)
['S', 'í', 't', 'i', 'o', 'd', 'o', 'p', 'i', 'c', 'a', '-', 'p', 'a', 'u', 'a', 'm', 'a', 'r', 'e', 'l', 'o', '2', '0', '2', '3']

Por que este código?

Utilizamos o método `char.isspace()` que é mais robusto do que `char != ' '`. Ele identifica todos os tipos de caracteres de espaço em branco, incluindo espaços, tabulações (`\t`) e quebras de linha (`\n`), garantindo uma filtragem completa e precisa conforme solicitado.

EXERCÍCIO 02
Algoritmo de Ordenação de Cartas
Objetivo: Elaborar um algoritmo para ordenar 13 cartas de espadas sob restrições físicas específicas.

A proposta desse exercício 2, à primeira vista, suscita reflexões interessantes sobre restrições de acesso a dados e seus impactos na complexidade de algoritmos. Quando transposto para execução manual — ordenar um pequeno baralho sob regras rígidas de manipulação — o problema se revela mais profundo: o algoritmo resultante apresenta, de forma inevitável, uma complexidade quadrática $O(n^2)$.

Essa constatação não decorre de detalhes de implementação, mas das próprias restrições impostas: acesso sequencial às cartas, movimentação unitária e inserção apenas nas extremidades. Em ciência da computação, tais limitações equivalem ao uso de pilhas e deques, estruturas que impedem a aplicação de métodos mais eficientes como Quicksort ou Mergesort. O resultado é um processo que se aproxima conceitualmente de algoritmos clássicos como Insertion Sort e Selection Sort, ambos notoriamente quadráticos no pior caso.

1. O Problema
O desafio consiste em ordenar manualmente 13 cartas de um baralho, sob restrições físicas severas:
  • Todas as cartas começam em uma mão (lista A);
  • A outra mão só pode segurar uma carta por vez;
  • Ao transferir cartas para pilhas auxiliares, só é permitido inseri-las no início ou no fim;
  • Não há inserção direta no meio de nenhuma pilha.
👉 Essas restrições tornam o problema análogo à manipulação de pilhas (stacks) e filas duplamente terminadas (deques) em ciência da computação.
2. Interpretação das Restrições
O mapeamento entre as regras manuais e conceitos computacionais é imediato:
  • Acesso sequencial → só a carta do topo é visível, como em uma pilha.
  • Movimento unitário → apenas uma carta por vez, sem operações em blocos.
  • Inserção limitada → apenas nas extremidades de uma pilha, comportamento típico de deque.

Esse modelo de acesso restrito inviabiliza algoritmos mais sofisticados, que dependem de acesso aleatório ou de divisão eficiente de listas.

3. Mapeamento para Algoritmos Clássicos

Dentro dessas limitações, o processo lembra dois métodos conhecidos:

3.1. Insertion Sort

Cada carta retirada de A deve ser colocada em sua posição correta em uma pilha auxiliar (B/C/D). No pior caso, inserir exige deslocar todas as cartas de uma pilha, resultando no custo acumulado: $1 + 2 + \dots + n = O(n^2)$

3.2. Selection Sort

Outra estratégia é escolher, a cada rodada, a menor carta restante e transferi-la para a pilha final. Encontrar a menor implica percorrer todas as n cartas restantes, repetido n vezes: $n + (n-1) + (n-2) + \dots + 1 = O(n^2)$

👉 Em ambos os casos, o limite inferior é quadrático.
4. Implementação em Python

Para ilustrar, segue uma simulação didática que respeita as restrições descritas:

import collections

def encontrar_lugar_para_inserir(numero, listas):
    for nome, lista in listas.items():
        if not lista:
            return nome, 'fim'
        if numero < lista[0]:
            return nome, 'inicio'
        if numero > lista[-1]:
            return nome, 'fim'
    return None, None

def mover_entre_listas_para_abrir_espaco(listas, historico):
    listas_ativas = {k: v for k, v in listas.items() if v}
    if len(listas_ativas) < 2:
        return False
    nome_destino = max(listas_ativas, key=lambda k: listas_ativas[k][0])
    lista_destino = listas_ativas[nome_destino]
    candidatos = []
    for nome_origem, lista_origem in listas_ativas.items():
        if nome_origem == nome_destino:
            continue
        num = lista_origem[0]
        if num < lista_destino[0] and (nome_destino, nome_origem, num) not in historico:
            candidatos.append((nome_origem, num, 'inicio'))
        elif num > lista_destino[-1] and (nome_destino, nome_origem, num) not in historico:
            candidatos.append((nome_origem, num, 'fim'))
    if not candidatos:
        return False
    origem, num, pos = min(candidatos, key=lambda c: abs(c[1] - lista_destino[0]))
    listas[origem].pop(0)
    if pos == 'inicio':
        lista_destino.insert(0, num)
    else:
        lista_destino.append(num)
    historico.add((origem, nome_destino, num))
    return True

def consolidar_listas(listas):
    historico = set()
    while sum(1 for l in listas.values() if l) > 1:
        if not mover_entre_listas_para_abrir_espaco(listas, historico):
            break
    for l in listas.values():
        if l: return l
    return []

# Demonstração
A = [3, 5, 6, 2, 4, 7, 8, 9, 13, 1, 12, 11, 10]
listas = collections.OrderedDict([('B', []), ('C', []), ('D', [])])
while A:
    num = A.pop(0)
    nome, pos = encontrar_lugar_para_inserir(num, listas)
    if nome:
        if pos == 'inicio': listas[nome].insert(0, num)
        else: listas[nome].append(num)
    else:
        mover_entre_listas_para_abrir_espaco(listas, set())

resultado = consolidar_listas(listas)
print("Lista final ordenada:", resultado)
5. Análise de Complexidade

O processo pode ser dividido em duas fases:

  • Distribuição:
    • Retirar cada carta de A → $O(n)$.
    • Inserções variam entre $O(1)$ (append) e $O(n)$ (insert no início).
    • Pior caso → $O(n^2)$.
  • Consolidação:
    • Movimentos entre listas → até $O(n)$ cada.
    • Total de movimentos → até $O(n^2)$.
Classificação final:
  • Melhor caso: $O(n)$
  • Pior caso: $O(n^2)$
  • Médio caso: tende ao quadrático.
6. Conclusão

As restrições impostas pelo problema conduzem inevitavelmente a um algoritmo de complexidade $O(n^2)$ no pior caso. Mesmo com otimizações de implementação (como o uso de deque para inserções em tempo constante nas extremidades), o limite teórico permanece.

Esse comportamento aproxima o exercício de um Insertion Sort ou Selection Sort em ambiente restrito, demonstrando que a ineficiência não resulta da técnica escolhida, mas do modelo de acesso limitado que define as regras do jogo.

Escrevi um artigo sobre isso. Se tiver interesse acesse: aqui.

EXERCÍCIO 03
Passos da Busca Linear
Objetivo: Calcular os passos necessários para uma busca linear pelo número 8 no array [2, 4, 6, 8, 10, 12, 13].

A busca linear é o método de busca mais simples. Ela percorre sequencialmente cada elemento do array, um por um, desde o início, até encontrar o valor desejado ou chegar ao final do array.

Pense na busca linear como procurar um livro específico em uma prateleira, começando pelo primeiro livro à esquerda e verificando um por um até encontrar o que você quer.
Contagem de Passos:
Array: `[2, 4, 6, 8, 10, 12, 13]`
Alvo: `8`
Passo 1: Compara 8 com o primeiro elemento, 2. Não são iguais.
Passo 2: Compara 8 com o segundo elemento, 4. Não são iguais.
Passo 3: Compara 8 com o terceiro elemento, 6. Não são iguais.
Passo 4: Compara 8 com o quarto elemento, 8. São iguais! O elemento foi encontrado.

Portanto, seriam necessários 4 passos para realizar a busca linear.

EXERCÍCIO 04
Passos da Busca Binária
Objetivo: Calcular os passos da busca binária para o mesmo array e alvo do exercício anterior.

A busca binária é um algoritmo muito mais eficiente, mas requer que o array esteja ordenado. A cada passo, ela elimina metade dos elementos restantes, comparando o alvo com o elemento do meio da seção de busca.

É como procurar uma palavra em um dicionário. Você não começa da primeira página. Você abre no meio e decide se a palavra que procura está na primeira ou na segunda metade, descartando 50% do dicionário a cada passo.
Contagem de Passos:
Array: `[2, 4, 6, 8, 10, 12, 13]`
Alvo: `8`
Passo 1: O array tem 7 elementos. O elemento do meio é o de índice 3, que é o valor `8`. Comparamos o alvo (8) com o valor do meio (8). Eles são iguais! O elemento foi encontrado.

Neste caso específico e favorável, a busca binária levaria apenas 1 passo.

Vamos considerar um alvo diferente, como o número 12, para ilustrar melhor:

Exemplo com Alvo = 12:
Passo 1: Meio é 8. Como 12 > 8, descartamos a metade esquerda e o meio. O novo array de busca é `[10, 12, 13]`.
Passo 2: O meio do novo array é 12. Comparamos 12 com 12. Encontrado! (2 passos)
EXERCÍCIO 05
Otimizando uma Função de O(N²) para O(N)
Objetivo: Reescrever a função ineficiente `greatestNumber` para ter uma complexidade de tempo linear.

A função original tem uma complexidade de $O(N^2)$ porque, para cada elemento do array (`for i in array`), ela percorre o array inteiro novamente (`for j in array`). Isso é extremamente ineficiente.

Para encontrar o maior número, não precisamos comparar cada número com todos os outros. Podemos simplesmente percorrer a lista uma única vez, mantendo um registro do maior número encontrado até o momento. É como assistir a uma corrida: você não precisa comparar a velocidade de cada corredor com todos os outros a todo momento, apenas precisa observar quem está na liderança.
# Função Original Ineficiente - O(N²)
def greatestNumber_inefficient(array):
for i in array:
isValTheGreatest = True
for j in array:
if j > i:
isValTheGreatest = False
if isValTheGreatest:
return i

# Função Otimizada - O(N)
def greatestNumber_efficient(array):
if not array: # Trata o caso de um array vazio
return None

greatest_so_far = array[0] # Assume que o primeiro é o maior

for number in array:
if number > greatest_so_far:
greatest_so_far = number # Atualiza o maior

return greatest_so_far

print(greatestNumber_efficient([2, 4, 6, 8, 10, 12, 13]))
13

Por que este código?

Esta nova versão percorre o array apenas uma vez. Ela inicializa uma variável `greatest_so_far` com o primeiro elemento e, em seguida, itera pelo resto do array. Se encontrar um número maior, atualiza a variável. Ao final do único loop, a variável conterá o maior número do array. Isso reduz a complexidade de quadrática ($O(N^2)$) para linear ($O(N)$), tornando-a drasticamente mais rápida para grandes conjuntos de dados.

EXERCÍCIO 06
O Problema do Trigo e do Xadrez
Objetivo: Criar uma função para o problema dos grãos de arroz no tabuleiro de xadrez e analisar sua complexidade.

O número de grãos em cada quadrado segue uma progressão geométrica de base 2. O quadrado 1 tem $2^0=1$ grão, o quadrado 2 tem $2^1=2$ grãos, o quadrado 3 tem $2^2=4$ grãos, e assim por diante. O quadrado `n` terá $2^{(n-1)}$ grãos.

Para encontrar o número do quadrado (`n`) a partir da quantidade de grãos (`g`), precisamos resolver a equação $g = 2^{(n-1)}$. Isso é um problema de logaritmo.

a) Função em Python
Aplicando logaritmo na base 2 em ambos os lados da equação $g = 2^{(n-1)}$, obtemos $\log_2(g) = n-1$. Portanto, $n = \log_2(g) + 1$. A biblioteca `math` do Python pode calcular isso diretamente.
import math

def encontrar_quadrado(graos):
# Verifica se o número de grãos é uma potência de 2 e maior que zero
if graos <= 0 or (graos & (graos - 1)) !=0:
return "Quantidade de grãos inválida. Deve ser uma potência de 2."

# Calcula o número do quadrado usando logaritmo
# n = log2(g) + 1
quadrado = int(math.log2(graos)) + 1
return quadrado

# Teste com o exemplo fornecido
print(f"Para 16 grãos, o quadrado é: {encontrar_quadrado(16)}")
print(f"Para 1024 grãos, o quadrado é: {encontrar_quadrado(1024)}")
Para 16 grãos, o quadrado é: 5 Para 1024 grãos, o quadrado é: 11
b) Notação Big O

A complexidade de tempo da função é $O(1)$, ou tempo constante.

Por quê? A função executa um número fixo de operações matemáticas (`math.log2`, adição, etc.), independentemente do tamanho do número de grãos de entrada. Seja para 16 grãos ou para um trilhão de grãos, o número de cálculos é o mesmo. Portanto, o tempo de execução não cresce com o tamanho da entrada, caracterizando a complexidade $O(1)$.

EXERCÍCIO 07
Complexidade de Tempo de Filtragem de Strings
Objetivo: Descrever a complexidade de tempo da função `selectAStrings` usando a Notação Big O.
def selectAStrings(array):
newArray = []
for i in range(len(array)):
if array[i].startswith("a"):
newArray.append(array[i])
return newArray

A complexidade de tempo desta função é $O(N)$, ou tempo linear, onde N é o número de strings no array de entrada.

A chave para analisar a complexidade é identificar os loops. A função contém um único loop `for` que itera sobre cada um dos N elementos do `array` exatamente uma vez. Dentro do loop, as operações (`startswith`, `append`) levam um tempo que não depende do tamanho do array, mas sim do comprimento da string, que podemos considerar constante em média. Como o trabalho principal cresce em proporção direta ao número de elementos no array, a complexidade é linear. Se o array dobrar de tamanho, o tempo de execução também irá aproximadamente dobrar.
EXERCÍCIO 08
Bubble Sort Bidirecional
Objetivo: Modificar o método `bubbleSort()` para que ele seja bidirecional (Cocktail Shaker Sort).

O algoritmo solicitado é conhecido como Cocktail Shaker Sort. Ele é uma variação do Bubble Sort que otimiza o processo ordenando em ambas as direções em cada passagem. Em uma passagem, ele move o maior elemento para o final (como o Bubble Sort) e, na passagem de volta, move o menor elemento para o início. Isso pode resolver o problema de "tartarugas" (elementos pequenos no final da lista) que tornam o Bubble Sort lento.

Pense nisso como um barman agitando um coquetel. Ele agita para cima e para baixo. O algoritmo faz o mesmo: uma passagem da esquerda para a direita "flutua" o maior item para o topo (final), e a passagem de volta, da direita para a esquerda, "afunda" o menor item para o fundo (início).

# Supondo que a função está dentro de uma classe com self._a (o array)
# e self.swap(i, j) como no exercício original.

def bidirectionalBubbleSort(self):
    left_index = 0
    right_index = len(self._a) - 1
    swapped = True

    while swapped:
        # Primeiro, passamos da esquerda para a direita (Bubble Sort padrão)
        swapped = False
        for i in range(left_index, right_index):
            if self._a[i] > self._a[i+1]:
                self.swap(i, i+1)
                swapped = True
        
        # Se nenhuma troca ocorreu, o array está ordenado.
        if not swapped:
            break

        # O elemento na `right_index` agora é o maior, então podemos diminuir o limite
        right_index = right_index - 1

        # Agora, passamos da direita para a esquerda, movendo o menor para o início
        swapped = False
        for i in range(right_index, left_index, -1):
            if self._a[i] < self._a[i-1]:
                self.swap(i, i-1)
                swapped = True
        
        # O elemento na `left_index` agora é o menor, podemos aumentar o limite
        left_index = left_index + 1

# Exemplo de como a classe poderia ser para teste:
class Sorter:
    def __init__(self, arr):
        self._a = arr

    def swap(self, i, j):
        self._a[i], self._a[j] = self._a[j], self._a[i]
        
    bidirectionalBubbleSort = bidirectionalBubbleSort # Atribui a função à classe

# Teste
my_array = [5, 1, 4, 2, 8, 0, 3, 9, 6, 7]
sorter = Sorter(my_array)
sorter.bidirectionalBubbleSort()
print(sorter._a)
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
EXERCÍCIO 09
Implementação do Bubble Sort
Objetivo: Escrever uma função em Python que implemente o Bubble Sort para ordenar números e testá-la.

O Bubble Sort é um algoritmo de ordenação simples que repetidamente percorre a lista, compara elementos adjacentes e os troca se estiverem na ordem errada. As passagens pela lista são repetidas até que nenhuma troca seja necessária, o que indica que a lista está ordenada.

O nome "Bubble Sort" (Ordenação por Bolha) vem da forma como os elementos maiores "borbulham" para o final da lista a cada passagem, como bolhas de ar subindo na água.

def bubble_sort_numeros(lista):
    n = len(lista)
    # Percorre todos os elementos da lista
    for i in range(n):
        # A flag `trocou` otimiza o algoritmo. Se não houver trocas em uma passagem,
        # a lista já está ordenada e podemos parar.
        trocou = False
        
        # A última i posições já estão no lugar certo
        for j in range(0, n-i-1):
            # Percorre a lista da esquerda para a direita
            # Troca se o elemento encontrado for maior que o próximo
            if lista[j] > lista[j+1]:
                lista[j], lista[j+1] = lista[j+1], lista[j]
                trocou = True
        
        if not trocou:
            break
    return lista

# --- Testes da Função ---
lista1 = [64, 34, 25, 12, 22, 11, 90]
lista2 = [5, 1, 4, 2, 8]
lista3 = [1, 2, 3, 4, 5] # Já ordenada
lista4 = [9, 8, 7, 6, 5] # Ordem reversa
lista5 = [] # Lista vazia

print(f"Lista original 1: {lista1}")
print(f"Lista ordenada 1: {bubble_sort_numeros(lista1.copy())}\n")

print(f"Lista original 2: {lista2}")
print(f"Lista ordenada 2: {bubble_sort_numeros(lista2.copy())}\n")

print(f"Lista original 3: {lista3}")
print(f"Lista ordenada 3: {bubble_sort_numeros(lista3.copy())}\n")

print(f"Lista original 4: {lista4}")
print(f"Lista ordenada 4: {bubble_sort_numeros(lista4.copy())}\n")

print(f"Lista original 5: {lista5}")
print(f"Lista ordenada 5: {bubble_sort_numeros(lista5.copy())}\n")
Lista original 1: [64, 34, 25, 12, 22, 11, 90] Lista ordenada 1: [11, 12, 22, 25, 34, 64, 90] Lista original 2: [5, 1, 4, 2, 8] Lista ordenada 2: [1, 2, 4, 5, 8] Lista original 3: [1, 2, 3, 4, 5] Lista ordenada 3: [1, 2, 3, 4, 5] Lista original 4: [9, 8, 7, 6, 5] Lista ordenada 4: [5, 6, 7, 8, 9] Lista original 5: [] Lista ordenada 5: []
EXERCÍCIO 10
Bubble Sort para Strings
Objetivo: Modificar a função `bubbleSort()` para ordenar uma lista de strings em ordem alfabética.

A beleza do Python é que a mesma lógica do Bubble Sort pode ser aplicada a strings sem modificação alguma. Os operadores de comparação (`>`, `<`, etc.) funcionam nativamente para strings, comparando-as em ordem lexicográfica (alfabética).

Quando você faz `"banana" > "abacaxi"`, o Python avalia isso como `True`, pois "b" vem depois de "a" no alfabeto. O algoritmo de Bubble Sort usa exatamente essa comparação para ordenar os elementos.

# A função é exatamente a mesma do exercício anterior.
# Vamos renomeá-la para clareza.
def bubble_sort_strings(lista):
    n = len(lista)
    for i in range(n):
        trocou = False
        for j in range(0, n-i-1):
            # A única linha que importa: a comparação.
            # Em Python, `>` funciona para strings alfabeticamente.
            if lista[j] > lista[j+1]:
                lista[j], lista[j+1] = lista[j+1], lista[j]
                trocou = True
        
        if not trocou:
            break
    return lista

# --- Testes da Função com Strings ---
lista_frutas = ["banana", "maçã", "uva", "abacaxi", "laranja"]
lista_nomes = ["Carlos", "Beatriz", "Ana", "Daniel"]
lista_mista = ["Python", "java", "C++", "python"] # 'p' minúsculo vem depois de 'J' maiúsculo

print(f"Lista original de frutas: {lista_frutas}")
print(f"Lista ordenada de frutas: {bubble_sort_strings(lista_frutas.copy())}\n")

print(f"Lista original de nomes: {lista_nomes}")
print(f"Lista ordenada de nomes: {bubble_sort_strings(lista_nomes.copy())}\n")

print(f"Lista original mista: {lista_mista}")
print(f"Lista ordenada mista: {bubble_sort_strings(lista_mista.copy())}\n")
Lista original de frutas: ['banana', 'maçã', 'uva', 'abacaxi', 'laranja'] Lista ordenada de frutas: ['abacaxi', 'banana', 'laranja', 'maçã', 'uva'] Lista original de nomes: ['Carlos', 'Beatriz', 'Ana', 'Daniel'] Lista ordenada de nomes: ['Ana', 'Beatriz', 'Carlos', 'Daniel'] Lista original mista: ['Python', 'java', 'C++', 'python'] Lista ordenada mista: ['C++', 'Python', 'java', 'python']

Observação sobre a ordenação mista: A ordenação padrão em Python é sensível a maiúsculas e minúsculas (case-sensitive). Caracteres maiúsculos vêm antes dos minúsculos na tabela ASCII. Para uma ordenação que ignore essa diferença, poderíamos modificar a comparação para `if lista[j].lower() > lista[j+1].lower():`.