O(n)
O(n)O(n)

Програмиране 51 - Сложност на Алгоритми

Когато говорим за алгоритми, обикновенно говорим за тяхната "скорост"

Трябва ни начин, по който да сравняваме алгоритми.

Обикновенно броим стъпките, които един алгоритъм прави, за да си свърши работата.

Логиката е, че алгоритъмът, който прави по-малко стъпки, за да приключи е по-бърз от този, който решава проблема с повече стъпки.

Броене на стъпки ~ Броене на инструкции

Моделът е по-сложен, но за простота, ще приемем че 1 стъпка на алгоритъма представлява 1 инструкция към процесора.

Броене на инструкции

  1. Деклариране на променлива и смяна на стойност.

  2. Индексиране на масив.

  3. Сравнение на стойности.

  4. Аритметичните операции.

  5. Викане на функция / метод

Броене на инструкции

package com.hackbulgaria.datastructures;

public class Examples {

  public static double average(int[] numbers) {
    double sum = 0;
    int i = 0;
    int n = numbers.length;

    while (i < n) {
      sum = sum + numbers[i];
      i = i + 1;
    }

    return sum / n;
  }

  public static void main(String[] args) {
    int[] array = { 1, 2, 3, 4 };
    System.out.println(String.format("Average: %.2f", average(array)));

  }
}

Броене на инструкции

1 + 1 + 2 + n * (1 + 3 + 2) + 1 =
1+1+2+n(1+3+2)+1=1 + 1 + 2 + n * (1 + 3 + 2) + 1 =
5 + 6*n
5+6n5 + 6*n

Какво е n?

Дължината на входа е много важна!

  • Ако искате да знаем "колко бърз" е даден алгоритъм, броим стъпките, които му трябват, за да приключи.

  • Тези стъпки обикновенно се дефинират като  функция от дължината на входа.

  • 5 + 6n означава 605 стъпки, за да сметнем средното аритметично от масив с n = 100 елемента.

Counting instructions

package com.hackbulgaria.datastructures;

public class Examples {

  public static boolean sumToZero(int[] numbers) {
    boolean result = false;
    int n = numbers.length;
    
    for(int i = 0; i < n; i += 1) {
      for(int j = 0; j < n; j += 1) {
        if(numbers[i] + numbers[j] == 0) {
          result = true;
          break;
        }
      }
    }
    
    return result;
  }

  public static void main(String[] args) {
    int[] array = { 2, 3, 4, -1, 1 };
    System.out.println(String.format("To zero: %b", 
                                     sumToZero(array)));

  }
}

Counting instructions

1 + 2 + n * (n * 5) + 1 =
1+2+n(n5)+1=1 + 2 + n * (n * 5) + 1 =
4 + 5 * n^2
4+5n24 + 5 * n^2
f(n) = 5 * n^2 + 4
f(n)=5n2+4f(n) = 5 * n^2 + 4
f(100) = 5 * 100^2 + 4 = 50004
f(100)=51002+4=50004f(100) = 5 * 100^2 + 4 = 50004

Броене на инструкции

f(n) = 6 + 5 * n
f(n)=6+5nf(n) = 6 + 5 * n
f1(n) = 5 * n
f1(n)=5nf1(n) = 5 * n
f(1000) = 5006
f(1000)=5006f(1000) = 5006
f1(1000) = 5000
f1(1000)=5000f1(1000) = 5000
f(10000) = 50006
f(10000)=50006f(10000) = 50006
f1(10000) = 50000
f1(10000)=50000f1(10000) = 50000
f(100000) = 500006
f(100000)=500006f(100000) = 500006
f1(100000) = 500000
f1(100000)=500000f1(100000) = 500000

Броене на инструкции

f(n) = c1 * n + c2
f(n)=c1n+c2f(n) = c1 * n + c2
  • Не се интересуваме от c2. Няма да видим съществена разлика в бързодействието.

  • Този начин на броене е труден и не особено интуитивен.

  • Представете си какво ще се случи, ако имаме if-ове.

Кой алгоритъм е по-бърз?

f1(n) = 5 * n
f1(n)=5nf1(n) = 5 * n
f2(n) = 10 * n
f2(n)=10nf2(n) = 10 * n

vs.

За нас, те ще са "еднакви" откъм скорост.

Броенето на инструкции е трудно и тежко. Трябва ни "по-мърлезив" подход за по-груба оценка.

"По-мързелив" подход за оценяване на алгоритми

f1(n) = c1 * n
f1(n)=c1nf1(n) = c1 * n
f2(n) = c2 * n
f2(n)=c2nf2(n) = c2 * n

vs.

Освен ако c1 и c2 не са със стойности >= на n, няма да взимаме и тези константи предвид. Правим си живота по-лесен.

Complexity Analysis

Ако имаме две функции, които при един и същи вход дават един и същи резултат

f(n) = x, g(n) = x
f(n)=x,g(n)=xf(n) = x, g(n) = x

Може да "анализираме" коя от двете функции ще бъде  по-ефективна.

Complexity Analysis - сравняване на функции

Ще сравняваме на много високо ниво. Ще се интересуваме от класове от функции, вместо конкретни константи.

(f(n) = c1 * n + c2) == (g(n) = c3 * n + c4)
(f(n)=c1n+c2)==(g(n)=c3n+c4)(f(n) = c1 * n + c2) == (g(n) = c3 * n + c4)

Класове от функции - Линейни

Всички функции от горния вид ще наричаме "линейни" и няма да се интересуваме от константите c1 и c2

f(n) = c1 * n + c2
f(n)=c1n+c2f(n) = c1 * n + c2

Класове от функции - Квадратни

Всички функции от горния вид ще са "квадратни" за нас и няма да се интересуваме от c1, c2, n и c3

f(n) = c1 * n^2 + c2 * n + c3
f(n)=c1n2+c2n+c3f(n) = c1 * n^2 + c2 * n + c3

Квадратните ф-и са "по-бавни" от линейните

n^2
n2n^2
n
nn

Сложност на алгоритъм

  • Сложността на един алгоритъм ще е основният начин, по който ще го измерваме.

  • Може да имаме "сложност по време" - това ни дава приближение за скоростта на алгоритъма.

  • Може да имаме и "сложност по памет" - това ни дава приближение колко памет ще заеме алгоритъма.

  • Ще се фокусираме върху "сложност по време"

Сложност на алгоритъм & Дължина на входа

  • Дължината на входа е основната, върху която може да определим сложността на дадем алгоритъм.

  • Обикновенно, когато обхождаме масив, дължината на входа е дължината на масива.

  • Ще разглеждаме и много други случаи.

Нека напишем търсене.

public class Example {
  public static boolean search(int element, int[] items) {
    for (int item : items) {
      if (item == element) {
        return true;
      }
    }

    return false;
  }
 
 public static void main(String[] args) {
    int[] array = { 2, 3, 4, -1 };
    int[] largeArray = new int[1000000];
    largeArray[1000000 - 1] = 2;
    System.out.println(search(2, array)); // 1 step
    System.out.println(search(2, largeArray)); // 999999 steps

  }
}

За сложност на алгоритъм, ще се интересуваме от "най-лошия случай"

Може да приключим търсенето за 1 стъпка, но ние ще оценим алгоритъма все едно елементът е последен.

Това се наирача "линейно" търсене, защото прави едно пълно обхождане, за да намери даден елемент.

Big Oh Notation

Когато гледаме за най-лошият вариант, има специална нотация за това. Нарина се "Big-Oh Notation"

Казваме, че сложността на нашето търсене е:

O(n)
O(n)O(n)

O(n) - Примери

Ако алгоритъмът има O(n) сложност, това означава, че стъпките, които са му нужни, са пропорционални на дължината на входа.

  • Линейното търсене е O(n)
  • Сумата на всички елементи в масив е O(n)
  • Всеки алгоритъм, който се нужда от едно пълно обхождане на даден масив е O(n)

Квадратични алгоритми

O(n)
O(n)O(n)
package com.hackbulgaria.datastructures;

public class Examples {

  public static boolean sumToZero(int[] numbers) {
    boolean result = false;
    int n = numbers.length;
    
    for(int i = 0; i < n; i += 1) {
      for(int j = 0; j < n; j += 1) {
        if(numbers[i] + numbers[j] == 0) {
          result = true;
          break;
        }
      }
    }
    
    return result;
  }

  public static void main(String[] args) {
    int[] array = { 1, 2, 3, 4, -1 };
    System.out.println(String.format("To zero: %b", 
                                     sumToZero(array)));

  }
}
O(n^2)
O(n2)O(n^2)
  • Стъпките, които му трябват са пропорционални на квадрата на дължината на входа.

  • Много по-бавни от линейните.

  • Два вложени цикъла обикновенно дават квадратичен алгоритъм.

O(1)
O(1)O(1)
  • Така нареченото "константно" време, което не зависи от дължината на входа.

  • Операциите в езика обикновенно са константни.

  • Достъпване на индекси, викане на функции / методи и други.

  • Може да го считате като "става веднага"

Сравняване на алгоритми по тяхната сложност

Ще сравняваме нашите алгоритми използвайки "Big-Oh" нотацията.

O(n) :> O(n^2)
O(n):>O(n2)O(n) :> O(n^2)
O(1) :> O(n)
O(1):>O(n)O(1) :> O(n)
O(1) :> O(lg(n)) :> O(n * lg(n)) :> O(n^2)
O(1):>O(lg(n)):>O(nlg(n)):>O(n2)O(1) :> O(lg(n)) :> O(n * lg(n)) :> O(n^2)

Programming 51 - Algo Complexity

By Hack Bulgaria

Programming 51 - Algo Complexity

  • 1,549