Escolar Documentos
Profissional Documentos
Cultura Documentos
DESCRIÇÃO
Algoritmos e Estruturas de Dados é parte do conhecimento fundamental que todo programador precisa saber
ou nunca vai avançar de verdade na profissão. Vamos ver a ponta do iceberg pra vocês terem uma idéia do
que isso significa. E vou aproveitar pra consertar alguns erros que cometi no episódio anterior.
Conteúdo:
00:00 - Intro
01:47 - Consertando meus erros de C
04:13 - Segmentos da Memória Virtual
11:53 - Arrays de Javascript são "Arrays"?
15:56 - Lista Ligada em C
21:37 - Hashtable em C
39:11 - Algoritmos de Ordenação
42:11 - Complexidade e Big O
43:30 - Vendedor Viajante e Fatorial
47:29 - Mergesort vs Quicksort
52:18 - Melhor e pior caso, Bubble vs Quick
55:45 - Livros sobre Algoritmos
Links:
* Visualgo.net (https://visualgo.net/bn/sorting)
* Twitter (https://twitter.com/akitaonrails)
* Instagram (https://instagram.com/akitaonrails)
* Facebook (https://facebook.com/akitaonrails)
* Podcasts (https://anchor.fm/akitando)
Podcast: https://anchor.fm/akitando/episodes/A...
Transcript: https://www.akitaonrails.com/2021/03/...
1
SCRIPT
Eu não programo em C faz muitos anos então muita coisa enferrujou mesmo. Eu
nunca fui um bom programador de C e agora então, só o básico. Eu editei aquele
script algumas vezes e mudei os exemplos enquanto fui revisando, e tirei muita
coisa de cabeça. Nisso acabei deixando passar vários erros que me deixaram bem
frustrado porque eu só vi depois. O bom foi que vários de vocês viram e
relataram nos comentários. Eu coloquei algumas na erratas daquele vídeo e vou
usar o video de hoje pra complementar o que eu acho que faltou falar.
A ideia destes vídeos não é ser um curso de C, nem de longe. É a mesma coisa na
faculdade, C ou Pascal era usado só pra não termos que usar alguma pseudo-
linguagem pra ensinar os conceitos. Tem várias nomenclaturas e conceitos que
aos olhos de quem programa C no dia a dia ficou feio. Muita coisa eu
simplifiquei pra explicação ficar mais fácil. Pra aprender C de verdade tem que
ver livros com The C Programming Language do Kernighan e Ritchie, os
criadores da linguagem e estudar muito código fonte.
(...)
Vamos começar com alguns erros pequenos. Tem um que se eu não prestar
atenção cometo toda hora sem perceber, até eu acho irritante. Provavelmente
porque por muitos anos programei em 32-bits, eu confundo quando converto bits
em bytes. Mas é simples, só dividir por 8. Endereços de 32-bits são armazenados
em 4 bytes. Mas endereços de 64-bits são 8 bytes. Só que minha memória
muscular me ferra.
2
Aos 6 minutos e 6 segundos do video eu falo que se você alocar um inteiro de
64-bits mas não for usar valores maiores que 255, vai estar desperdiçando 7 bytes
desses 8. E isso tá correto. Mas durante a edição a memória muscular ficou me
falando “32 bits é quatro bytes”, o que tá errado, e achei que eu tinha gravado
falando errado. Daí escrevi uma correção na tela de que iria desperdiçar 3 bytes.
Ou seja, eu falei certo na gravação e corrigi errado na edição. A mesma coisa
aconteceu aos 45 minutos e 20 segundos que eu falo que é 4 bytes, mas são 8.
Outra coisa que vira e mexe me confundo se falar muito rápido foi como aos 40
minutos e 54 segundos eu falo que 255 BYTES é um quarto de 1 megabyte. Tá
errado, lógico, é um quarto de KILObyte. 255 vezes quatro são 1024 bytes, ou
um kilobyte. Daí aos 6 minutos e 50 segundos eu falei errado o range de inteiros
de 8-bits com sinal, o correto é de menos 128 a positivo cento e vinte e sete. Em
hexa é mais difícil de errar porque vai de hexa FF até 7F.
Mais pro fim, depois dos 57 minutos quando faço a struct Person e começo a
fazer createPerson escrevi errado nome em vez de name e ficou meio estranho. Foi
um typo feinho mas como não chega a estragar o exemplo em si resolvi não
regravar só por causa disso. Pior foi quando dei copy e paste do código de
inicialização da struct pra dentro da função createPerson e esqueci os valores
hardcoded em vez de usar os argumentos. O código ficou desse jeito aqui, mas o
correto deveria ter editado pra ficar assim. Por isso a gente tem que tomar
cuidado ao ficar dando copy e paste de código.
Além desses erros menores, o erro que me deixou mais irritado foi perto dos 39
minutos quando eu quis explicar sobre stack e passagem de parâmetros por valor.
O exemplo original era com inteiros e nesse caso ela teria sido copiada 3 vezes
passando da main pra f1 e depois pra f2, mas eu substituí pela string “hello
world” e na hora nem pensei muito porque eu tava com pressa, só segui o mesmo
exemplo como se o string estivesse sendo duplicado na stack. Obviamente que
não tá. E pra explicar, preciso complicar a parte que eu não queria. Quando falei
de memória virtual pode parecer que ela é dividida só entre stack e heap, mas na
verdade tem mais que isso.
Se eu quiser complicar e mostrar mais, vai ficar deste outro jeito. Seguindo pra
baixo do heap a gente tem o segmento BSS que é o block starting symbol, ou que
alguns chamam de better save space. Toda variável global estática que só foi
declarada mas você não atribui nenhum valor fica nesse espaço, automaticamente
inicializado como zero ou nulo. É diferente de quando você usa
3
um malloc ou calloc que vai pegar algum trecho de memória disponível da
heap.
Embaixo do BSS tem mais dois segmentos que interessa pra gente hoje, a
ROData e a Text. Esse segmento Text, ao contrário do que você pode entender
pelo nome, é o espaço de endereços do seu programa executável propriamente
dito, é um segmento de código, das instruções em linguagem de máquina. Agora
o segmento ROData é mais interessante pra gente.
Quando eu fiz char hello[] = "Hello World" eu tinha dito que isso seria
alocado na stack, o string inteiro. Tá errado. Que burro, dá zero pra ele. E até
podia ser assim mesmo numa outra linguagem, mas no caso de C, o hello vai ser
uma referência pra um endereço que fica nesse espaço chamado ROData. Quando
o compilador GCC passa pelo meu código fonte, ele vê dados como
esse hello que é fixo e constante e grava dentro desse segmento no binário final.
RO é de Read Only, então ROData é um segmento de dados somente de leitura.
Isso não altera a explicação de passagem por valor. Toda vez que eu passo a
variável hello como argumento das funções f1 e f2, cada uma vai empilhar uma
nova cópia no stack, mas não do string inteiro, e sim dos 8 bytes de endereço que
apontam pro array "Hello World" que é fixo, constante e tá dentro do
segmento ROData.
Pense nesse segmento com uma versão estática do Heap, que é dinâmico. O
ROData é pra valores que você tem hardcoded no seu código fonte. Como
strings. Mas strings dinâmicas, por exemplo, de valores que vieram num JSON
que você puxou via API, de linhas do banco de dados que você puxou da rede,
tudo isso vai ser alocado no Heap e, mesma coisa, os ponteiros pra eles vão
sendo empilhados na stack durante a execução do programa.
Quando eu falei do formato ELF binário, que é o linguição de bits num formato
específico pro Linux executar, a primeira instrução começa no endereço hexa
1000. Mas no mesmo binário ELF existem outros segmentos que você pode
inclusive ver usando o comando no shell objdump -h e o executável que acabou
de compilar. Então, se passarmos esse objdump no nosso hello vão aparecer
todos os segmentos. Você vê que o décimo primeiro é o segmento init no
endereço hexa 1000 que eu falei antes e na décima quinta posição tem o endereço
hexa 2000 que é o segmento ROData.
Outra coisa que eu Não falei no episódio anterior quando expliquei ponteiros é
que depois de chamar malloc pra alocar espaço no heap, quando você termina o
que tinha pra fazer, a boa prática é chamar a função free, que avisa o gerenciador
de memória que aquele segmento não tá mais em uso e ele pode usar pra outra
coisa. Eu não fiz free pra explicação não ficar extensa mas também porque o
programa executa e sai muito rápido. E quando um programa termina, o sistema
operacional se encarrega de tornar toda memória que você tava usando
disponível de novo pra outra coisa, independente se você deu free antes ou não.
As duas linguagens modernas que tem um bom balanço entre comodidade e não
desperdiçar muita memória são o Swift e o Rust. Recomendo estudar o ARC do
5
Swift que é uma ajuda do compilador pra gerenciar memória via contador de
referências e o sistema de Borrow do Rust que é o mais diferente de todos.
Voltando aos meus erros, outra coisa que eu falei errado no video anterior foi
quando disse que o Stack, ou Pilha, tem dois tipos, LIFO e FIFO e logo na
sequência falei que tem Queues ou filas. Eu bobeei aqui, uma cadeia onde a gente
vai empilhando elementos no topo e desempilhando do topo é last in first out,
LIFO que é uma Pilha. Uma cadeia onde a gente vai empilhando elementos no
fim mas vai tirando do começo, ou seja, vai tirando por ordem de chegada, é first
in first out, FIFO também conhecido como Fila. Então toda pilha é LIFO e toda
fila é FIFO.
Pilhas são usadas na execução de quase todo programa. Filas você vê todo dia no
supermercado ou banco, ou se é desenvolvedor web. Todo servidor web tem uma
fila, as requisições http vão chegando e sendo enfileiradas e vão sendo servidas
na ordem de chegada. Todo job em background usa uma fila. Tudo que você
precisa segurar porque não dá pra processar tudo de uma vez usa uma fila. Em
sistemas distribuídos é particularmente importante pra coordenar e controlar a
carga. Não adianta sua linguagem ser rápida em conseguir iniciar um monte de
jobs assíncronos e sobrecarregar a máquina. Você precisa controlar quanto
processamento paralelo quer ter num determinado momento, e filas é a estrutura
básica por baixo desses tipos de mecanismos de controle.
E antes que alguém comente, eu disse que “quase” toda execução de programas
coloca frames no stack mas existem linguagens chamadas stackless, que significa
“sem stack”, embora isso não seja totalmente verdade. Inclusive existe uma
versão de Python stackless. Não é que elas não usam nada do stack, mas elas
criam um stack próprio no heap também. É um assunto longo por si só então só
quis mencionar pra quem tiver curiosidade, mas pode ser interessante em casos
de muita recursão ou concorrência onde você separa a stack de cada thread pra
evitar coisas como condições de corrida.
Continuando, ficou outra dúvida nos comentários sobre arrays, porque eu disse
que array é uma sequência contínua de elementos de mesmo tamanho, ou mesmo
tipo. Tipo, em C, meio que define o tamanho do elemento. Se for um uint8_t é
um inteiro sem sinal de 8-bits, portanto o tamanho é de 1 byte. Uma struct como
a Person que eu mostrei, tem 3 campos, uma string de 10 chars, depois 2 ints de
8-bits, portanto 12 bytes no mínimo. O tamanho e formato do binário é definido
pelo compilador e não vai ser necessariamente na ordem que eu mostro no
exemplo.
6
Mas num Javascript eu posso fazer arrays conterem strings, números, outros
arrays e o que eu quiser. Então ele é um array ou não é um array? Sendo honesto
nunca parei pra ver o código em C++ que implementa Arrays de Javascript mas
se ele for parecido com a forma que outras linguagens como Java ou C# da vida
fazem ele provavelmente é um array de ponteiros mesmo.
Se o espaço do Array acaba e você tenta inserir um novo elemento, ele vai alocar
um novo array um pouco maior e copiar todos os elementos da antiga pra nova, e
no final liberar o espaço do array original. E todos os elementos dentro vão ser
ponteiros, endereços pra objetos que pode ser objeto de string, objeto de número,
objeto de array. Esses objetos estão alocados em algum lugar no heap, mas nesse
array não vai ter o objeto em si, só o endereço que aponta pra esses objetos. E
todos os endereços vão ocupar o mesmo tamanho de 8-bytes, portanto são todos
elementos de mesmo tamanho, exatamente o requerimento pra um array.
Se uma linguagem suporta coisas como Generics, daí você consegue limitar um
array de objetos pra ter elementos só de um determinado tipo, como Strings, e
nunca aceitar um objeto de número por exemplo. Por baixo dos panos, no
binário, não faz diferença porque tudo vai ser um ponteiro. Mas importa pra
linguagem. Tipos em C eu acho mais importante pra determinar o layout binário
das coisas. Tipos em linguagens de alto nível como Java é pra determinar a
semântica do seu código.
Se você nunca estudou estruturas de dados sempre vai coçar a cabeça de porque
um Java da vida tem classes como ArrayList, LinkedList, Vector, ArrayDequeue,
HashSet, e tantas outras que herdam das interfaces de Iterable, Collection, Set e
List. Sem conhecimento prévio, à primeira vista, tudo parece só um array. É
impossível eu explicar tudo, mas quero tentar explicar dois tipos diferentes de
coleções hoje, que são dois dos mais usados pra vocês entenderem porque tem
diferença.
7
Vamos fazer de conta que na sua linguagem favorita ele instancia um array de 10
elementos por padrão. Daí você conecta num banco de dados, faz uma pesquisa e
começa a vir linhas de dados. Você vai colocando na posição 0 do array, a
próxima linha na posição 1, depois na posição 2 e vai indo. Aí preenche as 10
posições mas ainda tem mais linhas vindo do banco de dados. Por baixo dos
panos, vai alocar outro array, agora com 20 posições, copiar as 10 da anterior,
liberar o espaço dessas 10 antigas e agora você continua preenchendo, posição
10, posição 11 e vai indo.
Digamos que no final vieram 100 linhas da tabela do banco de dados. Pra
comportar isso na memória, seu array precisou ser expandido 10 vezes. Ou seja,
teve 10 operações de alocar um novo array e copiar o anterior nele. E na última
etapa, quando você tinha um array de 90 elementos e acabou espaço, ele precisou
alocar um de 100 elementos. Ou seja, num determinado momento você ocupou o
dobro de memória, além de ter que fazer operações de alocação e cópia de todos
os elementos até 9 vezes. É um desperdício de processamento e de memória.
Imagina se fosse uma tabela com cem mil linhas. Ou no outro extremo, imagine
se você precisasse de 100 arrays que só vão ter 2 elementos cada. Nas no nosso
faz de conta, cada array começa com 10 posições pré-alocadas. Agora você tem
800 posições que vão ficar desocupadas, mas ainda assim ocupando espaço na
memória. Então a linguagem não pode nem pré-alocar um array grande demais
pra pra não ter que processar muitas operações de alocar novo array e copiar tudo
e nem pode pré-alocar muito pequeno pra economizar memória. É um dilema,
por isso sempre que possível você deve iniciar arrays dizendo pra ele quanto de
espaço pretende usar.
Se você está no caso de listas que vão expandir e você não sabe quanto, pode
escolher não usar arrays. Vamos começar usando o struct que aprendemos e criar
uma chamada Node que traduz pra um nó, em português. Dentro vamos criar dois
elementos, um valor inteiro pra guardar qualquer número, e eu vou usar número
só porque Strings em C puro é mais chatinho e ia complicar o exemplo, mas eu
recomendo de lição de casa vocês tentarem fazer o valor do nó ser um String. O
próximo elemento é mais importante, vou chamar ele de previous pra apontar
pro nó que foi criado antes desse. Esse é o truque da lista ligada, é uma lista que
liga elementos um atrás do outro ou um na frente do outro, como você preferir.
8
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
struct Node {
int value;
struct Node *previous;
};
void main() {
struct Node *first = createNode(2020, NULL);
struct Node *second = createNode(2021, first);
struct Node *third = createNode(2022, second);
Pra facilitar, vamos criar uma função que aloca e inicializa um nó, semelhante ao
que fizemos no video anterior com a struct Person. Como argumentos ele vai
receber um valor inteiro qualquer e o nó que foi criado antes pra ele apontar pra
ele. Dentro vamos usar malloc pra alocar essa nova struct, depois preenchemos o
campo value com o que veio como primeiro argumento e checamos se o segundo
argumento veio nulo. No caso, o primeiro Node criado não aponta pra ninguém,
então deixa ele nulo, senão preenche o campo previous pra apontar pro nó que
passamos.
Como o iterator agora aponta pra mesma struct que a variável first, ela continua
não sendo nula. Imprime o valor de first, e o iterator vai ser o que estiver no
campo previous dela. Como ela foi a primeira a ser criada e passamos NULL no
segundo argumento quando criamos, ela não aponta pra mais ninguém. Agora o
iterator vai ser NULL e isso vai parar o loop do while, e também vai terminar a
função main porque não tem mais nada pra fazer, então o programa acaba e sai.
Um Node ligado a outro Node forma uma lista de Nodes ligados. Por isso
falamos em Lista ligada que é outra estrutura de dados importante na
computação. Ela tem várias vantagens em cima de um array. Pra começar, é fácil
adicionar novos nodes. Só alocar uma struct no Heap e fazer o campo de ponteiro
dela apontar pro último nó que foi criado. Assim não precisamos pré-alocar
espaço que não vamos precisar. Mais do que isso. Já pensaram como faz pra
inserir um elemento novo no meio de um array?
Como o array é sequencial e fixo, você é obrigado a fazer alguma coisa como
copiar todos os elementos do meio pro final pra outro lugar, inserir o valor novo
no meio, e copiar todos os elementos que guardamos de volta. Vai desperdiçar
espaço e processamento. No caso de uma lista ligada basta alocar uma nova
struct e mudar os ponteiros dos nodes ao redor da posição onde queremos inserir.
Fazemos o elemento seguinte apontar pra ele e a nova struct apontar pro
elemento anterior e pronto, inserimos no meio. O custo de inserir um novo
elemento no meio é praticamente igual a inserir no fim, e independe do tamanho
da lista.
Mas então, porque não usamos listas ligadas pra tudo? Porque ela tem uma
grande desvantagem. Repetindo, num array, pra eu pegar o valor do centésimo
10
elemento, basta pegar o endereço do primeiro e multiplicar o tamanho de cada
elemento vezes 100 e imediatamente chegamos. Mas numa lista ligada, preciso
pegar a primeira struct, e ir seguindo os ponteiros pro próximo elemento 100
vezes. Então quanto maior for a lista ligada, mais lento vai ser achar elementos
nela, porque cada struct fica em algum lugar aleatório no heap e não tem como
achar só com aritmética de ponteiro.
E tem outras estruturas importantes. A próxima que eu quero explicar pra você é
um Map, ou HashMap ou Hashtable ou dicionário. Tem vários nomes por
diversas particularidades de implementação. Na maioria das linguagens você
provavelmente chama só de Hash. É quando você cria uma lista, mas em vez de
navegar nela direto por posição, navega usando uma chave.
Vamos fazer mais um insertNode pro mesmo hash, mas com chave "Fabio" e
valor "Akita". Agora vamos imprimir na tela esses valores procurando pela
chave. Se fosse javascript seria o equivalente a um console ponto log entre
11
parênteses hash colchetes com a chave "hello". Mas a gente vai fazer uma função
chamada search que recebe como parâmetro o ponteiro do mesmo hash e a chave
"hello". Daí passamos o valor que ele vai retornar pro printf imprimir na tela.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
struct Node {
char *key;
char *value;
struct Node* next;
};
struct Hash {
struct Node *list[HASH_SIZE];
};
12
node = node->next;
}
return "";
}
void main() {
struct Hash *hash = (struct Hash*) malloc(sizeof(struct Hash));
insertNode(hash, "hello", "world");
insertNode(hash, "Fabio", "Akita");
Agora sabemos que precisamos implementar uma struct chamada Hash e pelo
menos duas funções, a insertNode e a search que é procurar em inglês. Vamos
começar pela struct Hash que vai conter uma variável chamada list que é um
array de ponteiro pra struct chamado Node. O hashtable vai ser implementado
com um array de tamanho fixo. Na prática nem precisava ser uma struct Hash, eu
podia só ter criado direto um array, mas só pra ficar mais fancy.
Só pra ficar menos feio vamos definir essa constante 100 com o nome de
HASH_SIZE lá em cima usando o pré-processamento #define, que eu expliquei
no episódio anterior. É uma forma de não largar números mágicos por aí. Quando
passarmos pelo compilador, imagine que vamos dar copy e paste do número 100
no lugar desse nome HASH_SIZE.
Agora temos que implementar a struct principal, que é a Node que vai conter os
campos principais de chave e valor que são as strings key e value. Depois, como
na lista ligada, um ponteiro pra next que vai ser o próximo Node na cadeia. De
propósito antes eu mostrei uma lista ligada que vai apontando pro Node anterior.
Aqui vai ser uma lista ligada na direção oposta, apontando pro próximo Node
mais novo, só pra mostrar que tanto faz a direção. Dependendo do exemplo que
você achar por aí vai ter jeitos diferentes de implementar.
Pra isso vamos fazer uma função chamada hashCode que recebe essa chave. Se
você é de Java já viu esse nome. Dentro começamos declarando um número
mágico, no caso cinco mil trezentos e oitenta e um. Poderia ser qualquer número.
Depois declaramos uma variável chamada c que a cada rodada do loop vai
pegando uma letra da chave. Esse loop vai repetindo até acabar as letras da chave
e o c ficar nulo e parar o loop.
O cálculo dentro do loop não é importante pra hoje, tem várias outras formas. Em
particular neste exemplo começa com o valor inicial da variável hash que é o
cinco mil e tantos acima. Daí multiplicamos por 33 que é o mesmo que fazer
bitwise shift pra esquerda de 5 bits, somamos ao hash original junto com o
endereço da primeira letra. E vamos fazendo isso letra a letra até o loop parar. No
final vamos ter um numerozão que identifica essa chave. Esse número pode ser
bem maior do que o tamanho do array e a gente gostaria que fossem posições
válidas dentro do nosso array.
Sempre que você tem um range ou sequência limitada, no nosso caso que vai de
0 a 99, mas temos um número maior que isso, pra caber, usamos a operação de
módulo. E aqui é mais um exemplo de porque matemática importa. Vamos
precisar de módulo. Na prática um módulo faz um wrap. Qualquer número
módulo 100, vai devolver um número menor que 100. Daí podemos usar isso
como um índice ou hash. Toda chave sempre vai chegar no mesmo hash, mas a
partir do hash não temos como voltar ao valor original da chave.
Antes de continuar, vamos criar uma função que instancia e popula uma struct
Node. Podemos chamar de createNode que recebe como argumentos os strings
14
de chave e valor, key e value. Dentro fazemos malloc pra reservar espaço pra
essa struct. Em seguida populamos os campos key e value da struct com os
valores que recebemos de argumento. Bem simples. Vamos manter o
campo next da struct como nulo.
Essa nossa variável list na struct Hash não é um array simples como mostramos
até agora. É um array onde cada posição vai apontar pra uma lista ligada, ou seja,
é tipo uma matriz. O conceito é assim: cada lista ligada pode ter quantos
elementos quisermos. Daí nesse array fixo vamos conseguir colocar mais
elementos do que o tamanho total de posições só do array. Estamos combinando
o melhor do array com o melhor da lista ligada numa estrutura só.
Vamos dar um exemplo, faz de conta que queremos inserir um valor "bla" com
uma chave "abc". E faz de conta que queremos inserir outro valor "foo" com uma
chave sendo "xyz" e agora o principal, faz de conta que o cálculo da nossa
função hashCode devolva o número 42 pra ambos os strings. Vamos simular um
caso de colisão.
Da primeira vez, vamos pra posição 42 do array list e apontamos pra uma struct
Node com a chave sendo 'abc' e valor sendo 'bla'. Na segunda tentativa o hash da
chave 'xyz' devolve o mesmo índice 42, mas na posição 42 do array já tem o
Node com chave "abc", então criamos um novo Node e fazemos o primeiro
apontar pra ele. Agora temos os dois armazenados na posição 42 do array,
entenderam?
Pra ficar claro, digamos que queremos incluir um terceiro par de valores cuja
chave é “foo” e o valor é “bar” e de novo, faz de conta que o hashCode pra essa
chave “foo” seja 42 também. Agora já existe uma lista ligada nessa posição do
array. Começamos do primeiro Node com chave “abc”. O next dela aponta pra
outro Node, com a chave “xyz” e agora o next é nulo, portanto é o fim da lista.
Basta fazer esse next apontar pro novo Node com chave “foo” e pronto,
adicionamos mais um elemento no hashtable. É isso que acontece num caso de
várias chaves colidirem e competirem pela mesma posição no array.
Só pro nosso exemplo ficar mais interessante faz de conta que antes de inserir a
chave hello já tinha um Node com chave ano e valor 2021 e faz de conta que se
passarmos a string ano pra função hashCode vai dar o número 41. Daí a gente
tenta inserir a chave hello e valor world e passando hello em hashCode sabemos
que volta 41 também. Criamos um novo Node com a chave “hello” e colocamos
seu endereço no ponteiro next do Node chave ano.
16
Esse é o mecanismo. O pior caso é quando a função de hashCode dá colisões
demais e a maioria dos elementos que tentamos inserir acaba na mesma posição
do array, e aí fica desbalanceado, por isso escolher a melhor fórmula vai fazer
diferença e por isso eu falei em uma função que tivesse uma distribuição
gaussiana, onde na média, cada posição do array vai ter mais ou menos o mesmo
número de elementos.
Finalmente, uma vez inserido, e se a gente quiser achar o valor que corresponde à
chave hello? Vamos criar a função search que devolve um ponteiro pra uma
String, que vai ser esse valor. E como parâmetro vamos receber a referência pra
struct Hash que contém o array list e como segundo parâmetro um ponteiro pra
string que contém a chave que estamos procurando, no caso "hello".
De novo, começamos calculando o tal hash da chave "hello" que já sabemos que
vai ser 41. Daí pegamos o primeiro Node da lista ligada que tá na posição 41 do
nosso array list. Agora começamos um loop. Enquanto a variável node não for
nulo, entra no corpo do Loop. Dentro comparamos o valor da chave nesse Node
com a chave que recebemos no parâmetro da função usando essa função do C
chamada strcmp ou string compare, que vai devolver zero se as duas strings que
passamos tiverem o mesmo valor.
No nosso exemplo vai dar false porque o primeiro Node contém um Item cuja
chave é ano. Sendo assim, pulamos esse if e fazemos a variável node apontar
pro next do atual. Repete o loop, a variável node ainda não é vazio.
No if comparamos de novo o string do campo key com a variável key do
parâmetro. E como esse segundo Node contém a chave hello, o strcmp vai dar
verdadeiro e podemos retornar o campo value do Node, que vai ser world.
A essa altura você deve estar pensando, "por que todo esse trabalho"? A
vantagem é que se nosso array estivesse todo cheio, e quisermos achar onde tá o
Node com a chave “hello”, rapidamente sabemos que tá na posição 41 do array,
porque o cálculo do hash independe de quantos elementos existem. Mesmo se a
hashtable tiver 200 Nodes já, chegamos na posição 41 com um simples cálculo.
17
Daí só precisamos fazer mais 2 comparações de chaves na lista ligada pra ir de
next em next até chegar no Node com chave “hello” e vamos achar o valor
“world”.
Se usássemos só um array pra inserir 200 elementos, mas o array começa com 10
elementos pré-alocados, ia precisar expandir e copiar todos os elementos do array
20 vezes. Mas pra achar um valor numa posição específica é rápido. Por outro
lado, se usássemos uma lista ligada pra inserir os 200 elementos, a inserção seria
super rápida e econômica. Mas pra procurar teríamos, no pior caso, que vascular
200 elementos. Mas juntando o array com a lista ligada, diminuímos
consideravelmente tanto o tempo de inserção quanto o tempo de procura.
Mas da forma como implementamos, não só agora eu posso usar uma string
como chave em vez de um número, como estamos segmentando, ou seja,
dividindo a lista ligada dentro de um array fixo. E isso nos dá o meio do caminho
do melhor dos dois mundos entre um array e uma lista ligada. Não vamos
desperdiçar memória e processamento tendo que duplicar o array toda vez que
chega no fim. E ao mesmo tempo podemos procurar itens razoavelmente rápido
porque em vez de vasculhar uma lista ligada inteira, vamos vasculhar só um
trecho dela.
Então, toda vez que você fizer um hash em Javascript ou outras linguagens que
suportam essa sintaxe de colchetes, por baixo dos panos é algo parecido com isso
que tá acontecendo. Nessa nossa implementação rudimentar,
esse search passando o hash e argumento hello seria o equivalente no Javascript
a escrever hash colchetes e hello dentro.
Isso é uma má idéia porque nenhuma versão de hashtable que você escrever em
Python puro vai ser mais rápido do que a que já vem nela. A explicação é
simples. Vamos ver como o Python implementa o hashtable, que ele chama
de dict. O Python mais usado é o CPython, e esse nome é porque o Python é
escrito em C. E se a gente for no GitHub e vasculhar o código fonte do CPython
vamos achar o arquivo dictobject.c que, chan chan chan chan, é escrito em C.
Esse em particular tem mais de 5 mil linhas de C. Por isso eu repito, o exemplo
18
que eu dei antes é uma simplificação rudimentar pra ser didático de explicar. Pra
ver o de verdade, você precisa ver esse do Python.
19
#include <stdio.h>
void main() {
int total = 15;
int array[] = { 40, 55, 11, 32, 67, 5, 74, 89, 38, 66, 27, 36, 79, 99, 2 };
int i, j, swap;
Dentro tem outro loop, fazendo j ir de 0 também até o total menos i. Isso porque
dentro comparamos o elemento na posição j com o seguinte que é j mais um,
então não pode ir até o último senão vamos tentar pegar um décimo sexto
elemento que não existe e vai dar pau. Daí comparamos entre o elemento na
posição j com o de j mais um e se o de j for maior, fazemos o swap. Swap nesse
caso é guardar o elemento de j mais um, copiar o elemento de j pra j mais um, e
na posição j gravar o que tava na variável swap. Em outras linguagens tem como
fazer swaps assim com uma operação só, mas por baixo dos panos sempre vai ter
uma variável intermediária.
Se achou complicado depois tente você mesmo executar esse código em C com
diferentes arrays, usando printf em cada etapa pra ver o que tem nas
variáveis i ou j e o que tem no array nesses posições pra entender o que tá
acontecendo. Mas o importante é entender o seguinte. Temos um loop dentro de
outro loop percorrendo todos os elementos do array. Na primeira passada tem 14
comparações. Na próxima passada, o i vai ser 1 então total menos 1 menos 1 são
20
13 comparações. Na passada seguinte o i vai ser 2 então total menos 2 menos 1
são 12 comparações e vamos assim até o i ser igual a 14 e acabar.
Ou seja, vamos ter 14 mais 13 mais 12 mais 11 e assim por diante, que vai dar
105 comparações. Se colocarmos um contador no começo do código e ir
incrementando dentro do segundo loop, no final podemos imprimir e ver que vai
dar 105. Pra somar todos os número de 0 até N precisamos da matemática de
análise combinatória. A fórmula é n vezes n menos 1 dividido por dois. Então 15
vezes 14 dividido por 2 é exatamente 105. Sendo essa fórmula da ordem de
grandeza de N vezes N ou seja N ao quadrado, também dizemos que esse
algoritmo tem complexidade quadrática.
Complexidade de tempo de um algoritmo é uma das coisas que você mais vai ver
quando se compara algoritmos que fazem a mesma coisa. Por exemplo, no caso
de ordenação de arrays, temos esse simples que é o Bubblesort e na média tem
complexidade quadrático que escrevemos na notação big-O como sendo O n ao
quadrado. Outros algoritmos melhores como o Mergesort ou Quicksort, na média
tem complexidade O n vezes log de n ou logarítmico. E aqui eu vou falar de
complexidade sem ser formal, então se você é matemático perdoe minha
nomenclatura mais solta.
21
Aliás, o algoritmo do vendedor viajante é o problema do sistema de logística de
qualquer Amazon da vida. O caminho mais curto pra passar entre diversos
endereços ou cidades diferentes. O jeito mais ingênuo de fazer isso, via força
bruta, tem complexidade fatorial, ou seja, é absurdamente caro de processar
porque a força bruta seria fazer a permutação de todas as possibilidades de rotas
entre todos os pontos de destino e no final comparar pra achar a mais curta. E um
algoritmo um pouco melhor vai cair de complexidade fatorial pra exponencial,
que continua sendo lento.
Mas voltando ao que nos interessa, acessar uma posição num array é
complexidade constante, ou seja, não importa se o array tem 10 elementos ou 10
mil elementos, sabendo a posição, recuperar um valor custa sempre o mesmo
tempo. Agora, procurar um elemento numa posição de uma lista ligada é
complexidade linear, depende do tamanho da lista. Como não dá pra fazer
aritmética de endereço, porque os elementos não são sequenciais um atrás do
outro, a gente precisa ir de Node em Node seguindo o endereço pro próximo.
Então pra achar o décimo Node de uma Lista Ligada onde só temos o endereço
do primeiro Node, precisamos fazer a operação de seguir pro próximo ponteiro 9
vezes.
22
Agora, na categoria de logarítmico, estão coisas como busca binária, que eu não
expliquei e não pretendo detalhar hoje pra não alongar. Mas pensa assim. Em vez
de procurar um a um linearmente, você pode usar a estratégia de segmentar a
procura quebrando sua lista primeiro ao meio, e recursivamente ir procurando em
listas da metade do tamanho e cada pedaço. É meio que a estratégia de dividir pra
conquistar. Imagina uma função chamada pesquisa. E dentro dela você quebra a
lista em duas e passa cada metade pra mesma função pesquisa, e vai fazendo
isso recursivamente. É a base da idéia de busca binária.
Então numa lista de 100 elementos, você quebra em 2. Agora tem a lista de 50.
Quebra de novo em 2. Vai ter 25. Quebra de novo. 12. De novo. 6. De novo 3.
De novo 1. Se o que procura estiver na primeira metade, talvez você ache em 6
operações em vez de 50. Essa mesma idéia tá por trás de algoritmos como
mergesort ou quicksort que são mais rápidos que bubblesort que é complexidade
quadrática. Se voltarmos na nossa animação de algoritmos de ordenação, vou
mostrar tanto o merge quanto o quick um embaixo do outro.
23
Por exemplo, pra acessar o valor de um array, eu repeti dezenas de vezes que é só
pegar o endereço do primeiro elemento e somar pelo número da posição vezes o
tamanho de bits de cada elemento. Num array de inteiros 64-bits de 100 posições,
pra pegar o valor na nonagésima posição basta pegar o endereço da posição 0 e
somar por oitenta e nove vezes 8 bytes que é o comprimento de inteiros de 64-
bits. Pra qualquer outra posição é o mesmo cálculo.
Por isso existe a opção de Lista Duplamente Ligada, onde cada Node aponta pro
próximo elemento e pro anterior ao mesmo tempo, assim dá pra começar uma
procura do começo da lista ou do fim da lista. Mesmo assim, a complexidade, a
ordem de grandeza, continua sendo linear, dependente da quantidade de
elementos na lista, sendo o pior caso se o elemento que procuramos estiver no
meio da lista, porque se começar a procurar do começo ou do fim, o meio é o
ponto mais longe agora.
24
tempo de inserção vira linear, porque tem que chegar até o fim da lista pra
adicionar o Node novo.
Mas a pesquisa, que num array seria tempo constante, no hashtable vai ser linear,
igual da lista ligada, O n, linear. Outra coisa importante, só porque dois
algoritmos tem complexidade Big O iguais, digamos, O n, linear, não quer dizer
que elas têm exatamente a mesma performance. Só significa que vão ficando
mais lentas linearmente em proporção à quantidade de elementos pra processar.
Ambos array e lista ligada tem complexidade O 1 constante pra inserir novos
elementos, mas inserir num array é mais rápido porque já tem o espaço reservado
pro elemento ao contrário da lista ligada que precisa chamar malloc pra reservar
um espaço no heap pra cada elemento. Mas proporcionalmente o tempo de cada
um é constante, independente de quantos elementos cada um tenha, entenderam?
O importante é lembrar que tudo que você codifica é um algoritmo (ou mais
corretamente, pode ser uma heurística, mas vamos deixar isso pra outro dia).
Todo algoritmo tem uma complexidade. Ela tem melhor caso, pior caso e tempo
médio. No melhor caso, todas são parecidas. Se você testar um bubblesort,
quicksort ou mergesort ou outros como insertion sort ou timsort, num array com
10 elementos, meio que não faz diferença nenhuma.
Você só vai sentir diferença quando começar a passar mil elementos, cem mil, 1
milhão, daí a diferença de tempo começa a ser mensurável e significativa. Isso é
pra lembrar que só porque o código rodou na sua máquina, testando com pouca
coisa, nem sempre vai funcionar pra todos os casos. Mas sem entender sobre
complexidade, só vai entender como seu código é ruim quando estiver rodando
em produção com dados reais, e vai ficar coçando a cabeça sem entender porque
e vai ficar botando a culpa na linguagem ou no framework.
Seu código roda em tempos diferentes dependendo dos dados que você passar.
Por exemplo, o melhor caso de um bubblesort é tentar ordenar uma lista que já tá
ordenada. Ele não vai fazer swap de nenhum elemento e ao terminar de comparar
com o último elemento, termina. Então vai rodar como se fosse O n, linear. Mas
ironicamente, num quicksort, o pior caso é justamente passar uma lista que já tá
ordenada, porque ele vai tentar ordenar e rodar em tempo quadrático em vez de
loglinear. Ou seja, só porque no tempo médio o quicksort é bom, não quer dizer
que é sempre bom.
Por isso mesmo, em linguagens como Java, Javascript, Python e outros, quando
você dá sort não necessariamente vai ser um quicksort por baixo. No caso de
Python se usa o algoritmo Timsort que é meio inspirado no mergesort com
25
insertion sort que eu não expliquei. É mais estável e com pior caso melhor do que
o quicksort. Vale a pena pesquisar. Mas o recado é que nem sempre o algoritmo
mais rápido vai ser o melhor sempre, porque se o pior caso derrubar seu sistema,
é melhor um algoritmo um pouco mais devagar mas que lide melhor com o pior
caso.
Finalmente, antes que alguém jogue nos comentários que eu não mencionei,
notação Big O é pode ficar controverso porque povo diz "ah, algoritmo X é
quadrático então é ruim, algoritmo Y é linear então é bom" mas não entende que
não é pra comparar só pelo Big O, especialmente sem levar em conta coisas
como melhor ou pior caso, e sem levar em conta coisas como Big Omega e Big
Theta. Na prática, Big O é uma notação de complexidade do limite superior, não
necessariamente do pior caso. Big Omega é notação pro limite inferior, não
necessariamente do melhor caso. E Big Theta é notação pro tempo entre Big O e
Big Omega.
26
porque aí uma solução que é boa pra um projeto pode não ser tão bom pra outro.
Como já disse no episódio do livro The Mythical Man Month, não existe bala de
prata e boa parte do trabalho de um programador é saber como escolher entre
diferentes opções.
Ainda faltam 3 volumes e os 4 que já existem são bem difíceis de ler e entender
sem ter uma base matemática forte. Eu mesmo não tenho paciência e só li trechos
específicos. Talvez uma hora faça um episódio de alguns assuntos dele que me
interessam. No geral, eu não recomendo esses livros. Pra começar são bem caros.
A coleção dos 4 livros na Amazon custa 200 dólares, que convertendo hoje vai
pra mais de mil reais. Qualquer livro que você achar que seja de estruturas de
dados e algoritmos é suficiente. Veja a bibliografia de qualquer universidade pra
ter referências.
Um que não é tão barato, melhor do que eu usei na faculdade, mas tá longe de ser
o monstro que é o do Knuth, procure pelo Introduction to Algorithms do Cormen,
Leiserson, Rivest e Stein. Diferente do que eu fiz neste vídeo, o livro do Cormen
vai começar ensinando o Insertion Sort, no capítulo seguinte vai falar sobre
notação assintótica que foi o que eu pulei quando falei sobre notação Big O e na
sequência vai explicar o que é a estratégia de dividir pra conquistar e depois um
pouco sobre análise probabilística.
Mas como eu disse, não importa muito qual livro, contanto que estude pelo
menos um que cubra esses tópicos principais de algoritmos, complexidade,
27
estruturas de dados e entenda o que são algoritmos de tempo polinomial versus
de tempo não polinomial. Isso é o que em vários vídeos eu venho falando que é
parte da base fundamental da programação. Enquanto você não souber esse
básico vai ter dificuldade de pular pra tópicos mais avançados e sempre vai ter
dúvida se o que tá codando é o melhor jeito pro problema que tem que resolver,
ou porque escolher biblioteca X em vez da Y ou porque certa boa prática
funciona em alguns casos mas não em outros.
28