2. Оценка и сложност на алгоритми [1.4]

Три главни свойства на компютърен алгоритъм:
  • простота и елегантност;
  • коректност;
  • бързодействие.

  • Нека разгледаме следния програмен фрагмент:

    cin >> n;
    sum = 0;
    for (i=0; i<n; i++)
     for (j=0; j<n; j++) sum++;
     

    * Колко бързо ще работи горната програма, т.е. какви са критериите по които се определя бързината й?
    * Eкспериментално да проверим за колко време ще се изпълни програмата.
    * За да изследваме по-общо нейното поведение е ще я изпълним с различни стойности на
    n.

    * Резултатите са обобщени в следната таблица (могат да бъдат такива):
    Размер на входа
    n
    Време за изпълнение
    сек.
    10 10-6
    100 10-4
    1000 0.01
    104 1.071
    106 106.5
    108 10663.6
    * От таблицата се вижда, че когато увеличаваме n (размерността на входа) 10 пъти, времето за изпълнение се увеличава 100 пъти.
    * Времето за изпълнение е пропорционално на  g(n) = c1n2 + c2n + c3, където c1, c2, c3 са константи, които се определят от дадената част от програмата (виж по-долу).
     
    * Сравняване на две функции:
     g1(n)= 2n2 и g2(n)= 200n,
    които показват времето за изпълнение на два дадени алгоритъма А1 и A2, в зависимост от n

     
     

    * Асимптотично алгоритъмът A2 е по-бърз и неговата сложност е линейна, докато тази на A1 е квадратична.

    n g1(n) g2(n)
    1 2 200
    10 200 2000
    100 2.104 2.104
    1000 2.106 2.105
    104 2.108 2.106
    106 2.1012 2.108

     Размер на входните данни

    * Нека е дадена задача, в която размерът на входните данни е определен от дадено цяло число n.
    * Почти всички задачи, които ще разглеждаме, притежават това свойство.
    * Ще поясним последното като разгледаме няколко примера:

    Пример 1.
    Да се сортира масив с n елемента.
    Размерът на входните данни се определя от броя n на елементите на масива .

    Пример 2.
    Да се намери най-големият общ делител на a и b.
    В този пример размерът на входните данни се определя от броя на двоичните цифри (битовете) на по-голямото от числата a и b.

    Пример 3.
    Да се намери покриващо дърво на граф.
    В този случай характеризираме размера на входа с две числа: брой на върховете и брой на ребрата.

    Асимптотична нотация

    * Когато се интересуваме от сложността на алгоритъм най-често се интересуваме как ще работи при достатъчно голям размер n на входните данни.
    * При формалното оценяване на сложността на алгоритмите изследваме поведението им при "достатъчно голямо" n (клонящо към безкрайност).

    1. O(f) определя множеството от всички функции  g, които нарастват не по-бързо от f, т.е. съществува константа c > 0 такава, че g (n) <= cf(n), за всички достатъчно големи стойности на n.

    2. Theta (f) определя множеството от всички функции g, които нарастват толкова бързо, колкото и f (с точност до константен множител), т.е. съществуват константи c1 > 0 и c2 > 0 такава, че c1f(n) <=  g (n) <= c2f(n), за всички достатъчно големи стойности на n.

    3. Omega (f) определя множеството от всички функции g, които нарастват не по-бавно от f, т.е. съществува константа c > 0 такава, че g(n) >= cf(n), за всички достатъчно големи стойности на n.

    O(f): Свойства и примери

    * Нотацията О(f) е най-често използваната при оценка на сложност на алгоритми и програми.
    * По-важни свойства на О(f) (с ~ означаваме принадлежност):

  •  рефлексивност: f ~ О( f );
  • транзитивност: ако f ~ О(g), g ~ О(h), то  f ~ О(h);
  • транспонирана симетрия:  ако f  ~ Omega (g), то g ~ O( f ) и обратно;
  • константите могат да бъдат игнорирани: за всяко k > 0, kF ~ О(F);
  • n, повдигнато в по-висока степен, нараства по-бързо: nr~ О(ns), за 0 < r < s.
  • нарастването на сума от функции се определя от най-бързо нарастващата от тях: f + g ~ max(O( f ), O(g));
  • ако f(n) е полином от степен d, то f ~ О(nd);
  • ако f нараства по-бързо от g, а g нараства по-бързо от h, то следва, че f нараства по-бързо от h.
  • Нарастване на най-често използваните функции:

    Функция / n
    1
    2
    10
    100
    1000
    5
    5
    5
    5
    5
    5
    log n
    0
    1
    3.32
    6.64
    9.96
    n
    1
    2
    10
    100
    1000
    n log n
    0
    2
    33.2
    664
    9996
    n2
    1
    4
    100
    104
    106
    n3
    1
    8
    1000
    106
    109
    2n
    2
    4
    1024
    1030
    10300
    n!
    1
    2
    3628800
    10157
    102567
    nn
    1
    4
    1010
    10200
    103000

    ** Определяне на сложност на алгоритъм.
    * Намиране на функцията, определяща зависимостта между големината на входните данни и времето за изпълнение.
    * Винаги разглеждаме най-лошия случай - при най-неблагоприянти входни данни.

    - елементарна операция - не зависи от размера на обработваните данни - O(1) ;
    - последователност от оператори - определя се от асимтотично най-бавния -  f + g ~ max(O( f ), O(g));
    - композиция на оператори - произведение от сложностите - f (g) ~ O( f*g);
    - условни оператори - определя се от асимтотично най-бавния между условието и различните случаи;
    - цикли, два вложени цикъла, p вложени цикли - O(n), O(n2), O(np) .

    Оценка на сложността на следните цикли (колко пъти ще се изпълни цикъла в най-лошия случай):

    // 1
    for (i = 0; i < n; i++)
     for (j = 0; j < n; j++, sum++);
    // 2
    for (i = 0; i < n; i++)
     for (j = 0; j < n; j++) if (a[i] == b[j]) return;
    // 3
    for (i = 0; i < n; i++)
     for (j = 0; j < n; j++) if (a[i] != b[j]) return;
    // 4
    for (i = 0; i < n; i++)
     for (j = 0; j < n; j++) if (a[i] == a[j]) return;
    // 5
    for (i = 0; i < n; i++)
     for (j = 0; j < i; j++) sum++;
    // 6
    for (i = 0; i < n; i++)
     for (j = 0; j < n*n; j++) sum++;
    // 7
    for (i = 0; i < n; i++)
     for (j = 0; j < i*i; j++) sum++;
    // 8
    for (i = 0; i < n; i++)
     for (j = 0; j < i*i; j++)
       for (k = 0; k < j*j; k++) sum++;


    Логаритмична сложност.

    Да разгледаме цикъла:
    for (sum = 0, i = 0; i < n; i *= 2) sum++;
    Променливата i приема стойности 1, 2, 4, ..., 2k, ...  докато надмине n. Цикълът се изпълнява [log n] пъти. Сложността е O(log n).

    Изчисляване на сложност при рекурсия.
    * Двоично търсене в сортиран масив - рекурсивен алгоритъм. 

    int binary_search(vector<int> v, int from, int to, int a)
    {  
       if (from > to)
          return -1;
       int mid = (from + to) / 2;
       if (v[mid] == a)
          return mid;
       else if (v[mid] < a)
          return binary_search(v, mid + 1, to, a);
       else
          return binary_search(v, from, mid - 1, a);
    }

    * Броим обръщенията към елементите на масива.
    * В рекурсивната функция се разглежда средния елемент и се прави едно рекурсивно извикване с два пъти по-малък масив.
    * Следователно, ако T(n) е функцията, която задава броя на обръщенията, то T(n) = T(n/2) + 1.
    * От равенствата
                    T(n) = T(n/2) + 1 = T(n/4) + 2 = T(n/8) + 3 = ... = T(n/2k) + k
    получаваме за n = 2k, че  T(n) = T(1) + log n, т.е. сложността на алгоритъма е O(log n).

    Проблеми на асимптотичната нотация - зависимост от входните данни
    * Най-добър случай, най-лош случай, обща сложност (примери - алгоритми за сортиране).
    * Проблеми при асимптотичната нотация [1.4.11]