Resumo
Até aqui o curso julgou os algoritmos por uma só pergunta: eles fazem o que dizem? Esta aula acrescenta a segunda, que decide se um programa correto serve na prática: quanto custa? O custo mede-se primeiro sem relógio, contando quantas vezes se executa a operação dominante, e resulta ser uma função do tamanho dos dados. O que importa dessa função não é o seu valor para um dado, mas como cresce: uma tabela de , , e mostra que as diferenças entre algoritmos, invisíveis com dez dados, se tornam abismos com um milhão. A notação apresenta-se como abreviatura dessas ordens de crescimento, sem definição formal, com exemplos já vistos no curso: Euclides, logarítmico; a soma de um vetor, linear; comparar todos os pares, quadrático; Fibonacci ingênuo, exponencial. Depois mede-se com o relógio de <chrono>, std::chrono::steady_clock, e aprende-se por que uma só medida engana, por que se mede com otimização e sem detectores, e como se confronta uma medida com uma previsão: ao duplicar , um algoritmo linear duplica o seu tempo e um quadrático o quadruplica.
Objetivos de aprendizagem
- Contar as operações dominantes de um algoritmo com um ou dois laços em função do tamanho dos dados.
- Classificar um algoritmo como constante, logarítmico, linear, ou quadrático, e prever como muda o seu tempo ao duplicar .
- Medir o tempo de execução com
std::chrono::steady_clocke comparar a medida com a previsão. - Escolher entre dois algoritmos para um tamanho de dados dado, justificando a escolha com a ordem de crescimento.
Contar operações
A segunda pergunta
As aulas Projetar laços corretos e Recursão ensinaram a provar que um algoritmo é correto: que termina e que o seu resultado cumpre a pós-condição. Ora, a aula Algoritmos numéricos mostrou dois testes de primalidade igualmente corretos, um com cerca de divisões e outro com cerca de , e a aula Recursão, duas maneiras corretas de calcular , uma com chamadas e outra com 29 somas. Se isso é assim, a correção não basta para escolher: entre dois algoritmos corretos, um pode responder num instante e o outro não terminar antes que a resposta deixe de importar. A segunda pergunta que se faz a um algoritmo é, portanto, quanto custa.
O custo poderia medir-se com um relógio, e a quarta seção o fará. Mas o tempo de uma execução depende da máquina, do compilador, das suas opções e do que o computador estiver fazendo ao mesmo tempo; dele não se segue nada sobre o algoritmo em outra máquina. O que não depende de nada disso é o número de passos que o algoritmo dá. Daí se segue a primeira decisão desta aula: medir o custo contando, e deixar o relógio para conferir a conta.
O tamanho e a operação dominante
Contar todos os passos (cada soma, cada comparação, cada incremento do índice, cada acesso à memória) seria tedioso e inútil, porque quase todos acompanham um principal em proporção fixa. Escolhe-se, portanto, a operação dominante: a que se executa pelo menos tantas vezes quanto qualquer outra, salvo um fator constante, e que por isso representa todas. Numa soma dos elementos de um vetor é a soma; na busca de um máximo, a comparação; num teste de primalidade, a divisão. O número de vezes que se executa expressa-se em função do tamanho da entrada, que se chama : o número de elementos do vetor, o número que se testa, o número de algarismos.
Um laço simples que percorre um vetor de elementos e faz com cada um uma quantidade fixa de trabalho executa a sua operação dominante vezes. O máximo de um vetor não vazio, por outro lado, começa com o primeiro elemento como candidato e compara cada um dos demais: comparações. A diferença entre e é real, mas, como se verá na terceira seção, não muda nada do que importa.
O pior caso
Alguns algoritmos não executam sempre o mesmo número de operações com dados do mesmo tamanho. A busca de um valor num vetor, percorrendo-o até encontrá-lo, faz uma só comparação se o valor está na primeira posição e se não está. Para falar do seu custo escolhe-se uma das duas situações, e a escolha habitual é o pior caso: o número máximo de operações entre todas as entradas de tamanho . A razão é a mesma pela qual um contrato se escreve para todas as entradas que cumprem a pré-condição: o pior caso é uma garantia, e o melhor caso, uma esperança. De modo que a busca linear custa comparações no pior caso, e quando esta aula diz “o custo” sem mais, diz o do pior caso.
Laços aninhados
A aula Laços ensinou a contar as iterações de dois laços aninhados. Se o interno faz sempre iterações para cada uma das do externo, o corpo interno executa-se vezes. Se o interno depende do externo, soma-se linha por linha. O caso que mais aparece ao trabalhar com coleções é o de todos os pares: comparar cada elemento com cada um dos que o seguem, como quando se busca saber se um vetor tem repetidos.
#include <print>int main(){ for (int n{10}; n <= 10'000; n *= 10) { long long comparisons{0}; for (int i{0}; i < n; ++i) { for (int j{i + 1}; j < n; ++j) { ++comparisons; } } std::println("n = {:>6}: {:>10} comparações; n(n - 1)/2 = {}", n, comparisons, static_cast<long long>(n) * (n - 1) / 2); }}n = 10: 45 comparações; n(n - 1)/2 = 45n = 100: 4950 comparações; n(n - 1)/2 = 4950n = 1000: 499500 comparações; n(n - 1)/2 = 499500n = 10000: 49995000 comparações; n(n - 1)/2 = 49995000O contador ocupa o lugar da comparação que o algoritmo real faria. Na iteração do externo, o interno vai de a e faz iterações; de modo que o total é , a soma da aula Laços:
A saída confirma a fórmula para cada tamanho. O contador é long long porque, a partir de de uns sessenta e cinco mil, o número de pares supera o máximo de int (de 32 bits nesta plataforma), e a fórmula multiplica depois de converter pela mesma razão: o contador de um algoritmo quadrático estoura muito antes que os dados.
Observe-se já o que a terceira seção generalizará: ao multiplicar por 10, as comparações multiplicam-se por cerca de 100. Com dez dados, 45 comparações; com dez mil, quase cinquenta milhões.
Carregando o conteúdo…