Por que o pior caso para essa função O (n ^ 2)?

44

Estou tentando me ensinar como calcular a notação BigO para uma função arbitrária. Encontrei essa função em um livro didático. O livro afirma que a função é O (n 2 ). Dá uma explicação do porquê disso, mas estou lutando para segui-lo. Gostaria de saber se alguém pode me mostrar a matemática por trás disso. Fundamentalmente, entendo que é algo menor que O (n 3 ), mas não consegui pousar independentemente em O (n 2 )

Suponha que recebamos três seqüências de números, A, B e C. Vamos assumir que nenhuma sequência individual contém valores duplicados, mas que pode haver alguns números que estão em duas ou três das seqüências. O problema da disjunção de três vias é determinar se a interseção das três seqüências está vazia, a saber, que não há elemento x tal que x ∈ A, x ∈ B e x ∈ C.

Aliás, isso não é um problema de lição de casa para mim - esse navio navegou anos atrás:), apenas eu tentando ficar mais esperto.

def disjoint(A, B, C):
        """Return True if there is no element common to all three lists."""  
        for a in A:
            for b in B:
                if a == b: # only check C if we found match from A and B
                   for c in C:
                       if a == c # (and thus a == b == c)
                           return False # we found a common value
        return True # if we reach this, sets are disjoint

[Editar] De acordo com o livro:

Na versão aprimorada, não é apenas uma questão de economizar tempo se tivermos sorte. Afirmamos que o pior caso de interrupção é O (n 2 ).

A explicação do livro, que luto para seguir, é a seguinte:

Para explicar o tempo de execução geral, examinamos o tempo gasto na execução de cada linha de código. O gerenciamento do loop for sobre A requer tempo O (n). O gerenciamento do loop for sobre B representa um total de tempo de O (n 2 ), uma vez que esse loop é executado n momentos diferentes. O teste a == b é avaliado O (n 2 ) vezes. O restante do tempo gasto depende de quantos pares correspondentes (a, b) existem. Como observamos, existem no máximo n pares desse tipo; portanto, o gerenciamento do loop sobre C e os comandos dentro do corpo desse loop usam no máximo o tempo O (n 2 ). O tempo total gasto é O (n 2 ).

(E para dar o devido crédito ...) O livro é: Estruturas de dados e algoritmos em Python por Michael T. Goodrich et. todos, Wiley Publishing, pág. 135

[Editar] Uma justificativa; Abaixo está o código antes da otimização:

def disjoint1(A, B, C):
    """Return True if there is no element common to all three lists."""
       for a in A:
           for b in B:
               for c in C:
                   if a == b == c:
                        return False # we found a common value
return True # if we reach this, sets are disjoint

No acima, você pode ver claramente que esse é O (n 3 ), porque cada loop deve ser executado ao máximo. O livro afirmaria que, no exemplo simplificado (dado primeiro), o terceiro loop é apenas uma complexidade de O (n 2 ); portanto, a equação de complexidade é como k + O (n 2 ) + O (n 2 ), que finalmente produz O (n 2 ).

Embora eu não possa provar que esse é o caso (portanto, a questão), o leitor pode concordar que a complexidade do algoritmo simplificado é pelo menos menor que o original.

[Edit] E para provar que a versão simplificada é quadrática:

if __name__ == '__main__':
    for c in [100, 200, 300, 400, 500]:
        l1, l2, l3 = get_random(c), get_random(c), get_random(c)
        start = time.time()
        disjoint1(l1, l2, l3)
        print(time.time() - start)
        start = time.time()
        disjoint2(l1, l2, l3)
        print(time.time() - start)

Rendimentos:

0.02684807777404785
0.00019478797912597656
0.19134306907653809
0.0007600784301757812
0.6405444145202637
0.0018095970153808594
1.4873297214508057
0.003167390823364258
2.953308343887329
0.004908084869384766

Como a segunda diferença é igual, a função simplificada é de fato quadrática:

insira a descrição da imagem aqui

[Editar] E ainda mais uma prova:

Se eu assumir o pior caso (A = B! = C),

if __name__ == '__main__':
    for c in [10, 20, 30, 40, 50]:
        l1, l2, l3 = range(0, c), range(0,c), range(5*c, 6*c)
        its1 = disjoint1(l1, l2, l3)
        its2 = disjoint2(l1, l2, l3)
        print(f"iterations1 = {its1}")
        print(f"iterations2 = {its2}")
        disjoint2(l1, l2, l3)

rendimentos:

iterations1 = 1000
iterations2 = 100
iterations1 = 8000
iterations2 = 400
iterations1 = 27000
iterations2 = 900
iterations1 = 64000
iterations2 = 1600
iterations1 = 125000
iterations2 = 2500

Usando o segundo teste de diferença, o pior resultado é exatamente quadrático.

insira a descrição da imagem aqui

SteveJ
fonte
6
O livro está errado ou a sua transcrição está.
candied_orange 8/09
6
Não. Errado está errado, independentemente de quão bem citado. Explique por que não podemos simplesmente assumir que esses ifs são da pior maneira possível quando fazem grandes análises de O ou aceitam os resultados que você está obtendo.
candied_orange 8/09
8
@candied_orange; Acrescentei mais uma justificativa para o melhor de minha capacidade - não para o meu forte. Peço que você permita novamente a possibilidade de estar de fato incorreto. Você fez o seu ponto, devidamente entendido.
SteveJ
8
Números aleatórios não são o seu pior caso. Isso não prova nada.
Telastyn
7
ahh OK. A "nenhuma sequência tem valores duplicados" muda o pior caso, pois C só pode ser acionado uma vez por cada A. Desculpe a frustração - é o que eu recebo por estar em uma
troca de pilha

Respostas:

63

O livro está realmente correto e fornece um bom argumento. Observe que os tempos não são um indicador confiável da complexidade algorítmica. Os tempos podem considerar apenas uma distribuição de dados especial ou os casos de teste podem ser muito pequenos: a complexidade algorítmica descreve apenas como o uso de recursos ou o tempo de execução são dimensionados para além de um tamanho de entrada adequadamente grande.

O livro argumenta que a complexidade é O (n²) porque o if a == bramo é inserido no máximo n vezes. Isso não é óbvio, porque os loops ainda são gravados como aninhados. É mais óbvio se extrairmos:

def disjoint(A, B, C):
  AB = (a
        for a in A
        for b in B
        if a == b)
  ABC = (a
         for a in AB
         for c in C
         if a == c)
  for a in ABC:
    return False
  return True

Essa variante usa geradores para representar resultados intermediários.

  • No gerador AB, teremos no máximo n elementos (por causa da garantia de que as listas de entrada não conterão duplicatas), e produzir o gerador leva a complexidade de O (n²).
  • A produção do gerador ABCenvolve um loop sobre o gerador ABde comprimento n e acima Cdo comprimento n , de modo que sua complexidade algorítmica também seja O (n²).
  • Essas operações não são aninhadas, mas ocorrem independentemente, de modo que a complexidade total é O (n² + n²) = O (n²).

Como os pares de listas de entrada podem ser verificados sequencialmente, segue-se que a determinação de qualquer número de listas disjuntas pode ser feita no tempo O (n²).

Essa análise é imprecisa porque supõe que todas as listas tenham o mesmo comprimento. Podemos dizer com mais precisão que ABtem no máximo comprimento min (| A |, | B |) e produzi-lo tem complexidade O (| A | • | B |). A produção ABCtem complexidade O (min (| A |, | B |) • | C |). A complexidade total depende então de como as listas de entrada são ordenadas. Com | A | ≤ | B | ≤ | C | obtemos total complexidade do pior caso de O (| A | • | C |).

Observe que ganhos de eficiência são possíveis se os contêineres de entrada permitirem testes rápidos de associação, em vez de ter que repetir todos os elementos. Esse pode ser o caso quando eles são classificados para que uma pesquisa binária possa ser feita ou quando são conjuntos de hash. Sem loops aninhados explícitos, isso seria semelhante a:

for a in A:
  if a in B:  # might implicitly loop
    if a in C:  # might implicitly loop
      return False
return True

ou na versão baseada em gerador:

AB = (a for a in A if a in B)
ABC = (a for a in AB if a in C)
for a in ABC:
  return False
return True
amon
fonte
4
Isso seria muito mais claro se abolíssemos essa nvariável mágica e conversássemos sobre as variáveis ​​reais em jogo.
Alexander
15
@code_dredd Não, não é, ele não tem conexão direta com o código. É uma abstração que prevê que len(a) == len(b) == len(c), apesar de verdadeira no contexto da análise da complexidade do tempo, tende a confundir a conversa.
Alexander
10
Talvez dizer que o código do OP tenha a pior complexidade O (| A | • | B | + min (| A |, | B |) • | C |) é suficiente para desencadear o entendimento?
Pablo H
3
Outra coisa sobre os testes de tempo: como você descobriu, eles não o ajudaram a entender o que estava acontecendo. Por outro lado, eles parecem ter dado a você mais confiança em enfrentar várias alegações incorretas, mas vigorosamente declaradas, de que o livro estava obviamente errado, então isso é uma coisa boa e, nesse caso, seu teste superou a mão-intuitiva. Para entender, uma maneira mais eficaz de testar seria executá-lo em um depurador com pontos de interrupção (ou adicionar impressões dos valores das variáveis) na entrada de cada loop.
sdenham 9/09
4
"Observe que os tempos não são um indicador útil da complexidade algorítmica." Acho que isso seria mais preciso se dissesse "rigoroso" ou "confiável", em vez de "útil".
Acumulação 9/09
7

Observe que, se todos os elementos forem diferentes em cada uma das listas assumidas, é possível iterar C apenas uma vez para cada elemento em A (se houver um elemento em B igual a). Portanto, o loop interno é O (n ^ 2) total

RiaD
fonte
3

Assumiremos que nenhuma sequência individual contém duplicado.

é uma informação muito importante.

Caso contrário, o pior caso da versão otimizada ainda seria O (n³), quando A e B são iguais e contêm um elemento duplicado n vezes:

i = 0
def disjoint(A, B, C):
    global i
    for a in A:
        for b in B:
            if a == b:
                for c in C:
                    i+=1
                    print(i)
                    if a == c:
                        return False 
    return True 

print(disjoint([1] * 10, [1] * 10, [2] * 10))

quais saídas:

...
...
...
993
994
995
996
997
998
999
1000
True

Então, basicamente, os autores assumem que o pior caso de O (n³) não deveria acontecer (por quê?) E "provam" que o pior caso é agora O (n²).

A otimização real seria usar conjuntos ou dictos para testar a inclusão em O (1). Nesse caso, disjointseria O (n) para cada entrada.

Eric Duminil
fonte
Seu último comentário é bastante interessante, não tinha pensado nisso. Você está sugerindo que isso se deve à sua capacidade de executar três operações O (n) em série?
SteveJ 9/09
2
A menos que você obtenha um hash perfeito com pelo menos um intervalo por elemento de entrada, não poderá testar a inclusão em O (1). Um conjunto classificado geralmente possui pesquisa O (log n). A menos que você esteja falando sobre custo médio, mas não é disso que se trata. Ainda assim, ter um conjunto binário equilibrado ficando difícil O (n log n) é trivial.
Jan Dorniak 9/09
@ JanDorniak: Excelente comentário, obrigado. Agora é um pouco estranho: eu ignorei o pior dos casos key in dict, exatamente como os autores. : - / Em minha defesa, acho muito mais difícil encontrar um ditado com nchaves e ncolisões de hash do que apenas criar uma lista com nvalores duplicados. E com um conjunto ou ditado, também não pode haver nenhum valor duplicado. Portanto, o pior caso é de fato O (n²). Vou atualizar minha resposta.
Eric Duminil 9/09
2
@ JanDorniak Eu acho que sets e dict são tabelas de hash em python, em oposição às árvores vermelho-pretas em C ++. Portanto, o pior caso absoluto é pior, até 0 (n) para uma pesquisa, mas o caso médio é O (1). Em oposição a O (log n) para C ++ wiki.python.org/moin/TimeComplexity . Dado que é uma pergunta python e que o domínio do problema leva a uma alta probabilidade de desempenho médio dos casos, não acho que a afirmação O (1) seja ruim.
Baldrickk 9/09
3
Acho que vejo a questão aqui: quando os autores dizem "assumiremos que nenhuma sequência individual contém valores duplicados", isso não é um passo para responder à pergunta; é antes uma condição prévia sob a qual a questão será abordada. Para fins pedagógicos, isso transforma um problema desinteressante em um que desafia as intuições das pessoas sobre o grande O - e parece ter sido bem-sucedido nisso, a julgar pelo número de pessoas que insistiram fortemente que O (n²) deve estar errado. .. Além disso, enquanto estiver discutido aqui, contar o número de etapas em um exemplo não é uma explicação.
sdenham 9/09
3

Para colocar as coisas nos termos que seu livro usa:

Eu acho que você não tem nenhum problema em entender que a verificação a == bé no pior caso O (n 2 ).

Agora, na pior das hipóteses, para o terceiro loop, cada ain Atem uma correspondência B, portanto o terceiro loop será chamado sempre. No caso em aque não existe C, ele será executado em todo o Cconjunto.

Em outras palavras, é 1 vez para todos ae 1 vez para todos c, ou n * n. O (n 2 )

Portanto, há O (n 2 ) + O (n 2 ) que seu livro aponta.

Marte
fonte
0

O truque do método otimizado é cortar custos. Somente se a e b corresponderem, c será valorizado. Agora você pode descobrir que, na pior das hipóteses, ainda teria que avaliar cada c. Isso não é verdade.

Você provavelmente acha que o pior caso é que toda verificação de a == b resulta em uma execução sobre C, porque toda verificação de a == b retorna uma correspondência. Mas isso não é possível porque as condições para isso são contraditórias. Para que isso funcione, você precisa de um A e um B que contenham os mesmos valores. Eles podem ser ordenados de maneira diferente, mas cada valor em A teria que ter um valor correspondente em B.

Agora aqui está o kicker. Não há como organizar esses valores para que, para cada a, você tenha que avaliar todos os b antes de encontrar sua correspondência.

A: 1 2 3 4 5
B: 1 2 3 4 5

Isso seria feito instantaneamente porque os 1s correspondentes são o primeiro elemento de ambas as séries. Sobre

A: 1 2 3 4 5
B: 5 4 3 2 1

Isso funcionaria para a primeira execução sobre A: apenas o último elemento em B produziria um acerto. Mas a próxima iteração sobre A já teria que ser mais rápida porque o último ponto em B já está ocupado por 1. E, de fato, isso levaria apenas quatro iterações dessa vez. E isso fica um pouco melhor a cada próxima iteração.

Agora eu não sou matemático, então não posso provar que isso acabará em O (n2), mas posso sentir isso nos meus tamancos.

Martin Maat
fonte
11
A ordem dos elementos não desempenha um papel aqui. O requisito significativo é que não haja duplicatas; o argumento é que os loops podem ser transformados em dois O(n^2)loops separados ; que fornece geral O(n^2)(as constantes são ignoradas).
AnoE 9/09
@AnoE De fato, a ordem dos elementos não importa. O que é exatamente o que estou demonstrando.
Martin Maat
Entendo o que você está tentando fazer e o que está escrevendo não está errado, mas da perspectiva do OP, sua resposta está mostrando principalmente por que uma linha de pensamento específica é irrelevante; não está explicando como chegar à solução real. O OP parece não dar uma indicação de que ele realmente acha que isso está relacionado à ordem. Portanto, não está claro para mim como essa resposta ajudaria o OP.
AnoE 9/09
-1

Fiquei perplexo no começo, mas a resposta de Amon é realmente útil. Quero ver se consigo fazer uma versão realmente concisa:

Para um determinado valor de ain A, a função se compara aa todos os bin possíveis Be o faz apenas uma vez. Portanto, para um dado, aele executa a == bexatamente o ntempo.

Bnão contém duplicatas (nenhuma das listas contém), portanto, para um dado a, haverá no máximo uma correspondência. (Essa é a chave). Onde houver uma correspondência, aserá comparado com todos os possíveis c, o que significa que a == cé realizado exatamente n vezes. Onde não há correspondência, a == cnão acontece.

Portanto, para um dado a, há ncomparações ou 2ncomparações. Isso acontece para todos a, portanto, o melhor caso possível é (n²) e o pior é (2n²).

TLDR: todo valor de aé comparado com todo valor de be contra todo valor de c, mas não contra toda combinação de be c. Os dois problemas se somam, mas não se multiplicam.

Douglas Winship
fonte
-3

Pense dessa maneira: alguns números podem estar em duas ou três das seqüências, mas o caso médio disso é que, para cada elemento do conjunto A, uma pesquisa exaustiva é realizada em b. É garantido que todos os elementos do conjunto A serão repetidos, mas está implícito que menos da metade dos elementos no conjunto b serão repetidos.

Quando os elementos do conjunto b são iterados, uma iteração acontece se houver uma correspondência. isso significa que o caso médio para essa função separada é O (n2), mas o pior caso absoluto para ela pode ser O (n3). Se o livro não entrar em detalhes, provavelmente daria a você um caso médio como resposta.

Eddie Ekpo
fonte
4
O livro é bastante claro que O (n2) é o pior caso, não o caso médio.
SteveJ 8/09
Uma descrição de uma função em termos de grande notação O geralmente fornece apenas um limite superior na taxa de crescimento da função. Associadas à grande notação O estão várias notações relacionadas, usando os símbolos o, Ω, ω e Θ, para descrever outros tipos de limites nas taxas de crescimento assintóticas. Wikipedia - Big O
candied_orange
5
"Se o livro não entrar em detalhes, provavelmente daria a você um caso médio como resposta". - Não. Sem nenhuma qualificação explícita, geralmente estamos falando sobre a complexidade da etapa do pior caso no modelo de RAM. Ao falar sobre operações em estruturas de dados, e é claro a partir do contexto, então nós pode estar falando sobre amortizados pior caso complexidade passo no modelo RAM. Sem qualificação explícita , geralmente não falaremos sobre o melhor caso, o caso médio, o caso esperado, a complexidade do tempo ou qualquer outro modelo, exceto a RAM.
Jörg W Mittag