Análise amortizada

Considere uma sequência de n execuções de um algoritmo que atua sobre uma estrutura de dados. Suponha que cada execução do algoritmo consome um tempo diferente conforme o estado em que a estrutura de dados se encontra. Em outras palavras, o consumo de tempo de cada execução depende das execuções anteriores. Tipicamente, cada execução lenta é precedida por muitas execuções rápidas. A análise amortizada procura fazer estimativas de consumo de tempo em situações como esta.

No contexto deste capítulo, é conveniente usar a palavra operação como sinônimo de algoritmo. Da mesma forma, convém dizer custo da operação em vez de consumo de tempo do algoritmo.

Esta página é inspirada no capítulo 17 de CLRS.

Introdução

Suponha que uma certa operação atua sobre uma estrutura de dados E e modifica a estrutura. Considere agora uma sequência de n execuções da operação:

  . para j := 1 até n faça
  .ooo Operação (E)

Cada execução da operação tem um certo custo, que depende do estado da estrutura E e portanto das execuções anteriores da operação.

Suponha que cada execução cara da operação é precedida por muitas execuções baratas. Mais precisamente, suponha que existe um número c tal que a soma dos custos das m primeiras execuções da operação não passa de cm. Então

tudo se passa como se cada execução tivesse custo ≤ c.

Dizemos que o custo amortizado (= amortized cost) de cada execução do operação não passa de c.

O custo amortizado não deve ser confundido com custo médio. O conceito de custo amortizado se aplica a uma sequência de execuções de uma operação em que o custo de cada execução depende das execuções anteriores. Já o custo médio é calculado sobre um conjunto de execuções independentes. (Veja, por exemplo, o consumo esperado do Quicksort aleatorizado.)

Exemplo: um contador binário

Um contador binário é um vetor de bits A[0 .. k−1]. O contador representa o número A[0]⋅20 + A[1]⋅21 + ⋯ + A[k−1]⋅2k−1.

k−1           2 1 0
0 1 0 1 0 1 1 1 1

A seguinte operação soma 1, em aritmética mod k, ao contador:

Operação (A)
1 . i := 0
2 . enquanto i < k e A[i] = 1
3 .ooo A[i] := 0
4 .ooo i := i + 1
5 . se i < k
6 .ooo A[i] := 1

(Estou tratando k como variável global.) Suponha que o custo da Operação é igual ao número de alterações de bits, ou seja, ao número de atribuições A[i] := * nas linhas 3 e 6. Cada execução da Operação altera 1 bit no melhor caso e k bits no pior.

Agora imagine uma sequência de n execuções da Operação, começando com o contador zerado:

11 . para i := 0 até k−1
12 .ooo A[i] := 0
13 . para j := 1 até n
14 .ooo Operação (A)

Qual o custo total da execução das linhas 13 a 14? No pior caso, cada execução da linha 14 produz k alterações de bits e portanto as n execuções da Operação têm custo nk no pior caso. Essa cota superior é correta mas exagerada, como veremos a seguir.

O bit A[0] é alterado em todas as execuções da Operação. O bit A[1] é alterado a cada duas execuções, ou seja, apenas n/2⌋ vezes. O bit A[2] é alterado apenas ⌊n/22⌋ vezes. O bit A[i] é alterado apenas ⌊n/2i⌋ vezes. Portanto, o custo total é menor que n + n/2 + n/22 + ⋯ + n/2i + ⋯   Essa soma não passa de

2n.

Segue daí que o custo amortizado de cada execução da Operação não passa de 2.

Metáfora contábil.  É interessante refazer as contas usando uma metáfora contábil (= accounting method). Digamos que cada alteração de um bit custa uma moeda. Antes de cada execução da Operação, o contador possui um certo estoque de moedas (acumulado ao longo das iterações anteriores). Para dar início a uma execução da Operação, é preciso acrescentar duas moedas ao estoque. Durante a execução, as atribuição nas linhas 3 e 6 consomem parte do estoque deixando um saldo que, como veremos, nunca é negativo. (A ideia é que a primeira das duas moedas seja usada para custear a atribuição A[i] := 1 na linha 6 e a segunda seja usada futuramente para custear a atribuição A[i] := 0 na linha 3.)

Graças a esse protocolo, temos o seguinte invariante do processo iterativo: antes de cada execução da Operação,

o número de moedas em estoque é igual ao número de bits de A que têm valor 1.

Prova: É evidente que a propriedade vale antes da primeira execução da Operação. Suponha agora que ela vale antes de uma execução qualquer da Operação. Nesse momento, p bits menos significativos valem 1 e o p+1-ésimo bit vale 0. O invariante garante que temos pelo menos p moedas em estoque. Além disso, duas novas moedas são acrescentadas ao estoque. O bloco de linhas 2 a 4 da Operação consome p moedas do estoque para atribuir 0 aos p bits menos significativos. Depois, o p+1-ésimo bit assume valor 1 na linha 6 e assim consome uma das moedas novas. Assim, o número total de moedas em estoque continua igual ao número de bits 1 e a propriedade continua válida no fim desta execução da Operação. Isso encerra a prova, CQD.

O invariante mostra que o estoque de moedas é sempre suficiente para custear as alterações de bits na linha 3. Concluímos assim que a sequência de n execuções da Operação consome apenas

2n  moedas.

Podemos dizer, um pouco mais vagamente, que o custo das n execuções da Operação é Ο(n). Essa conclusão pode ser resumida dizendo que, no sentido amortizado, cada execução da Operação na linha 14 tem custo  Ο(1) , ou seja, um custo que não depende de k nem de n.

Outros exemplos

O conceito de custo amortizado é útil para exprimir o consumo de tempo de vários algoritmos: