Existe um sistema por trás da magia da análise de algoritmos?


159

Há muitas perguntas sobre como analisar o tempo de execução de algoritmos (ver, por exemplo, e ). Muitos são semelhantes, por exemplo, aqueles que solicitam uma análise de custos de loops aninhados ou algoritmos de dividir e conquistar, mas a maioria das respostas parece ser feita sob medida.

Por outro lado, as respostas para outra pergunta geral explicam o quadro geral (em particular a análise assintótica) com alguns exemplos, mas não como sujar as mãos.

Existe um método geral estruturado para analisar o custo dos algoritmos? O custo pode ser o tempo de execução (complexidade do tempo) ou alguma outra medida de custo, como o número de comparações executadas, a complexidade do espaço ou qualquer outra coisa.

Isso deveria se tornar uma pergunta de referência que pode ser usada para apontar iniciantes; daí seu escopo mais amplo que o usual. Tome cuidado para fornecer respostas gerais, apresentadas didaticamente, ilustradas por pelo menos um exemplo, mas, no entanto, abrangem muitas situações. Obrigado!


3
Agradeço ao (s) autor (es) do StackEdit por facilitar a escrita de postagens tão longas, e meus leitores beta FrankW , Juho , Gilles e Sebastian por me ajudarem a resolver uma série de falhas que os rascunhos anteriores tinham.
Raphael

1
Hey @Raphael, isso é uma coisa maravilhosa. Eu pensei em sugerir a criação de um PDF para circular? Esse tipo de coisa pode se tornar uma referência realmente útil.
hadsed

1
@ hadsed: Obrigado, fico feliz que seja útil para você! Por enquanto, prefiro que circule um link para este post. No entanto, o conteúdo do usuário do SE é "licenciado sob o cc by-sa 3.0 com atribuição necessária" (consulte o rodapé da página) para que qualquer pessoa possa criar um PDF a partir dele, desde que seja atribuída.
Raphael

2
Eu não sou especialmente competente nisso, mas é normal que não haja referência ao teorema do Mestre em nenhuma resposta?
21714

1
@ababou Eu não sabia o que "normal" significa aqui. Do meu ponto de vista, o teorema do mestre não tem nada a ver: trata-se de analisar algoritmos, o teorema do mestre é uma ferramenta muito específica para resolver (algumas) recorrências (e mais ou menos nisso). Como a matemática foi abordada em outro lugar (por exemplo, aqui ), optei por cobrir apenas a parte do algoritmo à matemática aqui. Faço referências a posts que lidam com o trabalho da matemática em minha resposta.
Raphael

Respostas:


134

Traduzindo código para matemática

Dada uma semântica operacional (mais ou menos) formal , você pode traduzir o código (pseudo-) de um algoritmo literalmente em uma expressão matemática que fornece o resultado, desde que você possa manipular a expressão em uma forma útil. Isso funciona bem para aditivos medidas custo, tais como o número de comparações, swaps, declarações, acessos à memória, ciclos algumas necessidades máquina abstrata, e assim por diante.

Exemplo: Comparações no Bubblesort

Considere este algoritmo que classifica uma determinada matriz A:

 bubblesort(A) do                   1
  n = A.length;                     2
  for ( i = 0 to n-2 ) do           3
    for ( j = 0 to n-i-2 ) do       4
      if ( A[j] > A[j+1] ) then     5
        tmp    = A[j];              6
        A[j]   = A[j+1];            7
        A[j+1] = tmp;               8
      end                           9
    end                             10
  end                               11
end                                 12

Digamos que queremos realizar a análise usual do algoritmo de classificação, que é contar o número de comparações de elementos (linha 5). Notamos imediatamente que essa quantidade não depende do conteúdo da matriz A, apenas do seu comprimento . Portanto, podemos traduzir os loops (aninhados) literalmente em somas (aninhadas); a variável de loop se torna a variável de soma e o intervalo é transferido. Nós temos:nfor

Ccmp(n)=i=0n2j=0ni21==n(n1)2=(n2) ,

onde é o custo para cada execução da linha 5 (que contamos).1

Exemplo: Swaps no Bubblesort

por o subprograma que consiste em linhas para e por os custos para executar esse subprograma (uma vez).Pi,jijCi,j

Agora, digamos que queremos contar swaps , é com que frequência é executado. Este é um "bloco básico", que é um subprograma que é sempre executado atomicamente e tem algum custo constante (aqui, ). A contratação de tais blocos é uma simplificação útil que geralmente aplicamos sem pensar ou falar sobre isso.P6,81

Com uma tradução semelhante à anterior, chegamos à seguinte fórmula:

Cswaps(A)=i=0n2j=0ni2C5,9(A(i,j)) .

A(i,j) indica o estado da matriz antes da -ésima iteração de .(i,j)P5,9

Observe que eu uso vez de como parâmetro; em breve veremos o porquê. Eu não adiciono e como parâmetros de já que os custos não dependem deles aqui (no modelo de custo uniforme , que é); em geral, eles apenas podem.AnijC5,9

Claramente, os custos de dependem do conteúdo de (os valores e , especificamente), portanto, temos que prestar contas disso. Agora enfrentamos um desafio: como "desembrulhamos" ? Bem, podemos deixar explícita a dependência do conteúdo de :P5,9AA[j]A[j+1]C5,9A

C5,9(A(i,j))=C5(A(i,j))+{1,A(i,j)[j]>A(i,j)[j+1]0,else .

Para qualquer matriz de entrada, esses custos são bem definidos, mas queremos uma declaração mais geral; precisamos fazer suposições mais fortes. Vamos investigar três casos típicos.

  1. O pior caso

    Apenas olhando a soma e observando que , podemos encontrar um limite superior trivial para o custo:C5,9(A(i,j)){0,1}

    Cswaps(A)i=0n2j=0ni21=n(n1)2=(n2) .

    Mas isso pode acontecer , ou seja, existe um para esse limite superior ser alcançado? Como se vê, sim: se introduzirmos uma matriz inversamente classificada de elementos distintos em pares, toda iteração deve executar uma troca¹. Portanto, derivamos o número exato de piores casos de trocas de Bubblesort.A

  2. O melhor caso

    Por outro lado, há um limite inferior trivial:

    Cswaps(A)i=0n2j=0ni20=0 .

    Isso também pode acontecer: em uma matriz que já está classificada, o Bubblesort não executa uma única troca.

  3. O caso médio

    O pior e o melhor dos casos abrem uma lacuna. Mas qual é o número típico de swaps? Para responder a essa pergunta, precisamos definir o que "típico" significa. Em teoria, não temos motivos para preferir uma entrada a outra e, portanto, geralmente assumimos uma distribuição uniforme entre todas as entradas possíveis, ou seja, todas as entradas são igualmente prováveis. Nós nos restringimos a matrizes com elementos distintos aos pares e, portanto, assumimos o modelo de permutação aleatória .

    Em seguida, podemos reescrever nossos custos dessa maneira²:

    E[Cswaps]=1n!Ai=0n2j=0ni2C5,9(A(i,j))

    Agora temos que ir além da simples manipulação de somas. Observando o algoritmo, observamos que toda troca remove exatamente uma inversão em (nós sempre trocamos os vizinhos³). Ou seja, o número de swaps realizadas em é exatamente o número de inversões de . Assim, podemos substituir as duas somas internas e obterAAinv(A)A

    E[Cswaps]=1n!Ainv(A) .

    Para nossa sorte, o número médio de inversões foi determinado como sendo

    E[Cswaps]=12(n2)

    qual é o nosso resultado final. Observe que esse é exatamente metade do custo do pior caso.


  1. Observe que o algoritmo foi cuidadosamente formulado para que "a última iteração" i = n-1do loop externo que nunca faz nada não seja executada.
  2. " " é uma notação matemática para "valor esperado", que aqui é apenas a média.E
  3. Aprendemos ao longo do caminho que nenhum algoritmo que apenas troca elementos vizinhos pode ser assintoticamente mais rápido que o Bubblesort (mesmo em média) - o número de inversões é um limite inferior para todos esses algoritmos. Isso se aplica a, por exemplo, Classificação de inserção e Classificação de seleção .

O método geral

Vimos no exemplo que temos que traduzir a estrutura de controle em matemática; Vou apresentar um conjunto típico de regras de tradução. Também vimos que o custo de qualquer subprograma pode depender do estado atual , que é (aproximadamente) os valores atuais das variáveis. Como o algoritmo (geralmente) modifica o estado, o método geral é um pouco complicado de anotar. Se você começar a se sentir confuso, sugiro que você volte ao exemplo ou crie o seu.

Denotamos com o estado atual (imagine-o como um conjunto de atribuições de variáveis). Quando executamos um programa iniciando no estado , acabamos no estado (fornecido termina).ψPψψ/PP

  • Declarações individuais

    Dada apenas uma declaração S;, você atribui a ela . Isso normalmente será uma função constante.CS(ψ)

  • Expressões

    Se você tiver uma expressão Eda forma E1 ∘ E2(por exemplo, uma expressão aritmética em que possa haver adição ou multiplicação, adicione custos recursivamente:

    CE(ψ)=c+CE1(ψ)+CE2(ψ) .

    Observe que

    • o custo de operação pode não ser constante, mas depende dos valores de e ecE1E2
    • avaliação de expressões pode mudar o estado em vários idiomas,

    então você pode ter que ser flexível com esta regra.

  • Seqüência

    Dado um programa Pcomo sequência de programas Q;R, você adiciona os custos ao

    CP(ψ)=CQ(ψ)+CR(ψ/Q) .

  • Condicionais

    Dado um programa Pdo formulário if A then Q else R end, os custos dependem do estado:

    CP(ψ)=CA(ψ)+{CQ(ψ/A),A evaluates to true under ψCR(ψ/A),else

    Em geral, a avaliação Apode muito bem mudar o estado, daí a atualização para os custos de cada filial.

  • For-Loops

    Dado um programa Pdo formulário for x = [x1, ..., xk] do Q end, atribua custos

    CP(ψ)=cinit_for+i=1kcstep_for+CQ(ψi{x:=xi})

    onde é o estado antes do processamento para obter valor , ou seja, após a iteração ter sido definida como , ..., .ψiQxixx1xi-1

    Observe as constantes extras para manutenção de loop; a variável do loop deve ser criada ( ) e atribuída seus valores ( ). Isso é relevante, poiscinit_forcstep_for

    • computar o próximo xipode ser caro e
    • um forloop com corpo vazio (por exemplo, depois de simplificar em uma melhor configuração com um custo específico) não terá custo zero se executar iterações.
  • While-Loops

    Dado um programa Pdo formulário while A do Q end, atribua custos

    CP(ψ) =CA(ψ)+{0,A evaluates to false under ψCQ(ψ/A)+CP(ψ/A;Q), else

    Ao inspecionar o algoritmo, essa recorrência costuma ser bem representada como uma soma semelhante à dos for-loops.

    Exemplo: considere este pequeno algoritmo:

    while x > 0 do    1
      i += 1          2
      x = x/2         3
    end               4
    

    Ao aplicar a regra, obtemos

    C1,4({i:=i0;x:=x0}) =c<+{0,x00c+=+c/+C1,4({i:=i0+1;x:=x0/2}), else

    com alguns custos constantes para as declarações individuais. Assumimos implicitamente que estes não dependem do estado (os valores de e ); isso pode ou não ser verdade na "realidade": pense em transbordamentos!cix

    Agora temos que resolver essa recorrência para . Observamos que nem o número de iterações, nem o custo do corpo do loop dependem do valor de , para que possamos eliminá-lo. Ficamos com esta recorrência:C1,4i

    C1,4(x)={c>,x0c>+c+=+c/+C1,4(x/2), else

    Isso resolve com meios elementares para

    C1,4(ψ)=log2ψ(x)(c>+c+=+c/)+c> ,

    reintroduzir o estado completo simbolicamente; se , então .ψ={,x:=5,}ψ(x)=5

  • Chamadas de procedimento

    Dado um programa Pdo formulário M(x)para alguns parâmetros em xque Mé um procedimento com o parâmetro (nomeado) p, atribua custos

    CP(ψ)=ccall+CM(ψglob{p:=x}) .

    Observe novamente a constante extra (que pode de fato depender de !). As chamadas de procedimento são caras devido à maneira como são implementadas em máquinas reais e às vezes dominam o tempo de execução (por exemplo, avaliando a recorrência do número de Fibonacci ingenuamente).ccallψ

    Descrevo alguns problemas semânticos que você possa ter com o estado aqui. Você desejará distinguir o estado global e o local para chamadas de procedimento. Vamos supor que passamos apenas para o estado global aqui e obtemos Mum novo estado local, inicializado definindo o valor de ppara x. Além disso, xpode ser uma expressão que (geralmente) supomos que seja avaliada antes de ser aprovada.

    Exemplo: considere o procedimento

    fac(n) do                  
      if ( n <= 1 ) do         1
        return 1               2
      else                     3
        return n * fac(n-1)    4
      end                      5
    end                        
    

    De acordo com as regras, obtemos:

    Cfac({n:=n0})=C1,5({n:=n0})=c+{C2({n:=n0}),n01C4({n:=n0}), else=c+{creturn,n01creturn+c+ccall+Cfac({n:=n01}), else

    Observe que desconsideramos o estado global, pois facclaramente não acessa nenhum. Essa recorrência específica é fácil de resolver para

    Cfac(ψ)=ψ(n)(c+creturn)+(ψ(n)1)(c+ccall)

Nós cobrimos os recursos de idioma que você encontrará no pseudo-código típico. Cuidado com os custos ocultos ao analisar pseudo-código de alto nível; em caso de dúvida, desdobre. A notação pode parecer complicada e certamente é uma questão de gosto; os conceitos listados não podem ser ignorados. No entanto, com alguma experiência, você poderá ver imediatamente quais partes do estado são relevantes para qual medida de custo, por exemplo, "tamanho do problema" ou "número de vértices". O resto pode ser descartado - isso simplifica significativamente as coisas!

Se você acha que agora isso é muito complicado, saiba: é ! Obter custos exatos de algoritmos em qualquer modelo que seja tão próximo de máquinas reais que permita previsões de tempo de execução (mesmo as relativas) é um esforço árduo. E isso nem sequer considera cache e outros efeitos desagradáveis ​​em máquinas reais.

Portanto, a análise de algoritmos é frequentemente simplificada a ponto de ser matematicamente tratável. Por exemplo, se você não precisar de custos exatos, poderá superestimar ou subestimar a qualquer momento (para limites superiores ou inferiores): reduzir o conjunto de constantes, livrar-se de condicionais, simplificar somas e assim por diante.

Uma nota sobre custo assintótico

O que você normalmente encontrará na literatura e nas redes é a "análise Big-Oh". O termo apropriado é análise assintótica , o que significa que, em vez de derivar custos exatos, como fizemos nos exemplos, você atribui os custos apenas a um fator constante e no limite (grosso modo, "para grandes ").n

Isso é (geralmente) justo, pois declarações abstratas têm alguns custos (geralmente desconhecidos) na realidade, dependendo da máquina, sistema operacional e outros fatores, e tempos de execução curtos podem ser dominados pelo sistema operacional que está configurando o processo em primeiro lugar e outros enfeites. Então você fica com alguma perturbação.

Aqui está como a análise assintótica se relaciona com essa abordagem.

  1. Identifique operações dominantes (que induzem custos), ou seja, operações que ocorrem com mais frequência (até fatores constantes). No exemplo do Bubblesort, uma opção possível é a comparação na linha 5.

    Como alternativa, vincule todas as constantes para operações elementares pelo respetivo máximo (de cima). mínimo (abaixo) e faça a análise usual.

  2. Execute a análise usando contagens de execução desta operação como custo.
  3. Ao simplificar, permita estimativas. Apenas permita estimativas de cima se seu objetivo for um limite superior ( ) resp. abaixo, se você quiser limites inferiores ( ).OΩ

Certifique-se de entender o significado dos símbolos Landau . Lembre-se de que esses limites existem para todos os três casos ; usar não implica uma análise do pior caso.O

Leitura adicional

Existem muitos outros desafios e truques na análise de algoritmos. Aqui estão algumas leituras recomendadas.

Existem muitas perguntas marcadas que usam técnicas semelhantes a essa.


1
talvez alguns exemplos de referência e para o teorema mestre (e suas extensões ) para análise assintótica
Nikos M.

@ NikosM Está fora do escopo aqui (veja também os comentários sobre a pergunta acima). Observe que eu vinculo ao nosso post de referência sobre a resolução de recorrências que apresenta o teorema do mestre et al.
Raphael

@ Nikos M: meus US $ 0,02: enquanto o teorema mestre funciona para várias recorrências, não para muitas outras; existem métodos padrão para resolver recorrências. E existem algoritmos para os quais nem teremos recorrência, fornecendo o tempo de execução; algumas técnicas avançadas de contagem podem ser necessárias. Para alguém com boa formação matemática, sugiro o excelente livro de Sedgewick e Flajolet, "Analysis of Algorithms", que possui capítulos como "relações de recorrência", "funções geradoras" e "aproximações assintóticas". As estruturas de dados aparecem como exemplos ocasionais, e o foco está nos métodos!
21716 Jay

@Raphael Não consigo encontrar nenhuma menção na Web para esse método "Traduzindo código para matemática" com base na semântica operacional. Você pode fornecer qualquer referência a livro, artigo ou artigo que lide com isso de maneira mais formal? Ou, no caso de isso ter sido desenvolvido por você, você tem algo mais profundo?
Wyvern666

1
@ Wyvern666 Infelizmente, não. Eu mesmo inventei, na medida em que alguém possa pretender inventar algo assim. Talvez eu mesmo escreva uma obra citável em algum momento. Dito isto, todo o corpus de trabalho em torno da combinatória analítica (Flajolet, Sedgewick e muitos outros) é a base disso. Eles não se incomodam com a semântica formal do "código" na maioria das vezes, mas fornecem a matemática para lidar com os custos aditivos dos "algoritmos" em geral. Sinceramente, acho que os conceitos apresentados aqui não são muito profundos - a matemática em que você pode se aprofundar é.
Raphael

29

Contagens de declarações de execução

Existe outro método, defendido por Donald E. Knuth em sua série The Art of Computer Programming . Em contraste com a tradução de todo o algoritmo em uma fórmula , ele funciona independentemente da semântica do código no lado "juntando as coisas" e permite ir para um nível mais baixo somente quando necessário, começando pela visualização "olho de águia". Cada declaração pode ser analisada independentemente do restante, levando a cálculos mais claros. No entanto, a técnica se presta bem a códigos bastante detalhados, e não a pseudo-códigos de nível superior.

O método

É bastante simples em princípio:

  1. Atribua a cada declaração um nome / número.
  2. Atribua cada instrução algum custo .SiCi
  3. Determine para cada instrução seu número de execuções .Siei
  4. Calcular custos totais

    C=ieiCi .

Você pode inserir estimativas e / ou quantidades simbólicas a qualquer momento, enfraquecendo a resp. generalizando o resultado de acordo.

Esteja ciente de que a etapa 3 pode ser arbitrariamente complexa. Geralmente é lá que você precisa trabalhar com estimativas (assintóticas) como " " para obter resultados.e77O(nlogn)

Exemplo: pesquisa em profundidade

Considere o seguinte algoritmo gráfico-transversal:

dfs(G, s) do
  // assert G.nodes contains s
  visited = new Array[G.nodes.size]     1
  dfs_h(G, s, visited)                  2
end 

dfs_h(G, s, visited) do
  foo(s)                                3
  visited[s] = true                     4

  v = G.neighbours(s)                   5
  while ( v != nil ) do                 6
    if ( !visited[v] ) then             7
      dfs_h(G, v, visited)              8
    end
    v = v.next                          9
  end
end

Assumimos que o gráfico (não direcionado) é fornecido por listas de adjacência nos nós . Seja o número de arestas.{0,,n1}m

Apenas olhando para o algoritmo, vemos que algumas instruções são executadas com a mesma frequência que outras. Introduzimos alguns espaços reservados , e para as contagens de execução :ABCei

i123456789eiAABBBB+CCB1C

Em particular, pois todas as chamadas recursivas na linha 8 causam uma chamada na linha 3 (e uma é causada pela chamada original de ). Além disso, porque a condição deve ser verificada uma vez por iteração, mas novamente para que seja deixada.e8=e31foodfse6=e5+e7while

É claro que . Agora, durante uma prova de correção, mostraríamos que é executado exatamente uma vez por nó; isto é, . Porém, iteramos sobre todas as listas de adjacências exatamente uma vez e todas as arestas implicam duas entradas no total (uma para cada nó do incidente); obtemos iterações no total. Usando isso, derivamos a seguinte tabela:A=1fooB=nC=2m

i123456789ei11nnn2m+n2mn12m

Isso nos leva a custos totais de exatamente

C(n,m)=(C1+C2C8)+ n(C3+C4+C5+C6+C8)+ 2m(C6+C7+C9).

Instanciando valores adequados para o , podemos derivar custos mais concretos. Por exemplo, se quisermos contar acessos à memória (por palavra), usaríamosCi

i123456789Cin00110101

e pegue

Cmem(n,m)=3n+4m .

Leitura adicional

Veja no final da minha outra resposta .


8

A análise de algoritmos, como a prova de um teorema, é amplamente uma arte (por exemplo, existem programas simples (como o problema de Collatz ) que não sabemos como analisar). Podemos converter um problema de complexidade de algoritmo em um matemático, conforme respondido de maneira abrangente por Raphael , mas, a fim de expressar um limite ao custo de um algoritmo em termos de funções conhecidas, resta:

  1. Use técnicas que conhecemos das análises existentes, como encontrar limites com base nas recorrências que entendemos ou somar / integrais que podemos calcular.
  2. Mude o algoritmo para algo que sabemos analisar.
  3. Crie uma abordagem completamente nova.

1
Acho que não estou vendo como isso adiciona algo útil e novo, além de outras respostas. As técnicas já estão descritas em outras respostas. Isso me parece mais um comentário do que uma resposta à pergunta.
DW

1
Eu ouso dizer que as outras respostas provar que é não uma arte. Você pode não ser capaz de fazê-lo (isto é, a matemática), e alguma criatividade (sobre como aplicar a matemática conhecida) pode ser necessária, mesmo que você seja, mas isso é verdade para qualquer tarefa. Suponho que não aspiramos a criar nova matemática aqui. (De fato, esta pergunta, e suas respostas, destinavam-se a desmistificar todo o processo.)
Raphael

4
@Raphael Ari está falando sobre criar uma função reconhecível como limite, em vez de “o número de instruções executadas pelo programa” (que é o que sua resposta aborda). O caso geral é uma arte - não há algoritmo que possa criar um limite não trivial para todos os algoritmos. O caso comum, no entanto, é um conjunto de técnicas conhecidas (como o teorema do mestre).
Gilles

@Gilles Se tudo para o qual não existe algoritmo for uma arte, os artesãos (em particular os programadores) receberão um pagamento pior.
Raphael

1
O @AriTrachlenberg faz uma observação importante, porém, existem inúmeras maneiras de avaliar a complexidade de tempo de um algoritmo. As próprias definições da notação Big O sugerem ou afirmam diretamente sua natureza teórica, dependendo do autor. O "pior cenário" claramente deixa espaço aberto para conjecturas e / ou fatos novos entre qualquer um que dê N às pessoas na sala discutindo. Sem mencionar que a própria natureza das estimativas assintóticas é algo ... bem inexato.
Brian Ogden
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.