Website Summary Files Commits Branches @commit @diff @stats
path: root / Seminar01 / README.md
blob: 31453f08dc25f2bd9e6491469c483e2c145b0e62 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
# Първи семинар по структури от данни - 10.10.2024

## Алгоритъм
Формално понятието алгоритъм е първично. За него няма общоприета дефиниция. Все пак обаче алгоритми сме виждали и имаме някаква представа какво представляват.

За алгоритъм най - често се мисли като за крайна последователност от елементарни инструкции, използвани за да решат някакъв проблем или да извършат някакво изчисление. С течение на курса, изучавайки различните структури от данни, ще изучаваме и различните алгоритми, в които те се използват. Използването на правилните структури от данни често ни води до по - добри алгоритми. Но какво точно наричаме "по-добър алгоритъм"?

## Сложност по време
За да си отговорим на въпросът "алгоритъм А по - добър ли е от алгоритъм B", трябва да въведем някаква оценка. Първият ни опит би бил да напишем и двата алгоритъма и да ги пуснем върху достатъчно голям вход. Можем да измерим за колко време се изпълнява всеки един от тях (примерно в секунди) и да сравним двете времена.

Такъв тип оценки се правят, но те не винаги са точни. Причината за това е, че подобни оценки могат да зависят от много неща:
* Дали двата алгоритъма са пуснати на една и съща машина?
* Каква точно е машината? Какъв хардуер използва?
* Каква е операционната система на тази машина? Какви неща вървят заедно с нашите алгоритми?

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

## Дефиниция: Сложността е функция по големината на входа.

Нека разгледаме следния алгоритъм:
```cpp
unsigned algA(unsigned n)
{
    unsigned result = 0;
    for(unsigned i = 1; i <= n; i++)
    {
        result += i;
    }
    return result;
}
```
Този алгоритъм приема цяло число `n` и намира сумата на числата от 1 до `n`. Нараства ли броят на стъпките, които алгоритъмът изпълнява, с нарастване на входа? Отговорът е да - при вход 10 цикълът `for` ще се завърти 10 пъти. При вход 100 той ще се завърти 100 пъти.

Сега нека разгледаме друг алгоритъм, решаващ същата задача:
```cpp
unsigned algB(unsigned n)
{
    return n * (n + 1) / 2;
}
```
Формулата е добре позната (формула за аритметична прогресия). Нараства ли броят на стъпките, които алгоритъмът изпълнява, с нарастване на входа? Отговорът е не - при вход 10 и при вход 100 винаги ще имаме едно събиране, едно умножение и едно делене.

## Асимптотични нотации
Както казахме, интересува ни **как расте броя на стъпките при нарастване на входа**. В някакъв смисъл **не ни интересува колко точно са на брой а единствено как расте.** Ще въведем две нотации, които ще ни помагат да оценяваме алгоритми.

* Нотацията Θ ни дава точна граница.
* Нотацията О ни дава горна граница.

Нека използваме тези нотации за да дадем оценки на алгоритмите:

* algA = O(n)

* algA = O(n^2) - Това е вярно, понеже О е горна граница, но е безсмислено да се разглежда.

* algA = Θ(n)

* algA != Θ(n^2) - Θ е точна а не горна граница.

* algB = O(1)

* algB = O(n) - Това е вярно, понеже О е горна граница, но е безсмислено да се разглежда.

* algB = Θ(1)

* algB != Θ(n)

## Видове сложност по време
Когато разглеждаме сложността по време, имаме три случая:
* Най-добър случай
* Среден случай
* Най-лош случай

Нека се опитаме да разберем смисъла на тези понятия чрез пример:

```cpp
int linear_search(int* haysack, unsigned size, int needle)
{
    for(unsigned i = 0; i < size; i++)
    {
        if (haysack[i] == needle)
        {
            return i;
        }
    }
    return -1;
}
```
Това е алгоритъмът линейно търсене, той приема масив и число и връща индекса на числото, ако то се среща, и -1 ако не се среща в масива. В случая `n` е големината на масива (т.е. `size`). Няма смисъл да разглеждаме нарастването на търсения елемент.

* Най-добър случай: Тук приемаме оптимистичен подход. Нека елементът, който търсим, е винаги на първа позиция. Тогава, без значение колко расте масивът, винаги ще имаме едно сравнение и веднага ще връщаме от функцията. Можем да направим извод, че сложността в най - добрия случай е Θ(1).

* Най-лош случай: Най-лошият случай е когато елементът не присъства в масива. Тогава алгоритъмът прави възможно най-много стъпки. Тогава сложността е Θ(n).

* Среден случай: Анализът на средния случай е най-полезен, но и най-сложен. Можем да си мислим за него като осреднената оценка на стъпките върху всички възможни входове. Интуитивно сложността в средния случай отново е Θ(n).

Сложността в средния случай винаги се намира между сложността в най-добрия и сложността в най-лошия. Ако оценим най-лошия случай на Θ(n) а средния на Θ(n^2), то допускаме грешка.

## Сложност по памет
Сложността по памет на даден алгоритъм е броят на елементите памет, които алгоритъмът използва, без да броим паметта в която се разполага входа и паметта в която се разполага изхода. Отново използваме асимптотичните нотации за да даваме оценка.

## Пресмятане на сложности.
Нека разгледаме следния алгоритъм:
```cpp
int dummy(int n)
{
    int result = 0;
    while(n)
    {
        result++;
        n--;
    }
    return result;
}
```
Сложността на този алгоритъм е Θ(n). Защо? Отговорът "защото прави n стъпки" не е коректен. 
* Една стъпка за създаване на променливата `result` със стойност 0.

* `n` пъти увеличаваме `result` и `n` пъти намаляме `n`.

* "Една" стъпка за връщане на резултата.

Тоест тук стъпките са поне 2n + 2. И все пак алгоритъмът е със сложност Θ(n). Както казахме, **не се интересуваме от точна оценка а от това как нарастват стъпките.** Приемаме следните правила:
* Игнонираме мултипликативните константи:
    * 3n = Θ(n)
    * 7n = Θ(n)
    * 0.5n = Θ(n)
* Игнорираме събираемите
    * n + 7 = Θ(n)
    * 2n + 10 = Θ(n)
* Интересуваме се от функцията с най - висока асимптотика
    * n^2 + 7n + 3 = Θ(n^2)
    * 2^n + n^2 + 1 = Θ(2^n)

## Анализ на итеративни алгоритми
Един от начините да анализираме сложността на итеративни алгоритми е чрез метода на сумирането.
```cpp
for(int i = 0; i < n; i++)
{
    Θ(1)
}
```
$$
\sum_{i=0}^n \theta(1) = \theta(n)
$$

```cpp
for(int i = 0; i < n; i++)
{
    for(int j = 0; j < n; j++)
    {
        Θ(1)
    }
}
```
$$
\sum_{i=0}^n \sum_{j=0}^n \theta(1) = \sum_{i=0}^n \theta(n) = \theta(n^2)
$$

Забележка: Два вложени `for` цикъла не винаги означават Θ(n^2) сложност.
``` cpp
for(int i = 1; i < n; i *= 2)
{
    for (int j = 0; j < i; j++)
    {
        Θ(1)
    }
}
```

## Примери за алгоритми с различни сложности.

```cpp
bool is_odd(unsigned n)
{
    return (n % 2) == 1;
}
```

Сложност: Θ(1)

```cpp
bool is_prime(unsigned n)
{
    if(n < 2)
        return false;

    // Assuming sqrt = O(sqrt(n)).
    // (which is true in most cases)
    // https://en.wikipedia.org/wiki/Integer_square_root
    // binary search based algorithm can be used to assure Θ(lon(n)).
    unsigned square = sqrt(n);

    for(int i = 2; i <= square; i++)
    {
        if(n % i == 0)
        {
            return false;
        }
    }
    return true;
}
```
Сложност: Θ(sqrt(n))

```cpp
unsigned log_down(unsigned n, unsigned base = 2)
{
    unsigned result = 0;

    while(n)
    {
        ++result;
        n /= base;
    }
    return result;
}
```
Сложност: Θ(log(n))

Алгоритъм, който генерира всички подмножества на множество с големина n, би имал сложност Θ(2^n).

## Сортиращи алгоритми. Свойства на сортиращите алгоритми.

1. Стабилност - Запазва се относителната наредба на елементите с еднакви ключове. 
    * Не винаги сортираме числа. При сортиране на записи (примерно информация за студенти) сортираме по ключ (примерно факултетен номер).
    * Ако получим списък от записи за оценките на студенти от някой изпит, сортирани по имена, и ги сортираме със стабилен сортиращ алгоритъм по оценки, студентите с еднакви оценки ще останат сортирани и по имена.
    * Ако ги сортираме с нестабилен сортиращ алгоритъм това не е сигурно.

2. Адаптивност - Сортиращ алгоритъм нарицаме "адаптивен" ако се взема предвид текущата наредба на елементите.
3. in-place - Алгоритъм наричаме `in-place` ако не се използва допълнителна памет за неговата работа.

## Bubble sort
* Best case: Θ(n)
* Average case: Θ(n^2)
* Worst case: Θ(n^2)

* Стабилен сортиращ алгоритъм заради строгото неравенство.
* Адаптивен - Зависи от това какво разбираме под `почти сортиран` масив.
    * [1, 2, 3, 4, 5, 4] - Bubble sort би се справил добре върху този вход.
    * [2, 3, 4, 5, 1]    - В случая отново ще имаме Θ(n^2) стъпки.
* in-place: да

## Selection sort
* Best case: Θ(n^2)
* Average case: Θ(n^2)
* Worst case: Θ(n^2)

* Нестабилен сортиращ алгоритъм.
* Адаптивен - не
* in-place - да


## Insertion sort
* Best case: Θ(n)
* Average case: Θ(n^2)
* Worst case: Θ(n^2)

* Стабилен сортиращ алгоритъм.
* Адаптивен - да
* in-place - да

## Binary search
* Best case: Θ(1)
* Average case: Θ(log(n))
* Worst case: Θ(log(n))