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

Veja além do óbvio

Acredito que se você já tem um tempo na área de programação, seja como estudante, ou entusiasta, já deve ter ficado claro que um mesmo problema apresentado para 2 pessoas diferentes, certamente produzirá 2 algoritmos diferentes para resolvê-lo.

Um problema possui um espectro de soluções.

Porém, dentro dos caminhos que você pode escolher, há sempre alguns que vão performar melhor que outros. Em um mundo aonde os recursos não são tão escassos como já foram, muitas vezes nos deixamos levar pela primeira ideia que nos vem a mente, sem nos preocuparmos se nossa decisão terá algum impacto, dado o cenário em que o problema surgiu.
No entanto, quando falamos de grandes escalas, a preocupação com recursos e tempo de resposta passam a ser parte fundamental até mesmo do SLO (Service Level Objective) do seu produto, bem como da experiência do usuário.
Como exemplo dessa reflexão, dias atrás me deparei com uma situação em que eu tinha uma lista de emails e uma lista de usuários aonde cada elemento possuía um campo email, e eu precisava identificar nesta lista de usuários a diferença com a lista de emails.
Inicialmente, resolvi assim:

const filterWithSome = (emails, users) => {
    return emails.filter(
        (email) => !users.some((user) => user.email === email)
    );
};

Veja que não há nenhum problema no algoritmo. Ele está retornando da lista de emails, todos aqueles que não estão contidos na lista de usuários, o que é exatamente a solução do problema.
Porém, o cenário de sucesso desse algoritmo sempre atinge o pior custo de operação, pois suponha que na lista de usuários você possui 10 mil valores, e na lista de emails há 15 mil (5 mil a mais que são emails não contidos na lista de usuários). Isso significa pelo menos para 5 mil emails, a lista de usuários será percorrida por completo até determinar que o email não está presente ali. Isso sem contar os demais emails que farão um caminho similar, porém serão encontrados em algum ponto da lista de usuários.
O que significa que nosso pior caso é igual ao número de emails X número de usuários, ou seja O(n * m) em notação Big O.
Após alterar um pouco o algoritmo, cheguei em uma implementação que focava em reduzir esse custo. Veja:

const filterWithSet = (emails, users) => {
    const usersEmailsSet = new Set(users.map((user) => user.email));

    const difference = emails.filter((email) => !usersEmailsSet.has(email));
    return difference;
};

Essa abordagem está mapeando a lista de emails do array de usuários e o transformando em um Set, ao utilizar o método filter na lista de emails, ao invés de percorrer todos os itens da lista de usuários, agora com uma única operação de leitura é possível determinar se o email está presente no Set, ou não (veja mais sobre como Set funciona na documentação oficial). O que significa que esse algoritmo agora tem um custo de O(n).
Para verificar se a mudança de fato obteve algum impacto, desenvolvi o seguinte cenário de teste:

const { faker } = require('@faker-js/faker');
const emails = [];
const users = [];

Array.from({length: 50000}, (_, i) => i).forEach(()=> {
    const email = faker.internet.email({provider: 'fake'});
    emails.push(email);
    users.push({email});
});

Array.from({length: 10000}, (_, i) => i).forEach(()=> {
    const email = faker.internet.email({provider: 'fake'});
    emails.push(email);
});

const filterWithSome = (emails, users) => {
    return emails.filter(
        (email) => !users.some((user) => user.email === email)
    );
};
  
const filterWithSet = (emails, users) => {
    const usersEmailsSet = new Set(users.map((user) => user.email));

    const difference = emails.filter((email) => !usersEmailsSet.has(email));
    return difference;
};
  
console.time("filterWithSome");
filterWithSome(emails, users);
console.timeEnd("filterWithSome");

console.time("filterWithSet");
filterWithSet(emails, users);
console.timeEnd("filterWithSet");

Como pode ver, a configuração do teste gera 50 mil emails e os atribui para a lista de emails e para a lista de usuários; Na sequência, adiciono mais 10 mil emails para a lista de emails, o que significa que o array de email terá 10 mil valores diferentes da lista de usuários.
Após executá-lo, esse foi o resultado do tempo de execução para cada algoritmo, dado o cenário apresentado:

filterWithSome: 5.999s
filterWithSet: 9.168ms

Observe que a diferença é absurda, e enquanto a filtragem usando o Set custou aproximadamente 9 milisegundos, a outra abordagem custou quase 6 segundos.
A parte de todo o lado técnico apresentado, a reflexão que fica é: "Estou vendo além do óbvio?".
Nem sempre você vai precisar otimizar algo para obter performance, mas quando o momento chegar, exercite o entendimento do problema, e realmente conheça como a linguagem que você usa funciona e quais os limites de cada recurso.
Um forte abraço!

Carregando publicação patrocinada...
5

Excelente!

Esse é o tipo de coisa que muita gente não se preocupa, porque na maioria das vezes estão lidando com arrays pequenos e a diferença não é perceptível. Afinal, para poucos dados, tudo é rápido. O problema só aparece em grandes volumes, e é aí que conhecer melhor os algoritmos e estruturas de dados pode fazer toda a diferença.

Indo um pouco além, console.time é uma maneira rápida de medir o tempo de execução, mas tem alguns problemas (leia para mais detalhes, não é o foco aqui se alongar neste tópico). Enfim, um jeito de contornar esses problemas é usando uma lib dedicada, como o Benchmark.js (deixei um exemplo mais abaixo).

E aproveitando, dá pra melhorar ainda mais (se o desempenho for um fator crítico e estiver lidando com muitos dados). Métodos como map e filter recebem como parâmetro uma função de callback que é chamada para todos os elementos. Apesar de não fazer tanta diferença para poucos arrays pequenos, ainda sim chamadas de função têm o seu custo, e para muitos dados pode fazer diferença. Então a ideia é eliminar estas chamadas:

function filterForOf(emails, users) {
    const usersEmailsSet = new Set();
    for (const u of users) { // em vez de map, um for..of
        usersEmailsSet.add(u.email);
    }
    const diff = [];
    for (const email of emails) { // em vez de filter, um for..of
        if (!usersEmailsSet.has(email)) {
            diff.push(email);
        }
    }
    return diff;
}

E mais ainda: for..of é conveniente, mas também tem custo maior do que o for "tradicional". Então vamos fazer outra versão:

function filterFor(emails, users) {
    const usersEmailsSet = new Set();
    for (var i = 0; i < users.length; i++) { // for "tradicional" em vez de for..of
        usersEmailsSet.add(users[i].email);
    }
    const diff = [];
    for (var i = 0; i < emails.length; i++) { // for "tradicional" em vez de for..of
        if (!usersEmailsSet.has(emails[i])) {
            diff.push(emails[i]);
        }
    }
    return diff;
}

Enfim, fiz um teste com todas estas opções usando o Benchmark.js. O código completo:

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

const { faker } = require('@faker-js/faker');

const emails = [];
const users = [];
Array.from({ length: 50000 }, (_, i) => i).forEach(() => {
    const email = faker.internet.email({ provider: 'fake' });
    emails.push(email);
    users.push({ email });
});

Array.from({ length: 10000 }, (_, i) => i).forEach(() => {
    const email = faker.internet.email({ provider: 'fake' });
    emails.push(email);
});

function filterWithSome(emails, users) {
    return emails.filter((email) => !users.some((user) => user.email === email));
}

function filterWithSet(emails, users) {
    const usersEmailsSet = new Set(users.map((user) => user.email));
    const difference = emails.filter((email) => !usersEmailsSet.has(email));
    return difference;
}

function filterForOf(emails, users) {
    const usersEmailsSet = new Set();
    for (const u of users) {
        usersEmailsSet.add(u.email);
    }
    const diff = [];
    for (const email of emails) {
        if (!usersEmailsSet.has(email)) {
            diff.push(email);
        }
    }
    return diff;
}
function filterFor(emails, users) {
    const usersEmailsSet = new Set();
    for (var i = 0; i < users.length; i++) {
        usersEmailsSet.add(users[i].email);
    }
    const diff = [];
    for (var i = 0; i < emails.length; i++) {
        if (!usersEmailsSet.has(emails[i])) {
            diff.push(emails[i]);
        }
    }
    return diff;
}

suite
.add('filterWithSome', function () {
    filterWithSome(emails, users);
})
.add('filterWithSet', function () {
    filterWithSet(emails, users);
})
.add('filterForOf', function () {
    filterForOf(emails, users);
})
.add('filterFor', function () {
    filterFor(emails, users);
})
.on('complete', function () {
    console.log('Fastest is ' + this.filter('fastest').map('name'));
})
.on('cycle', function (event) {
    console.log(String(event.target));
})
.run({ 'async': true });

Ele mostra a quantidade de operações por segundo ("ops/sec"), ou seja, quanto mais, melhor. O resultado foi:

filterWithSome x 0.18 ops/sec ±18.44% (5 runs sampled)
filterWithSet x 158 ops/sec ±2.53% (81 runs sampled)
filterForOf x 179 ops/sec ±3.48% (76 runs sampled)
filterFor x 205 ops/sec ±2.54% (87 runs sampled)
Fastest is filterFor

Ou seja, o for tradicional foi mais rápido, seguido do for..of. Aqui dá pra ver que eliminar os callbacks fez diferença. Rodei mais algumas vezes e os resultados foram consistentes, com pouca variação nos números.

Ah, e também tem a opção de usar um objeto simples em vez de Set:

function filterObject(emails, users) {
    const usersEmails = {};
    for (var i = 0; i < users.length; i++) {
        usersEmails[users[i].email] = true;
    }

    const diff = [];
    for (var i = 0; i < emails.length; i++) {
        if (!usersEmails[emails[i]]) {
            diff.push(emails[i]);
        }
    }
    return diff;
}

Assim, cada email se torna uma propriedade do mesmo (e caso o email se repita, ele sobrescreve a mesma propriedade, ou seja, "já cuida dos valores duplicados").
Nos meus testes, esta solução se mostrou um pouco pior que filterWithSet, mas ainda sim bem melhor que filterWithSome. O que me parece é que a implementação do Set está mais otimizada do que o lookup das propriedades de um objeto.


Por curiosidade, a partir do Node 22 (e nas versões recentes dos principais browsers) é possível calcular a diferença entre dois Set's usando o método difference. Ou seja, também poderia ser assim:

function filterSetsDiff(emails, users) {
    const usersEmailsSet = new Set();
    for (var i = 0; i < users.length; i++) {
        usersEmailsSet.add(users[i].email);
    }
    // método difference funciona a partir do Node 22
    return [...new Set(emails).difference(usersEmailsSet)];
}

Porém, esta não se mostrou melhor que o for. Nos meus testes ela ficou um pouco pior que o filterObject (mas ainda é bem melhor que filterWithSome). Provavelmente porque precisa criar também o Set de emails, depois o método difference retorna outro Set, e por fim este é convertido para array. Esta volta toda acaba tendo um custo que neste caso parece não compensar.

2