Python: Manipulação de Listas e Matrizes
Published: 2023-05-12 21:19:11

## Listas

Como vimos anteriormente, Python possui uma estrutura de dados correspondente ao array em linguagens C-like: a lista. É a estrutura mais usada no campo da ciência de dados e machine learning cuja função principal é representar um conjunto de dados, logo, deve ser tratada com a devida importância.

As listas e matrizes são estruturas de dados que permitem armazenar um conjunto de valores em uma única variável. Essas estruturas são essenciais em Python e em outras linguagens de programação, pois permitem o armazenamento e manipulação de dados de forma eficiente e organizada.

As listas são usadas para armazenar uma sequência de elementos, permitindo que sejam acessados, adicionados ou removidos conforme necessário. Já as matrizes são uma extensão das listas, permitindo que sejam armazenados valores em uma tabela de duas ou mais dimensões. Uma matriz numa linguagem de programação se assemelha consideravelmente ao conceito de matriz na matemática, mas não com sob a mesma ótica.

Uma lista em Python é definida entre colchetes `[ ]`, e pode conter qualquer tipo de dado, incluindo strings, números, outros objetos e até outras listas. Por exemplo:

```python
minha_lista = [1, 2, 3, "quatro", "cinco"]
```

No entanto, utilizar listas de mais de um tipo não é considerado uma boa prática, principalmente quando se fala de linearidade de memória. Sabendo que uma lista é uma sequência contínua em memória, temos uma lista de 5 inteiros onde cada inteiro ocupa 4 bytes, logo, {% katex inline %} 5*4=20 {% endkatex %}. Se uma mesma lista possuir elementos de diferentes tipos, isso dificulta o armazenamento e diminui a eficiência da mesma. Se isso não for o suficiente para que você aceite essa boa prática, ao usar listas de diferentes tipos, você pode enfrentar diferentes erros de tipagem, visto que não há como mapear todos os erros de tipo presentes em listas heterogêneas.

Para acessar elementos específicos da lista, utilizamos a indexação, que começa em 0. Por exemplo, para acessar o primeiro elemento da lista acima, usamos:

```python
print(minha_lista[0])

## Output:
1
```

### Métodos de listas

Métodos são funções ligadas a tipos, objetos ou pacotes. No caso das listas, são funções que fazem uso das informações internas das mesmas para executar suas sub-rotinas. Os métodos das listas são majoritariamente usados para a sua manipulação, ou seja, alteração de elementos, mapeamento e afins. Dentre os métodos, temos:

### `list.append(value)`

O método `append` adiciona o valor do parâmetro `value` no final da lista que invocou o método.

```python
intro = ["mansão", "thug"]
intro.append("stronda")

print(intro)
```

Output:

```
$> python3 <nome_do_arquivo>.py
['mansão', 'thug', 'stronda']
```

### `list.remove(value)`

O método `remove` remove (não me diga) a primeira ocorrência do elemento `value` na lista que o invocou. Por “primeira ocorrência”, digo a primeira vez em que o elemento aparece, já que os valores dos elementos podem aparecer repetidamente numa mesma lista.

```python
dezenas = [10, 20, 30, 50, 40, 10]
dezenas.remove(10)

print(dezenas)
```

Output:

```
$> python3 <nome_do_arquivo>.py
[20, 30, 50, 40, 10]
```

### `list.count(elem)`

O métoro `count` conta (não me diga???) quantas vezes o elemento `elem` apareceu na lista que o invocou. Veja o exemplo:

```python
repetidos = [1, 2, 3, 1, 2, 1, 2, 3, 2, 1]
print(repetidos.count(1))
```

Output:

```
$> python3 <nome_do_arquivo>.py
4
```

### `list.sort(reverse=False, key=None)`

Onde:

- `reverse: bool` diz se a ordenação da lista deve ser crescente ou decrescente. Esse parâmetro recebe por padrão o valor `False`, portanto, é opcional;
- `key: callable` é a função que especifica o critério de ordenação.  Esse parâmetro recebe por padrão o valor `None`, portanto, é opcional;

O método `sort` ordena a lista que o invocou de acordo com os parâmetros especificados acima. Se nenhum valor for passado aos parâmetros, então a lista sofre uma ordenação crescente sem nenhum critério extra, apenas se baseando no tipo dos elementos da lista.

**Observação**: os parâmetros da função **DEVEM** ser passados de forma nomeada.

Veja o exemplo abaixo.

```python
sequencia_numerica = [2, 6, 1, 3, 8, 7, 4]
sequencia_numerica.sort()

print(sequencia_numerica)

sequencia_numerica.sort(reverse=True)
print(sequencia_numerica)

nomes = ["gustavo", "joão", "gabriela", "maria", "ana"]
nomes.sort(key=len)

print(nomes)
```

Output:

```
$> python3 <nome_do_arquivo>.py
[1, 2, 3, 4, 6, 7, 8]
[8, 7, 6, 4, 3, 2, 1]
['ana', 'joão', 'maria', 'gustavo', 'gabriela']
```

No trecho de código acima, temos duas listas: `sequencia_numerica`, de inteiros, e `nomes`, de strings. De imediato, percebemos 3 chamadas diferentes ao método `sort`, onde a primeira é uma ordenação simples e crescente, a segunda é uma ordenação decrescente e a terceira, uma crescente com critérios. O parâmetro key pode receber qualquer função que seja válida para o contexto da sua lista. Por exemplo, não podemos passar a função `len`, que mede o tamanho de uma sequẽncia, como valor de `key` para uma lista de inteiros, pois não há elementos nessa lista que se adequem ao requisito de `len`. Logo, o que essa função faz nesse contexto é ordenar as strings de forma crescente pelo seu tamanho.  

### Slices

Slices em Python são uma forma de selecionar partes específicas de uma lista, matriz ou sequência. Essa funcionalidade é extremamente útil para a manipulação de dados em Python, permitindo que sejam selecionados elementos específicos de uma estrutura de dados de forma rápida e eficiente. No entanto, apesar de sua utilidade, o uso de slices pode ser um pouco confuso para iniciantes em Python.

Slices são denotados pelo uso de colchetes `[]` e dois pontos `:` para especificar a faixa de elementos que você deseja selecionar. Como se fossemos selecionar um elemento da lista pelo seu índice, mas são usados dois pontos.

A sintaxe geral é a seguinte: `objeto[inicio:fim:passo]`, onde `inicio` é o índice inicial da seleção, `fim` é o índice final (exclusivo) da seleção e `passo` é a quantidade de elementos a serem pulados. Veja o exemplo abaixo:

```python
sequencia_numerica = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

primeiros_cinco = sequencia_numerica[:5]

pares = sequencia_numerica[::2]

print(sequencia_numerica)
print(sequencia_numerica[::])
print(primeiros_cinco)
print(pares)
```

No código acima, vemos a variável `sequencia_numerica`, que recebe uma lista de inteiros de 0 a 10, e abaixo mais duas variáveis, também listas, derivadas da primeira que utilizam o **slicing** para gerar novas listas a partir de uma original. Seguindo a sintaxe descrita, temos a lista `primeiros_cinco`, que recebe uma slice de `sequencia_numerica` que vai da posição inicial 0 (omitida) até a posição final 5 (explícita) com o passo de 1 (não pula nenhum índice). Já variável pares recebe uma slice onde tanto o inicio quanto o fim estão omitidos, ou seja, do começo da lista até o fim dela, e possui um passo de 2 (seleciona os índices de 2 em 2). O resultado do código acima é:

```
$> python3 <nome_do_arquivo>.py
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
[0, 1, 2, 3, 4]
[0, 2, 4, 6, 8, 10]
```

### List Comprehension

Em Python, existem várias maneiras de criar listas, uma delas é através de list comprehensions. List comprehensions permitem criar listas de uma forma mais concisa e legível, tornando o código mais fácil de ser entendido e mantido. Nomezinho chique, hein?

List comprehension é uma forma concisa de criar listas em Python. Essa funcionalidade permite criar uma nova lista a partir de uma lista existente ou outra estrutura de dados, aplicando uma expressão a cada elemento

A sintaxe de uma List Comprehension pode ser entendida como:

```python
[<expressao> for item in list]
```

Traduzindo para o português bom e claro: Aplique `expressão` para todo `item` da lista `list`.

Onde:

- `expressao` é qualquer sentença da linguagem que retorne um valor. Por exemplo, elevar ao quadrado retorna o resultado da potência;
- `item` representa cada elemento da lista a ser formada/manipulada. A expressão precisa ser compatível com o elemento.
- `list` é a lista a ser criada/manipulada. Por mais que a lista já exista, essa manipulação criará uma nova lista, pois estamos copiando a lista original e modificando cada elemento de acordo com a expressão;

Por exemplo, dado o seguinte código:

```python
lista = []

for el in range(1, 10):
    lista.append(el * 10)
```

É possível reescrevê-lo utilizando list comprehensions na forma:

```python
lista = [el*10 for el in range(1, 10)]
```

Ambos os trechos de código têm o mesmo resultado, que é criar num intervalo de 1 a 9 (10 é exclusivo), onde cada elemento deve ser multiplicado por 10.

Você pode, ainda, inserir condicionais na forma de instruções `if` em list comprehensions para limitar seu set de resultados. Por exemplo:

```python
pares = [el for el in range(10) if el % 2 == 0]

print(pares)
```

Output:

```
$> python3 <nome_do_arquivo>.py
[0, 2, 4, 6, 8]
```

A list comprehension do código acima gera uma lista de todos os números pares no intervalo de 0 a 9 (10 exclusivo) graças à instrução `if el % 2 == 0`.

No começo, pode parecer que você faz uma troca de legibilidade por eficiência e praticidade, mas com o passar do tempo, a sintaxe de list comprehensions começará a se impregnar na sua leitura e a memória muscular ajudará bastante nisso. Apenas pratique e aumente a dificuldade das comprehensions gradativamente. 

## Matrizes

Uma matriz pode ser entendida como uma lista que contém listas. Na interpretação matemática, uma lista é um vetor e uma matriz é um conjunto de {% katex inline %} n {% endkatex %} vetores representando uma matriz n-dimensional. A representação de uma matriz em python é dada da seguinte forma:

```python
matriz = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
```

Em vez de usar inteiros ou strings, você usa listas separadas por vírgula dentro de outra lista para representar uma matriz. Toda matriz possui as mesmas propriedades das listas, incluindo métodos, slices e list comprehensions. 

Ao criar matrizes, você insere listas dentro de listas. As list comprehensions seguem o mesmo princípio: é só você inserir comprehensions dentro de comprehensions. Por exemplo, olhe o código abaixo:

```python
matriz = [[i+j for j in range(3)] for i in range(1, 10, 3)]

print(matriz)
```

Isso criará uma matriz quadrada de ordem 3 idêntica à matriz do primeiro exemplo. Vamos dividí-la em partes para entendermos melhor. Olhando de fora para dentro:

- `for i in range(1, 10, 3)`  é responsável por iterar sobre as linhas da matriz. Ele começa em 1, termina em 9 (10 exclusivo) e avança de 3 em 3. Ou seja, ele itera sobre os valores 1, 4 e 7, que correspondem às posições das três linhas na matriz;
- A variável `i` representa a linha da matriz no loop;
- `for j in range(3)` é responsável por iterar sobre as colunas da matriz. Ele itera sobre os valores 0, 1 e 2, que correspondem às posições das três colunas na matriz.
- A expressão `i+j` dentro da list comprehension mais aninhada calcula o valor a ser adicionado na matriz. Essa expressão faz a soma de i, que representa a linha da matriz, com j, a coluna.

### Iteração e Manipulação de Matrizes

Para percorrer matrizes, seguimos a mesma lógica de quando precisamos percorrer uma lista: fazer um loop. Mas se eu quero acessar cada linha e coluna da matriz, vou precisar de mais do que só um loop. No caso das matrizes, no algoritmo mais básico de iteração, serão necessários {% katex inline %} n {% endkatex %} loops para uma matriz n-dimensional para acessar todos os seus elementos.

Bom, loops são simples, mas e se precisarmos manipular a matriz em operações como produto escalar, multiplicação entre matrizes e inversão? Vamos implementar na mão?

Para tal, usaremos a biblioteca `numpy` instalada no último material. Esta seção será fortemente baseada na documentação de NumPy, então é extremamente recomendado usar ambos como base.

A biblioteca `numpy` usa como base o elemento `array`, popularmente chamado **ndarray** (**n**-**d**imensional **array**), uma implementação própria da biblioteca similar às listas de Python, com a diferença de que são obrigatoriamente homogêneos. Arrays e listas compartilham diversos comportamentos, como slicing e indexação.

Veja o exemplo abaixo:

```python
import numpy as np

arr = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
```

Aqui, criamos um array de Numpy através da função `np.array`. O parâmetro dessa função pode ser uma lista de Python ou uma matriz. No caso acima, criamos uma matriz quadrada de ordem 3, onde, na linguagem Numpy, suas linhas e colunas são chamadas **axes** ou, no português claro, **eixos**. Logo, o primeiro axis dessa matriz possui tamanho 3 e o segundo, 3. Podemos, ainda, criar um array de tamanho `n` preenchido por zeros, uns ou qualquer outro valor através das funções `np.zeros(n)`, `np.ones(n)` e `np.full(n, v)`, respectivamente. Para criar um array que replique a função range, podemos utilizar a função `np.arange()`, que tem a mesma funcionalidade de `range()`. Para gerar arrays com valores igualmente distanciados entre si, podemos utilizar o método `array.linspace(start, stop, num=50`, que gera `num` números de um intervalo de `start` até `stop` exclusivo.  

Quanto as propriedades do array, temos três principais: número de dimensões, tamanho e formato. O número de dimensões é expresso pela propriedade `array.ndim` e retorna a quantidade de dimensões/eixos que o seu array possui (lembrando que um array pode ser um vetor ou uma matriz, é só uma nomenclatura utilizada pela biblioteca), o tamanho, pela propriedade `array.size`, que retorna o produto do número de linhas pelo número de colunas e o formado, pela `array.shape`, que retorna uma tupla de {% katex inline %} n {% endkatex %} inteiros indicando o número de elementos armazenados nas {% katex inline %} n {% endkatex %} dimensões do array.

Por exemplo:

```python
import numpy as np

odeio_matriz = np.array([[5, 2, 9], [8, 10, 13], [15, 16, 1]])

print(odeio_matriz.size)
print(odeio_matriz.ndim)
print(odeio_matriz.shape)
```

Output:

```
$> python3 <nome_do_arquivo>.py
9
2
(3, 3)
```

No que tange a manipulação de arrays, temos métodos como `array.transpose()`, que faz a transposta do array que o invocou. Dentre estes métodos, temos:

### `np.inner(a, b)`

Dados dois vetores a e b de mesmo tamanho, `inner` executa o produto interno entre a e b.

```python
import numpy as np

a = np.array([1, 2, 3])
b = np.array([4, 5, 6])

print("O produto interno é:", np.inner(a, b))
```

Output:

```
$> python3 <nome_do_arquivo>.py
O produto interno é: 32
```

### `np.dot(a, b)`

Dadas duas matrizes a e b, `dot` executa a multiplicação entre a e b. Para que essa operação ocorra com sucesso, temos que garantir que o número de colunas de a deve ser igual ao número de linhas de b.

```python
import numpy as np

a = np.array([[2, 4, 6], [1, 5, 2], [9, 7, 1]])
b = np.array([[10, 11, 3], [8, 16, 1], [5, 9, 7]])

print("A multiplicação resulta em:\n", np.dot(a, b))
```

Output:

```
$> python3 <nome_do_arquivo>.py
A multiplicação resulta em:
 [[ 82 140  52]
 [ 60 109  22]
 [151 220  41]]
```

### `array.trace()`

O **trace** ou, no português, traço retorna a soma da diagonal principal da matriz que o invocou.

```python
import numpy as np

a = np.array([
    [2, 4, 6],
    [1, 5, 2],
    [9, 7, 1]
])

print("O traço da matriz 'a' é:", a.trace())
```

Output:

```
$> python3 <nome_do_arquivo>.py
O traço da matriz 'a' é: 8
```

### `np.linalg.det(a)`

Dada uma matriz quadrada a, seu determinante pode ser calculado através da função `det`, localizada dentro do pacote `linalg` de NumPy. Se o determinante resultar em 0, significa que a matriz não admite inversa. Comumente, `det` retornará um número de ponto flutuante que atinge o limite de precisão de Python. Nesses casos, é válido utilizar a função `np.round()` para arredondar o resultado ao seu inteiro mais próximo.

```python
import numpy as np

a = np.array([
    [2, 2, 1],
    [1, 3, 1],
    [1, 2, 2]
])

determinante = np.linalg.det(a)

print("O determinante de 'a' é:", np.round(determinante))
```

Output:

```
$> python3 <nome_do_arquivo>.py
O determinante de 'a' é: 5.0
```

Se não tivessemos arredondado o determinante, seu resultado seria  {% katex inline %} 4.999999999999999 {% endkatex %}.

### `np.linalg.inv(a)`

Dada uma matriz quadrada a, o inverso de a pode ser calculado através da função `inv`, também dentro de `linalg`. Como a inversão de uma matriz depende do determinante da mesma ser diferente de 0, usaremos a matriz do exemplo anterior.

```python
import numpy as np

a = np.array([
    [2, 2, 1],
    [1, 3, 1],
    [1, 2, 2]
])

inversa = np.linalg.inv(a)

print("A inversa de 'a' é:\n", inversa)
```

Output:

```
$> python3 <nome_do_arquivo>.py
A inversa de 'a' é:
 [[ 0.8 -0.4 -0.2]
 [-0.2  0.6 -0.2]
 [-0.2 -0.4  0.8]]
```

A biblioteca NumPy dispõe de inúmeros métodos e funções para manipular matrizes. Dito isso, leia a documentação e, se possível, não se restrinja a somente uma fonte de conteúdo. Existem diversas bibliotecas, cada uma com sua aplicação e vantagem.

Aqui e agora, espero que você tenha compreendido os conceitos de listas e matrizes em Python, bem como suas principais funcionalidades e operações. As listas são estruturas de dados extremamente versáteis, permitindo armazenar elementos de diversos tipos e tamanhos, e possuem diversas operações que facilitam sua manipulação e análise. Já as matrizes, apesar de serem estruturas mais específicas, são muito úteis para representar dados de duas ou mais dimensões, como imagens, tabelas, entre outros. Os horizontes se expandirão à medida em que novos desafios surgem.