Notação Assimptótica e Teorema Mestre
Notação Assimptótica
A secção de IAED dos resumos sobre esta matéria já cobre os aspetos relevantes a abordar, ainda que relativamente superficialmente. Tentando relembrar estes conceitos de uma forma sucinta:
-
Majorante assimptótico:
É o limite assimptótico superior, com notação - afere a complexidade no pior caso.
-
Minorante assimptótico:
É o limite assimptótico inferior, com notação - afere a complexidade no melhor caso.
-
Tight-band:
É o limite assimptótico apertado, com notação - quando o melhor e o pior caso têm a mesma complexidade.
Houve ainda dois lemas associados à notação assimptótica abordados em aula, um deles diretamente relacionado com o limite acima:
Lema 1
Lema 2 - Transitividade
As provas de ambos os lemas encontram-se nas notas do professor, no fim da página.
Muitas vezes, uma abordagem que permite diminuir significativamente o tempo assimptótico em que é possível resolver um problema é usar uma abordagem de Dividir e Conquistar.
Dividir para Conquistar
Metodologia:
-
Dividir o problema a resolver num conjunto de subproblemas.
-
Resolver (recursivamente) cada um dos subproblemas.
-
Combinar as soluções dos subproblemas para obter a solução do problema original.
Exemplos de problemas que têm soluções deste tipo são
- Procura de um elemento numa array ordenada com Binary Search
- Travessia de uma árvore binária
- Ordenação de uma array com Merge Sort (pode ser útil rever, foi abordado em aula). As notas do professor abordam também a complexidade temporal de cada método do Merge Sort (e a sua complexidade total)
O Teorema Mestre oferece um método para calcular o crescimento assimptótico deste tipo de problemas.
Teorema Mestre
Teorema Mestre Simplificado
Sejam constantes e definido por
Diz então o Teorema Mestre Simplificado que
As constantes e devem ser pensadas da seguinte forma:
- Nesta solução de D&C, cada problema de tamanho divide-se em problemas de tamanho ;
- corresponde ao custo nesta solução para gerar os subproblemas, e, no fim, juntar os seus resultados (em relação a um problema de tamanho ).
Prova
Pode ajudar a acompanhar esta prova desenhar no papel a árvore descrita na prova.
Na raiz, temos que o preço (pontual) é dado por .
Num segundo nível, temos subproblemas de tamanho . Portanto, cada um tem um custo de . Consequentemente, no total, neste nível, a complexidade é .
No terceiro nível, dividimos cada problema em subproblemas, obtendo então subproblemas. Cada um destes subproblemas terá dimensão , pois dividimos cada problema no nível 2 vezes. Então, cada problema tem custo , pelo que a complexidade do nível todo é .
Fica então fácil de generalizar que no nível teremos problemas de tamanho com custo pontual .
Calcular a complexidade da nossa solução corresponde então a somar a complexidade de cada nível, até um nível da árvore em que o custo pontual é constante (neste caso assumimos que isso acontece apenas quando ). Para isso precisamos de saber quantas divisões temos de fazer até chegar a esse nível. A resposta é o valor tal que .
Ficamos então com o somatório:
Analisemos agora caso a caso:
- No caso 1, temos que .
Neste caso, o somatório explode e é dominado pelo último termo, ou seja a complexidade tem upper bound de
- No caso 2, ficamos com
- No caso 3, o somatório é majorado por uma série que converge (uma vez que ) pelo que
Exemplo 1
Tenhamos . Aqui, podemos dizer que:
Daqui, obtemos que
Assim sendo, admitimos que estamos na presença do caso 1, e que, portanto,
Exemplo 2
Tenhamos:
int f(int n) {
int i, j;
i = 0;
j = 0;
while (i < n) {
j++;
i += 2;
}
if (n > 1) {
i = 2*f(j) + f(j);
}
return i;
}
Podemos procurar ver como o ciclo se comporta (e qual é a sua condição de paragem) recorrendo ao método da tabela. Neste caso:
... | ... | ... |
Onde é a variável de controlo do loop (conta o número de iterações), e e são variáveis que vão sendo atualizadas durante o mesmo. Podemos notar que corresponde ao momento exatamente antes do loop.
Ora, podemos perceber que vai crescendo com aspeto - isto é, vai sempre sendo igual ao dobro do número de iterações atual do ciclo. Além disso, através da tabela, podemos também observar que, no fim do ciclo, .
Temos que o ciclo para com - ou seja, quando
Podemos, então, dizer que o ciclo corre vezes, e que a sua complexidade temporal é .
Como observado, no fim do ciclo, pelo que a chamada recursiva da função
(dentro do bloco if
) é realizada com duas vezes
(a função recursiva é chamada duas vezes), pelo que podemos admitir que essa secção
leva . A função total da função corresponde, portanto, a
Ora, daqui podemos retirar que
Estamos na presença do caso 2, e portanto podemos admitir que
Exemplo 3
Tenhamos:
int f(int n) {
int i = 0;
while (i * i < n) {
i++;
}
if (n > 1) {
i = f(n / 4) + f(n / 4) + f(n / 4)
}
return i;
}
Mais uma vez vamos recorrer ao método da tabela para perceber como o loop se comporta:
... | ... |
cresce, portanto, de igual forma ao número de ciclos. Ao mesmo tempo, temos que a condição de paragem é
A complexidade do loop é, portanto, .
Além disso, a chamada recursiva dentro do bloco if
é realizada três vezes, desta
vez sem recorrer a "variáveis auxiliares" - a chamada é sempre feita com .
Temos, então, que
de onde obtemos
Estamos, portanto, na presença do caso 3, e portanto
Exemplo 4
Tenhamos:
int f(int n) {
int i = 0;
int j = 0;
while (n * n > i) {
i += 2;
j++;
}
if (n > 1) {
i = 5 * f(n/2) + f(n/2) + f(n/2) + f(n/2)
}
while (j > 0) {
i += 2;
j--;
}
return i;
}
Neste caso temos dois loops, pelo que a complexidade de ambos será relevante para resolver o problema.
Fazendo a tabela para o primeiro loop:
... | ... | ... |
Temos que o primeiro loop para quando
pelo que a sua complexidade será .
Olhando já para o segundo loop (já voltamos ao if
no meio), podemos observar que
o número de iterações é exatamente o mesmo do primeiro - no primeiro, era igual
ao número de iterações do mesmo. Aqui, decrescemos até chegar a 0:
iterações, tal como em cima. A complexidade será, claro . A soma das suas
complexidades corresponderá a . Contudo, a constante é irrelevante
para o cálculo da complexidade, pelo que podemos só admitir que a complexidade conjunta
dos ciclos será do tipo .
A chamada recursiva é realizada 4 vezes, dentro do if
, sempre recorrendo a , pelo que vamos ter
pelo que estamos na presença do caso 2, e portanto
Existem mais alguns exemplos semelhantes nas notas do professor.
Teorema Mestre Generalizado
Este teorema pode ser estudado com mais detalhe aqui (incluindo a sua prova, bastante extensa).
Sejam constantes e definido por
Diz então o Teorema Mestre Generalizado que:
O primeiro caso precisa de uma condição de regularidade - pode ser encontrada nos slides e nas notas do professor, mas em ASA não é necessária.
Tal como no caso simples, devemos pensar nestas fórmulas da seguinte forma:
- Nesta solução de D&C, cada problema de tamanho divide-se em problemas de tamanho ;
- corresponde ao custo nesta solução para gerar os subproblemas, e, no fim, juntar os seus resultados (em relação a um problema de tamanho ).
O segundo caso tem ainda mais uma generalização, que não vamos indicar aqui mas pode ser encontrada na Wikipedia.
Prova
O professor disse que a prova do teorema generalizado era muito complicada e que
seria preciso bastante tempo para o explicar.
A prova não foi dada em aula pelo que também não a vamos fazer aqui.
Pode no entanto ser encontrada no livro Introduction to Algorithms, incluído na bibliografia da cadeira.
Exemplo
Tenhamos:
int f(int n) {
int i = 0;
int j = n;
if (n <= 1) return 1;
while (j > 0) {
i++;
j /= 2;
}
for (int k = 0; k < 4; k++) {
j += f(n/2);
}
while (i > 0) {
j += 2;
i--;
}
return j;
}
Neste exemplo estamos na presença de 3 loops. Em relação ao primeiro:
... | ... | ... |
Ora, temos que a condição de paragem é j == 1
(após isso acontece j == 0
e para), pelo que ocorrerão
iterações, ou seja o loop tem complexidade .
O segundo loop corre em tempo constante (exatamente 4 vezes), e contém a chamada recursiva da função, que é chamada com .
O terceiro loop corre também em tempo logarítmico (no final do primeiro loop, e neste estamos a decrementar 1 a 1 até chegar a 0).
Podemos, assim, dizer que
Ora, aqui não podemos aplicar o Teorema Mestre simplificado (não temos algo do tipo , mas sim ). Teremos, então, de recorrer ao Teorema Mestre generalizado. Aqui, consideramos == , e temos, claro, que
pelo que estamos na presença do caso 1, e portanto
Heaps e Heap Sort
Na terceira aula foram também abordados os conceitos de Heap e Heap Sort. Estes conteúdos já estão disponíveis na secção de resumos de IAED, pelo que não os abordaremos a fundo aqui. As notas do professor também explicam muito bem esta parte!
De qualquer maneira, para ter aqui algumas noções-chave:
Definição de Heap
Um array diz-se um heap se:
com
- A função
maxHeapify
/fixDown
assume que ambos os filhos do "nó" argumento são heaps. - A altura máxima de um heap com elementos é .
- Do ponto acima sai que podemos, no máximo, invocar recursivamente a função
maxHeapify
vezes. - A construção de um heap é feita de baixo para cima, da direita para a esquerda.
- O índice pelo qual queremos começar a construção, o índice do primeiro nó com filhos, é dado por . A prova está nas notas do professor. Abaixo podemos encontrar 2 exemplos que ilustram esta proposição (aqui, é o índice do primeiro nó com filhos).
De realçar que aqui consideramos um array indexado a partir de 1 (e não 0), daí ser, respetivamente, 2 e 4, e não 1 e 3.
Priority queues
Priority queues, ou filas de prioridade em português, são estruturas de dados cuja implementação corresponde, tipicamente, a heaps - há, tal como max Heaps e min Heaps (sendo o heap ordenado de modo decrescente ou crescente, respetivamente), max Priority Queues e min Priority Queues (heaps ordenados pela sua prioridade, portanto). Funcionam como uma "fila de supermercado" - cada pessoa pode ter um valor de prioridade, e o seu lugar é ditado por essa ordem.
São normalmente implementados desta maneira por ser uma maneira particularmente eficiente de organizar este tipo de informação. O elemento com mais prioridade, por exemplo, pode ser obtido em tempo constante (), a operação de obter qualquer valor (consoante, claro, a sua prioridade) realizada em , e a inserção e remoção de elementos também.