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?
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?
Risposte:
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 N
accetta il data.length
valore. 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 C
numero 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 data
dell'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 for
dell'istruzione. Ricorda che stiamo contando il numero di passaggi computazionali, nel senso che il corpo for
dell'istruzione viene eseguito i N
tempi. È lo stesso che aggiungere C
, N
volte:
f(N) = C + (C + C + ... + C) + C = C + N * C + C
Non esiste una regola meccanica per contare quante volte il corpo del for
viene 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 for
dell'istruzione.
Per ottenere il BigOh reale abbiamo bisogno dell'analisi asintotica della funzione. Questo è fatto approssimativamente in questo modo:
C
.f()
ottenere il polinomio nel suo standard form
.N
avvicina infinity
.Il nostro f()
ha due termini:
f(N) = 2 * C * N ^ 0 + 1 * C * N ^ 1
Togliere tutte le C
costanti 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' for
affermazione sulla frase numero uno è complicata. Mentre l'indice termina alle 2 * N
, l'incremento viene fatto di due. Ciò significa che il primo for
viene eseguito solo in N
passaggi 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 for
viene eseguito: N volte il primo, N - 2 il secondo, N - 4 il terzo ... fino allo stadio N / 2, sul quale il secondo for
non 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 C
provvedimenti.)
Abbiamo un problema qui: quando i
prende il valore N / 2 + 1
verso l'alto, la Somma interna termina con un numero negativo! È impossibile e sbagliato. Dobbiamo dividere la somma di due, essendo il fulcro del momento i
richiede 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 for
non 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à:
w
)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²)
O(n)
dove n
è il numero di elementi o O(x*y)
dove x
e y
sono le dimensioni dell'array. Big-oh è "relativo all'input", quindi dipende da quale sia il tuo input.
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.
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 libc
autore 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 bsearch
e qsort
può essere peggio se la funzione di confronto è patologica.
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
x&1==1
per verificare la stranezza?
x & 1
sarebbe sufficiente solo un test , non è necessario verificarlo == 1
; in C, x&1==1
viene 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.
Piccolo promemoria: la big O
notazione 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 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.
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.
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 !)
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, if
un'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.
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
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
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.
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.
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...)
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!
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.
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.
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 .
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.
Per il 1o caso, il ciclo interno è il tempo di esecuzione n-i
, quindi il numero totale di esecuzioni è la somma per i
andare da 0
a 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 0
e n
incluso per il loop esterno; quindi il ciclo interno viene eseguito quando j
è strettamente maggiore di n
, il che è quindi impossibile.
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);
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.
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.
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.
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:
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. . . .
Per il codice A, il ciclo esterno verrà eseguito per n+1
volte, il tempo "1" indica il processo che verifica se ancora soddisfano il requisito. E ciclo interno viene eseguito n
volte, n-2
volte .... 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)
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.
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.