Matar dragões - Reduzindo a complexidade de operações
Fortemente influenciado por este artigo do Leandro Proença https://dev.to/leandronsp/how-to-reduce-the-time-complexity-of-nested-loops-1lkd
Leia mais detalhes na thread que o Leandro escreveu no Twitter https://twitter.com/leandronsp/status/1467259424469364745
Temos que preencher uma tabela com o nome de uma transportadora, por motivos de regras de negócio. E agora, como proceder?
Definindo o problema um pouco mais rigidamente
Temos uma tabela na base de dados. Essa tabela contém dados sobre precificação de frete e um dos dados que serve para fazer esse cálculo é a transportadora que irá prover o serviço de frete. Essa informação é opcional.
Uma das modelagens possíveis é usar um esquema relacional e mencionar a transportadora numa chave estrangeira. Se adotarmos um esquema de integridade ocasional, precisamos assumir que a chave estrangeira aponta para algo que possivelmente não exista.
Vamos chamar a estrutura que lida com a precificação de PRECIFICACAO_FRETE
. Ele tem uma chave
estrangeira para transportadora chamada TRANSPORTADORA
, para a tabela TRANSPORTADORA
. A tabela
TRANSPORTADORA
de relevante para o caso de hoje contém a colunas CODIGO
e NOME
.
Modelando objetos
Temos algumas variações possíveis para modelar esses objetos. Vamos começar por Transportadora
.
Nesse momento, não iremos precisar alterar em nada os campos de Transportadora
, então precisamos
ter acesso somente leitura para código e nome. Usando tradicionais getters, poderíamos colocar assim:
public class Transportadora {
// não temos restrições para construtor, então ele está omitido
// não assuma, entretanto, que precisa ser construtor padrão
private int codigo;
private String nome;
public int getCodigo() {
return codigo;
}
public String getNome() {
return nome;
}
}
Note que aqui estou assumindo que o tipo de dados dentro da coluna TRANSPORTADORA.CODIGO
é um
inteiro tradicional de 4 bytes, representável por um int
do Java.
E precisamos modelar o objeto PrecificacaoFrete
. A única informação que temos sobre ele até
o momento é que ele pode ou não ter uma transportadora. Então a referência (seja ela direta ou
indireta) precisa assumir que irá ter o valor nulo. Eu vejo algumas alternativas para isso.
A primeira é uma referência direta a transportadora:
public class PrecificacaoFrete_direta {
// resto da classe omitida
private Transportadora transportadora;
public Transportadora getTransportadora() {
return transportadora;
}
}
Ou então uma referência a algo que pode ser tratado como transportadora, porém independente da implementação real por trás:
public class PrecificacaoFrete_ITransportadora {
// resto da classe omitida
private ITransportadora transportadora;
public ITransportadora getTransportadora() {
return transportadora;
}
}
Note que, para isso funcionar corretamente, precisaríamos que Transportadora
implementasse a
interface ITransportadora
e que essa interface tivesse alguma API nela definida. O caso específico
que estamos tratando uma interface de marcação (como o java.io.Serializable
) não seria a mais adequada.
E também tem o método mais tosco, guardar a referência ao código, e apenas isso:
public class PrecificacaoFrete_codigo {
// resto da classe omitida
private Integer transportadora;
public Integer getTransportadora() {
return transportadora;
}
}
Como a referência inexistente se aplica, uso o tipo Integer
para ter acesso ao null
.
Foi mais simples para nós usar esse último padrão, em que a PrecificacaoFrete
armazenasse
apenas a referência da transportadoravai código. Um dos motivos pelo qual isso foi escolhido foi
um mal entendimento com o MyBatis que não retornava referência nula quando existia um null
em PRECIFICACAO_FRETE.TRANSPORTADORA
, e também tratar o caso de transportadora que foi removida.
Isso é algo passível de acontecer pois esse é um sistema de integridade ocasional, como foi
definido no começo.
Outros fatores que colaboraram para se usar esse tipo de construção é porque a transportadora
não tem nenhuma propriedade que, naquele momento específico, fosse útil para o cadastro de
PRECIFICACAO_FRETE
.
O resgate do objeto de PrecificacaoFrete
é direto ao ponto: cada linha em PRECIFICACAO_FRETE
vira um objeto e no campo transportadora
eu coloco o valor da coluna TRANSPORTADORA
. Similarmente
com o objeto Transportadora
.
Usando os objetos
No primeiro momento, vou falar sobre o uso dele no cadastro. O usuário precisa selecionar uma transportadora
dentro da lista de transportadoras disponíveis. Ao fazer isso, o sistema chama algo que altera o estado
do objeto PrecificacaoFrete
que ele está trabalhando. Vamos supor que seja PrecificacaoFrete precificacaoFrete
:
void onChangeTranportadora(Transportadora novaTransportadora) {
if (novaTransportadora != null) {
this.precificacaoFrete.setTransportadora(novaTransportadora.getCodigo());
} else {
this.precificacaoFrete.setTransportadora(null);
}
}
Ok, so far, so good. Mas tem um momento que precisamos mostrar esses dados de modo tabulado. Como proceder?
Se considerarmos que cada coluna terá seu próprio trecho de código que mostra como exibir (Function<PrecificacaoFrete, String>
para cada coluna da tabela), poderíamos maltratar o usuário assim:
o usuário assim:
precificacaoFrete -> Optional.of(precificacaoFrete)
.map(PrecificacaoFrete::getTransportadora)
.map(Object::toString)
.orElse("")
Assim eu exibo o código da transportadora. Funciona? Bem, sim, mas é maltratar o usuário. Não é uma experiência
de uso muito adequada para o usuário mostrar o código da transportadora, o ideal seria mostrar o nome dela. Então,
vamos agradar o usuário? Precisamos pegar uma função nomeTransp
que transforme código da transportadora em seu
respectivo nome. Bem, vamos assumir que existe essa função Function<Integer, String> nomeTransp
:
precificacaoFrete -> Optional.of(precificacaoFrete)
.map(PrecificacaoFrete::getTransportadora)
.map(nomeTransp)
.orElse("")
Até aqui ótimo. Agora, precisamos definir nomeTransp
. E aqui iniciamos a matança de dragões.
Jeito naïve de obter o nome das transportadoras
A maneira mais ingênua de obter o nome das transportadoras seria fazer uma pergunta ao banco de dados
qual o nome da transportadora X
:
nomeTransp = codTransp -> {
Transportadora t = transportadoraMapper.resgataTransportadora(codTransp);
if (t != null) {
return t.getNome();
} else {
return "Transportadora " + codTransp + " sem cadastro";
}
};
Bem, parece razoável, não é? Mas, e se eu disser que isso implica fazer O(n)
consultas aos banco de dados?
Caímos no clássico problema n + 1
. Sem garantir o bom funcionamento de transportadoraMapper.resgataTransportadora
para fazer alguma espécie de cache de dados (talvez não esteja configurado), isso é catastrófico. Na minha situação, isso
é proibitivo, pois o código da view no GWT executa no lado JavaScript e eu sou proibido de fazer consultas síncronas ao
servidor, então precisaria de algum jeito de tornar isso assíncrono, mas isso são outros detalhes.
Ok, e como evitar o n + 1
? Que tal carregar todas as transportadoras numa lista transportadoras
?
Pois bem, com essa pré-condição de que temos todas as transportadoras carregadas, o código seria o seguinte:
nomeTransp = codTransp -> transportadoras.stream()
.filter(t -> t.getCodigo() == codTransp)
.findAny()
.orElseGet(() -> "Transportadora " + codTransp + " sem cadastro");
Pois bem, fim de história?
O dragão da complexidade
Óbvio que não. Aqui temos que verificar o quanto de esforço é dedicado a buscar essa informação.
Falei no começo que transportadoras
é uma lista, portanto posso assumir que é um List<Transportadora>
.
Isso ainda implica que ele terá um comportamento de complexidade distinto se a implementação for contígua em
memória (como ArrayList
) ou como lista ligada (como LinkedList
). Porém talvez seja só um vício de linguagem
meu e transportadoras
poderia ser qualquer Collection<Transportadora>
, como Set<Transportadora>
. Por hora,
vou ignorar esses detalhes e assumir que cada acesso a elemento custa algo de modo constante .
Em todas essas estruturas citadas, não está sendo feito nenhum acesso esperto, mas sim uma iteração comparando o elemento passado com um predicado arbitrário. Isso impede que algo esperto seja feito. Então, caso não exista o elemento na lista, será necessário passar o predicado por todos os elementos da lista. Podemos assumir que o predicado roda de modo oracular (na real, como vemos o código dele, ele é de fato ).
Então, de modo geral:
- precisamos acessar elementos
- acessar cada elemento custa
- testar cada elemento custa
Com isso temos que, para achar o nome de uma transportadora, fazemos uma operação em tempo linear . Bacana, né?
Pois bem… acessar o nome da transportadora é uma operação auxiliar dentro da operação principal de montar a tabela. Vamos por hora assumir que essa tabela só tenha essa única coluna. Portanto, vamos precisar fazer operações para precificações de frete. Para cada uma dessas operações, será realizada uma operação . Então, temos que teremos ao todo operações para rodar o povoamento de elementos.
Espera-se ter muito mais elementos de PrecificacaoFrete
do que de Transportadora
, então podemos dizer que .
Com isso, temos que . Bem, isso mesmo, temos um algoritmo quadrático aqui.
Mas, será que essa complexidade é irredutível mesmo?
Entra São Jorge
O que queremos é acessar a partir de um código uma transportadora. Sabemos que, devido a natureza desse dado, o código
da transportadora é único. Portanto, o mapeamento transportadora e código é uma bijeção. Então, com isso, podemos montar
um mapa, não é? Vamos chamar esse mapa de cod2transp
, ele é do tipo Map<Integer, Transportadora>
(posso usar HashMap
para operações em média de resgate porém em casos degenerados, ou TreeSet
que fornece acesso em ).
A partir desse mapeamento (ainda iremos montá-lo, se acalme), conseguimos definir nomeTransp
de modo bem mais simples:
nomeTransp = codTransp -> {
Transportadora t = cod2transp.get(codTransp);
if (t != null) {
return t.getNome();
} else {
return "Transportadora " + codTransp + " sem cadastro";
}
}
E, pronto. Como estamos usando um Map
, podemos assumir sem perder generalidade que é uma operação esperta,
com custo médio de para acessar. Logo, com isso, temos que a complexidade total que na estratégia naïve
era de para . Porque ainda precisamos percorrer os elementos do tipo PrecificacaoFrete
. São
elementos e precisamos percorrer todos eles, porém agora a operação auxiliar custa apenas , o que não altera
o valor geral da complexidade. Estamos lidando com complexidade agora linear.
Mas, isso não é a história toda, não é mesmo? Como temos transportadoras, armazenar em um mapa essas transportadoras todas ocupará no mínimo de memória extra, porém com mais meta-dados do que se tinha anteriormente (considere como sendo o fator constante multiplicando a função maior). Se for necessário por algum motivo manter a lista de transportadoras, isso significa que precisaremos de mais do que o dobro de memória extra do que a estratégia ingênua. Além disso, precisamos de mais código para manter e também computar minimamente o tempo que se passa criando o mapa dentro das equações.
Para criar o mapa, podemos ser simples e delegar o trabalho a um Collector
. De todos os disponíveis, o melhor para o nosso caso é o
Collectors.toMap(keyMapper, valueMapper)
.
cod2transp = transportadoras.stream()
.collect(
Collectors.toMap(
Transportadora::getCodigo,
Function.identity()
)
);
Se quiser algo mais old school:
cod2transp = new HashMap<>();
for (Transportadora t: transportadoras) {
cod2transp.put(t.getCodigo, t);
}
Ambas as alternativas usam operações para construir o mapa. Elas não são estritamente equivalentes, entretanto,
porque o Collectors.toMap
usado não aceita duplicações de chave. É possível, entretanto, tornar essas implementações “equivalentes”
se usarmos a seguinte função de merge:
cod2transp = transportadoras.stream()
.collect(
Collectors.toMap(
Transportadora::getCodigo,
Function.identity(),
(__, a) -> a
)
);
Pondo mais dragões nas lanças
Eu peguei um exemplo simplificado logo no começo do artigo, propositalmente. Mas, se levar em consideração mais fatores, a coisa começa a brilhar.
De início, coloquei como fator que impacta na precificação do frete apenas a transportadora. Mas na aplicação onde isso reside existem diversas outras informações que são tratadas de maneira bem semelhante. São elas (citando novamente a transportadora só por questões de completude):
- loja vendedora
- tipo do frete
- transportadora
- comprador
- praça do comprador
- região em que se situa a praça
Tudo com integridade ocasional, pois esses dados são importados de um sistema externo (inclusive quando ele é removido da origem, essa remoção deve ser propagada para o lado da aplicação para evitar problemas maiores no cadastro).
Cada peça dessas adiciona, para cada linha de precificação de frete, uma busca separada pelo nome do elemento. A complexidade ao todo não sai da quadrática, entretanto, mas o fator multiplicando o fator quadrático aumenta.
Além disso, fatores auxiliares levam a escolha da preferência por usar referência indireta aos objetos no lugar da referência propriamente
dita de objetos. Imagina resgatar, em uma consulta, os elementos da precificação e dos 6 tipos citados acima. Se for considerar apenas
chave/nome, na tupla de retorno seriam mais 6 colunas além das de referência ao código, isso envolvendo uma junção de 7 tabelas. Também tem a
questão de que há um desentendimento com o MyBatis, que não conseguimos fazer a referência nula ainda. Ou, se não for resgatar em uma única
consulta, temos o problema n+1
nas mãos.