Grande O, come lo calcoli / approssimalo?


881

La maggior parte delle persone con una laurea in CS certamente sapere cosa Big O sta per . Ci aiuta a misurare la scala di un algoritmo.

Ma io sono curioso, come si fa si calcolare o approssimare la complessità degli algoritmi?


4
Forse non hai davvero bisogno di migliorare la complessità del tuo algoritmo, ma dovresti almeno essere in grado di calcolarlo per decidere ...
Xavier Nodet,

5
Ho trovato questa una spiegazione molto chiara di Big O, Big Omega e Big Theta: xoax.net/comp/sci/algorithms/Lesson6.php
Sam Dutton,

33
-1: Sospiro, un altro abuso di BigOh. BigOh è solo un limite superiore asintotico e potrebbe essere utilizzato per qualsiasi cosa e non è solo correlato a CS. Parlare di BigOh come se ci fosse un unico non ha senso (un algoritmo di tempo lineare è anche O (n ^ 2), O (n ^ 3) ecc.). Dire che ci aiuta a misurare l' efficienza è anche fuorviante. Inoltre, che cos'è il collegamento alle classi di complessità? Se tutto ciò che ti interessa, sono le tecniche per calcolare i tempi di esecuzione degli algoritmi, come è rilevante?

34
Big-O non misura l'efficienza; misura quanto bene un algoritmo si ridimensiona con le dimensioni (potrebbe applicarsi anche ad altre cose oltre alle dimensioni, ma questo è ciò che probabilmente ci interessa qui) - e questo solo asintoticamente, quindi se sei sfortunato un algoritmo con un "piccolo" grande- O può essere più lento (se il Big-O si applica ai cicli) di uno diverso fino a raggiungere numeri estremamente grandi.
ILoveFortran,

4
La scelta di un algoritmo sulla base della sua complessità Big-O è di solito una parte essenziale della progettazione del programma. E 'sicuramente non è un caso di 'ottimizzazione prematura', che in ogni caso è una citazione tanto abusato selettivo.
Marchese di Lorne,

Risposte:


1481

Farò del mio meglio per spiegarlo qui in termini semplici, ma ti avverto che questo argomento richiede ai miei studenti un paio di mesi per capire finalmente. È possibile trovare ulteriori informazioni sul capitolo 2 di Strutture di dati e algoritmi nel libro Java .


Non esiste una procedura meccanica che può essere utilizzata per ottenere BigOh.

Come "libro di cucina", per ottenere il BigOh da un pezzo di codice è necessario innanzitutto rendersi conto che si sta creando una formula matematica per contare quanti passaggi di calcoli vengono eseguiti con un input di qualche dimensione.

Lo scopo è semplice: confrontare gli algoritmi da un punto di vista teorico, senza la necessità di eseguire il codice. Minore è il numero di passaggi, più veloce è l'algoritmo.

Ad esempio, supponiamo che tu abbia questo pezzo di codice:

int sum(int* data, int N) {
    int result = 0;               // 1

    for (int i = 0; i < N; i++) { // 2
        result += data[i];        // 3
    }

    return result;                // 4
}

Questa funzione restituisce la somma di tutti gli elementi dell'array e vogliamo creare una formula per contare la complessità computazionale di quella funzione:

Number_Of_Steps = f(N)

Quindi abbiamo f(N)una funzione per contare il numero di passaggi computazionali. L'input della funzione è la dimensione della struttura da elaborare. Significa che questa funzione è chiamata come:

Number_Of_Steps = f(data.length)

Il parametro Naccetta il data.lengthvalore. Ora abbiamo bisogno della definizione effettiva della funzione f(). Questo viene fatto dal codice sorgente, in cui ogni riga interessante è numerata da 1 a 4.

Esistono molti modi per calcolare BigOh. Da questo punto in poi supponiamo che ogni frase che non dipende dalla dimensione dei dati di input comporti un Cnumero costante di passi computazionali.

Aggiungiamo il numero individuale di passaggi della funzione e né la dichiarazione della variabile locale né l'istruzione return dipendono dalla dimensione datadell'array.

Ciò significa che le righe 1 e 4 eseguono C quantità di passaggi ciascuna e la funzione è in qualche modo simile a questa:

f(N) = C + ??? + C

La parte successiva è definire il valore fordell'istruzione. Ricorda che stiamo contando il numero di passaggi computazionali, nel senso che il corpo fordell'istruzione viene eseguito i Ntempi. È lo stesso che aggiungere C, Nvolte:

f(N) = C + (C + C + ... + C) + C = C + N * C + C

Non esiste una regola meccanica per contare quante volte il corpo del forviene eseguito, è necessario contarlo osservando cosa fa il codice. Per semplificare i calcoli, stiamo ignorando l'inizializzazione della variabile, le condizioni e le parti di incremento fordell'istruzione.

Per ottenere il BigOh reale abbiamo bisogno dell'analisi asintotica della funzione. Questo è fatto approssimativamente in questo modo:

  1. Porta via tutte le costanti C.
  2. Da f()ottenere il polinomio nel suo standard form.
  3. Dividi i termini del polinomio e ordinali per il tasso di crescita.
  4. Mantieni quello che diventa più grande quando si Navvicina infinity.

Il nostro f()ha due termini:

f(N) = 2 * C * N ^ 0 + 1 * C * N ^ 1

Togliere tutte le Ccostanti e le parti ridondanti:

f(N) = 1 + N ^ 1

Poiché l'ultimo termine è quello che diventa più grande quando si f()avvicina all'infinito (pensa ai limiti ) questo è l'argomento BigOh e la sum()funzione ha un BigOh di:

O(N)

Ci sono alcuni trucchi per risolverne alcuni difficili: usa le somme ogni volta che puoi.

Ad esempio, questo codice può essere facilmente risolto usando le sommazioni:

for (i = 0; i < 2*n; i += 2) {  // 1
    for (j=n; j > i; j--) {     // 2
        foo();                  // 3
    }
}

La prima cosa che ti è stata chiesta è l'ordine di esecuzione di foo(). Mentre il solito deve essere O(1), devi chiedere ai tuoi professori a riguardo. O(1)significa (quasi, per lo più) costante C, indipendente dalle dimensioni N.

L' foraffermazione sulla frase numero uno è complicata. Mentre l'indice termina alle 2 * N, l'incremento viene fatto di due. Ciò significa che il primo forviene eseguito solo in Npassaggi e dobbiamo dividere il conteggio per due.

f(N) = Summation(i from 1 to 2 * N / 2)( ... ) = 
     = Summation(i from 1 to N)( ... )

La frase numero due è ancora più complicata poiché dipende dal valore di i. Dai un'occhiata: l'indice i prende i valori: 0, 2, 4, 6, 8, ..., 2 * N, e il secondo forviene eseguito: N volte il primo, N - 2 il secondo, N - 4 il terzo ... fino allo stadio N / 2, sul quale il secondo fornon viene mai eseguito.

Sulla formula, ciò significa:

f(N) = Summation(i from 1 to N)( Summation(j = ???)(  ) )

Ancora una volta, stiamo contando il numero di passaggi . E per definizione, ogni somma dovrebbe sempre iniziare da una e terminare con un numero maggiore o uguale a uno.

f(N) = Summation(i from 1 to N)( Summation(j = 1 to (N - (i - 1) * 2)( C ) )

(Stiamo supponendo che foo()sia O(1)e adotti Cprovvedimenti.)

Abbiamo un problema qui: quando iprende il valore N / 2 + 1verso l'alto, la Somma interna termina con un numero negativo! È impossibile e sbagliato. Dobbiamo dividere la somma di due, essendo il fulcro del momento irichiede N / 2 + 1.

f(N) = Summation(i from 1 to N / 2)( Summation(j = 1 to (N - (i - 1) * 2)) * ( C ) ) + Summation(i from 1 to N / 2) * ( C )

Dal momento cruciale i > N / 2, l'interno fornon verrà eseguito e stiamo assumendo una complessità di esecuzione C costante sul suo corpo.

Ora le somme possono essere semplificate usando alcune regole di identità:

  1. Somma (w da 1 a N) (C) = N * C
  2. Somma (w da 1 a N) (A (+/-) B) = Somma (w da 1 a N) (A) (+/-) Somma (w da 1 a N) (B)
  3. Somma (w da 1 a N) (w * C) = C * Somma (w da 1 a N) (w) (C è una costante, indipendente da w)
  4. Somma (w da 1 a N) (w) = (N * (N + 1)) / 2

Applicando un po 'di algebra:

f(N) = Summation(i from 1 to N / 2)( (N - (i - 1) * 2) * ( C ) ) + (N / 2)( C )

f(N) = C * Summation(i from 1 to N / 2)( (N - (i - 1) * 2)) + (N / 2)( C )

f(N) = C * (Summation(i from 1 to N / 2)( N ) - Summation(i from 1 to N / 2)( (i - 1) * 2)) + (N / 2)( C )

f(N) = C * (( N ^ 2 / 2 ) - 2 * Summation(i from 1 to N / 2)( i - 1 )) + (N / 2)( C )

=> Summation(i from 1 to N / 2)( i - 1 ) = Summation(i from 1 to N / 2 - 1)( i )

f(N) = C * (( N ^ 2 / 2 ) - 2 * Summation(i from 1 to N / 2 - 1)( i )) + (N / 2)( C )

f(N) = C * (( N ^ 2 / 2 ) - 2 * ( (N / 2 - 1) * (N / 2 - 1 + 1) / 2) ) + (N / 2)( C )

=> (N / 2 - 1) * (N / 2 - 1 + 1) / 2 = 

   (N / 2 - 1) * (N / 2) / 2 = 

   ((N ^ 2 / 4) - (N / 2)) / 2 = 

   (N ^ 2 / 8) - (N / 4)

f(N) = C * (( N ^ 2 / 2 ) - 2 * ( (N ^ 2 / 8) - (N / 4) )) + (N / 2)( C )

f(N) = C * (( N ^ 2 / 2 ) - ( (N ^ 2 / 4) - (N / 2) )) + (N / 2)( C )

f(N) = C * (( N ^ 2 / 2 ) - (N ^ 2 / 4) + (N / 2)) + (N / 2)( C )

f(N) = C * ( N ^ 2 / 4 ) + C * (N / 2) + C * (N / 2)

f(N) = C * ( N ^ 2 / 4 ) + 2 * C * (N / 2)

f(N) = C * ( N ^ 2 / 4 ) + C * N

f(N) = C * 1/4 * N ^ 2 + C * N

E il BigOh è:

O(N²)

6
@arthur Sarebbe O (N ^ 2) perché avresti bisogno di un ciclo per leggere tutte le colonne e uno per leggere tutte le righe di una particolare colonna.
Abhishek Dey Das,

@arthur: dipende. È O(n)dove nè il numero di elementi o O(x*y)dove xe ysono le dimensioni dell'array. Big-oh è "relativo all'input", quindi dipende da quale sia il tuo input.
Mooing Duck,

1
Ottima risposta, ma sono davvero bloccato. In che modo la somma (i da 1 a N / 2) (N) si trasforma in (N ^ 2/2)?
Parsa,

2
@ParsaAkbari Come regola generale, la somma (i da 1 a a) (b) è a * b. Questo è solo un altro modo di dire b + b + ... (a volte) + b = a * b (per definizione per alcune definizioni di moltiplicazione di numeri interi).
Mario Carneiro,

Non così rilevante, ma solo per evitare confusione, c'è un piccolo errore in questa frase: "l'indice i prende i valori: 0, 2, 4, 6, 8, ..., 2 * N". L'indice i in realtà sale a 2 * N - 2, quindi il ciclo si interromperà.
Albert,

201

Big O fornisce il limite superiore per la complessità temporale di un algoritmo. Di solito viene utilizzato insieme all'elaborazione di set di dati (elenchi) ma può essere utilizzato altrove.

Alcuni esempi di come viene utilizzato nel codice C.

Supponiamo di avere una matrice di n elementi

int array[n];

Se volessimo accedere al primo elemento dell'array questo sarebbe O (1) poiché non importa quanto sia grande l'array, ci vuole sempre lo stesso tempo costante per ottenere il primo elemento.

x = array[0];

Se volessimo trovare un numero nell'elenco:

for(int i = 0; i < n; i++){
    if(array[i] == numToFind){ return i; }
}

Questo sarebbe O (n) poiché al massimo dovremmo cercare in tutta la lista per trovare il nostro numero. Il Big-O è ancora O (n) anche se potremmo trovare il nostro numero il primo tentativo di eseguire il ciclo una volta perché Big-O descrive il limite superiore per un algoritmo (omega è per limite inferiore e theta è per limite stretto) .

Quando arriviamo ai loop nidificati:

for(int i = 0; i < n; i++){
    for(int j = i; j < n; j++){
        array[j] += 2;
    }
}

Questo è O (n ^ 2) poiché per ogni passaggio del ciclo esterno (O (n)) dobbiamo rivedere di nuovo l'intero elenco in modo che le n si moltiplichino lasciandoci con n al quadrato.

Questo sta a malapena graffiando la superficie, ma quando si arriva ad analizzare algoritmi più complessi entra in gioco la matematica complessa che coinvolge prove. Spero che questo ti familiarizzi almeno con le basi.


Grande spiegazione! Quindi, se qualcuno dice che il suo algoritmo ha una complessità O (n ^ 2), vuol dire che utilizzerà loop annidati?
Navaneeth KN,

2
Non proprio, qualsiasi aspetto che porti a n tempi quadrati sarà considerato come n ^ 2
asyncwait,

@NavaneethKN: non vedrai sempre il ciclo nidificato, poiché le chiamate di funzione possono> O(1)funzionare da sole. Nelle API standard C, ad esempio, bsearchè intrinsecamente O(log n), strlenè O(n)ed qsortè O(n log n)(tecnicamente non ha garanzie e lo stesso quicksort ha una complessità nel caso peggiore O(n²), ma supponendo che il tuo libcautore non sia un idiota, la sua complessità media è O(n log n)e usa una strategia di selezione pivot che riduce le probabilità di colpire il O(n²)caso). Ed entrambi bsearche qsortpuò essere peggio se la funzione di confronto è patologica.
ShadowRanger

95

Mentre sapere come capire il tempo di Big O per il tuo particolare problema è utile, conoscere alcuni casi generali può fare molto per aiutarti a prendere decisioni nel tuo algoritmo.

Ecco alcuni dei casi più comuni, tratti da http://en.wikipedia.org/wiki/Big_O_notation#Orders_of_common_functions :

O (1) - Determinare se un numero è pari o dispari; utilizzando una tabella di ricerca a dimensione costante o una tabella hash

O (logn): ricerca di un elemento in un array ordinato con una ricerca binaria

O (n): ricerca di un elemento in un elenco non ordinato; aggiungendo due numeri di n cifre

O (n 2 ) - Moltiplicare due numeri di n cifre per un semplice algoritmo; aggiunta di due matrici n × n; ordinamento a bolle o ordinamento per inserzione

O (n 3 ) - Moltiplicare due matrici n × n per semplice algoritmo

O (c n ) - Trovare la soluzione (esatta) al problema del commesso viaggiatore usando la programmazione dinamica; determinare se due affermazioni logiche sono equivalenti usando la forza bruta

O (n!) - Risolve il problema del venditore ambulante tramite la ricerca della forza bruta

O (n n ) - Spesso usato al posto di O (n!) Per derivare formule più semplici per la complessità asintotica


Perché non usare x&1==1per verificare la stranezza?
Samy Bencherif,

2
@SamyBencherif: sarebbe un modo tipico di verificare (in realtà, x & 1sarebbe sufficiente solo un test , non è necessario verificarlo == 1; in C, x&1==1viene valutato come x&(1==1) grazie alla precedenza dell'operatore , quindi in realtà è lo stesso del test x&1). Penso che tu stia leggendo male la risposta; c'è un punto e virgola lì, non una virgola. Non sta dicendo che avresti bisogno di una tabella di ricerca per i test pari / dispari, sta dicendo che entrambi i test pari / dispari e il controllo di una tabella di ricerca sono O(1)operazioni.
ShadowRanger

Non conosco l'affermazione sull'uso nell'ultima frase, ma chiunque lo faccia sostituisce una classe con un'altra non equivalente. La classe O (n!) Contiene, ma è strettamente più grande di O (n ^ n). L'equivalenza effettiva sarebbe O (n!) = O (n ^ ne ^ {- n} sqrt (n)).
condizionale

43

Piccolo promemoria: la big Onotazione viene utilizzata per indicare la complessità asintotica (cioè quando la dimensione del problema cresce all'infinito) e nasconde una costante.

Ciò significa che tra un algoritmo in O (n) e uno in O (n 2 ), il più veloce non è sempre il primo (anche se esiste sempre un valore di n tale che per problemi di dimensione> n, il primo algoritmo è il più veloce).

Nota che la costante nascosta dipende molto dall'implementazione!

Inoltre, in alcuni casi, il runtime non è una funzione deterministica della dimensione n dell'input. Prendi ad esempio l'ordinamento usando l'ordinamento rapido: il tempo necessario per ordinare un array di n elementi non è una costante ma dipende dalla configurazione iniziale dell'array.

Esistono diverse complessità temporali:

  • Caso peggiore (di solito il più semplice da capire, anche se non sempre molto significativo)
  • Caso medio (di solito molto più difficile da capire ...)

  • ...

Una buona introduzione è un'introduzione all'analisi degli algoritmi di R. Sedgewick e P. Flajolet.

Come dici tu premature optimisation is the root of all evil, e la profilazione (se possibile) in realtà dovrebbe sempre essere usata quando ottimizzi il codice. Può anche aiutarti a determinare la complessità dei tuoi algoritmi.


3
In matematica, O (.) Significa un limite superiore e theta (.) Significa che hai un limite sopra e sotto. La definizione è effettivamente diversa in CS o è solo un abuso comune della notazione? Secondo la definizione matematica, sqrt (n) è sia O (n) che O (n ^ 2), quindi non è sempre possibile che ci sia qualche n dopo la quale una funzione O (n) è più piccola.
Douglas Zare,

28

Vedendo le risposte qui, penso che possiamo concludere che la maggior parte di noi si avvicina effettivamente all'ordine dell'algoritmo osservandolo e usando il buon senso invece di calcolarlo con, ad esempio, il metodo master come si pensava all'università. Detto questo, devo aggiungere che anche il professore ci ha incoraggiato (in seguito) a pensarci effettivamente invece di calcolarlo.

Inoltre vorrei aggiungere come viene fatto per le funzioni ricorsive :

supponiamo di avere una funzione come ( codice schema ):

(define (fac n)
    (if (= n 0)
        1
            (* n (fac (- n 1)))))

che calcola ricorsivamente il fattoriale di un determinato numero.

Il primo passo è provare a determinare le caratteristiche prestazionali per il corpo della funzione solo in questo caso, non viene fatto nulla di speciale nel corpo, solo una moltiplicazione (o il ritorno del valore 1).

Quindi la prestazione per il corpo è: O (1) (costante).

Quindi provare e determinare questo per il numero di chiamate ricorsive . In questo caso abbiamo n-1 chiamate ricorsive.

Quindi la prestazione per le chiamate ricorsive è: O (n-1) (l'ordine è n, mentre buttiamo via le parti insignificanti).

Quindi unisci quei due e avrai le prestazioni per l'intera funzione ricorsiva:

1 * (n-1) = O (n)


Peter , per rispondere ai tuoi problemi sollevati; il metodo che descrivo qui lo gestisce abbastanza bene. Ma tieni presente che questa è ancora un'approssimazione e non una risposta matematicamente corretta. Il metodo qui descritto è anche uno dei metodi che ci hanno insegnato all'università e, se ricordo bene, è stato usato per algoritmi molto più avanzati rispetto al fattoriale che ho usato in questo esempio.
Naturalmente tutto dipende da quanto bene è possibile stimare il tempo di esecuzione del corpo della funzione e il numero di chiamate ricorsive, ma questo è altrettanto vero per gli altri metodi.


Sven, non sono sicuro che il tuo modo di giudicare la complessità di una funzione ricorsiva funzionerà per quelle più complesse, come fare una ricerca dall'alto / basso / sommatoria / qualcosa in un albero binario. Certo, potresti ragionare su un semplice esempio e trovare la risposta. Ma immagino che dovresti davvero fare un po 'di matematica per quelli ricorsivi?
Peteter,

3
+1 per la ricorsione ... Anche questo è bellissimo: "... anche il professore ci ha incoraggiato a pensare ..." :)
TT_

Sì, è così bello. Tendo a pensarlo in questo modo, più alto è il termine dentro O (..), più il lavoro che stai facendo / macchina sta facendo. Pensarlo in relazione a qualcosa potrebbe essere un'approssimazione, ma lo sono anche questi limiti. Ti dicono solo come aumenta il lavoro da fare quando aumenta il numero di input.
Abhinav Gauniyal,

26

Se il tuo costo è un polinomio, mantieni il termine di ordine più alto, senza il suo moltiplicatore. Per esempio:

O ((n / 2 + 1) * (n / 2)) = O (n 2 /4 + n / 2) = O (n 2 /4) = O (n 2 )

Questo non funziona per serie infinite, intendiamoci. Non esiste una ricetta unica per il caso generale, sebbene per alcuni casi comuni si applichino le seguenti disparità:

O (log N ) <O ( N ) <O ( N log N ) <O ( N 2 ) <O ( N k ) <O (e n ) <O ( n !)


8
sicuramente O (N) <O (NlogN)
jk.

22

Ci penso in termini di informazioni. Qualsiasi problema consiste nell'apprendimento di un certo numero di bit.

Il tuo strumento di base è il concetto di punti di decisione e la loro entropia. L'entropia di un punto di decisione è l'informazione media che ti darà. Ad esempio, se un programma contiene un punto di decisione con due rami, l'entropia è la somma della probabilità di ciascun ramo moltiplicata per il log 2 della probabilità inversa di quel ramo. Questo è quanto impari eseguendo quella decisione.

Ad esempio, ifun'istruzione con due rami, entrambi ugualmente probabili, ha un'entropia di 1/2 * log (2/1) + 1/2 * log (2/1) = 1/2 * 1 + 1/2 * 1 = 1. Quindi la sua entropia è di 1 bit.

Supponiamo di cercare una tabella di N articoli, come N = 1024. Questo è un problema a 10 bit perché log (1024) = 10 bit. Quindi, se riesci a cercarlo con le dichiarazioni IF che hanno risultati altrettanto probabili, dovrebbero prendere 10 decisioni.

Questo è ciò che ottieni con la ricerca binaria.

Supponiamo di fare una ricerca lineare. Guardi il primo elemento e chiedi se è quello che vuoi. Le probabilità sono 1/1024 che è e 1023/1024 che non lo è. L'entropia di tale decisione è 1/1024 * log (1024/1) + 1023/1024 * log (1024/1023) = 1/1024 * 10 + 1023/1024 * circa 0 = circa 0,01 bit. Hai imparato molto poco! La seconda decisione non è molto migliore. Ecco perché la ricerca lineare è così lenta. In effetti è esponenziale nel numero di bit che devi imparare.

Supponiamo che tu stia eseguendo l'indicizzazione. Supponiamo che la tabella sia preordinata in molti bin e che tu usi alcuni di tutti i bit della chiave per indicizzare direttamente la voce della tabella. Se ci sono 1024 bin, l'entropia è 1/1024 * log (1024) + 1/1024 * log (1024) + ... per tutti i 1024 possibili risultati. Questo è 1/1024 * 10 volte 1024 risultati, o 10 bit di entropia per quella operazione di indicizzazione. Ecco perché l'indicizzazione della ricerca è veloce.

Ora pensa all'ordinamento. Hai N articoli e hai un elenco. Per ogni elemento, devi cercare la posizione dell'elemento nell'elenco, quindi aggiungerlo all'elenco. Quindi l'ordinamento richiede circa N volte il numero di passaggi della ricerca sottostante.

Quindi, in base a decisioni binarie che hanno esiti approssimativamente ugualmente probabili, tutti prendono delle fasi O (N log N). Un algoritmo di ordinamento O (N) è possibile se si basa sulla ricerca di indicizzazione.

Ho scoperto che quasi tutti i problemi di prestazioni algoritmiche possono essere esaminati in questo modo.


Wow. Hai qualche riferimento utile su questo? Sento che questa roba è utile per me per progettare / refactor / programmi di debug.
Jesvin Jose,

3
@aitchnyu: Per quello che vale, ho scritto un libro che copre questo e altri argomenti. È da tempo esaurito, ma le copie stanno andando a un prezzo ragionevole. Ho provato a convincere GoogleBooks a prenderlo, ma al momento è un po 'difficile capire chi ha il copyright.
Mike Dunlavey,

21

Iniziamo dall'inizio.

Innanzitutto, accetta il principio secondo cui alcune semplici operazioni sui dati possono essere eseguite in O(1)tempo, ovvero in un tempo indipendente dalla dimensione dell'input. Queste operazioni primitive in C consistono in

  1. Operazioni aritmetiche (ad es. + O%).
  2. Operazioni logiche (ad es. &&).
  3. Operazioni di confronto (ad es. <=).
  4. Operazioni di accesso alla struttura (ad es. Indicizzazione di array come A [i] o puntatore che segue l'operatore ->).
  5. Assegnazione semplice come la copia di un valore in una variabile.
  6. Chiamate alle funzioni di libreria (ad es. Scanf, printf).

La giustificazione per questo principio richiede uno studio dettagliato delle istruzioni della macchina (passaggi primitivi) di un tipico computer. Ciascuna delle operazioni descritte può essere eseguita con un numero limitato di istruzioni della macchina; spesso sono necessarie solo una o due istruzioni. Di conseguenza, diversi tipi di istruzioni in C possono essere eseguite nel O(1)tempo, cioè in un periodo di tempo costante indipendente dall'input. Questi semplici includono

  1. Dichiarazioni di assegnazione che non comportano chiamate di funzione nelle loro espressioni.
  2. Leggi le dichiarazioni.
  3. Scrivere istruzioni che non richiedono chiamate di funzione per valutare gli argomenti.
  4. Le istruzioni jump interrompono, continuano, vanno e restituiscono espressione, dove espressione non contiene una chiamata di funzione.

In C, molti loop for si formano inizializzando una variabile di indice su un valore e incrementando quella variabile di 1 ogni volta intorno al loop. Il ciclo for termina quando l'indice raggiunge un limite. Ad esempio, il for-loop

for (i = 0; i < n-1; i++) 
{
    small = i;
    for (j = i+1; j < n; j++)
        if (A[j] < A[small])
            small = j;
    temp = A[small];
    A[small] = A[i];
    A[i] = temp;
}

utilizza la variabile indice i. Aumenta i di 1 ogni volta intorno al ciclo e le iterazioni si interrompono quando raggiungo n - 1.

Tuttavia, per il momento, concentrati sulla semplice forma di for-loop, in cui la differenza tra i valori finali e iniziali, divisa per la quantità con cui viene incrementata la variabile indice, ci dice quante volte facciamo il giro . Questo conteggio è esatto, a meno che non ci siano modi per uscire dal loop tramite un'istruzione jump; è in ogni caso un limite superiore al numero di iterazioni.

Ad esempio, il ciclo for itera ((n − 1) − 0)/1 = n − 1 times, poiché 0 è il valore iniziale di i, n - 1 è il valore più alto raggiunto da i (ovvero, quando raggiungo n − 1, il ciclo si interrompe e non si verifica alcuna iterazione con i = n− 1) e 1 viene aggiunto a i ad ogni iterazione del loop.

Nel caso più semplice, in cui il tempo trascorso nel corpo del loop è lo stesso per ogni iterazione, possiamo moltiplicare il limite superiore big-oh per il corpo per il numero di volte attorno al loop . A rigor di termini, dobbiamo quindi aggiungere il tempo O (1) per inizializzare l'indice del ciclo e il tempo O (1) per il primo confronto dell'indice del ciclo con il limite , perché testiamo ancora una volta di quanto non facciamo il giro del ciclo. Tuttavia, a meno che non sia possibile eseguire il ciclo zero volte, il tempo per inizializzare il ciclo e testare il limite una volta è un termine di ordine inferiore che può essere eliminato dalla regola di somma.


Ora considera questo esempio:

(1) for (j = 0; j < n; j++)
(2)   A[i][j] = 0;

Sappiamo che la riga (1) richiede O(1)tempo. Chiaramente, facciamo il giro n volte, come possiamo determinare sottraendo il limite inferiore dal limite superiore trovato sulla linea (1) e quindi aggiungendo 1. Poiché il corpo, linea (2), impiega O (1) tempo, possiamo trascurare il tempo per incrementare j e il tempo per confrontare j con n, entrambi i quali sono anche O (1). Pertanto, il tempo di esecuzione delle righe (1) e (2) è il prodotto di n e O (1) , che è O(n).

Allo stesso modo, possiamo limitare il tempo di esecuzione del loop esterno costituito da linee da (2) a (4), che è

(2) for (i = 0; i < n; i++)
(3)     for (j = 0; j < n; j++)
(4)         A[i][j] = 0;

Abbiamo già stabilito che il ciclo di linee (3) e (4) richiede O (n) tempo. Pertanto, possiamo trascurare il tempo O (1) per incrementare i e verificare se i <n in ciascuna iterazione, concludendo che ogni iterazione del ciclo esterno impiega O (n) tempo.

L'inizializzazione i = 0 del loop esterno e il test (n + 1) della condizione i <n richiedono allo stesso tempo O (1) e possono essere trascurati. Infine, osserviamo che facciamo il giro del ciclo esterno n volte, prendendo O (n) tempo per ogni iterazione, dando un O(n^2)tempo di esecuzione totale .


Un esempio più pratico.

inserisci qui la descrizione dell'immagine


Cosa succede se un'istruzione goto contiene una chiamata di funzione? Qualcosa come step3: if (M.step == 3) {M = step3 (done, M); } passaggio 4: if (M.step == 4) {M = passaggio 4 (M); } if (M.step == 5) {M = step5 (M); vai al passaggio 3; } if (M.step == 6) {M = step6 (M); vai al passaggio 4; } return cut_matrix (A, M); come sarebbe calcolata la complessità allora? sarebbe un'aggiunta o una moltiplicazione? considerando il passaggio 4 è n ^ 3 e il passaggio 5 è n ^ 2.
Taha Tariq,

14

Se si desidera stimare empiricamente l'ordine del codice anziché analizzarlo, è possibile inserire una serie di valori crescenti di n e tempo nel codice. Traccia i tuoi tempi su una scala di registro. Se il codice è O (x ^ n), i valori dovrebbero cadere su una linea di pendenza n.

Ciò ha diversi vantaggi rispetto allo studio del codice. Per prima cosa, puoi vedere se sei nell'intervallo in cui il tempo di esecuzione si avvicina al suo ordine asintotico. Inoltre, potresti scoprire che un codice che pensavi fosse l'ordine O (x) è in realtà l'ordine O (x ^ 2), ad esempio, a causa del tempo impiegato nelle chiamate in libreria.


Solo per aggiornare questa risposta: en.wikipedia.org/wiki/Analysis_of_algorithms , questo link ha la formula di cui hai bisogno. Molti algoritmi seguono una regola di potenza, se la tua è, con 2 timepoints e 2 runtime su una macchina, possiamo calcolare la pendenza su un diagramma log-log. Che è a = log (t2 / t1) / log (n2 / n1), questo mi ha dato l'esponente per l'algoritmo in, O (N ^ a). Questo può essere confrontato con il calcolo manuale usando il codice.
Christopher John,

1
Ciao, bella risposta. Mi chiedevo se sei a conoscenza di qualche libreria o metodologia (ad esempio lavoro con Python / R) per generalizzare questo metodo empirico, vale a dire come adattare varie funzioni di complessità ad un set di dati di dimensioni crescenti e scoprire quale sia rilevante. Grazie
agenis il

10

Fondamentalmente la cosa che cresce del 90% delle volte è solo l'analisi dei loop. Hai loop singoli, doppi, tripli nidificati? Hai tempo di esecuzione O (n), O (n ^ 2), O (n ^ 3).

Molto raramente (a meno che tu non stia scrivendo una piattaforma con una vasta libreria di base (come ad esempio .NET BCL o STL di C ++) incontrerai qualcosa di più difficile che guardare i tuoi loop (per affermazioni, mentre, vai a, eccetera...)


1
Dipende dagli anelli.
Kelalaka,

8

La notazione O grande è utile perché è facile da lavorare e nasconde complicazioni e dettagli non necessari (per alcune definizioni di non necessarie). Un buon modo di elaborare la complessità degli algoritmi di divisione e conquista è il metodo ad albero. Supponiamo che tu abbia una versione di quicksort con la procedura mediana, quindi ogni volta dividi l'array in subarrays perfettamente bilanciati.

Ora costruisci un albero corrispondente a tutti gli array con cui lavori. Alla radice hai l'array originale, la radice ha due figli che sono i subarrays. Ripetere l'operazione fino a quando non si hanno matrici a elemento singolo nella parte inferiore.

Poiché possiamo trovare la mediana nel tempo O (n) e dividere l'array in due parti nel tempo O (n), il lavoro svolto su ciascun nodo è O (k) dove k è la dimensione dell'array. Ogni livello dell'albero contiene (al massimo) l'intero array in modo che il lavoro per livello sia O (n) (le dimensioni dei sottoparagrafi si sommano a n, e poiché abbiamo O (k) per livello possiamo aggiungere questo) . Ci sono solo i livelli di log (n) nella struttura poiché ogni volta che dimezziamo l'input.

Pertanto possiamo limitare in alto la quantità di lavoro di O (n * log (n)).

Tuttavia, Big O nasconde alcuni dettagli che a volte non possiamo ignorare. Valuta di calcolare la sequenza di Fibonacci con

a=0;
b=1;
for (i = 0; i <n; i++) {
    tmp = b;
    b = a + b;
    a = tmp;
}

e supponiamo solo che aeb siano BigInteger in Java o qualcosa in grado di gestire numeri arbitrariamente grandi. Molte persone direbbero che questo è un algoritmo O (n) senza battere ciglio. Il ragionamento è che hai n iterazioni nel ciclo for e O (1) funziona a lato del ciclo.

Ma i numeri di Fibonacci sono grandi, l'n-esimo numero di Fibonacci è esponenziale in n, quindi solo la sua memorizzazione prenderà l'ordine di n byte. Eseguire l'aggiunta con numeri interi grandi richiederà O (n) quantità di lavoro. Quindi la quantità totale di lavoro svolto in questa procedura è

1 + 2 + 3 + ... + n = n (n-1) / 2 = O (n ^ 2)

Quindi questo algoritmo funziona in tempo quadradico!


1
Non dovresti preoccuparti di come sono memorizzati i numeri, non cambia il fatto che l'algoritmo cresca a un limite superiore di O (n).
mikek3332002,

8

Meno utile in generale, penso, ma per completezza c'è anche un Big Omega Ω , che definisce un limite inferiore sulla complessità di un algoritmo, e un Big Theta Θ , che definisce sia un limite superiore che inferiore.


7

Suddividi l'algoritmo in pezzi per i quali conosci la grande notazione O e combinali attraverso grandi operatori O. Questo è l'unico modo che conosco.

Per ulteriori informazioni, consultare la pagina di Wikipedia sull'argomento.


7

Familiarità con algoritmi / strutture dati che utilizzo e / o analisi rapida dell'annidamento dell'iterazione. La difficoltà è quando si chiama una funzione di libreria, possibilmente più volte - spesso si può essere incerti sul fatto che a volte si chiama la funzione inutilmente o quale implementazione stanno utilizzando. Forse le funzioni di libreria dovrebbero avere una misura di complessità / efficienza, che sia Big O o qualche altra metrica, disponibile nella documentazione o anche in IntelliSense .


6

Quanto a "come si calcola" Big O, questo fa parte della teoria della complessità computazionale . Per alcuni (molti) casi speciali potresti essere in grado di fornire alcune semplici euristiche (come moltiplicare i conteggi dei loop per i loop nidificati), esp. quando tutto ciò che desideri è una stima del limite superiore e non ti dispiace se è troppo pessimista, il che immagino sia probabilmente la tua domanda.

Se vuoi davvero rispondere alla tua domanda per qualsiasi algoritmo, la cosa migliore che puoi fare è applicare la teoria. Oltre all'analisi semplicistica del "caso peggiore", ho trovato molto utile l' analisi ammortizzata nella pratica.


6

Per il 1o caso, il ciclo interno è il tempo di esecuzione n-i, quindi il numero totale di esecuzioni è la somma per iandare da 0a n-1(perché inferiore a, non inferiore o uguale a) del n-i. Alla fine n*(n + 1) / 2, quindi O(n²/2) = O(n²).

Per il 2o loop, iè compreso 0e nincluso per il loop esterno; quindi il ciclo interno viene eseguito quando jè strettamente maggiore di n, il che è quindi impossibile.


5

Oltre a utilizzare il metodo master (o una delle sue specializzazioni), collaudo sperimentalmente i miei algoritmi. Ciò non può dimostrare che una particolare classe di complessità sia raggiunta, ma può fornire rassicurazione che l'analisi matematica sia appropriata. Per aiutare con questa rassicurazione, utilizzo gli strumenti di copertura del codice insieme ai miei esperimenti, per assicurarmi di esercitare tutti i casi.

Come esempio molto semplice diciamo che volevi fare un controllo di integrità sulla velocità dell'ordinamento dell'elenco del framework .NET. È possibile scrivere qualcosa di simile al seguente, quindi analizzare i risultati in Excel per assicurarsi che non superino una curva n * log (n).

In questo esempio misuro il numero di confronti, ma è anche prudente esaminare il tempo effettivo richiesto per ciascuna dimensione del campione. Tuttavia, è necessario prestare ancora più attenzione a misurare l'algoritmo e non includere artefatti dall'infrastruttura di test.

int nCmp = 0;
System.Random rnd = new System.Random();

// measure the time required to sort a list of n integers
void DoTest(int n)
{
   List<int> lst = new List<int>(n);
   for( int i=0; i<n; i++ )
      lst[i] = rnd.Next(0,1000);

   // as we sort, keep track of the number of comparisons performed!
   nCmp = 0;
   lst.Sort( delegate( int a, int b ) { nCmp++; return (a<b)?-1:((a>b)?1:0)); }

   System.Console.Writeline( "{0},{1}", n, nCmp );
}


// Perform measurement for a variety of sample sizes.
// It would be prudent to check multiple random samples of each size, but this is OK for a quick sanity check
for( int n = 0; n<1000; n++ )
   DoTest(n);

4

Non dimenticare di consentire anche complessità spaziali che possono anche essere motivo di preoccupazione se si hanno risorse di memoria limitate. Quindi, ad esempio, potresti sentire qualcuno che desidera un algoritmo di spazio costante che è fondamentalmente un modo per dire che la quantità di spazio occupato dall'algoritmo non dipende da alcun fattore all'interno del codice.

A volte la complessità può derivare da quante volte viene chiamato, quanto spesso viene eseguito un ciclo, quanto spesso viene allocata la memoria e così via è un'altra parte per rispondere a questa domanda.

Infine, il big O può essere usato nel caso peggiore, nel caso migliore e nei casi di ammortamento in cui generalmente è il caso peggiore che viene utilizzato per descrivere quanto un algoritmo può essere negativo.


4

Ciò che spesso viene trascurato è il comportamento previsto dei tuoi algoritmi. Non cambia il Big-O del tuo algoritmo , ma si riferisce alla frase "ottimizzazione prematura ..."

Il comportamento previsto del tuo algoritmo è - molto attenuato - quanto velocemente puoi aspettarti che l'algoritmo funzioni sui dati che molto probabilmente vedrai.

Ad esempio, se stai cercando un valore in un elenco, è O (n), ma se sai che la maggior parte degli elenchi che vedi hanno il tuo valore in anticipo, il comportamento tipico del tuo algoritmo è più veloce.

Per dirlo davvero, devi essere in grado di descrivere la distribuzione di probabilità del tuo "spazio di input" (se hai bisogno di ordinare un elenco, con quale frequenza verrà già ordinato quell'elenco? Con che frequenza viene completamente invertito? spesso è per lo più ordinato?) Non è sempre fattibile che tu lo sappia, ma a volte lo fai.


4

ottima domanda!

Dichiarazione di non responsabilità: questa risposta contiene dichiarazioni false, vedere i commenti seguenti.

Se stai usando il Big O, stai parlando del caso peggiore (più su cosa significhi in seguito). Inoltre, c'è un theta maiuscola per il caso medio e un grande omega per il caso migliore.

Dai un'occhiata a questo sito per una bella definizione formale di Big O: https://xlinux.nist.gov/dads/HTML/bigOnotation.html

f (n) = O (g (n)) significa che ci sono costanti positive c e k, tali che 0 ≤ f (n) ≤ cg (n) per tutti n ≥ k. I valori di c e k devono essere fissi per la funzione f e non devono dipendere da n.


Ok, quindi ora cosa intendiamo per complessità "migliore" e "peggiore"?

Questo è probabilmente il più chiaramente illustrato attraverso esempi. Ad esempio, se stiamo usando la ricerca lineare per trovare un numero in un array ordinato, il caso peggiore è quando decidiamo di cercare l'ultimo elemento dell'array poiché ciò richiederebbe tanti passaggi quanti sono gli elementi dell'array. Il caso migliore sarebbe quando cerchiamo il primo elemento poiché avremmo finito dopo il primo controllo.

Il punto di tutte queste complessità aggettivistiche è che stiamo cercando un modo per rappresentare graficamente la quantità di tempo che un ipotetico programma esegue fino al completamento in termini di dimensioni di particolari variabili. Tuttavia, per molti algoritmi si può sostenere che non esiste una sola volta per una particolare dimensione di input. Si noti che ciò è in contraddizione con il requisito fondamentale di una funzione, qualsiasi input non dovrebbe avere più di un output. Quindi creiamo più funzioni per descrivere la complessità di un algoritmo. Ora, anche se la ricerca di un array di dimensioni n può richiedere tempi variabili a seconda di ciò che stai cercando nell'array e in modo proporzionale a n, possiamo creare una descrizione informativa dell'algoritmo usando il caso medio e il caso medio e le classi peggiori.

Mi dispiace che sia scritto così male e non abbia molte informazioni tecniche. Ma si spera che renderà più facile pensare alle classi di complessità temporale. Una volta che ti senti a tuo agio con questi, diventa una semplice questione di analizzare il tuo programma e cercare cose come for-loop che dipendono dalle dimensioni dell'array e dal ragionamento basato sulle tue strutture di dati che tipo di input si tradurrebbe in casi banali e che input risulterebbe nei casi peggiori.


1
Questo non è corretto Big O significa "limite superiore" non il caso peggiore.
Samy Bencherif,

1
È un'idea sbagliata comune che big-O si riferisca al caso peggiore. Come si collegano O e Ω al caso peggiore e migliore?
Bernhard Barker,

1
Questo è fuorviante. Big-O significa limite superiore per una funzione f (n). Omega significa limite inferiore per una funzione f (n). Non è affatto correlato al caso migliore o al caso peggiore.
Tasneem Haider,

1
Puoi usare Big-O come limite superiore per il caso migliore o peggiore, ma a parte questo, sì nessuna relazione.
Samy Bencherif

2

Non so come risolverlo a livello di codice, ma la prima cosa che la gente fa è che campioniamo l'algoritmo per determinati schemi nel numero di operazioni fatte, diciamo 4n ^ 2 + 2n + 1 abbiamo 2 regole:

  1. Se abbiamo una somma di termini, il termine con il tasso di crescita più elevato viene mantenuto, con altri termini omessi.
  2. Se abbiamo un prodotto di diversi fattori, vengono omessi i fattori costanti.

Se semplifichiamo f (x), dove f (x) è la formula per il numero di operazioni eseguite, (4n ^ 2 + 2n + 1 spiegato sopra), otteniamo il valore big-O [O (n ^ 2) in questo Astuccio]. Ma ciò dovrebbe rendere conto dell'interpolazione di Lagrange nel programma, che potrebbe essere difficile da attuare. E se il vero valore O grande fosse O (2 ^ n), e potremmo avere qualcosa come O (x ^ n), quindi questo algoritmo probabilmente non sarebbe programmabile. Ma se qualcuno mi dimostra di avere torto, dammi il codice. . . .


2

Per il codice A, il ciclo esterno verrà eseguito per n+1volte, il tempo "1" indica il processo che verifica se ancora soddisfano il requisito. E ciclo interno viene eseguito nvolte, n-2volte .... Così, 0+2+..+(n-2)+n= (0+n)(n+1)/2= O(n²).

Per il codice B, sebbene il ciclo interno non intervenga ed esegua il foo (), il ciclo interno verrà eseguito per n volte dipende dal tempo di esecuzione del ciclo esterno, che è O (n)


1

Vorrei spiegare il Big-O in un aspetto un po 'diverso.

Big-O è solo per confrontare la complessità dei programmi, il che significa quanto crescono velocemente quando aumentano gli input e non il tempo esatto che viene speso per fare l'azione.

IMHO nelle formule big-O è meglio non usare equazioni più complesse (potresti semplicemente attenersi a quelle nel seguente grafico.) Tuttavia potresti comunque usare altre formule più precise (come 3 ^ n, n ^ 3, .. .) ma a volte ciò può essere fuorviante! Quindi meglio tenerlo il più semplice possibile.

inserisci qui la descrizione dell'immagine

Vorrei sottolineare ancora una volta che qui non vogliamo ottenere una formula esatta per il nostro algoritmo. Vogliamo solo mostrare come cresce quando crescono gli input e confrontarli con gli altri algoritmi in quel senso. Altrimenti faresti meglio a usare metodi diversi come il benchmarking.

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.