Este documento se refere aos exercícios da LE1, segunda lista, que pode ser encontrado neste documento.
Para questões de comparações práticas, implementei os mesmos algoritmos nas suas versões usando Listas Encadeadas e outra usando Unboxed Arrays, que basicamente são Arrays como em C, porém imutáveis!
Em Haskell
temos dois tipos de Arrays:
- Boxed Arrays
- Unboxed Arrays
Essa estrutura é implementada como todas as outras em Haskell
: de forma lazy
, ou seja
os elementos apenas são computadas quando necessário!
Para cada valor nesse Array existe um ponteiro na memória! Isso é perfeito pois podemos definir os elementos de forma recursiva em termos um do outro, além de ser possível de criar um Array infinito.
Porém, caso o Array seja muito grande, o gasto de memória será muito grande e sua eficiência não será notada! Junto a isso, temos que a Estrutura de Dados Array geralmente é usada para grandes operações com diversos elementos, ou seja, o acesso por índice é extremamente necessário! Para isso, existem os Unboxed Arrays.
Essa estrutura se assemelha com os Arrays em C
! O acesso por índice é significantevamente mais rápido.
Entretanto, há algumas desvantagens, como não ser possível usar tipos de dados compostos como valores.
Exemplo: o tipo String
é apenas uma lista de Char
([Char]
). Apenas tipos únicos podem compor um Unboxed
Array!
Outra desvantagem é que perdemos a característica laziness
da linguagem, pois assim que um do elementos
desse Array for computado, todos os outros também serão!
Esse tipo de Array se encaixa melhor para as implementações dos algoritmos já que sua eficiência pode
ser comparada com os Arrays em C
!
Criei um novo tipo, que representa um Umboxed Array!
Cada Array em Haskell
possui um limite mínimo e máxixo de posições,
representados por uma 2-Tupla
ou Par
. Nesse caso é uma tupla de Inteiros.
type Elem = Int
type Arr = UArray
type Matriz = Arr (Int, Int) Elem
Para criar uma Matriz
, criei duas funções:
- para converter a partir de uma lista
- para criar aleatoriamente
Na fromList
eu uso a função listArray
do módulo Data.Array.Unboxed
que recebe
os limites mínimos e máximos e uma lista e devolve um UArray
que nesse caso é uma Matriz
.
Como esse Array
tem duas dimensões, o limite mínimo e máximo são Pares
, arranjados da seguinte
forma:
((minLinhas, minColunas), (maxLinhas, maxColunas))
fromList :: Int -> Int -> [Elem] -> Matriz
fromList i j = listArray ((0, 0),(i-1, j-1))
Já a função matriz
, devolve uma Matriz
com os valores aleatoriamente gerados. Porém,
como Haskell
é uma linguagem puramente funcional, ela obedece a Transparência Referencial, que diz
que se uma função recebe os mesmos argumentos, ela irá retornar o mesmo resultado.
Dito isso, se eu criar duas Matrizes
2x2, essa função irá me retornar a mesma Matriz
duas vezes!
Para solucionar isso eu poderia fazer com que essa função recebesse uma seed
diferente a cada
chamada (passando por parâmetro), mas achei desnecessário para fins de testes.
matriz :: Int -> Int -> Matriz
matriz m n = fromList m n values'
where format _ 0 _ = []
format m' n' xs = (take m' xs) : format m' (n'-1) (drop m' xs)
values = format m n (take (m*n) (randomRs (3, 10) (mkStdGen (m*n))))
values' = concat values
Soma de Matrizes
! Caso os limites sejam diferentes, eu devolvo um Array
vazio, caso contrário
crio um novo Array
, porém somo cada elemento da Matriz A
com seu correspondente na Matriz B
, gerando uma Matriz C
somaMatriz :: Matriz -> Matriz -> Matriz
somaMatriz a b
| bounds a /= bounds b = array ((0,0),(-1,0)) []
| otherwise = listArray (bounds a) $ zipW (+) xs ys
where xs = elems a
ys = elems b
Multiplicação de Matrizes
! Essa foi um pouco mais complicada, porém, vamos lá:
Primeiro, verifico se o número máximo de colunas da Matriz A
é diferente do número máximo
de linhas da Matriz B
! Se for, eu retorno um Array
vazio.
Quando forem iguais, eu crio ranges
ou “distância” do número mínimo de linhas e colunas de A
e
número de colunas de B
, respectivamente.
Depois, crio uma compreensão de listas de duas dimensões (os elementos de Array são representados por uma lista,
porém internamente eles são otimizados para agir como Arrays
) e para cada liinha de A
e colunas de B
eu realizo a soma da multiplicação de cada elemento de A
na posição (linhaA, colunaA)
com seu
correspondente elementos em B
na posição (colunaA, colunaB)
.
multiplicaMatriz :: Matriz -> Matriz -> Matriz
multiplicaMatriz a b
| y0' /= x1' = array ((0,0),(-1,0)) []
| otherwise = array ((0, 0), (x0', y1')) resultado
where ((x0, y0), (x0', y0')) = bounds a
((_, y1), (x1', y1')) = bounds b
linhasA = range (x0, x0')
colunasA = range (y0, y0')
colunasB = range (y1, y1')
resultado =
[ ((la, cb),
sum
[ a ! (la, ca) * b ! (ca, cb)
| ca <- colunasA
])
| la <- linhasA
, cb <- colunasB
]
Algumas funções para manipular Matrizes
!
Funções para:
- Calcular a
Matriz
absoluta a partir de outraMatriz
- Negar uma
Matriz
- Retornar todas as linhas de uma
Matriz
- Retornas todas colunas de uma
Matriz
- Criar a transposta de uma
Matriz
- Imprimir uma
Matriz
formatada
absMatriz :: Matriz -> Matriz
absMatriz a = listArray (bounds a) $ map (abs) xs
where xs = elems a
negateMatriz :: Matriz -> Matriz
negateMatriz a = listArray (bounds a) $ map (negate) xs
where xs = elems a
linhas :: Matriz -> Int
linhas m = numLinhas + 1
where (_, (numLinhas, _)) = bounds m
colunas :: Matriz -> Int
colunas m = numColunas + 1
where (_, (_, numColunas)) = bounds m
transpose :: Matriz -> Matriz
transpose a = array (bounds a)
[ ((linha, coluna), a ! (coluna, linha))
| linha <- [sl..el]
, coluna <- [sc..ec]
]
where ((sl, sc), (el, ec)) = bounds a
printMatriz :: Matriz -> IO ()
printMatriz m = putStrLn $ concat
[ "┌ ", unwords (replicate (colunas m) blank), " ┐\n"
, unlines
[ "│ " ++ unwords (map (\j -> fill . show $ m ! (i,j)) [0..cols]) ++ " │" | i <- [0..lin] ]
, "└ ", unwords (replicate (colunas m) blank), " ┘"
]
where xs = elems m
strings = map (show) xs
widest = maximum $ map (length) strings
fill str = replicate (widest - length str) ' ' ++ str
blank = fill ""
cols = (colunas m) - 1
lin = (linhas m ) - 1
Minha própria implementação da função zipWith
, que aplica uma função
ao mesmo tempo que junta duas listas!
zipW :: (a -> b -> c) -> [a] -> [b] -> [c]
zipW _ [] _ = []
zipW _ _ [] = []
zipW f (x:xs) (y:ys) = f x y : zipW f xs ys
Já para a implementação de Lista eu criei uma nova Estrutura dados (Pública) que
representa uma Matriz
! O Construtor M
possui linhas e colunas do tipo Int
e os
valores são representados como uma lista de duas dimensões do tipo fornecido. Note que
em Haskell
, as funções linhas
, colunas
e valores
são automaticamente implementadas!
Essa Matriz
também deriva das classes de tipo Eq
e Ord
, ou seja, cada Matriz
pode ser
comparada com outras!
data Matriz a = M { linhas :: Int
, colunas :: Int
, valores :: [[a]]
} deriving (Eq, Ord, Show, Generic, Generic1, NFData, NFData1)
Também defino algumas instâncias de outras classes de tipo:
- A classe de tipoe
Foldable
permite eu implementar as funçõeslength
,foldr
efoldMap
, porém, nesse caso, preciso apenas dalength
- Fazer parte da classe de tipo
Functor
significa que essa estrutura pode ser mapeada, ou seja, transforma algo da categoriaa
parab
. A funçãomap
é uma implementação dafmap
da classe de tipoFunctor
, porém especializada emListas
.Essa instância permite que eu use
fmap
diretamente numaMatriz
ao invés de eu ter que pegar os valores dela e mapear. - Geralmente não devemos usar a instância da classe de tipos
Show
, porém, como os valores são representados por uma lista, decidi implementar essa instância. - A instância princicpal! A classe de tipo
Num
permite que eu use os operadores(+)
,(*)
entre outras funções! É nessa instância que defino as guard clauses, ou seja, decido se umaMatriz
é válida para ser somada ou multiplicada.Também defino as funções
abs
,negate
, que possuem a mesma finalidade que aabsMatriz
enegateMatriz
na implementação comArrays
.Já função
signum
retorna 1 caso o número seja positivo, -1 se for negativo e 0 se o argumento for 0. Implementei ela para caso receba umaMatriz mXn
ela retorne umaMatriz Identidade
dem
linhas en
colunas, a partir de uma lista infinita. - Além das instâncias, derivo da classe de tipo
Generic
, de forma simplória, implementa uma instância genérica que possui duas funções:class Generic a where -- Codifica a representação do tipo abstrato do usuário type Rep a :: * -> * -- Converte do tipo abstrato para a representação from :: a -> (Rep a) x -- Converte da representação para o tipo abstrato to :: (Rep a) x -> a
Programação genérica em
Haskell
é muito mais profundo do que isso, e apenas utilizei pela facilidade que ela traz. Ainda preciso me aprofundar nisso.E a
Generic1
, é a variação que aceita parâmetros de tipos no ADT do usuário.Explicarei o porquê de precisar dela no decorrer deste documento.
- Também derivo da classe de tipo
NFData
tem a função de implementar a funçãornf
, que significa Reduce a value to Normal Form. Aqui vai uma breve explicação:Haskell
é uma linguagem lazy, e, na prática, isso acontece:#include <stdio.h> int soma(int x, int y) { return x + y; } int main() { int cinco = soma(1 + 1, 1 + 2); int sete = soma(1 + 2, 1 + 3); printf("Cinco: %d\n", five); return 0; }
Neste trecho de código, o seguinte acontece:
- Antes da função
soma
ser chamada, o programa computa o resultado de1 + 1
e1 + 2
- Depois, chamamos a função com
2
e3
como argumentos e5
é devolvido, inserindo o valor no endereço de
- Antes da função
memória em que a variável cinco
aponta.
- Fazemos o mesmo procedimento para a variável
sete
- Imprimimos na tela apenas a variável
cinco
Vamos ver o mesmo códigom em Haskell
:
soma :: Int -> Int -> Int
soma x y = x + y
main :: IO ()
main = do
let cinco = soma (1 + 1) (1 + 2)
sete = soma (1 + 2) (1 + 3)
putStrLn $ "Cinco: " ++ show cinco
Aqui acontece o seguinte:
- Ao invés de computar
1 + 1
e1 + 2
, o compilador vai alocar na memória uma referência ou “promesa” dessa computação
passar ela para a função soma
- A variável
cinco
guarda uma promesa da computação desoma
que guarda as promessas das duas computações anteriores - Quando finalmente imprimimos a variável
cinco
, ela é computada, o que desencadeia a computação da funçãosoma
, que
por consequência, computa 1 + 1
e 1 + 2
- Por curiosidade, a variável
sete
não é computada em momento algum, então ela é descartada (:
Só que esse comportamento laziness ou “preguiçoso” me atrapalhou na hora de fazer as medições de tempo na soma e multiplicação das matrizes… Então precisei forçar a computação dos valores. Mas como todo conhecimento sempre tem suas dependências, vamos para mais uma explicação:
Em Haskell
, podemos usar a função seq :: a -> b -> b
, que recebe dois argumentos e devolve o segundo, porém ela força a computação
dos dois. No caso, b
só será computado se a
também for! Mas tem um porém: o seq
só computa o valor em WHNF Weak Head Normal Form.
Exemplos práticos:
-- | só irá computar o cabeçalho da lista (1)
two = [1,2,3] `seq` 2
-- | Apenas irá computar a Mônada Maybe e remover o Just, o undefined não será computado...
maybeError = Just undefined `seq` 2
Por isso existe o deepseq
, que irá forçar a computação, recursivamente da estrutura.
Derivando a classe de tipo NFData
, a função rnf
é implementada automaticamente para meu ADT Matriz
, e a NFData1
,
tem a mesma finalidade que a Generic1
: aceitar parâmetros de tipos!
instance Foldable Matriz where
length (M _ _ xs) = length $ concat xs
foldMap = undefined
foldr = undefined
instance Functor Matriz where
fmap f (M n m xs) = M n m (map (map f) xs)
instance Show m => Show (Matriz m) where
show (M _ _ []) = "[]"
show m@(M _ _ _) = printMatriz m
instance Num a => Num (Matriz a) where
(+) (M m n xs) (M m' n' ys)
| m /= m' = M 0 0 []
| n /= n' = M 0 0 []
| otherwise = M m n (soma xs ys)
fromInteger = undefined
signum (M m n _)
| m /= n = M 0 0 []
| otherwise = M m n (take m (take m <$> sign))
abs (M m n xs) = M m n (map (map abs) xs)
negate (M m n xs) = M m n (map (map negate) xs)
(*) a@(M _ n _) b@(M m' _ _)
| n /= m' = M 0 0 []
| otherwise = multiplica a b
Já a soma e a multiplicação, ao contrário da implementação com Arrays
, recebem apenas os valores
da Matriz
, que são uma lista bidimensional!
A soma
é tão simples quanto compor a função zipW
, passando como argumento os valores da Matriz A
e Matriz B
(veja na instância da classe de tipo Num
).
Na função multiplica
, uso outro algoritmo: crio a transposta de B
e mapeio os valores de A
aplicando uma função que mapeia cada coluna fazendo a multiplicação de cada coluna da transposta de B
e depois somo todos os valores.
Isso significa que tenho dois loop:
- aplica uma função em cada coluna de
A
- para cada coluna de
A
, mapeio as colunas da transposta deB
- uso a
zipW
para multiplicar, a partir de umaclosure
as linhas deA
eB
- por fim, somo a lista multiplicada
closure: uma função que encapsula o escopo acima dela, ou seja, ela “lembra” do estado anterior.
soma :: Num a => [[a]] -> [[a]] -> [[a]]
soma = (zipW . zipW) (+)
multiplica :: Num a => Matriz a -> Matriz a -> Matriz a
multiplica (M m _ xs) b@(M _ n _) = M m n resultado
where (M _ _ tys) = transpose b
dot x y = sum $ zipW (*) x y
resultado = map (\col -> map (dot col) tys) xs
Basicamente as mesmas funções da implementação com Arrays
, porém modificadas para aceitar a
Estrutura de Dados Matriz
transpose :: Num a => Matriz a -> Matriz a
transpose (M m n []) = M m n []
transpose (M m n ([]:xss)) = transpose (M m n xss)
transpose (M m n ((x:xs):xss)) = M m n (hd:ys)
where hd = (x : [h | (h:_) <- xss])
(M _ _ ys) = transpose (M m n (xs : [t | (_:t) <- xss]))
printMatriz :: Show a => Matriz a -> String
printMatriz m = concat
[ "┌ ", unwords (replicate (colunas m) blank), " ┐\n"
, unlines
[ "│ " ++ unwords (fmap (\j -> fill $ strings ! (i,j)) [1..colunas m]) ++ " │" | i <- [1..linhas m] ]
, "└ ", unwords (replicate (colunas m) blank), " ┘"
]
where strings@(M _ _ v) = fmap show m
widest = maximum $ fmap length v
fill str = replicate (widest - length str) ' ' ++ str
blank = fill ""
Tirando a zipW
, temos novas funções de apoio!
sign
-> cria uma lista infinita na qual representa umaMatriz Identidade
(!)
-> crio um novo operador, para acessar o elemento da posição(i, j)
de uma lista bidimensionalencode
-> um pequeno cálculo para tornar o uso do operador(!!)
mais seguro, sem exeções
sign :: Num a => [[a]]
sign = (1:repeat 0) : fmap (0:) sign
(!) :: Matriz a -> (Int,Int) -> a
(!) (M _ n xs) (i, j) = v !! (encode n (i, j))
where v = concat xs
encode :: Int -> (Int,Int) -> Int
encode m (i,j) = (i - 1) * m + j - 1
zipW :: (a -> b -> c) -> [a] -> [b] -> [c]
zipW _ [] _ = []
zipW _ _ [] = []
zipW f (x:xs) (y:ys) = f x y : zipW f xs ys
Funções para medir o tempo de cada operação!
Funciona da seguinte maneira:
- crio um novo “cronômetro” com a função
start
, que devolve umaRef
envolvida pela MônadaIO
. - para cada “checkpoint”, ou seja, cada momento que eu preciso delimitar e gravar o tempo,
uso a função
timerc
. - depois, uso a
getVals
- passando o resultado destart
- que retorna todos os valores gravados a partir detimerc
. - passo o resultado de
getVals
para otimert
que formata e devolve todos os checkpoints com o tempo calculado.
start :: IO (IORef [a])
start = newIORef []
getVals :: IORef a -> IO a
getVals = readIORef
timert :: [(String, T.UTCTime)] -> [String]
timert (_:[]) = error "1???"
timert ([]) = error "2???"
timert ((s,x):b@(s',y):z) = ((pure $ mconcat [s, " -> ", s', ": ", show (T.diffUTCTime y x)]) ++) $ case z of
[] -> []
zz -> timert (b : zz)
timerc :: IORef [(String, T.UTCTime)] -> String -> IO ()
timerc vr s = do
vvv <- readIORef vr
vvv' <- timerb s vvv
writeIORef vr vvv'
Aqui apresento as tabelas com os resultados de tempo e número de operações para cada implementação
Tamanho /n/ | Soma de Matrizes | Multiplicação de Matrizes | ||
---|---|---|---|---|
Tempo (ms) | N° Oper. | Tempo (ms) | /N° Oper. | |
100 | 3.2462 | 4x10^4 | 14.0466 | 1x10^5 |
300 | 29.3783 | 36x10^4 | 542.8951 | 9x10^5 |
500 | 53.6058 | 1x10^6 | 2854.7191 | 25x10^5 |
1000 | 124.8110 | 4x10^6 | 29771.6901 | 1x10^7 |
Na soma
eu realizo essas operações:
- extrair elementos da
Matriz A
- extrair elementos da
Matriz B
- somar os elementos
- criar
Matriz C
Já na multiplicação
eu realizo 10 operações:
- os limites de
A
- os limites de
B
- as linhas de
A
- as colunas de
A
- as colunas de
B
- a soma dos resultados
- acesso por index
Matriz A
- acesso por index
Matriz B
- multiplição
- criação da nova
Matriz C
Tamanho /n/ | Soma de Matrizes | Multiplicação de Matrizes | ||
---|---|---|---|---|
Tempo (ms) | N° Oper. | Tempo (ms) | /N° Oper. | |
100 | 14.1199 | 2x10^4 | 58.7417 | 20200 |
300 | 63.2772 | 18x10^4 | 1007.2398 | 180600 |
500 | 153.6067 | 5x10^5 | 4938.1908 | 501000 |
1000 | 590.9047 | 2x10^6 | 43807.4136 | 2002000 |
Percebemos que apenas pelo fato de usarmos uma Estrutura de Dados como uma Lista Encadeada, o tempo exigido chega a ser incalculável!
Mesmo que na soma
o número de operações seja menor do que em Arrays
, o acesso a cada
elemento é mais demorado, pois os elementos no são gravados continuamente na memória!
Já na multiplicação
, mesmo eu realizando a transposta de cada lista, o número de operações também é menor,
entretanto, sofre da mesma desvantagem de acesso das Listas Encadeadas!