Executando verificação de segurança...
2

Complexidade de algoritmos é uma daquelas coisas que vc só percebe que faz diferença quando vc conhece. Quem não conhece não vai perceber, na verdade sequer vai chegar a conceber que aquilo pode fazer alguma diferença. Exagerando um pouco, é como se o Tarzan dissesse que pode ir de cipó pra qualquer lugar e isso é o suficiente, pois ele não conhece nenhum outro meio de transporte, e portanto não consegue nem imaginar que há como ir mais rápido.

E o pior de tudo é que, de fato, na maioria dos casos comuns vai "funcionar". Afinal, para poucos dados, tudo é rápido. Hoje os computadores estão tão rápidos, que se um algoritmo tiver que manipular apenas 10, 500 ou 2000 itens, qualquer algoritmo vai ser "rápido".

No artigo indicado acima tem um exemplo claro desse problema. A tabela que tem lá é mais completa, mas enfim, vamos considerar apenas um algoritmo que tem complexidade O(n2) e outro que tem O(nlogn). Veja como a diferença fica cada vez maior conforme o tamanho dos dados aumenta:

nnlognn2
101
1664256
2562.04865.536
4.09649.15216.777.216
65.5361.048.5654.294.967.296
1.048.47620.969.5201,09 trilhões
16.775.616402.614.784281,4 trilhões

Ou seja, conforme o N aumenta, o algoritmo O(nlogn) cresce a uma taxa muito menor do que o O(n2). E quanto maior o N, maior a diferença entre os dois.

O problema é que na maioria dos casos mais comuns, lidamos com poucos dados. Páginas e retornos de API web costumam ser paginados, raramente estamos lidando com centenas de milhões de itens. Por isso que na maioria dos casos "funciona" e as pessoas acham que não precisam saber sobre complexidade de algoritmos.

Para poucos itens, qualquer algoritmo rodará em menos de 1 segundo, o que é imperceptível para nós humanos. Mesmo que o algoritmo mais lento demore 0.1 segundo e o mais rápido demore 0.001 segundo (ou seja, 100 vezes mais rápido), ainda sim não perceberíamos a diferença. Só quando tivermos milhões de itens, e o algoritmo mais rápido rodar em 1 minuto enquanto o mais lento demora várias horas, é que as pessoas param para pensar no que estão fazendo.


Outro detalhe é que a complexidade de algoritmos está diretamente ligada a outro assunto importantíssimo: estruturas de dados.

Cada estrutura (fila, pilha, lista ligada, árvore, etc) tem a complexidade de suas operações muito bem documentadas. Por exemplo, "para adicionar elementos é O(n), para buscar um elemento é O(logn), para remover é O(n), etc". E a escolha da estrutura correta pode fazer toda a diferença no algoritmo: o seu caso vai ter mais alterações (adicionar/remover) ou acessos? Os dados precisam estar ordenados ou tanto faz em qual posição adicionar? etc etc etc... Sabendo das necessidades específicas, você consegue escolher a estrutura mais adequada, e essa simples escolha pode fazer toda a diferença no resultado final.

Mas tem gente que não liga pra isso e usa array (ou "objeto") pra tudo (e "funciona", pois sempre tem menos que 100 itens, então tanto faz). Por isso tem tanta gente que diz que não precisa saber, que nunca usou, etc. Só lamento...

Carregando publicação patrocinada...
1

Obrigado por esclarecer a questão, amigo. A tabela deixou bem claro a grandissíma diferença que existe. Esse conhecimento, aparentemente, é uma ponte para quem atravessa todos os dias o laguinho a pé. Parece fácil fazer a travessia, mas depois de um tempo se percebe o quão mais prático e e útil foi ter construído uma ponte enquanto outros se dedicaram apenas a se molhar no laguinho.

1

Só pra ilustrar, um exemplo prático em JavaScript. Suponha que eu tenha um array assim:

[
  { nome: 'Fulano', idade: 20 },
  { nome: 'Ciclano', idade: 31 },
  { nome: 'Beltrano', idade: 20 },
  { nome: 'Trajano', idade: 31 },
  { nome: 'João', idade: 42 },
]

E eu queira criar outro array que tem os nomes agrupados por idade:

[
  { idade: 20, nomes: [ 'Fulano', 'Beltrano' ] },
  { idade: 31, nomes: [ 'Ciclano', 'Trajano' ] },
  { idade: 42, nomes: [ 'João' ] }
]

A ideia mais simples é usar um for, e para cada elemento, verifico se a idade já existe no resultado. Se não existir, cria o array de nomes, e se já existir, adiciona nesse array:

// usando for e find
const result = [];
for (const item of dados) {
    // procura pela idade no resultado
    let elemento = result.find(e => e.idade == item.idade);
    if (!elemento) { // se não tem, cria o array
        elemento = { idade: item.idade, nomes: [] };
        result.push(elemento);
    }
    // adiciona o nome
    elemento.nomes.push(item.nome);
}
console.log(result);

Ok, funciona. Mas esse algoritmo tem um porém: para cada item do array dados, eu uso find para verificar se a idade já está no resultado - pois eu tenho que saber se preciso adicionar uma idade que ainda não está lá. Só que a busca em um array é linear, ou seja, find no fundo é outro loop (que está "escondido", pois ele faz isso internamente). Claro que ele interrompe o loop quando encontra o elemento, mas mesmo assim, conforme o array result cresce, fica cada vez mais custoso percorrê-lo para fazer esta busca.

Daria para melhorar isso usando inicialmente um objeto para mapear cada idade para seu respectivo array, assim o custo da busca é diminuído:

let result = {};
for (const item of dados) {
    result[item.idade] ||= { idade: item.idade, nomes: [] };
    result[item.idade].nomes.push(item.nome);
}
result = Object.values(result);
console.log(result);

O operador ||= é usado para atribuir um valor caso ele ainda não esteja definido. Por exemplo, a ||= b é o mesmo que a = a || b (se a estiver definido, usa o valor dele, senão usa o valor de b). No caso, result[item.idade] estará definido somente se a idade já estiver no resultado. Caso não esteja, adiciona-a no objeto, cujo valor será o objeto que contém a idade e o respectivo array de nomes.

Isso é mais rápido que find porque a busca de uma propriedade em objetos é mais rápida (não é linear como no array, não precisa fazer um loop pelos elementos até encontrar a idade).

No final, pegamos apenas os valores do objeto, e o resultado é o array que queríamos.

Fiz um teste usando o Benchmark.js, segue todo o código abaixo:

var Benchmark = require('benchmark');
var suite = new Benchmark.Suite;

const dados = [];
// cria 10 mil itens
for (let i = 0; i < 10000; i++) {
    dados.push({ nome: 'Fulano ' + i, idade: i % 80 });
}

suite
.add('for/find', function () {
    const result = [];
    for (const item of dados) {
        let elemento = result.find(e => e.idade == item.idade);
        if (!elemento) {
            elemento = { idade: item.idade, nomes: [] };
            result.push(elemento);
        }
        elemento.nomes.push(item.nome);
    }
})
.add('object', function () {
    let result = {};
    for (const item of dados) {
        result[item.idade] ||= { idade: item.idade, nomes: [] };
        result[item.idade].nomes.push(item.nome);
    }
    result = Object.values(result);
})
.on('complete', function () {
    console.log('Fastest is ' + this.filter('fastest').map('name'));
})
.on('cycle', function (event) {
    console.log(String(event.target));
})
.run({ 'async': true });

Criei um array com 10 mil itens, com idades variando entre 0 e 79. O resultado está abaixo (lembrando que os números são de operações por segundo, ou seja, quanto mais, melhor):

for/find x 1,021 ops/sec ±0.72% (90 runs sampled)
object x 9,144 ops/sec ±1.70% (91 runs sampled)
Fastest is object

Vale lembrar também que os números podem variar de acordo com cada máquina. Mas em geral, o resultado final (qual é mais rápido) não deve variar.

Enfim, usando o objeto deu umas 9 mil operações por segundo, enquanto o for com find deu apenas mil.

Se aumentarmos o intervalo de idades para 0 a 99, a diferença entre eles aumenta:

for/find x 849 ops/sec ±0.73% (88 runs sampled)
object x 8,354 ops/sec ±1.03% (90 runs sampled)
Fastest is object

Se usarmos um intervalo de 0 a 999 (sei que 999 é muito para idade, mas imagine que fosse outro campo, como uma pontuação qualquer, por exemplo), a diferença aumenta ainda mais:

for/find x 95.69 ops/sec ±2.09% (68 runs sampled)
object x 6,275 ops/sec ±1.38% (79 runs sampled)
Fastest is object

Sim, mais de 6 mil operações por segundo, contra menos de 100 (os números estão no formato americano, a vírgula separa os milhares e o ponto é o separador decimal).

Quanto menos idades repetidas, pior fica, porque o array result fica com mais elementos, e find acaba - na média - demorando mais para verificar se uma idade já existe. Já para um objeto o impacto é menor, porque a busca por uma propriedade não é linear (provavelmente usa alguma tabela de hash ou algo similar - cuja busca é geralmente constante - para ser tão rápido).


Mas como eu já disse, para arrays pequenos não faz tanta diferença assim, e em alguns casos o for pode até ser mais rápido. Por exemplo, para um array com 30 itens e idades entre 0 e 29, o for foi mais rápido:

for/find x 879,554 ops/sec ±1.50% (86 runs sampled)
object x 774,850 ops/sec ±2.04% (82 runs sampled)
Fastest is for/find

Isso porque tem o custo extra de criar o objeto e no final criar outro array contendo somente os valores (quando chamamos Object.values). Esse custo só se paga se tivermos mais elementos. E novamente, isso pode variar de acordo com o hardware, mas enfim, na minha máquina o for já ficou mais lento com apenas 50 itens.

E reforço a questão de que isso será imperceptível para poucos arrays pequenos (a questão de 0.1 segundo versus 0.001 segundo, ambos não dá para perceber, apesar de um ter sido 100 vezes mais rápido). Isso só começa a fazer uma diferença notável com muitos arrays grandes, e por isso que muita gente não liga pra isso e acha que é inútil.

Mas é importante saber sim. São várias dessas pequenas ineficiências que quando somadas acabam degradando aos poucos o sistema. Claro que se for um sistema simples e pequeno, de criticidade baixa, e que seguramente trabalha sempre com poucos dados, aí talvez não precise otimizar tanto. Mas é importante saber desses detalhes sim, para não ser pego de surpresa quando os problemas de desempenho começarem.

1

Excelente explicacação!! So queria fazer uma correção, hash table não tem tempo O(LogN) e sim O(1) ou seja é constante. E sim o javascript usa hash table no object e no Map. Quando você passa uma chave para um objeto ele transforma isso no hash que vai apontar diretamente para o enredeço de memoria (simplificando). Ou seja independente no numero de elementos dentro do object voce vai ter sempre o mesmo tempo de acesso.

2

Já corrigi o texto, obrigado!

Mas vale lembrar que "constante" não quer dizer "sempre o mesmo tempo", pois depende de detalhes internos do algoritmo. Por exemplo, conforme a quantidade de itens cresce, pode ter alguma demora adicional para resolver colisões (e o tempo total disso tudo depende dos algoritmos escolhidos), etc.

Mas enfim, O(logn) estava errado mesmo, eu pensei uma coisa e escrevi outra :-)