Perché il caso peggiore per questa funzione O (n ^ 2)?


44

Sto cercando di insegnare a me stesso come calcolare la notazione BigO per una funzione arbitraria. Ho trovato questa funzione in un libro di testo. Il libro afferma che la funzione è O (n 2 ). Fornisce una spiegazione del perché, ma faccio fatica a seguirlo. Mi chiedo se qualcuno potrebbe essere in grado di mostrarmi la matematica dietro perché è così. Fondamentalmente, capisco che è qualcosa di meno di O (n 3 ), ma non potrei atterrare indipendentemente su O (n 2 )

Supponiamo che ci vengano fornite tre sequenze di numeri, A, B e C. Assumeremo che nessuna singola sequenza contenga valori duplicati, ma che ci possano essere alcuni numeri che sono in due o tre delle sequenze. Il problema della disgiunzione dell'insieme a tre vie consiste nel determinare se l'intersezione delle tre sequenze è vuota, vale a dire che non esiste alcun elemento x tale che x ∈ A, x ∈ B e x ∈ C.

Per inciso, questo non è un problema per me - quella nave ha navigato anni fa:), solo io sto cercando di diventare più intelligente.

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

[Modifica] Secondo il libro di testo:

Nella versione migliorata, non siamo semplicemente noi a risparmiare tempo se siamo fortunati. Sosteniamo che il tempo di esecuzione nel caso peggiore per disgiunto è O (n 2 ).

La spiegazione del libro, che faccio fatica a seguire, è questa:

Per tenere conto del tempo di esecuzione complessivo, esaminiamo il tempo impiegato per eseguire ciascuna riga di codice. La gestione del ciclo for over A richiede tempo O (n). La gestione del ciclo for over B tiene conto per un totale di O (n 2 ) tempi, poiché quel loop viene eseguito in tempi diversi. Il test a == b viene valutato O (n 2 ) volte. Il resto del tempo impiegato dipende da quante coppie corrispondenti (a, b) esistono. Come abbiamo notato, ci sono al massimo n coppie del genere, quindi la gestione del loop su C e i comandi all'interno del corpo di quel loop, usano al massimo O (n 2 ). Il tempo totale trascorso è O (n 2 ).

(E per dare il giusto credito ...) Il libro è: Strutture di dati e algoritmi in Python di Michael T. Goodrich et. tutti, Wiley Publishing, pag. 135

[Modifica] Una giustificazione; Di seguito è riportato il codice prima dell'ottimizzazione:

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

In quanto sopra, puoi vedere chiaramente che questo è O (n 3 ), perché ogni ciclo deve essere eseguito al massimo. Il libro affermerebbe che nell'esempio semplificato (dato prima), il terzo ciclo è solo una complessità di O (n 2 ), quindi l'equazione della complessità va come k + O (n 2 ) + O (n 2 ) che alla fine produce O (n 2 ).

Anche se non posso provare questo (quindi la domanda), il lettore può concordare sul fatto che la complessità dell'algoritmo semplificato è almeno inferiore a quella originale.

[Modifica] E per dimostrare che la versione semplificata è quadratica:

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)

I rendimenti:

0.02684807777404785
0.00019478797912597656
0.19134306907653809
0.0007600784301757812
0.6405444145202637
0.0018095970153808594
1.4873297214508057
0.003167390823364258
2.953308343887329
0.004908084869384766

Poiché la seconda differenza è uguale, la funzione semplificata è effettivamente quadratica:

inserisci qui la descrizione dell'immagine

[Modifica] E ancora ulteriori prove:

Se presumo il caso peggiore (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)

rendimenti:

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

Utilizzando il secondo test della differenza, il risultato peggiore è esattamente quadratico.

inserisci qui la descrizione dell'immagine


6
O il libro è sbagliato o la tua trascrizione lo è.
candied_orange

6
No. Sbagliato è sbagliato indipendentemente da quanto ben citato. Spiega perché non possiamo semplicemente assumerli se va nel peggiore dei modi quando fanno grandi analisi O o accettiamo i risultati che stai ottenendo.
candied_orange

8
@candied_orange; Ho aggiunto ulteriori giustificazioni al meglio delle mie capacità, non al mio seme forte. Vorrei chiederti di nuovo di consentire la possibilità che tu possa davvero essere errato. Hai espresso il tuo punto, debitamente preso.
SteveJ,

8
I numeri casuali non sono il caso peggiore. Ciò non dimostra nulla.
Telastyn,

7
ahh. va bene. La "nessuna sequenza ha valori duplicati" cambia il caso peggiore poiché C può innescarsi una sola volta per ogni A. Mi dispiace per la frustrazione - questo è quello che ottengo per essere su stackexchange in ritardo di sabato: D
Telastyn

Risposte:


63

Il libro è davvero corretto e fornisce una buona argomentazione. Si noti che i tempi non sono un indicatore affidabile della complessità algoritmica. I tempi potrebbero considerare solo una speciale distribuzione dei dati, oppure i casi di test potrebbero essere troppo piccoli: la complessità algoritmica descrive solo come l'utilizzo delle risorse o il tempo di esecuzione va oltre una dimensione di input adeguatamente grande.

Il libro sostiene che la complessità è O (n²) perché il if a == bramo viene inserito al massimo n volte. Questo non è ovvio perché i loop sono ancora scritti come nidificati. È più ovvio se lo estraiamo:

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

Questa variante utilizza generatori per rappresentare risultati intermedi.

  • Nel generatore ABavremo al massimo n elementi (a causa della garanzia che gli elenchi di input non contengano duplicati) e produrre il generatore richiede complessità O (n²).
  • La produzione del generatore ABCcomporta un ciclo sul generatore ABdi lunghezza n e Cdi lunghezza n , in modo che anche la sua complessità algoritmica sia O (n²).
  • Queste operazioni non sono nidificate ma avvengono in modo indipendente, quindi la complessità totale è O (n² + n²) = O (n²).

Poiché le coppie di elenchi di input possono essere controllate in sequenza, ne consegue che determinare se un numero qualsiasi di elenchi è disgiunto può essere eseguito in tempo O (n²).

Questa analisi è imprecisa perché presuppone che tutti gli elenchi abbiano la stessa lunghezza. Possiamo dire più precisamente che ABha al massimo lunghezza min (| A |, | B |) e la sua produzione ha complessità O (| A | • | B |). La produzione ABCha complessità O (min (| A |, | B |) • | C |). La complessità totale dipende quindi da come sono ordinati gli elenchi di input. Con | A | ≤ | B | ≤ | C | otteniamo la complessità totale nel caso peggiore di O (| A | • | C |).

Si noti che i guadagni di efficienza sono possibili se i contenitori di input consentono test di appartenenza rapidi anziché dover iterare su tutti gli elementi. Questo potrebbe essere il caso in cui sono ordinati in modo da poter effettuare una ricerca binaria o quando sono set di hash. Senza loop nidificati espliciti, questo apparirebbe come:

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

o nella versione basata su generatore:

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

4
Ciò sarebbe molto più chiaro se avessimo appena abolito questa nvariabile magica e avessimo parlato delle variabili reali in gioco.
Alexander

15
@code_dredd No, non lo è, non ha una connessione diretta al codice. È un'astrazione che prevede che len(a) == len(b) == len(c), sebbene sia vero nel contesto dell'analisi della complessità temporale, tende a confondere la conversazione.
Alexander,

10
Forse dire che il codice di OP presenta la complessità peggiore O (| A | • | B | + min (| A |, | B |) • | C |) è sufficiente per innescare la comprensione?
Pablo H,

3
Un'altra cosa sui test di cronometraggio: come hai scoperto, non ti hanno aiutato a capire cosa stava succedendo. D'altra parte, sembrano averti dato ulteriore fiducia nel resistere a varie affermazioni errate ma dichiarate con forza che il libro era ovviamente sbagliato, quindi è una buona cosa, e in questo caso, i tuoi test hanno battuto il gesto della mano intuitivo. Per capire, un modo più efficace di test sarebbe quello di eseguirlo in un debugger con punti di interruzione (o aggiungere stampe dei valori delle variabili) all'ingresso di ciascun ciclo.
sdenham,

4
"Si noti che i tempi non sono un utile indicatore della complessità algoritmica." Penso che questo sarebbe più accurato se dicesse "rigoroso" o "affidabile" piuttosto che "utile".
Accumulo

7

Si noti che se tutti gli elementi sono diversi in ciascuno dell'elenco che si presume, è possibile ripetere C una sola volta per ogni elemento in A (se c'è un elemento in B che è uguale). Quindi il ciclo interno è O (n ^ 2) totale


3

Supponiamo che nessuna sequenza individuale contenga duplicati.

è un'informazione molto importante.

Altrimenti, il caso peggiore della versione ottimizzata sarebbe comunque O (n³), quando A e B sono uguali e contengono un elemento duplicato n volte:

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))

che produce:

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

Quindi, in sostanza, gli autori presumono che il caso peggiore O (n³) non dovrebbe accadere (perché?), E "dimostrano" che il caso peggiore è ora O (n²).

La vera ottimizzazione sarebbe quella di utilizzare set o dicts per testare l'inclusione in O (1). In tal caso, disjointsarebbe O (n) per ogni input.


Il tuo ultimo commento è piuttosto interessante, non ci avevo pensato. Stai suggerendo che è dovuto alla tua capacità di eseguire tre operazioni O (n) in serie?
SteveJ,

2
A meno che non si ottenga un hash perfetto con almeno un bucket per elemento di input, non è possibile verificare l'inclusione in O (1). Un set ordinato di solito ha una ricerca O (log n). A meno che tu non stia parlando del costo medio, ma non è questo il problema. Tuttavia, avere un set binario bilanciato che diventa difficile O (n log n) è banale.
Jan Dorniak,

@JanDorniak: ottimo commento, grazie. Ora è un po 'imbarazzante: ho ignorato il caso peggiore key in dict, proprio come hanno fatto gli autori. : - / A mio avviso, penso che sia molto più difficile trovare un dict con le nchiavi e nle collisioni di hash che semplicemente creare un elenco con nvalori duplicati. E con un set o un dict, in realtà non può esserci alcun valore duplicato. Quindi il caso peggiore è effettivamente O (n²). Aggiornerò la mia risposta.
Eric Duminil,

2
@JanDorniak Penso che set e dicts siano tabelle hash in Python rispetto agli alberi rosso-neri in C ++. Quindi il caso peggiore in assoluto è peggiore, fino a 0 (n) per una ricerca, ma il caso medio è O (1). A differenza di O (log n) per C ++ wiki.python.org/moin/TimeComplexity . Dato che si tratta di una domanda su Python e che il dominio del problema porta a un'alta probabilità di prestazioni medie del caso, non credo che l'affermazione O (1) sia scadente.
Baldrickk,

3
Penso di vedere il problema qui: quando gli autori dicono "supponiamo che nessuna sequenza individuale contenga valori duplicati", questo non è un passo nel rispondere alla domanda; è piuttosto una condizione preliminare in base alla quale verrà affrontata la questione. A scopi pedagogici, questo trasforma un problema poco interessante in uno che sfida le intuizioni delle persone riguardo alla grande O - e sembra aver avuto successo in questo, a giudicare dal numero di persone che hanno fortemente insistito sul fatto che O (n²) deve essere sbagliato. .. Inoltre, mentre è discutibile qui, contare il numero di passaggi in un esempio non è una spiegazione.
sdenham,

3

Per mettere le cose nei termini che il tuo libro usa:

Penso che tu non abbia problemi a capire che il controllo per il a == bcaso peggiore è O (n 2 ).

Nel peggiore dei casi per il terzo loop, ogni ain Aha una corrispondenza in B, quindi il terzo loop verrà chiamato ogni volta. Nel caso in cui anon esista C, verrà eseguito l'intero Cset.

In altre parole, è 1 volta per ogni ae 1 volta per ogni c, o n * n. O (n 2 )

Quindi c'è O (n 2 ) + O (n 2 ) che il tuo libro sottolinea.


0

Il trucco del metodo ottimizzato è tagliare gli angoli. Solo se aeb corrispondono, c sarà dato la pena dare un'occhiata. Ora potresti immaginare che nel caso peggiore dovresti comunque valutare ogni c. Questo non è vero.

Probabilmente pensi che il caso peggiore sia che ogni controllo per a == b si traduca in una corsa su C perché ogni controllo per a == b restituisce una corrispondenza. Ma questo non è possibile perché le condizioni per questo sono contraddittorie. Affinché ciò funzioni, avrai bisogno di una A e una B che contengano gli stessi valori. Possono essere ordinati diversamente ma ogni valore in A dovrebbe avere un valore corrispondente in B.

Ora ecco il kicker. Non c'è modo di organizzare questi valori in modo tale che per ciascuno a sia necessario valutare tutte le b prima di trovare la corrispondenza.

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

Questo sarebbe fatto istantaneamente perché gli 1 corrispondenti sono il primo elemento di entrambe le serie. Che dire

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

Funzionerebbe per il primo passaggio su A: solo l'ultimo elemento in B produrrebbe un colpo. Ma la prossima iterazione su A dovrebbe già essere più veloce perché l'ultimo punto in B è già occupato da 1. E in effetti questa volta occorrerebbero solo quattro iterazioni. E questo migliora un po 'con ogni iterazione successiva.

Ora non sono un matematico, quindi non posso provare che questo finirà in O (n2) ma lo sento sui miei zoccoli.


1
L'ordine degli elementi non ha un ruolo qui. Il requisito significativo è che non ci sono duplicati; l'argomento quindi è che i loop possono essere trasformati in due O(n^2)loop separati ; che dà in generale O(n^2)(le costanti vengono ignorate).
AnoE

@AnoE In effetti, l'ordine degli elementi non ha importanza. È esattamente ciò che sto dimostrando.
Martin Maat,

Vedo cosa stai cercando di fare e ciò che stai scrivendo non è sbagliato, ma dal punto di vista di OP, la tua risposta mostra principalmente perché un particolare treno di pensieri è irrilevante; non sta spiegando come arrivare alla soluzione effettiva. L'OP non sembra dare un'indicazione del fatto che pensa che ciò sia correlato all'ordine. Quindi non mi è chiaro come questa risposta possa aiutare l'OP.
AnoE

-1

All'inizio fu sconcertato, ma la risposta di Amon è davvero utile. Voglio vedere se riesco a fare una versione davvero concisa:

Per un dato valore di ain A, la funzione si confronta acon ogni possibile bin Be lo fa solo una volta. Quindi per un dato asi esibisce a == besattamente nvolte.

Bnon contiene duplicati (nessuna delle liste lo fa), quindi per un dato aci sarà al massimo una corrispondenza. (Questa è la chiave). Dove c'è una partita, averrà confrontato con ogni possibile c, il che significa che a == cviene eseguito esattamente n volte. Dove non c'è partita, a == cnon succede affatto.

Quindi, per un dato dato a, ci sono nconfronti o 2nconfronti. Questo accade per ogni a, quindi il caso migliore è (n²) e il peggio è (2n²).

TLDR: ogni valore di aviene confrontato con ogni valore di be contro ogni valore di c, ma non con ogni combinazione di be c. I due problemi si sommano, ma non si moltiplicano.


-3

Pensaci in questo modo, alcuni numeri possono essere in due o tre delle sequenze, ma il caso medio di questo è che per ogni elemento dell'insieme A, una ricerca esaustiva viene eseguita in b. È garantito che ogni elemento nel set A verrà ripetuto ma implica che meno della metà degli elementi nel set b verrà ripetuta.

Quando gli elementi nel set b vengono ripetuti, si verifica un'iterazione in caso di corrispondenza. ciò significa che il caso medio per questa funzione disgiunta è O (n2) ma il caso peggiore assoluto per essa potrebbe essere O (n3). Se il libro non fosse entrato nei dettagli, probabilmente ti darebbe un caso medio come risposta.


4
Il libro è abbastanza chiaro che O (n2) è il caso peggiore, non il caso medio.
SteveJ,

Una descrizione di una funzione in termini di notazione O grande di solito fornisce solo un limite superiore al tasso di crescita della funzione. Associate alla notazione O grande sono diverse notazioni correlate, usando i simboli o, Ω, ω e Θ, per descrivere altri tipi di limiti sui tassi di crescita asintotici. Wikipedia - Big O
candied_orange

5
"Se il libro non fosse entrato nei dettagli, probabilmente ti darebbe un caso medio come risposta." - Uhm, no. Senza alcuna qualifica esplicita, di solito stiamo parlando della complessità del passo peggiore nel modello RAM. Quando si parla di operazioni su strutture di dati, ed è chiaro dal contesto, allora si potrebbe parlare della complessità del passo peggiore ammortizzata nel modello RAM. Senza una qualifica esplicita , generalmente non parleremo del caso migliore, del caso medio, del caso previsto, della complessità temporale o di qualsiasi altro modello tranne la RAM.
Jörg W Mittag
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.