Nesta aula vamos dominar as duas estruturas de dados mais presentes no Python do dia a dia: listas e tuplas. A ideia é entender não só a sintaxe de cada uma, mas o que as diferencia, quais métodos importam de verdade e por que essa escolha aparece o tempo todo em projetos de dados e IA.

Começando pela justificativa, porque ela dá sentido ao resto. Em Machine Learning você lida com estruturas de dados o tempo inteiro: datasets são listas de registros, as camadas de uma rede neural são listas de módulos, os pesos de um modelo ficam guardados em arrays — que por baixo são listas otimizadas —, predições chegam como listas de valores e um grid search nada mais é que listas de opções combinadas. Entender a fundo como listas funcionam em Python é a base para entender os arrays do NumPy e os tensores do PyTorch, que são evoluções diretas desse conceito.

Uma lista é uma coleção ordenada, mutável, capaz de guardar elementos de tipos diferentes — e é a estrutura mais usada da linguagem. Criar uma é questão de colchetes:

# Lista vazia
vazia = []
vazia2 = list()

# Lista de inteiros
notas = [7.5, 8.0, 6.5, 9.0, 5.5]

# Lista de strings
nomes = ["Ana", "Bruno", "Carla"]

# Lista mista (possível, mas evite em dados)
mista = [1, "texto", 3.14, True, None]

# Lista de listas (matriz)
matriz = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
]

O acesso é por índice, começando em zero. Índices negativos contam a partir do final, o que evita aquela aritmética com len() - 1 comum em outras linguagens:

frutas = ["maçã", "banana", "laranja", "uva", "melão"]

print(frutas[0])    # maçã   — primeiro elemento
print(frutas[2])    # laranja — terceiro elemento
print(frutas[-1])   # melão  — último elemento
print(frutas[-2])   # uva    — penúltimo elemento

Um degrau acima do índice está o fatiamento, que devolve uma sublista. A sintaxe é lista[inicio:fim:passo], e o detalhe que mais confunde no começo é que o índice fim fica de fora:

numeros = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

print(numeros[2:5])    # [2, 3, 4]       — do índice 2 ao 4
print(numeros[:4])     # [0, 1, 2, 3]    — do início ao índice 3
print(numeros[6:])     # [6, 7, 8, 9]    — do índice 6 ao final
print(numeros[::2])    # [0, 2, 4, 6, 8] — de 2 em 2
print(numeros[::-1])   # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0] — invertida
print(numeros[1:8:3])  # [1, 4, 7]       — do 1 ao 7, de 3 em 3

Esse recurso é a forma mais direta de separar um conjunto de dados em treino e teste, operação que você vai repetir à exaustão:

dados = list(range(100))  # 100 exemplos

treino = dados[:80]        # 80% para treino
teste = dados[80:]         # 20% para teste

print(f"Treino: {len(treino)} exemplos")
print(f"Teste: {len(teste)} exemplos")

Saída:

Treino: 80 exemplos
Teste: 20 exemplos

Por serem mutáveis, listas aceitam atribuição tanto em posições individuais quanto em intervalos inteiros:

notas = [7.0, 8.0, 6.0, 9.0]

notas[2] = 7.5         # altera o terceiro elemento
print(notas)           # [7.0, 8.0, 7.5, 9.0]

notas[1:3] = [8.5, 8.0]  # altera um intervalo
print(notas)           # [7.0, 8.5, 8.0, 9.0]

O repertório de métodos é grande, mas se organiza bem por finalidade. Para acrescentar elementos há três caminhos, e a diferença entre eles importa: append coloca um item no fim, insert escolhe a posição e extend incorpora vários de uma vez a partir de outro iterável.

linguagens = ["Python", "Java"]

# append: adiciona um elemento ao final
linguagens.append("R")
print(linguagens)  # ['Python', 'Java', 'R']

# insert: insere em uma posição específica
linguagens.insert(1, "Julia")
print(linguagens)  # ['Python', 'Julia', 'Java', 'R']

# extend: adiciona múltiplos elementos de outro iterável
linguagens.extend(["Scala", "C++"])
print(linguagens)  # ['Python', 'Julia', 'Java', 'R', 'Scala', 'C++']

Do lado da remoção, a escolha depende de você saber o valor ou a posição: remove apaga a primeira ocorrência de um valor, pop tira pelo índice e ainda devolve o elemento, del funciona por índice ou fatia e clear esvazia tudo.

frutas = ["maçã", "banana", "laranja", "banana", "uva"]

# remove: remove a primeira ocorrência do valor
frutas.remove("banana")
print(frutas)  # ['maçã', 'laranja', 'banana', 'uva']

# pop: remove e retorna o elemento no índice (padrão: último)
ultimo = frutas.pop()
print(ultimo)  # uva
print(frutas)  # ['maçã', 'laranja', 'banana']

segundo = frutas.pop(1)
print(segundo)  # laranja
print(frutas)   # ['maçã', 'banana']

# del: remove por índice ou fatia
numeros = [1, 2, 3, 4, 5]
del numeros[2]
print(numeros)  # [1, 2, 4, 5]

del numeros[1:3]
print(numeros)  # [1, 5]

# clear: remove todos os elementos
numeros.clear()
print(numeros)  # []

Para localizar e contabilizar valores, count e index resolvem, e o operador in responde à pergunta mais frequente de todas:

notas = [7.0, 8.5, 6.0, 9.0, 8.5, 7.0, 8.5]

print(notas.count(8.5))    # 3 — quantas vezes aparece
print(notas.index(9.0))    # 3 — índice da primeira ocorrência
print(9.0 in notas)        # True — verifica se existe
print(10.0 in notas)       # False
print(10.0 not in notas)   # True

Na ordenação mora uma distinção que vale memorizar: sort() reordena a lista original no lugar, enquanto sorted() devolve uma lista nova e preserva a de partida. Ambos aceitam reverse e um key para critérios personalizados:

numeros = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3]

# sort: ordena in-place (modifica a lista original)
numeros.sort()
print(numeros)  # [1, 1, 2, 3, 3, 4, 5, 5, 6, 9]

numeros.sort(reverse=True)
print(numeros)  # [9, 6, 5, 5, 4, 3, 3, 2, 1, 1]

# sorted: retorna nova lista, não modifica a original
original = [3, 1, 4, 1, 5]
nova = sorted(original)
print(original)  # [3, 1, 4, 1, 5] — inalterada
print(nova)      # [1, 1, 3, 4, 5]

# Ordenando por critério customizado
alunos = [("Ana", 8.5), ("Bruno", 6.0), ("Carla", 9.2)]
alunos.sort(key=lambda a: a[1], reverse=True)
print(alunos)  # [('Carla', 9.2), ('Ana', 8.5), ('Bruno', 6.0)]

Somam-se a isso as funções embutidas de agregação e mais dois métodos de uso constante, reverse e copy:

numeros = [1, 2, 3, 4, 5]

print(len(numeros))       # 5  — tamanho
print(sum(numeros))       # 15 — soma
print(min(numeros))       # 1  — mínimo
print(max(numeros))       # 5  — máximo

# reverse: inverte in-place
numeros.reverse()
print(numeros)            # [5, 4, 3, 2, 1]

# copy: cópia superficial
copia = numeros.copy()
copia.append(0)
print(numeros)            # [5, 4, 3, 2, 1] — original inalterado
print(copia)              # [5, 4, 3, 2, 1, 0]

A menção a cópia leva direto a um dos erros mais comuns em Python, e especialmente perigoso em ML, onde você manipula datasets grandes: atribuir uma lista a outra variável não cria cópia alguma. Os dois nomes passam a apontar para o mesmo objeto, e modificar por um afeta o outro.

# ERRADO — ambas apontam para o mesmo objeto
original = [1, 2, 3, 4, 5]
alias = original          # não é uma cópia!
alias.append(99)
print(original)           # [1, 2, 3, 4, 5, 99] — original foi modificado!

# CORRETO — formas de fazer uma cópia
copia1 = original.copy()
copia2 = original[:]      # slicing completo
copia3 = list(original)

copia1.append(100)
print(original)           # inalterado
print(copia1)             # tem o 100

E há uma segunda camada nesse problema: quando a lista contém outras listas, a cópia superficial duplica apenas o invólucro externo. As sublistas continuam compartilhadas, e é aí que entra o copy.deepcopy():

import copy

matriz = [[1, 2], [3, 4], [5, 6]]

rasa = matriz.copy()
rasa[0].append(99)
print(matriz)   # [[1, 2, 99], [3, 4], [5, 6]] — matriz original afetada!

profunda = copy.deepcopy(matriz)
profunda[0].append(100)
print(matriz)   # [[1, 2, 99], [3, 4], [5, 6]] — inalterada

O básico de list comprehension já apareceu em Loops — for, while, break, continue e range(); aqui vale explorar as formas mais elaboradas. Combinando com o operador ternário, a comprehension classifica em vez de apenas filtrar:

numeros = range(-5, 6)
classificados = ["positivo" if n > 0 else "zero" if n == 0 else "negativo"
                 for n in numeros]
print(classificados)

Saída:

['negativo', 'negativo', 'negativo', 'negativo', 'negativo', 'zero', 'positivo', 'positivo', 'positivo', 'positivo', 'positivo']

Aninhando uma comprehension dentro da outra, você constrói matrizes sem escrever loops explícitos:

# Criando uma matriz 3x3 de zeros
matriz = [[0 for j in range(3)] for i in range(3)]
print(matriz)  # [[0, 0, 0], [0, 0, 0], [0, 0, 0]]

# Tabela de multiplicação
tabela = [[i * j for j in range(1, 4)] for i in range(1, 4)]
for linha in tabela:
    print(linha)

Saída:

[1, 2, 3]
[2, 4, 6]
[3, 6, 9]

E com dois for na mesma comprehension, o caminho inverso: achatar uma lista de listas numa sequência única.

matriz = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
achatada = [elemento for linha in matriz for elemento in linha]
print(achatada)  # [1, 2, 3, 4, 5, 6, 7, 8, 9]

Do outro lado da moeda estão as tuplas: parecidas com listas, mas imutáveis — depois de criadas, não mudam. São definidas com parênteses ou, na verdade, pela simples presença das vírgulas.

# Com parênteses
coordenada = (10.5, 20.3)
rgb = (255, 128, 0)

# Sem parênteses (a vírgula é que cria a tupla)
ponto = 3, 7
print(type(ponto))  # <class 'tuple'>

# Tupla com um elemento (a vírgula é obrigatória)
unitaria = (42,)
print(type(unitaria))   # <class 'tuple'>
nao_tupla = (42)
print(type(nao_tupla))  # <class 'int'>

# Tupla vazia
vazia = ()
vazia2 = tuple()

Repare no caso da tupla de um elemento só: sem a vírgula, os parênteses são apenas agrupamento matemático e você acaba com um inteiro. É uma pegadinha clássica.

Para leitura, tudo funciona como nas listas — indexação, fatiamento e len():

cores = ("vermelho", "verde", "azul")

print(cores[0])     # vermelho
print(cores[-1])    # azul
print(cores[1:])    # ('verde', 'azul')
print(len(cores))   # 3

A diferença aparece na escrita, que simplesmente não é permitida:

ponto = (3, 7)
ponto[0] = 10  # TypeError: 'tuple' object does not support item assignment

Convém entender bem o alcance dessa imutabilidade: ela vale para os vínculos da tupla, não para o conteúdo dos objetos que ela guarda. Se um deles for mutável, como uma lista, esse objeto continua modificável por dentro:

dados = ([1, 2, 3], [4, 5, 6])
dados[0].append(99)   # isso funciona!
print(dados)          # ([1, 2, 3, 99], [4, 5, 6])
dados[0] = [7, 8, 9]  # isso não funciona — TypeError

Onde as tuplas realmente brilham é no desempacotamento, um dos recursos mais elegantes da linguagem. Ele distribui os elementos em variáveis de uma só vez, aceita um asterisco para capturar o meio e usa o sublinhado como convenção para descartar o que não interessa:

ponto = (10, 20)
x, y = ponto
print(f"x={x}, y={y}")  # x=10, y=20

# Desempacotamento com *
primeiro, *meio, ultimo = (1, 2, 3, 4, 5)
print(primeiro)  # 1
print(meio)      # [2, 3, 4]
print(ultimo)    # 5

# Muito usado para ignorar valores
_, segundo, _ = (10, 20, 30)
print(segundo)   # 20

Em loops, esse mesmo mecanismo é o que permite iterar sobre pares já separando os componentes — padrão onipresente em código de ML:

pares = [(1, "um"), (2, "dois"), (3, "três")]

for numero, nome in pares:
    print(f"{numero} = {nome}")

Saída:

1 = um
2 = dois
3 = três

Justamente por serem imutáveis, tuplas têm só dois métodos:

numeros = (1, 2, 3, 2, 4, 2, 5)

print(numeros.count(2))   # 3 — quantas vezes 2 aparece
print(numeros.index(4))   # 4 — índice da primeira ocorrência de 4

Com as duas estruturas na mesa, resta a decisão de projeto: quando usar cada uma. A lista é a escolha quando a coleção vai mudar — receber, perder ou alterar elementos —, quando os elementos são homogêneos e quando você a constrói dinamicamente. É o caso de exemplos de treino, do histórico de perdas ao longo do treinamento e dos resultados de predição.

A tupla entra quando os dados não devem mudar, quando você quer deixar explícito que aquilo é constante, quando as posições têm significado fixo — coordenadas x e y, um par chave-valor, um RGB — e quando a coleção precisa servir de chave de dicionário, algo que listas não podem fazer. Pense em dimensões de uma imagem, coordenadas geográficas ou configurações fixas de um modelo.

Esse último ponto é uma consequência direta da imutabilidade e vale ver funcionando:

# Tupla como chave de dicionário — funciona
cache = {}
cache[(3, 3)] = "valor para entrada (3,3)"

# Lista como chave — não funciona
cache[[3, 3]] = "valor"  # TypeError: unhashable type: 'list'

Boa parte das operações, no entanto, é comum às duas estruturas: concatenar, repetir, testar pertencimento, comparar e converter de uma para a outra.

a = [1, 2, 3]
b = [4, 5, 6]

# Concatenação
print(a + b)          # [1, 2, 3, 4, 5, 6]

# Repetição
print(a * 3)          # [1, 2, 3, 1, 2, 3, 1, 2, 3]

# Verificação de pertencimento
print(2 in a)         # True
print(10 not in a)    # True

# Comparação
print([1, 2, 3] == [1, 2, 3])   # True
print([1, 2, 3] == [1, 2, 4])   # False

# Conversão entre lista e tupla
lista = [1, 2, 3]
tupla = tuple(lista)
de_volta = list(tupla)

Vale ainda reunir as funções embutidas que acompanham essas estruturas. Além das agregações e da ordenação, note any() e all(), que resumem uma lista de booleanos numa resposta só e são excelentes para validação de dados:

numeros = [4, 2, 7, 1, 9, 3, 6, 5, 8]

print(len(numeros))       # 9
print(sum(numeros))       # 45
print(min(numeros))       # 1
print(max(numeros))       # 9
print(sorted(numeros))    # [1, 2, 3, 4, 5, 6, 7, 8, 9]
print(list(reversed(numeros)))  # [8, 5, 6, 3, 9, 1, 7, 2, 4]

# enumerate: índice + valor
for i, n in enumerate(numeros):
    print(f"[{i}] = {n}")

# zip: combinar listas
nomes = ["Ana", "Bruno", "Carla"]
notas = [8.5, 7.0, 9.2]
for nome, nota in zip(nomes, notas):
    print(f"{nome}: {nota}")

# any e all — muito úteis em validação de dados
flags = [True, True, False, True]
print(any(flags))   # True  — pelo menos um True
print(all(flags))   # False — nem todos são True

dados_validos = [x > 0 for x in [1, 2, 3, 4]]
print(all(dados_validos))   # True — todos positivos

Para amarrar o conjunto, um exemplo que simula operações típicas sobre um dataset de ML — separar features e rótulos, dividir treino e teste, calcular estatísticas e normalizar — tudo com listas, tuplas e comprehensions:

# Dataset: lista de tuplas (features, label)
dataset = [
    (1.2, 3.4, 0),
    (2.1, 1.5, 1),
    (0.8, 4.2, 0),
    (3.3, 2.8, 1),
    (1.9, 3.1, 0),
    (2.7, 1.2, 1),
    (0.5, 4.8, 0),
    (3.1, 2.0, 1),
    (1.5, 3.9, 0),
    (2.9, 1.8, 1),
]

# Separar features e labels
features = [(x, y) for x, y, _ in dataset]
labels = [label for _, _, label in dataset]

print(f"Total de exemplos: {len(dataset)}")
print(f"Distribuição de classes: {labels.count(0)} negativos, {labels.count(1)} positivos")

# Dividir em treino (80%) e teste (20%)
corte = int(len(dataset) * 0.8)
treino = dataset[:corte]
teste = dataset[corte:]

print(f"\nTreino: {len(treino)} exemplos")
print(f"Teste: {len(teste)} exemplos")

# Extrair todas as features x1 para calcular estatísticas
x1_valores = [x for x, y, _ in dataset]
media_x1 = sum(x1_valores) / len(x1_valores)
print(f"\nEstatísticas de x1:")
print(f"  Média: {media_x1:.2f}")
print(f"  Mínimo: {min(x1_valores):.2f}")
print(f"  Máximo: {max(x1_valores):.2f}")

# Normalizar x1 para intervalo [0, 1]
min_x1 = min(x1_valores)
max_x1 = max(x1_valores)
x1_normalizado = [(x - min_x1) / (max_x1 - min_x1) for x in x1_valores]
print(f"\nx1 normalizado: {[round(v, 2) for v in x1_normalizado]}")

Saída:

Total de exemplos: 10
Distribuição de classes: 5 negativos, 5 positivos

Treino: 8 exemplos
Teste: 2 exemplos

Estatísticas de x1:
  Média: 2.00
  Mínimo: 0.50
  Máximo: 3.30

x1 normalizado: [0.25, 0.59, 0.11, 1.0, 0.5, 0.79, 0.0, 0.93, 0.36, 0.86]

Fixando o essencial: listas são coleções ordenadas e mutáveis, indexadas a partir do zero e com índices negativos contando do fim; o fatiamento [inicio:fim:passo] produz sublistas sem alterar a original; o núcleo de métodos gira em torno de append, insert, extend, remove, pop, sort, index e count. Atribuir uma lista a outra variável não copia nada — use .copy(), [:] ou, para estruturas aninhadas, copy.deepcopy(). Tuplas são imutáveis e brilham no desempacotamento e como chaves de dicionário. E any() com all() resolvem validações de forma compacta.


Os seis exercícios seguintes exercitam fatiamento, métodos de lista, a armadilha da cópia e o desempacotamento de tuplas. Resolva antes de abrir a resposta comentada.

  1. Dada a lista abaixo, use slicing para extrair três sublistas: os três primeiros elementos, os três últimos elementos, e os elementos de índice par (0, 2, 4, 6...).

    letras = ["a", "b", "c", "d", "e", "f", "g", "h"]
    

    ✓ Resposta:
    letras = ["a", "b", "c", "d", "e", "f", "g", "h"]
    
    tres_primeiros = letras[:3]
    print(tres_primeiros)   # ['a', 'b', 'c']
    
    tres_ultimos = letras[-3:]
    print(tres_ultimos)     # ['f', 'g', 'h']
    
    indices_pares = letras[::2]
    print(indices_pares)    # ['a', 'c', 'e', 'g']
    
  2. Escreva uma função remover_duplicatas(lista) que receba uma lista e retorne uma nova lista com os elementos únicos, mantendo a ordem original de aparição. Não use set() diretamente na solução principal — implemente com um loop. Depois mostre como ficaria com set() e explique por que a versão com set não garante a ordem.

    ✓ Resposta:
    # Versão com loop — preserva a ordem
    def remover_duplicatas(lista):
        vistos = []
        for elemento in lista:
            if elemento not in vistos:
                vistos.append(elemento)
        return vistos
    
    # Versão com set — NÃO garante ordem
    def remover_duplicatas_set(lista):
        return list(set(lista))
    
    dados = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5]
    print(remover_duplicatas(dados))       # [3, 1, 4, 5, 9, 2, 6] — ordem preservada
    print(remover_duplicatas_set(dados))   # ordem imprevisível, ex: [1, 2, 3, 4, 5, 6, 9]
    

    A versão com set() não garante ordem porque sets são coleções não ordenadas em Python. A partir do Python 3.7, dicionários preservam ordem de inserção, mas sets não têm essa garantia. Em ML, se você está removendo duplicatas de IDs de exemplos ou de categorias, a ordem pode ser importante para manter correspondência com outras listas.

  3. Explique o que será impresso e por quê:

    a = [1, 2, 3]
    b = a
    c = a.copy()
    
    b.append(4)
    c.append(5)
    
    print(a)
    print(b)
    print(c)
    

    ✓ Resposta:
    a → [1, 2, 3, 4]
    b → [1, 2, 3, 4]
    c → [1, 2, 3, 5]
    

    b = a não cria uma cópia — b e a apontam para o mesmo objeto na memória. Quando b.append(4) é chamado, a lista subjacente é modificada, e tanto a quanto b refletem essa mudança.

    c = a.copy() cria uma cópia independente. Quando c.append(5) é chamado, apenas c é afetado. A lista original a (e portanto b também) permanece sem o 5.

  4. Use list comprehension aninhada para criar a matriz identidade 4x4 (1s na diagonal principal, 0s no resto). O resultado deve ser:

    [[1, 0, 0, 0],
     [0, 1, 0, 0],
     [0, 0, 1, 0],
     [0, 0, 0, 1]]
    

    ✓ Resposta:
    identidade = [[1 if i == j else 0 for j in range(4)] for i in range(4)]
    
    for linha in identidade:
        print(linha)
    

    Saída:

    [1, 0, 0, 0]
    [0, 1, 0, 0]
    [0, 0, 1, 0]
    [0, 0, 0, 1]
    

    A expressão 1 if i == j else 0 coloca 1 quando o índice da linha é igual ao índice da coluna (diagonal principal) e 0 caso contrário.

  5. Dado o dataset abaixo como lista de tuplas (nome, nota1, nota2, nota3), use desempacotamento e list comprehension para criar uma lista de tuplas (nome, media) ordenada da maior para a menor média.

    alunos = [
        ("Ana", 8.0, 7.5, 9.0),
        ("Bruno", 6.0, 5.5, 7.0),
        ("Carla", 9.5, 9.0, 8.5),
        ("Diego", 7.0, 8.0, 7.5),
    ]
    

    ✓ Resposta:
    alunos = [
        ("Ana", 8.0, 7.5, 9.0),
        ("Bruno", 6.0, 5.5, 7.0),
        ("Carla", 9.5, 9.0, 8.5),
        ("Diego", 7.0, 8.0, 7.5),
    ]
    
    ranking = [(nome, (n1 + n2 + n3) / 3)
               for nome, n1, n2, n3 in alunos]
    
    ranking_ordenado = sorted(ranking, key=lambda x: x[1], reverse=True)
    
    for nome, media in ranking_ordenado:
        print(f"{nome}: {media:.2f}")
    

    Saída:

    Carla: 9.00
    Ana: 8.17
    Diego: 7.50
    Bruno: 6.17
    
  6. Explique a diferença entre lista.sort() e sorted(lista). Em que situação você preferiria cada um? Dê um exemplo concreto de ML onde a escolha importa.

    ✓ Resposta:

    lista.sort() ordena a lista in-place: modifica a lista original e retorna None. Não cria nenhum objeto novo na memória.

    sorted(lista) retorna uma nova lista ordenada sem modificar a original. A lista original permanece intacta.

    Em ML, a escolha importa nas seguintes situações:

    Prefira sorted() quando você precisa manter a lista original, por exemplo ao dividir um dataset em treino e teste e querer ver tanto a versão original quanto a ordenada por algum critério:

    historico_perdas = [0.95, 0.82, 0.74, 0.68, 0.61]
    melhores_perdas = sorted(historico_perdas)
    # historico_perdas ainda preserva a ordem cronológica
    

    Prefira .sort() quando a lista original não é mais necessária e você quer economizar memória, por exemplo ao ordenar um grande dataset por data antes de processamento sequencial:

    eventos = carregar_eventos()   # lista enorme
    eventos.sort(key=lambda e: e["timestamp"])
    # processa na ordem correta sem criar cópia
    

Para aprofundar: