Skip to content

📊 Анализ на алгоритми. Бързо търсене и бързо сортиране

⚖️ Дълбок анализ на алгоритми

В състезателното програмиране не е достатъчно просто да напишем работещ код. Той трябва да работи в рамките на времевото ограничение (обикновено 1 секунда). Съвременните съдии изпълняват около \(10^8\) операции в секунда.

🕒 Сложност по време и Ограничения (Time Complexity)

Познаването на ограниченията на входните данни (\(N\)) често ни подсказва необходимата сложност на решението:

Ограничение за \(N\) Допустима сложност Типични алгоритми
\(N \le 10\) \(O(N!)\) Пълно изчерпване (Backtracking), Пермутации
\(N \le 20\) \(O(2^N)\) ДП с битови маски, Recursion
\(N \le 500\) \(O(N^3)\) Флойд-Уоршал, Умножение на матрици, ДП
\(N \le 2000\) \(O(N^2)\) Bubble/Insertion Sort, ДП, BFS/DFS от всеки връх
\(N \le 100,000\) \(O(N \log N)\) Sorting, Heap, Segment Tree, Binary Search
\(N \le 1,000,000\) \(O(N)\) или \(O(N \log N)\) KMP, Hash, Two Pointers, Union-Find
\(N \ge 10^{18}\) \(O(\log N)\) или \(O(1)\) Матрично повдигане, Математически формули

📜 Master Theorem (За рекурсивни алгоритми)

За рекуррентна връзка от вида \(T(n) = a T(n/b) + f(n)\): 1. Ако \(f(n) = O(n^c)\) и \(c < \log_b a\), то \(T(n) = \Theta(n^{\log_b a})\). 2. Ако \(f(n) = \Theta(n^c \log^k n)\) и \(c = \log_b a\), то \(T(n) = \Theta(n^c \log^{k+1} n)\). 3. Ако \(f(n) = \Omega(n^c)\) и \(c > \log_b a\), то \(T(n) = \Theta(f(n))\).


🔍 Бързо търсене (Двоично търсене) - Разширено

Двоичното търсене не е само за намиране на число в масив. То е мощен метод за намиране на отговор на задача, ако отговорът е монотонен.

1. Стандартно двоично търсене (std::lower_bound)

Намира първия елемент, който е \(\ge\) на търсения.

int lowerBound(const std::vector<int>& arr, int target) {
    int left = 0, right = arr.size(); // Внимавайте с границите [0, N)
    while (left < right) {
        int mid = left + (right - left) / 2;
        if (arr[mid] >= target) {
            right = mid;
        } else {
            left = mid + 1;
        }
    }
    return left; // Връща индекс или N, ако няма такъв
}

2. Двоично търсене по отговор (Binary Search on Answer)

Използва се, когато търсим минимална стойност \(X\), за която условие \(P(X)\) е изпълнено (и за всяко \(Y > X\), \(P(Y)\) също е изпълнено).

Пример: "Какво е минималното време, за което \(K\) машини могат да произведат \(M\) продукта?" - Функцията check(time) проверява дали за time секунди могат да се произведат \(\ge M\) продукта. Тя е монотонна (повече време -> повече продукти).

bool check(long long time, int m, const std::vector<int>& machines) {
    long long products = 0;
    for (int speed : machines) {
        products += time / speed;
        if (products >= m) return true;
    }
    return products >= m;
}

long long solve(int m, std::vector<int>& machines) {
    long long left = 0, right = 1e18; // Достатъчно голяма горна граница
    long long ans = right;

    while (left <= right) {
        long long mid = left + (right - left) / 2;
        if (check(mid, m, machines)) {
            ans = mid;
            right = mid - 1; // Пробваме по-малко време
        } else {
            left = mid + 1; // Трябва ни повече време
        }
    }
    return ans;
}

⚡ Алгоритми за сортиране в детайли

1. Бързо сортиране (Quick Sort) - Оптимизации

Класическият Quick Sort може да дегенерира до \(O(N^2)\) при сортиран масив, ако винаги избираме първия/последния елемент за Pivot.

Оптимизация - Random Pivot: Избирането на случаен индекс за Pivot прави вероятността за лош случай астрономически малка.

#include <cstdlib> // за rand()

void quickSort(std::vector<int>& arr, int low, int high) {
    if (low >= high) return;

    // Избор на случаен pivot
    int pivotIndex = low + rand() % (high - low + 1);
    int pivot = arr[pivotIndex];
    std::swap(arr[pivotIndex], arr[high]); // Местим pivot най-отзад

    int i = low - 1;
    for (int j = low; j < high; j++) {
        if (arr[j] < pivot) {
            i++;
            std::swap(arr[i], arr[j]);
        }
    }
    std::swap(arr[i + 1], arr[high]);

    quickSort(arr, low, i);
    quickSort(arr, i + 2, high);
}

2. Сортиране чрез сливане (Merge Sort)

За разлика от Quick Sort, Merge Sort винаги работи за \(O(N \log N)\) и е стабилен (запазва реда на равните елементи). Използва се често за броене на инверсии.

void merge(std::vector<int>& arr, int left, int mid, int right) {
    std::vector<int> temp;
    int i = left, j = mid + 1;

    while (i <= mid && j <= right) {
        if (arr[i] <= arr[j]) temp.push_back(arr[i++]);
        else temp.push_back(arr[j++]);
    }
    while (i <= mid) temp.push_back(arr[i++]);
    while (j <= right) temp.push_back(arr[j++]);

    for (int k = 0; k < temp.size(); k++) arr[left + k] = temp[k];
}

void mergeSort(std::vector<int>& arr, int left, int right) {
    if (left >= right) return;
    int mid = left + (right - left) / 2;
    mergeSort(arr, left, mid);
    mergeSort(arr, mid + 1, right);
    merge(arr, left, mid, right);
}

3. C++ std::sort

Вградената функция std::sort<algorithm>) използва хибриден алгоритъм (Introsort): 1. Започва с Quick Sort. 2. Ако дълбочината на рекурсията стане прекалено голяма, превключва на Heap Sort (за да гарантира \(O(N \log N)\)). 3. За малки подмасиви използва Insertion Sort (защото е по-бърз за малко \(N\)).


🏁 Заключение и Съвети

  1. Винаги използвайте std::sort, освен ако нямате специфична причина (напр. броене на инверсии с Merge Sort).
  2. За задачи с търсене на "минималното X, за което е изпълнено...", винаги мислете за Binary Search on Answer.
  3. Внимавайте с типовете данни (long long) при двоичното търсене, за да избегнете препълване при left + right.